Compare commits
51 Commits
v1.2.0
...
c001a081c2
| Author | SHA1 | Date | |
|---|---|---|---|
| c001a081c2 | |||
| 97a6836444 | |||
| c79bafa179 | |||
| 21c85a8b88 | |||
| 73ac08af17 | |||
| 27a6e2952c | |||
| 969fd01f5c | |||
| 278aedb201 | |||
| ae821254ef | |||
| 4cff3167ed | |||
| e76f3b8d84 | |||
| 38115783b4 | |||
| 636fe0df8f | |||
| 8d1c8f320b | |||
| e7c2c4c6a9 | |||
| 6de5eb4f07 | |||
| 13b70dfbe8 | |||
| 51bff7564f | |||
| 38d2586466 | |||
| 24f51e932d | |||
| 551d25075f | |||
| 6f0f05aa00 | |||
| 00d769b466 | |||
| 6a727e936b | |||
| 55aa287296 | |||
| e2a79467df | |||
| f2457113a2 | |||
| b03cb211b3 | |||
| 716947228e | |||
| ab99a9ab5d | |||
| 9009dade81 | |||
| efbd6e8974 | |||
| a6d1a648d1 | |||
| 7479cb485f | |||
| 7004b5b020 | |||
| de81c74e53 | |||
| 678ba51725 | |||
| 5a444ec8f2 | |||
| 4d485432c0 | |||
| 4c79874278 | |||
| 29c132ecf3 | |||
| b023d6f726 | |||
| b18ac25ccc | |||
| 2a562d0b14 | |||
| e7633e15de | |||
| 8c4aaa51fa | |||
| 29db4c01c0 | |||
| ecff144449 | |||
| 38c14005c6 | |||
| 29565831d1 | |||
| 507556f158 |
@@ -20,6 +20,21 @@
|
||||
# Die Version kommt aus tauri.conf.json (von desktop-version.sh geschrieben
|
||||
# oder als eingecheckte Basislinie vorhanden) -- dieses Skript liest sie nur,
|
||||
# es schreibt sie nicht. Dieses Skript kennt kein Secret.
|
||||
#
|
||||
# Signatur (quick-260917-kgc): Die Tauri-CLI legt beim Bau mit
|
||||
# `createUpdaterArtifacts` neben jedem Bundle eine `<bundle>.sig` ab
|
||||
# (minisign, eine Base64-Zeile). Dieses Skript traegt deren INHALT als
|
||||
# `files.<plattform>.signature` ins Manifest -- die `.sig`-Datei selbst wird
|
||||
# nicht kopiert (die API liefert JSON). Pflichtregel: fehlt die `.sig`,
|
||||
# bricht das Skript ab, wenn TAURI_SIGNING_PRIVATE_KEY gesetzt ist ODER der
|
||||
# Kanal nicht `dev` ist (main/Tag -- dort sind Signaturen Pflicht,
|
||||
# `--no-sign` gibt es nur lokal); sonst Warnung und das Feld entfaellt
|
||||
# (lokaler Bau mit `tauri build --no-sign` bleibt moeglich, die API antwortet
|
||||
# auf /desktop/update dann 204). Neues Feld `updateVersion` = die SemVer-Form
|
||||
# fuer den Updater: X.Y.Z bei live/dev, X.Y.Z-beta.g<sha7> bei beta (das
|
||||
# `g` ist Pflicht, ein rein numerischer SHA mit fuehrender Null waere kein
|
||||
# gueltiger SemVer-Identifier). Der Schluessel wird nur auf Gesetztsein
|
||||
# geprueft, nie gelesen oder ausgegeben.
|
||||
set -eu
|
||||
|
||||
REQUIRE=""
|
||||
@@ -79,6 +94,43 @@ esac
|
||||
COMMIT="$(git rev-parse --short=7 HEAD)"
|
||||
BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
|
||||
# SemVer-Form fuer den Updater (siehe Kopfkommentar): erst NACH der
|
||||
# Versionspruefung bilden, SUFFIX/SHA_SHORT sind aus der Kanalentscheidung
|
||||
# bekannt.
|
||||
UPDATE_VERSION="$VERSION"
|
||||
if [ "$CHANNEL" = beta ]; then
|
||||
UPDATE_VERSION="$VERSION-beta.g$SHA_SHORT"
|
||||
fi
|
||||
|
||||
SIGN_REQUIRED=0
|
||||
if [ -n "${TAURI_SIGNING_PRIVATE_KEY:-}" ] || [ "$CHANNEL" != dev ]; then
|
||||
SIGN_REQUIRED=1
|
||||
fi
|
||||
|
||||
# read_signature <bundle-pfad>: schreibt den Inhalt von <bundle-pfad>.sig
|
||||
# nach stdout (genau eine Base64-Zeile). Fehlt die Datei: bei
|
||||
# SIGN_REQUIRED=1 Fehler und exit 1, sonst Warnung und leere Ausgabe.
|
||||
read_signature() {
|
||||
SIG_FILE="$1.sig"
|
||||
if [ -f "$SIG_FILE" ]; then
|
||||
SIG_CONTENT="$(cat "$SIG_FILE")"
|
||||
# grep prueft zeilenweise -- die Zeilenzahl deshalb getrennt erzwingen.
|
||||
SIG_LINES="$(printf '%s\n' "$SIG_CONTENT" | wc -l | tr -d ' ')"
|
||||
if [ "$SIG_LINES" -ne 1 ] || ! printf '%s' "$SIG_CONTENT" | grep -qE '^[A-Za-z0-9+/=]+$'; then
|
||||
echo "Signaturdatei $SIG_FILE hat nicht die erwartete Form (genau eine Base64-Zeile)." >&2
|
||||
exit 1
|
||||
fi
|
||||
printf '%s' "$SIG_CONTENT"
|
||||
return 0
|
||||
fi
|
||||
if [ "$SIGN_REQUIRED" = 1 ]; then
|
||||
echo "Signaturdatei $SIG_FILE fehlt. Im CI muessen TAURI_SIGNING_PRIVATE_KEY und TAURI_SIGNING_PRIVATE_KEY_PASSWORD an den tauri-build-Schritten gesetzt sein; --no-sign ist nur lokal erlaubt." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Warnung: Signaturdatei $SIG_FILE fehlt (Bau ohne Schluessel, Kanal dev) -- Feld signature entfaellt, kein Update in der App." >&2
|
||||
return 0
|
||||
}
|
||||
|
||||
mkdir -p "$DESKTOP_DIST"
|
||||
# Alte Pakete/Manifest entfernen, Platzhalter (.gitkeep) bleibt erhalten.
|
||||
rm -f "$DESKTOP_DIST"/*.AppImage "$DESKTOP_DIST"/*.exe "$DESKTOP_DIST/manifest.json"
|
||||
@@ -90,9 +142,11 @@ trap 'rm -f "$TMP_MANIFEST"' EXIT INT TERM
|
||||
LINUX_NAME=""
|
||||
LINUX_SIZE=""
|
||||
LINUX_SHA=""
|
||||
LINUX_SIG=""
|
||||
WINDOWS_NAME=""
|
||||
WINDOWS_SIZE=""
|
||||
WINDOWS_SHA=""
|
||||
WINDOWS_SIG=""
|
||||
|
||||
case ",$REQUIRE," in
|
||||
*,linux,*)
|
||||
@@ -107,7 +161,10 @@ case ",$REQUIRE," in
|
||||
cp "$APPIMAGE_SRC" "$DESKTOP_DIST/$LINUX_NAME"
|
||||
LINUX_SIZE="$(stat -c %s "$DESKTOP_DIST/$LINUX_NAME")"
|
||||
LINUX_SHA="$(sha256sum "$DESKTOP_DIST/$LINUX_NAME" | cut -d' ' -f1)"
|
||||
echo "linux: $LINUX_NAME (${LINUX_SIZE} Bytes, sha256 $LINUX_SHA)"
|
||||
LINUX_SIG="$(read_signature "$APPIMAGE_SRC")"
|
||||
[ -n "$LINUX_SIG" ] || [ "$SIGN_REQUIRED" = 0 ] || exit 1
|
||||
if [ -n "$LINUX_SIG" ]; then LINUX_SIGNED="signiert"; else LINUX_SIGNED="ohne Signatur"; fi
|
||||
echo "linux: $LINUX_NAME (${LINUX_SIZE} Bytes, sha256 $LINUX_SHA, $LINUX_SIGNED)"
|
||||
;;
|
||||
esac
|
||||
|
||||
@@ -124,7 +181,10 @@ case ",$REQUIRE," in
|
||||
cp "$NSIS_SRC" "$DESKTOP_DIST/$WINDOWS_NAME"
|
||||
WINDOWS_SIZE="$(stat -c %s "$DESKTOP_DIST/$WINDOWS_NAME")"
|
||||
WINDOWS_SHA="$(sha256sum "$DESKTOP_DIST/$WINDOWS_NAME" | cut -d' ' -f1)"
|
||||
echo "windows: $WINDOWS_NAME (${WINDOWS_SIZE} Bytes, sha256 $WINDOWS_SHA)"
|
||||
WINDOWS_SIG="$(read_signature "$NSIS_SRC")"
|
||||
[ -n "$WINDOWS_SIG" ] || [ "$SIGN_REQUIRED" = 0 ] || exit 1
|
||||
if [ -n "$WINDOWS_SIG" ]; then WINDOWS_SIGNED="signiert"; else WINDOWS_SIGNED="ohne Signatur"; fi
|
||||
echo "windows: $WINDOWS_NAME (${WINDOWS_SIZE} Bytes, sha256 $WINDOWS_SHA, $WINDOWS_SIGNED)"
|
||||
;;
|
||||
esac
|
||||
|
||||
@@ -132,25 +192,29 @@ esac
|
||||
# manuelles String-Zusammenbauen von JSON).
|
||||
jq -n \
|
||||
--arg version "$VERSION" \
|
||||
--arg updateVersion "$UPDATE_VERSION" \
|
||||
--arg channel "$CHANNEL" \
|
||||
--arg commit "$COMMIT" \
|
||||
--arg buildTime "$BUILD_TIME" \
|
||||
--arg linuxName "$LINUX_NAME" \
|
||||
--argjson linuxSize "${LINUX_SIZE:-null}" \
|
||||
--arg linuxSha "$LINUX_SHA" \
|
||||
--arg linuxSig "$LINUX_SIG" \
|
||||
--arg windowsName "$WINDOWS_NAME" \
|
||||
--argjson windowsSize "${WINDOWS_SIZE:-null}" \
|
||||
--arg windowsSha "$WINDOWS_SHA" \
|
||||
--arg windowsSig "$WINDOWS_SIG" \
|
||||
'{
|
||||
version: $version,
|
||||
updateVersion: $updateVersion,
|
||||
channel: $channel,
|
||||
commit: $commit,
|
||||
buildTime: $buildTime,
|
||||
files: (
|
||||
{}
|
||||
+ (if $linuxName != "" then { linux: { name: $linuxName, size: $linuxSize, sha256: $linuxSha } } else {} end)
|
||||
+ (if $windowsName != "" then { windows: { name: $windowsName, size: $windowsSize, sha256: $windowsSha } } else {} end)
|
||||
+ (if $linuxName != "" then { linux: ({ name: $linuxName, size: $linuxSize, sha256: $linuxSha } + (if $linuxSig != "" then { signature: $linuxSig } else {} end)) } else {} end)
|
||||
+ (if $windowsName != "" then { windows: ({ name: $windowsName, size: $windowsSize, sha256: $windowsSha } + (if $windowsSig != "" then { signature: $windowsSig } else {} end)) } else {} end)
|
||||
)
|
||||
}' > "$DESKTOP_DIST/manifest.json"
|
||||
|
||||
echo "Manifest geschrieben: $DESKTOP_DIST/manifest.json (Version $VERSION, Kanal $CHANNEL)"
|
||||
echo "Manifest geschrieben: $DESKTOP_DIST/manifest.json (Version $VERSION, updateVersion $UPDATE_VERSION, Kanal $CHANNEL)"
|
||||
|
||||
Executable
+178
@@ -0,0 +1,178 @@
|
||||
#!/bin/sh
|
||||
# desktop-stamp.sh -- Stempel aus Version + letztem Commit an den
|
||||
# Desktop-Pfaden berechnen und pruefen, ob dazu bereits fertige Pakete im
|
||||
# Zwischenspeicher des Runners liegen (quick-260917-jdh).
|
||||
#
|
||||
# Zweck: Der CI-Job `desktop` baut die Rust/Tauri-Pakete heute bei jedem
|
||||
# Push auf main, auch wenn sich an der Desktop-App seit dem letzten Bau
|
||||
# nichts geaendert hat. Dieses Skript berechnet einen Stempel aus der
|
||||
# aktuellen Versionsnummer und dem letzten Commit an den Desktop-Pfaden;
|
||||
# liegen zu diesem Stempel bereits geprueft-vollstaendige Pakete im
|
||||
# Zwischenspeicher, kann der Job den kompletten Bau ueberspringen.
|
||||
#
|
||||
# Aufrufformen:
|
||||
# desktop-stamp.sh stamp -- Stempel berechnen, Ausgaben: stamp, version,
|
||||
# sha7, skip_allowed (Version aus
|
||||
# desktop-version.sh --print; SHA aus dem
|
||||
# letzten Commit an DESKTOP_PATHS)
|
||||
# desktop-stamp.sh check -- prueft, ob ein per stamp-cache restaurierter
|
||||
# desktop-dist/ vollstaendig und stempel-echt
|
||||
# ist, Ausgaben: reuse, bei Treffer zusaetzlich
|
||||
# files
|
||||
#
|
||||
# Umgebungsvariablen:
|
||||
# DESKTOP_TAG nur fuer stamp, lokale Probe (siehe desktop-version.sh)
|
||||
# GITHUB_REF nur fuer stamp: skip_allowed=true ausschliesslich bei
|
||||
# refs/heads/main (Beta-Kanal) -- Tags bauen immer neu
|
||||
# GITHUB_OUTPUT nur fuer stamp: wenn gesetzt, werden alle Ausgaben
|
||||
# zusaetzlich per >> hineingeschrieben (wie im CI ueblich)
|
||||
# CACHE_HIT nur fuer check: Ergebnis von actions/cache/restore
|
||||
# (steps.<id>.outputs.cache-hit), muss exakt "true" sein
|
||||
# STAMP_VERSION nur fuer check: Version aus dem stamp-Schritt, muss mit
|
||||
# der Version im Manifest uebereinstimmen
|
||||
# STAMP_SHA7 nur fuer check: 7-stelliger SHA aus dem stamp-Schritt,
|
||||
# nur fuer die Log-Zeile bei Treffer
|
||||
# DESKTOP_DIST nur fuer check: Zielordner der Pakete (Vorgabe:
|
||||
# desktop-dist, wie desktop-collect.sh)
|
||||
#
|
||||
# DESKTOP_PATHS deckt alle Bau-Eingaben ab: apps/desktop (inkl.
|
||||
# Cargo.lock/package.json), die drei Skripte und die Workflow-Datei selbst
|
||||
# (eine Aenderung an der Stempelregel muss selbst einen Neubau ausloesen).
|
||||
# `pnpm-lock.yaml` steht bewusst NICHT in der Liste: die Tauri-CLI-Version
|
||||
# haengt an apps/desktop/package.json (darin enthalten), und der Desktop-Bau
|
||||
# liest ausserhalb von apps/desktop keine Werkstatt-Datei -- frontendDist ist
|
||||
# ../src innerhalb von apps/desktop, keine Abhaengigkeit auf packages/*.
|
||||
#
|
||||
# quick-260917-kgc: `check` verlangt zusaetzlich `updateVersion` und je
|
||||
# Plattform `files.<p>.signature` im gecachten Manifest -- ein Cache-Stand
|
||||
# aus der Zeit vor der Update-Funktion (oder aus einem Bau mit --no-sign)
|
||||
# wird nie uebernommen, sonst lieferte der Update-Endpunkt dauerhaft 204.
|
||||
# Ein Schluesselwechsel (`pubkey` in tauri.conf.json unter apps/desktop)
|
||||
# aendert den Stempel automatisch, weil apps/desktop in DESKTOP_PATHS liegt.
|
||||
#
|
||||
# Dieses Skript kennt kein Secret.
|
||||
set -eu
|
||||
|
||||
DESKTOP_PATHS="apps/desktop .gitea/scripts/desktop-version.sh .gitea/scripts/desktop-collect.sh .gitea/scripts/desktop-stamp.sh .gitea/workflows/ci.yml"
|
||||
|
||||
out() {
|
||||
printf '%s=%s\n' "$1" "$2"
|
||||
if [ -n "${GITHUB_OUTPUT:-}" ]; then
|
||||
printf '%s=%s\n' "$1" "$2" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_stamp() {
|
||||
VERSION="$("$(dirname "$0")/desktop-version.sh" --print)"
|
||||
|
||||
# Bewusst ungequotet: Wortaufteilung der leerzeichengetrennten Pfadliste.
|
||||
LAST="$(git log -1 --format=%H -- $DESKTOP_PATHS)"
|
||||
if [ -z "$LAST" ]; then
|
||||
echo "Keine Historie zu den Desktop-Pfaden -- im CI ist fetch-depth: 0 Pflicht." >&2
|
||||
exit 1
|
||||
fi
|
||||
SHA7="$(git rev-parse --short=7 "$LAST")"
|
||||
SUBJECT="$(git log -1 --format=%s "$LAST")"
|
||||
|
||||
SKIP=false
|
||||
REF="${GITHUB_REF:-}"
|
||||
if [ "$REF" = "refs/heads/main" ]; then
|
||||
SKIP=true
|
||||
echo "Stempel $VERSION-$LAST ($SHA7: $SUBJECT) -- Ueberspringen erlaubt (main)."
|
||||
else
|
||||
echo "Stempel $VERSION-$LAST ($SHA7: $SUBJECT) -- Tag oder fremder Zweig, es wird immer gebaut."
|
||||
fi
|
||||
|
||||
out stamp "$VERSION-$LAST"
|
||||
out version "$VERSION"
|
||||
out sha7 "$SHA7"
|
||||
out skip_allowed "$SKIP"
|
||||
}
|
||||
|
||||
cmd_check() {
|
||||
CACHE_HIT="${CACHE_HIT:-}"
|
||||
STAMP_VERSION="${STAMP_VERSION:-}"
|
||||
STAMP_SHA7="${STAMP_SHA7:-}"
|
||||
DESKTOP_DIST="${DESKTOP_DIST:-desktop-dist}"
|
||||
MANIFEST="$DESKTOP_DIST/manifest.json"
|
||||
|
||||
no_reuse() {
|
||||
echo "Kein uebernehmbarer Stand ($1) -- Desktop wird gebaut."
|
||||
rm -f "$DESKTOP_DIST"/*.AppImage "$DESKTOP_DIST"/*.exe "$MANIFEST"
|
||||
out reuse false
|
||||
exit 0
|
||||
}
|
||||
|
||||
check_file() {
|
||||
# $1 = Manifest-Schluessel (linux|windows), $2 = Dateiname
|
||||
FPATH="$DESKTOP_DIST/$2"
|
||||
if [ ! -f "$FPATH" ]; then
|
||||
no_reuse "Datei $2 fehlt"
|
||||
fi
|
||||
SIZE="$(stat -c %s "$FPATH")"
|
||||
EXP_SIZE="$(jq -r ".files.$1.size" "$MANIFEST")"
|
||||
if [ "$SIZE" != "$EXP_SIZE" ]; then
|
||||
no_reuse "Groesse von $2 weicht ab ($SIZE statt $EXP_SIZE)"
|
||||
fi
|
||||
SHA="$(sha256sum "$FPATH" | cut -d' ' -f1)"
|
||||
EXP_SHA="$(jq -r ".files.$1.sha256" "$MANIFEST")"
|
||||
if [ "$SHA" != "$EXP_SHA" ]; then
|
||||
no_reuse "Pruefsumme von $2 weicht ab"
|
||||
fi
|
||||
SIG="$(jq -r ".files.$1.signature // empty" "$MANIFEST")"
|
||||
if [ -z "$SIG" ]; then
|
||||
no_reuse "Signatur fuer $2 fehlt im Manifest"
|
||||
fi
|
||||
}
|
||||
|
||||
if [ "$CACHE_HIT" != "true" ]; then
|
||||
no_reuse "kein Zwischenspeicher zum Stempel"
|
||||
fi
|
||||
|
||||
if [ ! -f "$MANIFEST" ] || ! jq -e . "$MANIFEST" >/dev/null 2>&1; then
|
||||
no_reuse "Manifest fehlt oder ist kein gueltiges JSON"
|
||||
fi
|
||||
|
||||
CHANNEL="$(jq -r .channel "$MANIFEST")"
|
||||
if [ "$CHANNEL" != "beta" ]; then
|
||||
no_reuse "Kanal $CHANNEL statt beta"
|
||||
fi
|
||||
|
||||
MVERSION="$(jq -r .version "$MANIFEST")"
|
||||
if [ "$MVERSION" != "$STAMP_VERSION" ]; then
|
||||
no_reuse "Version $MVERSION statt $STAMP_VERSION"
|
||||
fi
|
||||
|
||||
UPDATE_VERSION="$(jq -r '.updateVersion // empty' "$MANIFEST")"
|
||||
if [ -z "$UPDATE_VERSION" ]; then
|
||||
no_reuse "updateVersion fehlt im Manifest (Stand vor der Update-Funktion)"
|
||||
fi
|
||||
|
||||
LINUX_NAME="$(jq -r '.files.linux.name // empty' "$MANIFEST")"
|
||||
WINDOWS_NAME="$(jq -r '.files.windows.name // empty' "$MANIFEST")"
|
||||
if [ -z "$LINUX_NAME" ] || [ -z "$WINDOWS_NAME" ]; then
|
||||
no_reuse "Dateiname fehlt im Manifest"
|
||||
fi
|
||||
|
||||
check_file linux "$LINUX_NAME"
|
||||
check_file windows "$WINDOWS_NAME"
|
||||
|
||||
COMMIT="$(jq -r .commit "$MANIFEST")"
|
||||
BUILD_TIME="$(jq -r .buildTime "$MANIFEST")"
|
||||
echo "Desktop unveraendert seit $STAMP_SHA7: Pakete $LINUX_NAME, $WINDOWS_NAME aus dem Zwischenspeicher (gebaut aus $COMMIT am $BUILD_TIME)"
|
||||
out reuse true
|
||||
out files "$LINUX_NAME,$WINDOWS_NAME"
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
stamp)
|
||||
cmd_stamp
|
||||
;;
|
||||
check)
|
||||
cmd_check
|
||||
;;
|
||||
*)
|
||||
echo "Aufruf: $0 stamp|check" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -17,8 +17,13 @@
|
||||
# GITEA_TOKEN Zugriffstoken (Pflicht im echten Lauf; im CI aus secrets.REGISTRY_TOKEN
|
||||
# ueber `env`). Wird nie ausgegeben und nie als Argument uebergeben --
|
||||
# der Authorization-Header kommt aus einer temporaeren Datei.
|
||||
# GITEA_API API-Basis; sonst GITHUB_API_URL, sonst GITHUB_SERVER_URL/api/v1,
|
||||
# sonst http://localhost:3002/api/v1 (nur lokal erreichbar).
|
||||
# GITEA_API API-Basis (expliziter Override). Ohne Angabe NIE die oeffentliche
|
||||
# Adresse (GITHUB_API_URL / GITHUB_SERVER_URL zeigen auf
|
||||
# git.vicolab.de hinter dem Proxy, der grosse Uploads abbricht --
|
||||
# Release 1.2.0 hatte deshalb zunaechst keine Anhaenge): im CI wird
|
||||
# das Host-Gateway des Job-Containers aus /proc/net/route ermittelt
|
||||
# und Gitea direkt auf Port 3002 angesprochen (derselbe Weg wie der
|
||||
# Registry-Push), lokal http://localhost:3002/api/v1.
|
||||
# GITEA_REPO owner/repo; sonst GITHUB_REPOSITORY, sonst schalli/tessera-ctl.
|
||||
# CHANGELOG_FILE Pfad zur Aenderungsliste; Vorgabe CHANGELOG.md.
|
||||
# DESKTOP_DIST Ordner mit den Desktop-Paketen und manifest.json (Phase 18,
|
||||
@@ -74,8 +79,24 @@ if ! echo "$TAG" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
fi
|
||||
VERSION="${TAG#v}"
|
||||
|
||||
API="${GITEA_API:-${GITHUB_API_URL:-${GITHUB_SERVER_URL:+${GITHUB_SERVER_URL}/api/v1}}}"
|
||||
API="${API:-http://localhost:3002/api/v1}"
|
||||
# Host-Gateway des Containers: Default-Route in /proc/net/route, Gateway als
|
||||
# Hex in Little-Endian (z. B. 010011AC = 172.17.0.1). Reines POSIX sh.
|
||||
host_gateway() {
|
||||
[ -r /proc/net/route ] || return 1
|
||||
gw=$(awk '$2 == "00000000" { print $3; exit }' /proc/net/route)
|
||||
[ -n "$gw" ] || return 1
|
||||
printf '%d.%d.%d.%d\n' \
|
||||
"0x$(printf '%s' "$gw" | cut -c7-8)" "0x$(printf '%s' "$gw" | cut -c5-6)" \
|
||||
"0x$(printf '%s' "$gw" | cut -c3-4)" "0x$(printf '%s' "$gw" | cut -c1-2)"
|
||||
}
|
||||
|
||||
if [ -n "${GITEA_API:-}" ]; then
|
||||
API="$GITEA_API"
|
||||
elif [ -n "${GITHUB_ACTIONS:-}${CI:-}" ] && GW=$(host_gateway); then
|
||||
API="http://$GW:3002/api/v1"
|
||||
else
|
||||
API="http://localhost:3002/api/v1"
|
||||
fi
|
||||
API="${API%/}"
|
||||
REPO="${GITEA_REPO:-${GITHUB_REPOSITORY:-schalli/tessera-ctl}}"
|
||||
echo "Gitea-API: $API Repo: $REPO Tag: $TAG"
|
||||
|
||||
@@ -9,6 +9,14 @@
|
||||
# in der Pipeline. `tauri.conf.json`/`Cargo.toml` bleiben dabei immer rein
|
||||
# numerisch (X.Y.Z), weil NSIS' Windows-Ressourcenfelder das verlangen; die
|
||||
# Beta-Kennzeichnung lebt ausschliesslich im Dateinamen-Suffix.
|
||||
# quick-260917-jdh: Job `desktop` ueberspringt den Bau auf `main`, wenn zum
|
||||
# Stempel (Version aus dem letzten Freigabe-Tag + letzter Commit an den
|
||||
# Desktop-Pfaden, siehe .gitea/scripts/desktop-stamp.sh) bereits fertige
|
||||
# Pakete im Zwischenspeicher des Runners liegen; Tags v* bauen immer neu;
|
||||
# `publish` bleibt unveraendert.
|
||||
# quick-260917-kgc: Die beiden tauri-build-Schritte signieren die Pakete mit dem
|
||||
# Updater-Schluessel (Secrets TAURI_SIGNING_PRIVATE_KEY/_PASSWORD, nur an diesen
|
||||
# zwei Schritten); desktop-collect.sh traegt die .sig-Inhalte ins Manifest.
|
||||
name: Tessera CI/CD
|
||||
|
||||
on:
|
||||
@@ -78,17 +86,46 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
# Die folgenden drei Schritte stehen bewusst vor setup-node/apt/rustup:
|
||||
# Sie sollen beim Ueberspringen (Desktop-Stand unveraendert) gar nicht
|
||||
# erst die teuren Werkzeuge installieren (quick-260917-jdh).
|
||||
- name: Desktop-Stempel berechnen
|
||||
id: stamp
|
||||
run: sh .gitea/scripts/desktop-stamp.sh stamp
|
||||
|
||||
- name: Fertige Pakete zum Stempel suchen
|
||||
id: stamp-cache
|
||||
if: steps.stamp.outputs.skip_allowed == 'true'
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: desktop-dist
|
||||
key: desktop-dist-stamp-${{ steps.stamp.outputs.stamp }}
|
||||
# Bewusst OHNE restore-keys: act_runner sucht auch zum Hauptschluessel
|
||||
# per Praefix -- ein aelterer Stand darf nie als Treffer gelten.
|
||||
|
||||
- name: Gefundene Pakete pruefen
|
||||
id: reuse
|
||||
env:
|
||||
CACHE_HIT: ${{ steps.stamp-cache.outputs.cache-hit }}
|
||||
STAMP_VERSION: ${{ steps.stamp.outputs.version }}
|
||||
STAMP_SHA7: ${{ steps.stamp.outputs.sha7 }}
|
||||
run: sh .gitea/scripts/desktop-stamp.sh check
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
if: steps.reuse.outputs.reuse != 'true'
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Enable pnpm via corepack
|
||||
if: steps.reuse.outputs.reuse != 'true'
|
||||
run: corepack enable && corepack prepare pnpm@9.15.0 --activate
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.reuse.outputs.reuse != 'true'
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Systemabhaengigkeiten
|
||||
if: steps.reuse.outputs.reuse != 'true'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
@@ -98,12 +135,14 @@ jobs:
|
||||
lld llvm clang nsis
|
||||
|
||||
- name: Rust-Toolchain
|
||||
if: steps.reuse.outputs.reuse != 'true'
|
||||
run: |
|
||||
curl -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable
|
||||
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||
"$HOME/.cargo/bin/rustup" component add clippy
|
||||
|
||||
- name: Cargo-Zwischenspeicher
|
||||
if: steps.reuse.outputs.reuse != 'true'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
@@ -118,33 +157,59 @@ jobs:
|
||||
restore-keys: desktop-cargo-
|
||||
|
||||
- name: Windows-Werkzeuge
|
||||
if: steps.reuse.outputs.reuse != 'true'
|
||||
run: |
|
||||
rustup target add x86_64-pc-windows-msvc
|
||||
command -v cargo-xwin >/dev/null 2>&1 || cargo install --locked cargo-xwin
|
||||
|
||||
- name: Version setzen
|
||||
if: steps.reuse.outputs.reuse != 'true'
|
||||
run: sh .gitea/scripts/desktop-version.sh
|
||||
|
||||
- name: Rust pruefen
|
||||
if: steps.reuse.outputs.reuse != 'true'
|
||||
working-directory: apps/desktop/src-tauri
|
||||
run: |
|
||||
cargo check
|
||||
cargo clippy
|
||||
|
||||
- name: Alte Bundles entfernen
|
||||
if: steps.reuse.outputs.reuse != 'true'
|
||||
run: |
|
||||
rm -rf apps/desktop/src-tauri/target/release/bundle
|
||||
rm -rf apps/desktop/src-tauri/target/x86_64-pc-windows-msvc/release/bundle
|
||||
|
||||
- name: Linux-AppImage bauen
|
||||
if: steps.reuse.outputs.reuse != 'true'
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
run: pnpm --filter @tessera/desktop exec tauri build --bundles appimage
|
||||
|
||||
- name: Windows-Installer bauen (Cross-Bau)
|
||||
if: steps.reuse.outputs.reuse != 'true'
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
run: pnpm --filter @tessera/desktop exec tauri build --runner cargo-xwin --target x86_64-pc-windows-msvc --bundles nsis
|
||||
|
||||
- name: Pakete einsammeln
|
||||
if: steps.reuse.outputs.reuse != 'true'
|
||||
run: sh .gitea/scripts/desktop-collect.sh --require linux,windows
|
||||
|
||||
- name: Pakete unter dem Stempel ablegen
|
||||
# Nur nach echtem Bau und nur auf main -- Tag-Pakete tragen keinen
|
||||
# Beta-Suffix und duerfen nie unter einem Stempel liegen. Ein bereits
|
||||
# vorhandener Schluessel loest bei actions/cache/save nur eine Info
|
||||
# aus, keinen Fehler.
|
||||
if: steps.reuse.outputs.reuse != 'true' && steps.stamp.outputs.skip_allowed == 'true'
|
||||
uses: actions/cache/save@v4
|
||||
with:
|
||||
path: desktop-dist
|
||||
key: desktop-dist-stamp-${{ steps.stamp.outputs.stamp }}
|
||||
|
||||
# Laeuft in beiden Faellen: im Skip-Fall sichert er den restaurierten
|
||||
# Stand unter dem neuen SHA, deshalb muss `publish` nichts wissen.
|
||||
- name: Uebergabe an publish
|
||||
uses: actions/cache/save@v4
|
||||
with:
|
||||
|
||||
@@ -43,3 +43,6 @@ user-files/
|
||||
# Desktop-Pakete aus dem Bau (Phase 18)
|
||||
desktop-dist/*
|
||||
!desktop-dist/.gitkeep
|
||||
|
||||
# Privater Updater-Signierschluessel liegt ausserhalb des Repos (~/.tessera/desktop-updater/) -- nie einchecken
|
||||
*.key
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
---
|
||||
context: default
|
||||
phase: betrieb-nach-live-gehen
|
||||
task: null
|
||||
total_tasks: 0
|
||||
status: paused
|
||||
last_updated: 2026-09-16T10:28:05.211Z
|
||||
---
|
||||
|
||||
# Wiedereinstieg — v1.1.0 ist live, nichts angefangen
|
||||
|
||||
## Critical Anti-Patterns
|
||||
|
||||
Alle aus tatsaechlichen Fehlschlaegen dieser und der vorigen Sitzungen.
|
||||
|
||||
| Muster | Beschreibung | Schwere | Vermeidung |
|
||||
|--------|--------------|---------|------------|
|
||||
| Zaehlung ohne Ansehen | Der Planer zaehlte fuenf Tastenreihen im Rechner, es sind sechs — die Mindesthoehe schnitt die unterste Reihe ab. Erst der Browser-Blick fand es. | blocking | Zahlen, die eine Groesse tragen, im Browser messen (Bounding-Box, scrollHeight), nicht aus dem Quelltext ableiten. |
|
||||
| Gespeicherte Werte vergessen | Konstanten zu aendern haette fuer den User NICHTS bewirkt — react-grid-layout nimmt gespeicherte Layout-Eintraege woertlich (inkl. minW/minH). | blocking | Bei jeder Aenderung an Vorgaben pruefen, ob persistierte Daten dieselben Werte tragen; dann beim Laden ueberschreiben/anheben. |
|
||||
| `git checkout -- <Datei>` als Falsifizierungs-Rueckweg | setzte auch die noch unkommittierte Nutz-Aenderung zurueck. | advisory | Vor Rueckbau-Proben committen, oder Patchdatei + `git apply -R`. |
|
||||
| Zwei Schreiber auf einer Datei | Uebersetzungs-Fix musste warten, weil der laufende Executor de.json/en.json anfasste. | advisory | Quick-Tasks mit ueberlappenden Dateien nacheinander ausfuehren. |
|
||||
| Platte voll durch Bau-Cache | vier Docker-Bauten + CI: `no space left on device`. | advisory | `docker builder prune -af` und `docker image prune -f` bei Bedarf; Volumes nie anfassen. |
|
||||
| Bericht statt Arbeitsbaum | (aus Etappe 2) Agenten brachen nach getaner Arbeit ab. | blocking | `git status` ist die Wahrheit. |
|
||||
|
||||
<current_state>
|
||||
**Gemessen 2026-09-16 10:28Z:** `git status --porcelain` leer, `main == origin/main`
|
||||
(29fe3d7), keine async-jobs, keine angefangene Arbeit.
|
||||
|
||||
**Live:** tessera.ctl.de laeuft `v1.1.0` (User hat gepullt, "sieht gut aus").
|
||||
**Beta:** alpha.tessera.ctl.de holt `beta`/`latest` — derselbe Stand e0d4532.
|
||||
**Registry:** beta/latest/live/v1.0.0/v1.1.0. **Gitea-Releases:** 1.0.0 und 1.1.0
|
||||
(letzterer von der Pipeline angelegt — erster CI-Beweis des Release-Wegs).
|
||||
**Schalter:** AUS und bleibt es. **Mandantenfaehigkeit:** RUHT (User 2026-09-14).
|
||||
**Tests:** Web 49/309, API 67/1078, Werkzeug 253/253. **Ledger:** 15 offen / 1
|
||||
zurueckgestellt / 23 geschlossen / 39.
|
||||
</current_state>
|
||||
|
||||
<completed_work>
|
||||
Seit 2026-09-14: WINDOWS #29, Etappe 3c, Kanalmodell + Versionsstempel,
|
||||
Fehler-melden-Knopf, Erstfreigabe v1.0.0 (live 15.09.), Dashboard-Umbau +
|
||||
Nachbesserung, Aenderungsliste (CHANGELOG.md, Seite "Was ist neu",
|
||||
Gitea-Release je Tag), Uebersetzungs-Fix, Freigabe v1.1.0. Alle als Quick-Tasks
|
||||
mit voller Kette; Details in der Quick-Task-Tabelle in `.planning/STATE.md`.
|
||||
</completed_work>
|
||||
|
||||
<remaining_work>
|
||||
Nichts Angefangenes. Naechste Arbeit kommt vom User (Feedback aus dem Betrieb).
|
||||
Ohne Termin: Desktop-Client-Todo, Ship Phase 17 (blockiert bei open_count 15),
|
||||
Ledger #35/#36/#37. Nicht ansprechen: Mandantenfaehigkeit, Lizenzierung.
|
||||
</remaining_work>
|
||||
|
||||
<decisions_made>
|
||||
Siehe `HANDOFF.json` — Kanalmodell, Versionsnummern-Regel (Funktionen -> mittlere
|
||||
Stelle, Fixes -> dritte), Mandantenfaehigkeit ruht, Lizenzmodell nur festgehalten,
|
||||
Dashboard-Entscheidungen (inhaltsgetriebene Minima, Ueberschreiben gespeicherter
|
||||
Minima, ganze Kachel Griff, preventCollision).
|
||||
</decisions_made>
|
||||
|
||||
<blockers>
|
||||
Keine. Eine nicht-blockierende Handreichung fuer den User: auf alpha einmalig
|
||||
`IMAGE_TAG=beta` und die zwei `image:`-Zeilen (Kap. 9) — bis dahin laeuft alpha
|
||||
ueber `latest`, das dasselbe Abbild ist.
|
||||
</blockers>
|
||||
|
||||
## Required Reading (in order)
|
||||
|
||||
1. `.planning/STATE.md` — Session Continuity + Quick-Task-Tabelle
|
||||
2. `CHANGELOG.md` — Regel: jede Aenderung sofort unter `## Unveröffentlicht`
|
||||
3. `docs/anleitung-betrieb.md` Kapitel 9 — Freigabe/Hotfix-Rezept
|
||||
4. `.planning/WINDOWS.md` — 15 offen, davon nur #35/#36/#37 ohne Mandantenbezug
|
||||
|
||||
## Infrastructure State
|
||||
|
||||
- Live-Server tessera.ctl.de (`IMAGE_TAG=live`, eigene DB); alpha 192.168.13.12
|
||||
(Beta); Deploy macht der User (pull + `up -d --force-recreate api web`).
|
||||
- Lokal: `db`, `api`, `web`, `mailhog` laufen (web aus c3d8e16); Admin
|
||||
admin/admin123; DB ohne Host-Port (IP per `docker inspect`, tessera:tessera_dev);
|
||||
Prisma-Binary aus `apps/api/node_modules/.bin/prisma`.
|
||||
- Gitea 1.26.2 + Runner auf diesem Rechner; Runner arbeitet EINEN Auftrag
|
||||
gleichzeitig; CI ueber API localhost:3002 beobachtbar (Token aus Push-URL, nie
|
||||
ausgeben). `grep` ist hier ugrep (`$` als Anker).
|
||||
|
||||
<next_action>
|
||||
`/gsd-resume-work`, dann das, was der User nennt — als `/gsd-quick --validate`
|
||||
mit voller Kette; Browser-Nachweis per Playwright MCP gegen lokale Container;
|
||||
CHANGELOG pflegen; bei "Version freigeben" das Rezept aus Kap. 9.
|
||||
</next_action>
|
||||
+27
-16
@@ -4,14 +4,14 @@ milestone: v1.2
|
||||
current_phase: 18
|
||||
current_phase_name: desktop-client-fertigstellen
|
||||
status: verified
|
||||
stopped_at: Completed 18-06-PLAN.md — Windows-Bedienprobe des Nutzers steht aus
|
||||
last_updated: "2026-09-16T15:27:16.943Z"
|
||||
last_activity: 2026-09-17
|
||||
last_activity_desc: Quick 260917-gsh/gyd/h2s — alle sieben Nebenbefunde der Windows-Bedienprobe + Akzentfarbe per Hex + Logo in Akzentfarbe
|
||||
state_head: a777814034ee5907cb4d151669447f015841082d
|
||||
stopped_at: "WINDOWS #35, #36 und der Lint-Rueckstand abgeschlossen und verifiziert (inkl. Klicktest am laufenden System); Push und CI-Lauf fuer das Image stehen an. CI-Lauf 388 war rot durch einen Absturz des Gitea-Runners (act_runner v0.6.1, panic: close of closed channel) — Lint/Type-Check/Tests waren darin gruen, nur der Desktop-Job starb, dadurch wurde Build & Publish Images uebersprungen."
|
||||
last_updated: "2026-09-21T08:10:00.000Z"
|
||||
last_activity: 2026-09-21
|
||||
last_activity_desc: Quick 260921-9ie, 260921-a1d und 260921-bi2 — Biome lauffaehig und Lint-Tor scharf, Benutzerverwaltung meldet abgewiesene Aktionen sichtbar, Lint-Rueckstand 2923 → 465; alle drei verifiziert, der letzte zusaetzlich per Klicktest am laufenden System
|
||||
state_head: 4d485432c003a6caf68f6d85aff7de0bd27794e2
|
||||
progress:
|
||||
total_phases: 18
|
||||
completed_phases: 15
|
||||
completed_phases: 16
|
||||
total_plans: 89
|
||||
completed_plans: 88
|
||||
milestone_name: Plattform-Berechtigungen
|
||||
@@ -28,12 +28,12 @@ See: .planning/PROJECT.md (updated 2026-07-17)
|
||||
|
||||
## Current Position
|
||||
|
||||
Phase: 18 (desktop-client-fertigstellen) — IN PROGRESS
|
||||
Plan: 6 of 6 (18-02 abgeschlossen)
|
||||
Status: 18-02 (CI-Job desktop, Cache-Uebergabe an publish, Release-Anhaenge) fertig; 18-03 (Web-Oberflaeche), 18-04 (Client-Updatepruefung), 18-05 (Windows-Cross-Bau + Pipeline-Beweis), 18-06 (Freigabe) stehen aus
|
||||
Last activity: 2026-09-17 - Quick-Tasks 260917-gsh/gyd/h2s: Akzentfarbe per Hex + Logo in Akzentfarbe, Web-Robustheit (Ruecksprung, Sitzungswaechter, i18n), Desktop-Erkennung + Beta-Label + deutscher Installer — lokal im Browser UND auf der Windows-VM (CI 246, Paket 4c93555) bestaetigt
|
||||
Phase: 18 (desktop-client-fertigstellen) — COMPLETE (2026-09-17, Verifikation passed, Windows-Bedienprobe bestanden)
|
||||
Plan: 6 of 6
|
||||
Status: Alle 18 Phasen abgeschlossen; Version 1.2.0 freigegeben. Kein laufender Meilenstein. Nach 1.2.0 auf main (Beta): Bildmarke in Akzentfarbe, CI-Desktop-Skip, Favoriten-Symbol/-Sortierung, Desktop-Server-Adresse, Update in der App (signiert), Versionszeile auf der Setup-Seite — alles verifiziert und auf VM/CI nachgewiesen
|
||||
Last activity: 2026-09-21 - Quick 260921-bi2: Lint-Rueckstand 2923 → 465 Warnungen abgebaut (Konfiguration, maschinelle Fixes, toter Code, 155 Handkorrekturen Barrierefreiheit); Beinahe-Schaden abgewendet — Biomes als sicher eingestufte useImportType-Korrektur haette die NestJS-Abhaengigkeitsspritze zerstoert, unbemerkt von tsc und 1124 gruenen Tests
|
||||
|
||||
Progress: [███░░░░░░░] 33%
|
||||
Progress: [██████████] 99%
|
||||
|
||||
## Performance Metrics
|
||||
|
||||
@@ -417,7 +417,7 @@ Gerettet aus `.continue-here.md`. Relevant fuer die noch offenen Live-Tests.
|
||||
| 260911-nke | **Etappe 3b — Benutzerdimension in den Datenbankregeln.** Migration `20260911120000_rls_user_dimension_personal_tables`: `current_user_id()` (liest `app.current_user`, `NULLIF` fuer den Leerstring), `forTenant(prisma, tenantId, userId?)` mit optionalem drittem Parameter (kein Schwesterhelfer — der Inventar-Detektor haette ihn nicht gesehen), beide `set_config` in EINER Anweisung, `$transaction` behaelt zwei Eintraege. Regeln der ZEHN persoenlichen Tabellen in der Form `tenantId = current_tenant_id() AND (current_user_id() IS NULL OR userId = current_user_id())` — ein Aufruf ohne Benutzer (Admin, Hintergrunddienst) sieht weiter den ganzen Mandanten. `SearchProvider`/`TenderRssFeedSource` mit vier befehlsgetrennten Regeln (jab-Praezedenz), Mandantenhaelften unveraendert; GroupMembership/ModuleGrant/PasswordResetToken/TenderMatch bewusst ohne Benutzerdimension (Verwaltungs-/Anmelde-/Hintergrundobjekte). 34 Nutzer-CRUD-Aufrufstellen in 8 Diensten reichen den Benutzer durch, Scheduler und Verwaltungswege bleiben zweistellig. **SECHS loch-behauptende Pruefungen statt drei** — und die Umkehrung war nicht trivial: die alten massen OHNE Benutzer, eine naive Umkehrung waere nach der Migration rot geworden, weil der Aufruf ohne Benutzer per Absicht beide sieht; jede wurde zu ZWEI (alte Messung unter neuem Namen als gewollte Eigenschaft, Umkehrung MIT Benutzer). 13 Extraktionsstellen im Werkzeug auf die neue Migration umgeleitet. **Wirkungslos mit ausgeschaltetem Schalter** (Rolle `tessera` hat BYPASSRLS, live bestaetigt) — blockiert das Live-Gehen am Dienstag nicht. Angenommene offene Flanke, festgehalten statt verschwiegen: ein Aufrufer, der den Benutzer vergisst, sieht den ganzen Mandanten (heutiger Stand, keine Verschlechterung) — WINDOWS #34; die dreistelligen Spec-Zusicherungen sind je Datei, nicht je Methode, das Gate 'keine zweistellige Form' ist ein Shell-Check, nicht CI — vom Verifizierer als Bewusstseinspunkt vermerkt. **Verifiziert 13/13** (1020/1020 Tests, Typpruefung sauber, 203/203 Live-Pruefungen; `NULLIF` durch Rueckbau falsifiziert, 33 Pruefungen rot; alle zehn Regeln live in `pg_policies` gelesen) | 2026-09-11 | f0b531b,07fc653,b62a905 | [260911-nke-mandantentrennung-etappe-3b-benutzerdime](./quick/260911-nke-mandantentrennung-etappe-3b-benutzerdime/) |
|
||||
| 260909-eor | Etappe 1 der Mandantentrennung: Anmeldeweg mandantenfaehig gemacht und alle Zugriffe klassifiziert. **Kernfund (#20):** `forTenant()` setzte den Mandantenkontext per set_config auf der Transaktionsverbindung, dispatchte die Abfrage aber ueber den aeusseren Client — empirisch reproduziert (set_config auf Backend-PID 254999, Abfrage auf 255000, Kontext dort NULL). Die Trennung hat damit nie funktioniert, auch nicht an den Stellen, die sie scheinbar nutzten; nach dem Scharfschalten haetten diese Abfragen NULL Zeilen geliefert, was der LDAP-Loeschzweig als 'Gruppe im Verzeichnis verschwunden' gedeutet und geloescht haette. Behoben und live nachgewiesen. Der Anmeldeweg bekam drei SECURITY-DEFINER-Funktionen als schmale Ausnahme (feste Spaltenliste, Gleichheitsbedingung, LIMIT 1) — eine Policy haette nicht gereicht, weil sie zwangslaeufig die ganze Tabelle freigibt. Browser-Gegenprobe lokal bestanden: Anmeldung laedt das Portal, falsches Kennwort verraet weiterhin nicht welches Feld, Kennwort-vergessen laeuft durch (der einzige Protokollfehler war ein lokal fehlender Mailserver, also NACH dem Datenbankzugriff). Klassifikation aller 227 Zugriffe in 59 Einheiten, maschinell gegen Abdriften abgesichert: 31 muessen mandantengebunden werden, 9 teilweise, 16 betreffen keine mandantengebundene Tabelle, 3 bleiben bewusst uebergreifend. 701 Tests gruen | 2026-09-09 | da0ac04 | [260909-eor-anmeldeweg-mandantenfaehig-machen-und-al](./quick/260909-eor-anmeldeweg-mandantenfaehig-machen-und-al/) |
|
||||
| 260910-jab | Die drei zu kurz greifenden Datenbankregeln geschlossen — T-JTS-02, T-JTS-03, WINDOWS #19 (bewusste Reihenfolge-Abweichung, vorgezogen auf Nutzerwunsch, statt wie geplant nach Etappe 2). Neue, handgeschriebene, lokal angewandte Migration `20260910120000_rls_widen_membership_grant_and_platform_read`: `GroupMembership` prueft jetzt beide Seiten der Beziehung (Gruppe UND Benutzer), `ModuleGrant` prueft zusaetzlich beide moeglichen Ziele mit Leer-Zulassung (D-04), `TenderRssFeedSource` bekommt vier nach Befehl getrennte Regeln (Lesen schliesst plattformweite Zeilen ein, Schreiben verlangt weiterhin einen Mandanten — die Trennung ist noetig, weil ein einzelner USING-Ausdruck sonst auch UPDATE/DELETE mitregelt). `SearchProvider` bewusst NICHT angefasst: die WINDOWS-#19-Praemisse ist fuer dieses Modell widerlegt (kein Codeweg erzeugt eine mandantenlose Zeile). Drei loch-behauptende Pruefungen im Wegwerf-Werkzeug UMGEKEHRT statt geloescht (66→74 Pruefungen), mit Verweis auf die alten Pruefungsnamen und Befundkennungen im Meldetext. Genau EIN Anwendungspfad musste mitgebunden werden (`TenderRssFeedSourceService.listForUser`) — sonst haette die Reparatur ihn still von 'liefert nach dem Scharfschalten nichts' auf 'liefert nur die plattformweiten Zeilen, taeuscht Vollstaendigkeit vor' verschlechtert; Falsifizierungsnachweis gefuehrt (Bindung zurueckgenommen, genau ein Test rot, zurueckgesetzt). WINDOWS #19 geschlossen mit Beleg, WINDOWS #24 neu angelegt (Verwaltungsweg fuer plattformweite Zeilen unter der Anwendungsrolle fehlt weiterhin — verschwindet nicht mit #19). Aktenstand kohaerent: Klassifikation, Kritikschrift (neuer Abschnitt "Regelschluss T-JTS-02, T-JTS-03 und WINDOWS #19" mit Signaltabelle beider Fehlerrichtungen je Regel), Betriebsanleitung, WINDOWS.md — fuenf ueberholte Bestandsstellen mit Nachtraegen versehen, alte Messprotokolle bleiben woertlich stehen. Selbst gemessen statt uebernommen: Baseline 833/56 Tests, 66/66 Live-Pruefungen; Endstand 839/56, 74/74; keine zweite Sitzungsvariable fuer den Benutzer gefunden (nur `app.current_tenant`). Rule-1-Fix: implizites `any` in `tenders.controller.ts` nach der Bindung behoben. `npx prisma` versuchte ungefragt Prisma 8 herunterzuladen — abgebrochen, lokale gepinnte 6.19.3 verwendet | 2026-09-10 | f4f3115,6b23735,03fb3bf | [260910-jab-mandantentrennung-die-drei-zu-kurz-greif](./quick/260910-jab-mandantentrennung-die-drei-zu-kurz-greif/) |
|
||||
| 260914-ebg | **WINDOWS #29 geschlossen — Zielrollen-Riegel in `UserController.update()`/`remove()`.** Ein ADMIN kann den SUPER_ADMIN seines Mandanten nicht mehr aendern (Kennwort, isActive, Rolle, Anmeldename, E-Mail) oder loeschen; Riegel nach der Mandantengrenze, vor der Rollenzuweisungs-Pruefung (Vorlage `AuthService.adminResetPassword`, T-FH9-04). Acht neue Spec-Tests (8 -> 16), Baseline 1020 -> 1028 Tests / 62 Dateien, Falsifizierung durch Rueckbau `Tests 2 failed | 14 passed (16)` (Test 9/13), unabhaengig vom Verifizierer wiederholt. Kopfkommentar `adminResetPassword` nachgezogen (T-FH9-05 nicht mehr offen). Ledger 16 offen / 1 zurueckgestellt / 19 geschlossen / 36 gesamt: #29 fixed, NEU #35 (Biome-Konfiguration im Bestand nicht lauffaehig, `pnpm lint` Leerlauf) und #36 (Admin-Frontend verschluckt 403 still). Verifiziert 6/6, gepusht. | 2026-09-14 | 759ea3b,63f9df0,70d007b | [260914-ebg-windows-29-schliessen-rechteausweitung-a](./quick/260914-ebg-windows-29-schliessen-rechteausweitung-a/) |
|
||||
| 260914-ebg | **WINDOWS #29 geschlossen — Zielrollen-Riegel in `UserController.update()`/`remove()`.** Ein ADMIN kann den SUPER_ADMIN seines Mandanten nicht mehr aendern (Kennwort, isActive, Rolle, Anmeldename, E-Mail) oder loeschen; Riegel nach der Mandantengrenze, vor der Rollenzuweisungs-Pruefung (Vorlage `AuthService.adminResetPassword`, T-FH9-04). Acht neue Spec-Tests (8 -> 16), Baseline 1020 -> 1028 Tests / 62 Dateien, Falsifizierung durch Rueckbau `Tests 2 failed / 14 passed (16)` (Test 9/13), unabhaengig vom Verifizierer wiederholt. Kopfkommentar `adminResetPassword` nachgezogen (T-FH9-05 nicht mehr offen). Ledger 16 offen / 1 zurueckgestellt / 19 geschlossen / 36 gesamt: #29 fixed, NEU #35 (Biome-Konfiguration im Bestand nicht lauffaehig, `pnpm lint` Leerlauf) und #36 (Admin-Frontend verschluckt 403 still). Verifiziert 6/6, gepusht. | 2026-09-14 | 759ea3b,63f9df0,70d007b | [260914-ebg-windows-29-schliessen-rechteausweitung-a](./quick/260914-ebg-windows-29-schliessen-rechteausweitung-a/) |
|
||||
| 260914-eym | **Etappe 3c — Systemkontext fuer die Hintergrunddienste.** Migration `20260914120000_rls_system_context_read`: `is_system_context()`, fuenf permissive `system_read_policy ... FOR SELECT` (DkvModuleConfig, LdapConfig, LdapFieldMapping, TenderMatch, TenderSavedSearch); `forSystem(prisma)` in Array-Form mit ausdruecklichem Zuruecksetzen von Mandant/Benutzer, `forTenant()` setzt `app.system_context` zurueck (kein Erben, gemessen). Sechs Faelle: DKV-Planer einmal-abfragen-viele-bedienen (Auftrag je Mandant, WINDOWS #21 fixed); Mail-Transport je Versand aus der SmtpConfig des Empfaenger-Mandanten mit unveraenderter Umgebungs-Rueckfallkette, Startpfad und Mailer-Fabrik entfallen (WINDOWS #30 fixed, SmtpConfig ohne Systemregel); ldap `getAllActiveConfigs()` und Boot-Nachverschluesselung lesen ueber Systemkontext, schreiben je Mandant gebunden; tender-digest Kandidaten und tender-matching Suchprofile ueber Systemkontext, Schleifen gebunden; admin-seed nur dokumentiert (Tenant ohne Regel). Detektor mit fuenfter Erkennungsform `forSystem(` und exakter Erlaubnisliste (falsifiziert: Fremddatei 1 rot, Zweitaufruf 2 rot). Werkzeug 203 -> 253 (`runSystemContextChecks`: ungebunden 0 / System beide Mandanten / Schreiben abgewiesen 42501 bzw. count 0 / kein Erben / pg_policies 34, 5x SELECT). Rueckbau (a) 5 rot mit gelungenem Insert, (b) 1 rot, (c1) 253 gruen + (c2) 5 rot, (d) 2/3 rot. Tests 1028 -> 1054 / 62 -> 64 Dateien, tsc 0, 29 Dateien gegen 5e0e408, Schalter AUS (Compose/.env/Schema/Lockfile unveraendert). Klassifikation 61/179/5, 72 Paare, sechs Zeilen `system-gebunden`; Kritikschrift (y1)-(y5); Auftrag 3c Erledigt. Ledger 15 offen / 1 zurueckgestellt / 21 geschlossen / 37 gesamt; NEU #37 (prozessweiter Single-Flight-Riegel `processInbox`). Verifiziert 9/9, gepusht. | 2026-09-14 | 3d64567,6e2a641,939c812 | [260914-eym-mandantentrennung-etappe-3c-systemkontex](./quick/260914-eym-mandantentrennung-etappe-3c-systemkontex/) |
|
||||
| 260914-ku1 | **Zwei Auslieferungskanaele und Versionsstempel.** `main` = Beta (Etiketten `beta` + `latest`), Tag `vX.Y.Z` = Live (Etiketten `live` + `vX.Y.Z`), Zweig `live` ohne Tag nur geprueft — Entscheidung in `.gitea/scripts/publish-images.sh` (`--print-plan`), CI-Trigger `branches: [main, live]` + `tags: [v*]`, `fetch-depth: 0`. Versionsstempel `APP_VERSION/APP_CHANNEL/APP_COMMIT/APP_BUILD_TIME` als Build-Args in beide Dockerfiles (web zur Bauzeit als `NEXT_PUBLIC_APP_*`, api als Laufzeit-ENV; Vorgabe `dev`). `GET /health/version` liefert name/version/channel/commit/buildTime, Startlog `Tessera API vX (channel) commit`. Web: `app-version.ts`, `AppVersionBadge` in `sidebar.tsx` (sidebar-footer.tsx ist seit ba02b25 toter Code). `docker-compose.prod.yml`: `image: ...:${IMAGE_TAG:-beta}`. Betriebshandbuch Kapitel 9 (Zwei Kanaele, Freigabe, Hotfix ohne Datenbankaenderung, neuer Live-Server), ci-cd-setup.md auf gemessenen Stand. Falsifiziert: Build mit `v9.9.9-test live` -> Stempel in dist und Web-Bundle, ohne Args `dev`. Echter CI-Lauf 297 gruen (5:18 min), Abbilder `beta`/`latest` tragen `ea6aa99 beta`. Tests API 1054 -> 1060 / 64 -> 65 Dateien, Web 233 -> 243 / 38 -> 40, tsc 0, 20 Dateien gegen 6c19451. Offen: Zweig `live` + Tag `v1.0.0` nach dem Fehler-melden-Knopf anlegen; Handgriffe fuer den User (IMAGE_TAG je Server) im SUMMARY. Verifiziert 8/8, gepusht. | 2026-09-14 | cdb571c,9731501,ea6aa99 | [260914-ku1-zwei-auslieferungskanaele-beta-auf-main-](./quick/260914-ku1-zwei-auslieferungskanaele-beta-auf-main-/) |
|
||||
| 260914-m97 | **Fehler-melden-Knopf.** Kaefer-Knopf in der Kopfzeile: Bildschirmfoto VOR dem Dialog (`html-to-image` 1.11.13, laengste Kante 1600 px, `computeCaptureSize`), Dialog mit Vorschau, Haekchen und "Was ist passiert?"; Fehlerpuffer (Ringpuffer 20: window.onerror, unhandledrejection, console.error, fehlgeschlagene fetch-Antworten — keine Ruempfe/Cookies/Tokens); `POST /bug-reports` als Multipart (FileInterceptor 4 MiB -> 413, PNG-Signatur -> 400, kein Empfaenger -> 409, Drossel 5/10 min -> 429, Versandfehler -> 502; Mandant/Benutzer nur aus der Sitzung); E-Mail mit PNG-Anhang und Kontext (URL, Web-/API-Version+Kanal+Commit, Browser, Fenster, Zeitpunkt, Benutzer, letzte Fehler) ueber `MailService.sendBugReport` (Anhaenge; Kennwort-Reset bleibt verschluckend). Empfaenger: neue nullable Spalte `SmtpConfig.bugReportRecipient` (Migration `20260914170000`), Feld "Fehlermeldungen an" unter Administrator -> SMTP, Rueckfall `TESSERA_BUGREPORT_TO` (docker-compose.prod.yml). Handbuecher Anwender/Administration/Betrieb. Tests API 1060 -> 1076 / 67 Dateien, Web 243 -> 260 / 43 Dateien, tsc 0, `--frozen-lockfile` 0, 35 Dateien gegen 5c42c55, vier Commits. CI-Lauf 299 gruen (zweiter Versuch, erster scheiterte an Gitea-DB). Browser-Beweis durch den Orchestrator: E-Mail mit 85-KB-PNG (ohne Dialog, OKLCH korrekt) in mailhog, 409-Pfad im Dialog. Ledger #38 (Rule-1-Fix `@Expose()`) als fixed. Verifiziert 9/9 + Browser, gepusht. | 2026-09-14 | 54121c1,60b0ee8,b41be21,77117de | [260914-m97-fehler-melden-knopf-bildschirmfoto-der-a](./quick/260914-m97-fehler-melden-knopf-bildschirmfoto-der-a/) |
|
||||
@@ -436,6 +436,17 @@ Gerettet aus `.continue-here.md`. Relevant fuer die noch offenen Live-Tests.
|
||||
| 260917-gsh | **Akzentfarbe als Hex-Code eingebbar; Bildmarke uebernimmt die Akzentfarbe.** `normalizeHexColor()` in `lib/color.ts` (optionales `#`, 3-stellige Kurzform, Kleinschreibung; 9 Tests), Textfeld neben dem Farbwaehler mit Zwei-Wege-Sync, `aria-invalid` + Fehlertext + gesperrtes Speichern bei ungueltigem Wert (7 Komponententests), i18n `settings.account.accentColorHex/-Invalid`. Gedrehte Kachel in `LogoMark` per Inline-Style `fill: var(--primary, #ffed00)` — folgt `applyAccentColor`, Anmeldeseite bleibt gelb. Tests Web 365 -> 381 / 57 Dateien, tsc 0. | 2026-09-17 | 795c6a4,1601d97,db478e0 | [260917-gsh-akzentfarbe-in-einstellungen-konto-zusae](./quick/260917-gsh-akzentfarbe-in-einstellungen-konto-zusae/) |
|
||||
| 260917-gyd | **Web-Robustheit: Ruecksprung nach Anmeldung, Sitzungswaechter, Widgets-Seite uebersetzt.** Middleware leitet auf `/login?next=<Pfad>` (Helfer `lib/safe-next.ts`: nur relative Pfade, kein `//`, kein `\\`, kein `/login`; Tests), Anmeldeseite springt nach Erfolg dorthin. Neue Server Action `fetchSessionState()` (authenticated/unauthenticated/unavailable): bei 401/403 oder 200 ohne Benutzer wird das Sitzungscookie geloescht und der Header leitet auf `/login?next=…` — 5xx/Netzwerkfehler bleiben still (kein Redirect bei API-Ausfall). Befund vom Testserver-DB-Reset: `/auth/me` liefert bei geloeschtem Benutzer 200 mit leerem Body. `settings/dashboard`: `common.loading` + `settings.widgets.empty` statt englischer Hartkodierung. Tests Web gruen, tsc 0. | 2026-09-17 | 4b279ea,474d170,2868ffe | [260917-gyd-web-nach-anmeldung-zurueck-zur-ursprueng](./quick/260917-gyd-web-nach-anmeldung-zurueck-zur-ursprueng/) |
|
||||
| 260917-h2s | **Desktop-Client-Erkennung, Beta-Hinweis, deutscher Installer.** Rust: `with_desktop_marker()` haengt `desktop=1` an beide Navigationen zur Server-Adresse (Store bleibt sauber); `update_labels()` — bei gleicher Version nennt Tray/Benachrichtigung „Neuen Beta-Stand {commit}“ statt „Version X“ (5 Rust-Tests). Web: Middleware setzt Cookie `tessera_desktop=1` (`withDesktopCookie` um jede Rueckgabe), `lib/desktop-client.ts` (`isDesktopClient`/`useIsDesktopClient`), `DesktopDownloadLinks` rendert im Client nichts, `DesktopContextMenuGuard` im RootLayout blockt Rechtsklick ausser in Eingabefeldern. Installer: `bundle.windows.nsis` languages German, kein Sprachwahldialog, installerIcon icon.ico, Header/Sidebar-BMP (Markengelb + Tessera-Zeichen, resvg-Quelle), installMode currentUser; Handbuecher ergaenzt. Web-Tests 417 / 63 Dateien, tsc 0. Browser: Cookie, Link-Ausblendung, Kontextmenue, Ruecksprung, 401-Waechter lokal bestaetigt; Installer/Client-Cookie nach CI auf der Windows-VM. | 2026-09-17 | 5bdabf5,d9b94bd,2cd4adc | [260917-h2s-desktop-client-web-erkennt-den-client-do](./quick/260917-h2s-desktop-client-web-erkennt-den-client-do/) |
|
||||
| 62 | **Freigabe 1.2.0** (CHANGELOG umbenannt f7f406a, live ff auf main, Tag v1.2.0; Abbilder live/v1.2.0 gebaut). Release-Anhaenge schlugen im CI fehl: publish-release.sh nahm GITHUB_API_URL (git.vicolab.de, Proxy bricht 82-MB-Upload ab, curl 92). Anhaenge vom Host ueber localhost:3002 nachgetragen; Skript nimmt jetzt NIE die oeffentliche Adresse — im CI Host-Gateway aus /proc/net/route:3002, lokal localhost:3002 (507556f, docs/ci-cd-setup.md). | 2026-09-17 | 2956583 | — |
|
||||
| 260917-jdf | **Bildmarke: ganzes T uebernimmt die Akzentfarbe.** Die vier olivfarbenen Kacheln fuellen sich mit `color-mix(in oklab, var(--primary, #ffed00) 54%, #363636)` (Konstanten `BRAND_OLIVE_MIX`/`BRAND_OLIVE_FILL` in `brand.ts`, Rueckfall-Attribut `#9c9440` bleibt); kalibriert auf `#ffed00 → #9c9440` exakt (Referenzrechnung `260917-jdf-oklab-kalibrierung.cjs`, `brand.test.ts` rechnet nach). Nebenbefund: `--primary` ist per globals.css immer `oklch(0.91 0.19 102)` ≈ `#fbe405`, der Rueckfall greift nie — Standardkacheln `#9a903f` statt `#9c9440` (unsichtbar). Anmeldeseite/icon.svg unveraendert. Web 424 Tests. Browser: Akzent `#0057b8` → Kacheln `#284a7b`, Zuruecksetzen → `#9a903f`. Verifikation passed 9/9. | 2026-09-17 | ecff144,29db4c0 | [260917-jdf-bildmarke-ganzes-t-uebernimmt-die-akzent](./quick/260917-jdf-bildmarke-ganzes-t-uebernimmt-die-akzent/) |
|
||||
| 260917-jdh | **CI: Job `desktop` ueberspringt den Rust-Bau, wenn der Desktop-Stand unveraendert ist.** Neues `.gitea/scripts/desktop-stamp.sh` (`stamp`: Version aus `desktop-version.sh --print` + voller SHA von `git log -1 -- apps/desktop desktop-version.sh desktop-collect.sh desktop-stamp.sh ci.yml`; `check`: Manifest/Kanal/Version/Groesse/sha256 des restaurierten `desktop-dist`). Drei neue Schritte direkt nach dem Checkout (stamp → `cache/restore` `desktop-dist-stamp-<Stempel>` nur auf main → check), 13 Bau-Schritte mit `if: steps.reuse.outputs.reuse != 'true'`, nach echtem Bau `cache/save` unter dem Stempel; `Uebergabe an publish` und `publish` unveraendert; Tags bauen immer. Doku: Betriebshandbuch Kap. 10, ci-cd-setup.md 4/6, Entwicklungsanleitung. Verifikation passed 9/9 (lokale Proben). **Offen: CI-Beweis nach Push** (baut → Docs-Push ueberspringt → Desktop-Push baut neu; `cache/save` bei belegtem Schluessel beobachten). | 2026-09-17 | 8c4aaa5,e7633e1 | [260917-jdh-ci-job-desktop-ueberspringen-wenn-apps-d](./quick/260917-jdh-ci-job-desktop-ueberspringen-wenn-apps-d/) |
|
||||
| 260917-jdd | **Favoriten-Widget: Symbol trotz Zertifikatsfehler/interner Adresse, Favoriten sortierbar.** API: `undici@7.28.0` (exakt, war schon im Lockfile) — `LENIENT_TLS_AGENT` (`rejectUnauthorized: false`) als Dispatcher NUR in `fetchWithRedirectGuard`, SSRF-Schutz (DNS/private IPs/Redirects/Timeouts/Deckel) byteweise unveraendert; `PUT /favorites/order` `{widgetId, ids}` VOR den `:id`-Routen, `reorder()` in `withTenantTransaction` mit `userId`+`widgetId` je Eintrag, eine 400-Meldung; Icon-Proxy mit `nosniff` + CSP sandbox. Web: `FavoriteIcon` Kette Proxy-Bild → bei Fehler Direktbild `{origin}/favicon.ico` (nur http/https, no-referrer) → Buchstabe; Pfeile „Nach oben/unten“ im Bearbeitungsmodus, optimistisch + Reload bei Fehler; Altbestand `position 0` normalisiert sich beim ersten Klick. Befund: `discoverFavoriteIconUrl` liefert nie null (immer Origin-Rueckfall) — deshalb haengt der Browser-Ersatzweg am Bildfehler. API 1101 / Web 429 Tests. Browser: `self-signed.badssl.com` → Proxy-Symbol; `http://192.168.13.11:3002` → Proxy 502 → Direktbild; Sortierung ueber Reload, DB-Positionen 0..3. Verifikation 15/15 + Browser. | 2026-09-17 | 2a562d0,b18ac25,b023d6f | [260917-jdd-favoriten-widget-favicon-ersatzweg-bei-u](./quick/260917-jdd-favoriten-widget-favicon-ersatzweg-bei-u/) |
|
||||
| 260917-jn2 | **Desktop-Client: Server-Adresse sichtbar und nachtraeglich aenderbar.** Rust: `TrayIconBuilder::with_id("main")`, `TrayItems { connected, update }` in `app.manage`, `apply_server()` setzt Tooltip `Tessera – {host}` + gesperrte Menuezeile `Verbunden mit {host}` an einer Stelle; Tray-Eintrag `Server-Adresse ändern…` navigiert zu `setup_page_url()` (`http://tauri.localhost/setup.html` unter Windows, sonst `tauri://localhost/setup.html`); Commands `get_server_url`/`open_server` (kein Capability-Eintrag noetig — Remote-Origin darf keine Commands rufen); `parse_server_url` (nur http/https) gemeinsam; `spawn_version_check` herausgezogen, `update`-Klick liest Adresse per `stored_server_url` beim Klick. setup.html: Vorbelegung, „Aktuell verbunden mit“, „Abbrechen“. Web: Einstellungen → Desktop-App zeigt im Client „Verbunden mit: {origin}“ + Hinweis (`settings.desktop.*`). 18 Rust-Tests, Web 431. Browser: Web-Block mit Cookie bestaetigt. Verifikation human_needed: **Windows-VM-Probe mit CI-Paket offen** (Tooltip, Menuezeile, Adresse aendern/Abbrechen, Wechsel ohne Neustart). | 2026-09-17 | 29c132e,4c79874,4d48543 | [260917-jn2-desktop-client-aktuelle-server-adresse-s](./quick/260917-jn2-desktop-client-aktuelle-server-adresse-s/) |
|
||||
| 260917-kgc | **Desktop-Client: Update in der App (tauri-plugin-updater, signierte Pakete).** Client: Plugin 2.11 + `semver`, `plugins.updater.pubkey` (minisign; privater Schluessel + Passwort NUR unter `~/.tessera/desktop-updater/` auf dem Dev-Rechner, Gitea-Secrets `TAURI_SIGNING_PRIVATE_KEY`/`_PASSWORD`), Endpunkt zur Laufzeit `{server}/api-proxy/desktop/update?target&arch¤t&base`, `is_update_newer` (hoehere Basis → Update; gleiche Basis nur bei `beta.g<sha7>` mit anderem Commit; kleinere/gleiche Live → nichts), Pruefung 15 s / Download 600 s (Plugin-Timeout gilt fuer beides), Tray „Auf Version X / Beta-Stand <sha7> aktualisieren" → Fortschritt → passiver NSIS-Installer startet die App neu (Linux: `app.restart()`), Fehler → Benachrichtigung + Download-Seite im Browser, `http://` → gesperrt „Update nur über https möglich". API: `GET /desktop/update` (statisch VOR `download/:platform`, `base` nur Origin, 204 ohne `signature`/`updateVersion`). CI: `createUpdaterArtifacts`, Secrets nur an den zwei `tauri build`-Schritten, `desktop-collect.sh` schreibt `signature` + `updateVersion` (`X.Y.Z-beta.g<sha7>`), `desktop-stamp.sh check` verlangt beides. 33 Rust-Tests, 23 API-Tests. **Nachweise erbracht:** CI baut `.sig` fuer beide Plattformen (Cross-Bau rustls ok); alpha-Endpunkt 200/400; Windows-VM: Client 7479cb4 → Tray-Klick → Neustart als a6d1a64, Adresse erhalten. Bereits installierte Clients (≤ 1.2.0) brauchen einmal den Browser-Installer. | 2026-09-17 | 678ba51,de81c74,7004b5b,7479cb4 | [260917-kgc-desktop-client-update-in-der-app-herunte](./quick/260917-kgc-desktop-client-update-in-der-app-herunte/) |
|
||||
| 260918-gza | **Fehlermeldung: Herkunft ausweisen (Browser/Desktop-App, Betriebssystem, App-Version).** Betreff traegt direkt nach `[Tessera Fehlermeldung]` ein Kuerzel `[Browser]` / `[Desktop/Windows]` / `[Desktop/Linux]` (`[Desktop]` bei altem Client ohne Details); Mailtext bekommt die Zeile `Herkunft:` — Browser: `Browser — <Name> <Hauptversion> auf <OS>` aus dem User-Agent (reine Regex-Helfer `origin.ts`, keine Abhaengigkeit), Desktop: `Desktop-App (<OS>), Tessera-App <Version> · Stand <Commit>`. Kette: Rust `with_client_marker` haengt neben `desktop=1` die Parameter `dv`/`dc`/`dos` an (drei Aufrufstellen unveraendert, nach In-App-Update automatisch frisch) → Middleware setzt Cookie `tessera_desktop_client` = `<dv>|<dc>|<dos>` (musterbereinigt, nur wenn alle drei da) → `getDesktopClientInfo()` → vier optionale DTO-Felder `clientKind/clientOs/clientVersion/clientCommit` (whitelist deklariert, alte Web-Baue/Clients bleiben gueltig) → `describeOrigin()`. Rohe Zeilen `Browser:`/`Fenster:` bleiben; Kuerzel auch in der einen Protokollzeile; nichts in DB, `main.ts` unangetastet (T-GZA-01..04). Tests: API 1124 (origin 10 neu), Web 447, Rust 37, Typecheck sauber. Plan-Pruefer und Verifier bestanden (9/9 must_haves). **Nachweise lokal (mailhog):** Browser → `[Browser] … Herkunft: Browser — Chrome 154 auf Linux`; Desktop-Marker wie der Rust-Client (`?desktop=1&dv=1.2.0&dc=a6d1a64&dos=windows`) → Cookie `1.2.0%7Ca6d1a64%7Cwindows`, `[Desktop/Windows] … Herkunft: Desktop-App (Windows), Tessera-App 1.2.0 · Stand a6d1a64`. **Offen:** Windows-VM-Probe mit echtem Client nach CI-Bau und alpha-Deploy durch den User. | 2026-09-18 | 7169472,b03cb21,f245711,e2a7946 | [260918-gza-fehlermeldung-herkunft-ausweisen-browser](./quick/260918-gza-fehlermeldung-herkunft-ausweisen-browser/) |
|
||||
| fast | **Desktop-Client: Setup-Seite zeigt Version und Stand der App** („Tessera-App 1.2.0 · Stand a6d1a64"; ohne Stempel nur Version) — Command `get_client_info`, Helfer `client_info_label` (2 Tests), `<p id="client-info">` in setup.html, CHANGELOG. Diente zugleich als zweiter Desktop-Stand fuer den Update-Nachweis. 35 Rust-Tests. | 2026-09-18 | a6d1a64 | — |
|
||||
| 260921-9ie | **Biome lauffaehig machen und das Lint-Tor scharf schalten (WINDOWS #35).** `biome.json` per `biome migrate` auf Biome 2.5.0 gezogen: `organizeImports` nach `assist.actions.source`, `linter.rules.recommended` → `preset: "recommended"`, `javascript.parser.unsafeParameterDecoratorsEnabled` (NestJS-Parameter-Dekoratoren: 238 parse-Fehler in 19 Dateien → 0), `quoteStyle: single` (belegt: 1496 einfach-gequotete Importzeilen gegen null doppelte), `vcs.useIgnoreFile`, Ausschluss von `**/__fixtures__/**` (nur html/zip/xml, keine TS-Datei) und `globals.css` (Tailwind-4-At-Regeln). `lint`-Skript (`biome lint .`) in allen fuenf Workspaces; `turbo.json` bekommt `globalDependencies: ["biome.json"]`, sonst liefert der Cache nach einer Regelaenderung alte Ergebnisse. **Zweig (b) gewaehlt, gemessen:** `biome check .` → Exit 1/760 Fehler, `biome lint .` → Exit 1/275, also kein "nur Warnungen"-Ausweg; Skript ruft `lint` statt `check` (haelt 319 Formatierungsbefunde draussen, kein Rundumumbau), Rest gezielt auf `warn` → 0 Fehler, Exit 0. **Sicherheit:** Gruppe `security` bleibt auf `error`, maschinell geprueft; die 6 `noScriptUrl`-Treffer lagen ausnahmslos in der ausgeschlossenen HTML-Testvorlage, keiner in echtem Quellcode. **Registereintrag #35 war in zwei Punkten falsch:** Pfad ist `apps/api/src/user/...` (Einzahl), und die Wirkung des Parser-Schalters betrug 238 statt 17 Fehler. **Nachweise (dreifach unabhaengig — Planer, Orchestrator, Verifier):** `pnpm lint` → "5 successful, 5 total", Exit 0 (vorher "No tasks were executed"); Gegenprobe mit Wegwerfdatei (`debugger`) → Exit 1 mit `noDebugger`, danach Baum wieder sauber; repo-weit 0 parse-Fehler, 0 Fehler; Diff nur Konfiguration/Skripte/Doku, keine Quelldatei, `pnpm-lock.yaml` unveraendert. Verifikation passed (7/7). **Offen als eigener Durchlauf:** rund 2800 Warnungen (`any`-Familie, Barrierefreiheit in `apps/web`), in `docs/anleitung-entwicklung.md` als bewusster Rueckstand festgehalten. | 2026-09-21 | 6a727e9,00d769b,6f0f05a | [260921-9ie-windows-35-biome-json-fuer-biome-2-5-0-r](./quick/260921-9ie-windows-35-biome-json-fuer-biome-2-5-0-r/) |
|
||||
| 260921-a1d | **Benutzerverwaltung: verbotene Aktionen melden sich jetzt (WINDOWS #36).** Drei Stellen in `apps/web/src/app/(portal)/admin/users/page.tsx` verschluckten Server-Antworten still (`if (res.ok)` ohne else, `catch {}` mit dem Kommentar `// silently fail`): Liste laden, Formular speichern, Loeschen. Sichtbare Wirkung vorher: Formular blieb offen, Loeschdialog stand still, beim gescheiterten Laden log die Seite mit "Keine Benutzer gefunden". Jetzt je ein Banner (`role="alert"`) im Listenkopf, im Formulardialog und im Loeschdialog; `readApiMessage(res)` liest ausschliesslich `body.message` und zeigt den Servertext in einem deutschen Rahmensatz, sonst eine uebersetzte Ersatzmeldung — auch wenn der `fetch` selbst wirft. Vier Schluessel `admin.users.errors.*` in `de.json` **und** `en.json` (Katalog-Paritaet 890/890 geprueft). Dazu `canManageRow`: einem ADMIN werden Bearbeiten/Loeschen in der SUPER_ADMIN-Zeile gar nicht erst angeboten (seit #29 im Alltag erreichbar), "Details" bleibt ueberall. **`apps/api` blieb unangetastet** — der Zielrollen-Riegel im Controller ist und bleibt die wirksame Grenze, der versteckte Knopf ist Ergonomie darueber, kein Ersatz; eigenes Gatter im Plan weist das nach. Gemessen: keine Namen/IDs/Stapelspuren in den 403-Rumpftexten (kein eigener ExceptionFilter in `apps/api/src`). **Nachweise (dreifach unabhaengig):** Web-Tests 66 Dateien/459 Tests gruen (vorher 65/447, neue `users-page.test.tsx` prueft echten DOM-Text via `getByText`/`within`, nicht nur State-Setter), type-check Exit 0, `pnpm lint` 5/5 ohne neue Fehlerrang-Meldung, `silently fail` im Code 3 → 0, `role="alert"` 0 → 3, Diff nur vier Dateien unter `apps/web`. Verifikation passed (7/7). | 2026-09-21 | 38d2586,51bff75,13b70df | [260921-a1d-windows-36-benutzerverwaltung-zeigt-bei-](./quick/260921-a1d-windows-36-benutzerverwaltung-zeigt-bei-/) |
|
||||
| 260921-bi2 | **Lint-Rueckstand abgebaut: 2923 → 465 Warnungen (WINDOWS #35 Folgearbeit).** Seit das Lint-Tor wirklich prueft, war der Rueckstand sichtbar. Aufgeteilt nach Risiko statt nach Datei: (1) Konfiguration — zwei begruendete `overrides`, (2) maschinelle Fixes + toter Code, (3) Barrierefreiheit von Hand. **Der wichtigste Befund ist ein Beinahe-Schaden:** Biomes `style/useImportType`-Korrektur ist als *safe* eingestuft, zerstoert in `apps/api` aber die NestJS-Abhaengigkeitsspritze — `__metadata("design:paramtypes", [PrismaService, …])` kollabiert zu `[Function, …]`, 61 von 65 Dateien betroffen, API startet nicht mehr. Dabei bleibt `tsc` gruen **und alle 1124 API-Tests bleiben gruen**, weil kein einziger Test `createTestingModule` aufruft — das waere durch jedes vorhandene Tor unbemerkt bis auf alpha durchgelaufen. Planer und Plan-Pruefer haben es unabhaengig voneinander reproduziert (Datei kompiliert, Metadatenzeile verglichen). Deshalb zweiter `overrides`-Eintrag auf `apps/api/**`. Zweite Falle, ebenfalls gemessen: `--only=<regel>` schaltet eine in der Konfiguration abgeschaltete Regel wieder AN — ein repo-weites `biome lint . --only=useImportType --write` haengt die Ausnahme aus (77 API-Dateien veraendert). Nur pfadgebundene Aufrufe. **Nachweis, dass sich nichts geaendert hat, ist NICHT die Testsuite**, sondern ein sha256 ueber alle 593 erzeugten `__metadata`-Zeilen: `6e1583f1…`, vor und nach dem Umbau identisch, dreifach geprueft. Barrierefreiheit: 155 Handkorrekturen in 53 Dateien (Symbole 71, Knopf-Typen 52, Beschriftungen 22, Rollen/Semantik 10) — je Fundstelle entschieden, ob ein Symbol dekorativ (`aria-hidden`) oder die einzige Beschriftung ist (`<title>`/`aria-label`); in der Seitenleiste erkannt, dass der Text beim Einklappen verschwindet, dort also ein echter Name noetig ist. Alle neuen Texte ueber next-intl in de **und** en (892/892 Schluessel). **Zahlen:** gesamt 2923 → 465, echter Quellcode 856 → 386, Testdateien 2067 → 79, Fehler-Rang durchgehend 0. **Bewusst NICHT angefasst, benannt statt stillschweigend:** 288 `noExplicitAny` im Quellcode (echte Typarbeit), 20 `useExhaustiveDependencies` (je ein moeglicher Effekt-Fehler), 30 a11y-Befunde mit Bedienentscheidungsbedarf, `noUselessSwitchCase` (Fallmarke dokumentiert Absicht), sowie fuenf tote Stellen, die Symptome echter Luecken sind — darunter: Passwortwechsel-Seite leitet nach erzwungenem Wechsel nicht weiter, Loeschknopf in `VehicleTable` ohne Besetztzustand, `force-password-change.interceptor` liest das HTTP-Verfahren und fragt es nie ab. **Klicktest am laufenden System** (echte Abbilder, Playwright): API meldet `healthy` und `Nest application successfully started` — Abhaengigkeitsspritze zur Laufzeit bewiesen; `Abbrechen` legt nichts an, `Speichern` legt an; als ADMIN bietet die SUPER_ADMIN-Zeile nur noch `Details`; abgewiesene Server-Antwort erscheint sichtbar als "Der Server hat die Aktion abgelehnt: …". Testbenutzer wieder geloescht. Verifikation passed. | 2026-09-21 | 8d1c8f3,636fe0d,+11 | [260921-bi2-lint-rueckstand-abbauen-mechanische-fixe](./quick/260921-bi2-lint-rueckstand-abbauen-mechanische-fixe/) |
|
||||
|
||||
## Deferred Items
|
||||
|
||||
@@ -477,8 +488,8 @@ sind. Kein Anlass, sie vorher erneut vorzulegen.
|
||||
|
||||
## Session Continuity
|
||||
|
||||
Last session: 2026-09-17T11:10:00Z
|
||||
Resumed: 2026-09-17 — Sitzung ueber /gsd-resume-work fortgesetzt (HANDOFF.json abgearbeitet und entfernt).
|
||||
Stopped at: Alle sieben Nebenbefunde der Windows-Bedienprobe + Akzentfarbe per Hex + Bildmarke in Akzentfarbe umgesetzt (Quick 260917-gsh/gyd/h2s), CI 246 gruen, auf der Windows-Test-VM 8233 mit Paket 1.1.0-beta.4c93555 bestaetigt (deutscher Installer mit Tessera-Grafik/-Symbol, Cookie-Erkennung im Client: keine Download-Links, kein Kontextmenue, Tray „Neuen Beta-Stand herunterladen“). Nichts angefangen. Alpha laeuft noch mit 280aab6-Abbildern — User pullt selbst; alpha-DB am 17.09. neu angelegt (Admin-Passwort dort unbekannt). Naechste Freigabe 1.2.0 auf Zuruf (Kap. 9).
|
||||
Last session: 2026-09-21T04:50:00Z
|
||||
Resumed: 2026-09-21 — Sitzung ueber /gsd-resume-work fortgesetzt. Stand geprueft: Arbeitsbaum sauber, main == origin/main auf 55aa287, CI-Lauf 387 fuer 55aa287 erfolgreich (Beta-Images gebaut). Push und CI aus dem letzten Stopp-Punkt sind damit erledigt.
|
||||
Stopped at: Warte auf Nutzerentscheidung, womit weitergearbeitet wird. Offen fuer den User: alpha pullen (web+api) und danach am Windows-VM-Client die echte Fehlermeldung schicken (Betreff `[Desktop/Windows]` + `Herkunft:`-Zeile pruefen); eigenen Arbeitsplatz-Client einmal per Browser-Installer erneuern; Freigabe 1.3.0 auf Zuruf. Technisch offen im Ledger: WINDOWS #35 (Biome laeuft nicht — biome.json:3 `organizeImports` ist in Biome 2.5.0 unbekannt, `biome check` bricht mit Konfigurationsfehler ab, reproduziert 2026-09-21) und WINDOWS #36 (403-Antworten bleiben in handleSubmit/handleDelete ohne sichtbare Reaktion).
|
||||
Resume file: None
|
||||
Last activity: 2026-09-17 - Quick 260917-gsh/gyd/h2s abgeschlossen und auf Windows-VM bestaetigt; Beta auf alpha wartet auf Pull
|
||||
Last activity: 2026-09-21 - Quick 260921-bi2: Lint-Rueckstand 2923 → 465 Warnungen abgebaut (Konfiguration, maschinelle Fixes, toter Code, 155 Handkorrekturen Barrierefreiheit); Beinahe-Schaden abgewendet — Biomes als sicher eingestufte useImportType-Korrektur haette die NestJS-Abhaengigkeitsspritze zerstoert, unbemerkt von tsc und 1124 gruenen Tests
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
---
|
||||
schema_version: 1
|
||||
open_count: 15
|
||||
open_count: 13
|
||||
waived_count: 1
|
||||
fixed_count: 23
|
||||
fixed_count: 25
|
||||
total_count: 39
|
||||
last_updated: 2026-09-16T09:00:26.845Z
|
||||
last_updated: 2026-09-21T05:40:29.821Z
|
||||
---
|
||||
|
||||
# Broken Windows Ledger
|
||||
@@ -49,8 +49,8 @@ last_updated: 2026-09-16T09:00:26.845Z
|
||||
| 32 | quick-260911-gwh | deviation | apps/web/src/components/settings/smtp-settings-form.tsx | | Bereich settings: getSmtpConfig liefert nach dem Scharfschalten (#18) null, der Controller antwortet 200 mit leerem Rumpf, fetchSmtp (settings-api.ts) laeuft mit res.json() auf den leeren Rumpf und wirft, smtp-settings-form.tsx verschluckt das in .catch(() => {}) -- leeres Formular 'nicht eingerichtet', waehrend die Zugangsdaten physisch da sind. Ein erneutes Speichern unter der ungebundenen Form scheitert am Eindeutigkeitsindex SmtpConfig_tenantId_key (PrismaClientUnknownRequestError, gemessen in Aufgabe 1 Pruefung 8) -- nach diesem Lauf ist saveSmtpConfig gebunden und trifft die eigene Zeile, dieser Rest bestand nur unter der ungebundenen Form vor dieser Aenderung. Dieselbe 200-leerer-Rumpf-Kette wie #28. Etappe-4-Vorabpruefung (s4)(e). Das Frontend wird von 260911-gwh NICHT geaendert. Siehe docs/mandantentrennung-etappe2-fehlerrichtung.md, Abschnitt 'Bereich settings' (s3)/(s4). | open | | 2026-09-11T11:57:50.484Z | |
|
||||
| 33 | quick-260911-mkj | unmet-truth | apps/api/src/tenders/tenders.seed.ts | | Modellaufrufe auf Empfaengern, die weder this.prisma noch eine const X = forTenant(-Zuweisung noch ein Transaktionsparameter sind, sind fuer ALLE vier Erkennungsformen der Bestandsaufnahme unsichtbar. Gemessen 260911-mkj: tenders/tenders.seed.ts (Funktionsparameter prisma: PrismaService, tenderRssFeedSource.findFirst/create, kein Eintrag in der Bestandsaufnahme) und tenders/backfill-tender-source.ts (eigenstaendiges Skript mit new PrismaClient(), tender.findMany/update, durch RELATION_SPEC_EXCEPTIONS laut gehalten). Beide beruehren nur den plattformglobalen Katalog bzw. die plattformweite RSS-Verwaltung (WINDOWS #24), heute ungefaehrlich; OFFEN ist der Mechanismus (ein kuenftiger Dienst mit Parameter-Empfaenger auf einer geschuetzten Tabelle bliebe unsichtbar). Zu schliessen vor Etappe 4 durch eine Zaehlung ALLER <Kennung>.<Modell>.<Operation>(-Anker gegen die bekannte Empfaengermenge, Ueberschuss laut. | open | | 2026-09-11T14:48:09.723Z | |
|
||||
| 34 | quick-260911-nke | deviation | apps/api/src/prisma/prisma-tenant.extension.ts | | Etappe 3b: ein Nutzer-CRUD-Aufrufer, der den Benutzer an forTenant() vergisst, sieht den ganzen Mandanten (IS-NULL-Form) — gleicher Stand wie vor 20260911120000, keine Verschlechterung, aber kein Netz. Die Bestandsaufnahme unterscheidet nur mandanten-gebunden/ungebunden, nicht benutzer-gebunden; ein Waechter, der jede Methode mit userId-Parameter auf das dritte Argument prueft, ist NICHT gebaut. Bis dahin sind die dreistelligen Spec-Zusicherungen je Dienst das einzige Netz. Vor dem Scharfschalten (Etappe 4, rls-preflight.mjs) zu entscheiden: Waechter bauen oder Rest benennen. | open | | 2026-09-11T15:46:08.295Z | |
|
||||
| 35 | quick-260914-ebg | deviation | biome.json | | Biome ist im Bestand nicht lauffaehig: biome.json traegt den in Biome 2.5.0 unbekannten Schluessel organizeImports (gehoert unter assist), Biome bricht bei jedem Aufruf mit Konfigurationsfehler ab; zusaetzlich fehlt javascript.parser.unsafeParameterDecoratorsEnabled, ohne den jeder NestJS-Parameter-Dekorator ein Parse-Fehler ist (17 allein in user.controller.ts). Der CI-Schritt Lint ruft pnpm lint = turbo lint, keine App hat ein lint-Skript - der Schritt ist ein Leerlauf, der gruen meldet. CLAUDE.md und docs/anleitung-entwicklung.md beschreiben Biome als aktives Werkzeug. Gemessen 260914-ebg; das dortige Gate lief mit einer Ersatzkonfiguration im Scratchpad, relativ zur Baseline (0 Fehler, Warnungen je Datei 22/25/20, alle noExplicitAny-Familie; biome format ebenfalls unsauber, Anfuehrungszeichen-Stil). Zu entscheiden: biome.json reparieren (organizeImports nach assist, Parser-Schalter, quoteStyle single) und ein lint-Skript je App anlegen, dann die Warnungen in einem eigenen Durchlauf abbauen oder als Regelabschaltung begruenden. | open | | 2026-09-14T08:38:04.079Z | |
|
||||
| 36 | quick-260914-ebg | deviation | apps/web/src/app/(portal)/admin/users/page.tsx | | handleSubmit und handleDelete pruefen nur res.ok ohne else-Zweig und fangen mit leerem catch - ein 403 der API fuehrt zu keiner sichtbaren Reaktion (Formular bleibt offen, Loeschdialog bleibt stehen, keine Meldung). Bestehendes Verhalten fuer alle 403-Wege (fremder Mandant, Selbstloeschung); seit 260914-ebg (WINDOWS #29) ist der Fall fuer einen ADMIN im Alltag erreichbar, weil die SUPER_ADMIN-Zeile in der eigenen Benutzerliste steht und Aendern/Loeschen darauf jetzt 403 liefert. Familie der still verschluckten Antworten (#28, #32). Frontend von 260914-ebg NICHT geaendert (ausserhalb der Erlaubnisliste). Zu schliessen: Fehlermeldung aus dem Antwortrumpf anzeigen und die Aktionsknoepfe fuer SUPER_ADMIN-Zeilen einem ADMIN gar nicht erst anbieten. | open | | 2026-09-14T08:38:12.619Z | |
|
||||
| 35 | quick-260914-ebg | deviation | biome.json | | Biome ist im Bestand nicht lauffaehig: biome.json traegt den in Biome 2.5.0 unbekannten Schluessel organizeImports (gehoert unter assist), Biome bricht bei jedem Aufruf mit Konfigurationsfehler ab; zusaetzlich fehlt javascript.parser.unsafeParameterDecoratorsEnabled, ohne den jeder NestJS-Parameter-Dekorator ein Parse-Fehler ist (17 allein in user.controller.ts). Der CI-Schritt Lint ruft pnpm lint = turbo lint, keine App hat ein lint-Skript - der Schritt ist ein Leerlauf, der gruen meldet. CLAUDE.md und docs/anleitung-entwicklung.md beschreiben Biome als aktives Werkzeug. Gemessen 260914-ebg; das dortige Gate lief mit einer Ersatzkonfiguration im Scratchpad, relativ zur Baseline (0 Fehler, Warnungen je Datei 22/25/20, alle noExplicitAny-Familie; biome format ebenfalls unsauber, Anfuehrungszeichen-Stil). Zu entscheiden: biome.json reparieren (organizeImports nach assist, Parser-Schalter, quoteStyle single) und ein lint-Skript je App anlegen, dann die Warnungen in einem eigenen Durchlauf abbauen oder als Regelabschaltung begruenden. | fixed | | 2026-09-14T08:38:04.079Z | 2026-09-21T05:06:47.012Z |
|
||||
| 36 | quick-260914-ebg | deviation | apps/web/src/app/(portal)/admin/users/page.tsx | | handleSubmit und handleDelete pruefen nur res.ok ohne else-Zweig und fangen mit leerem catch - ein 403 der API fuehrt zu keiner sichtbaren Reaktion (Formular bleibt offen, Loeschdialog bleibt stehen, keine Meldung). Bestehendes Verhalten fuer alle 403-Wege (fremder Mandant, Selbstloeschung); seit 260914-ebg (WINDOWS #29) ist der Fall fuer einen ADMIN im Alltag erreichbar, weil die SUPER_ADMIN-Zeile in der eigenen Benutzerliste steht und Aendern/Loeschen darauf jetzt 403 liefert. Familie der still verschluckten Antworten (#28, #32). Frontend von 260914-ebg NICHT geaendert (ausserhalb der Erlaubnisliste). Zu schliessen: Fehlermeldung aus dem Antwortrumpf anzeigen und die Aktionsknoepfe fuer SUPER_ADMIN-Zeilen einem ADMIN gar nicht erst anbieten. | fixed | | 2026-09-14T08:38:12.619Z | 2026-09-21T05:40:29.821Z |
|
||||
| 37 | quick-260914-eym | deviation | apps/api/src/dkv/dkv.service.ts | | Der Single-Flight-Riegel processing in DkvService.processInbox ist EIN prozessweites Boolean, nicht je Mandant. Seit 260914-eym laeuft je aktivem Mandanten ein eigener Cron-Auftrag (dkv-inbox-poll:<tenantId>); ueberschneiden sich zwei Ticks verschiedener Mandanten, bricht der zweite still ab (Warnzeile 'already processing') und der Mandant wartet bis zum naechsten Intervall - kein Datenverlust, Verzoegerung; mit EINEM Mandanten unveraendert. Der Tick blieb in 3c laut Auftrag unangetastet (T-EYM-09, accept mit Aufzeichnung). Zu schliessen: Riegel je Mandant (Set<tenantId>) mit Test 'zwei Mandanten gleichzeitig, beide werden bedient'. | open | | 2026-09-14T09:51:24.295Z | |
|
||||
| 38 | quick-260914-m97 | deviation | apps/api/src/bug-reports/dto/bug-report.dto.ts | 71 | Rule 1: @Expose() auf errors ergaenzt, damit die @Transform-Normalisierung auch bei ganz fehlendem Multipart-Feld greift (class-transformer transformiert nur vorhandene Schluessel) | fixed | | 2026-09-14T15:04:31.846Z | 2026-09-14T15:17:38.808Z |
|
||||
| 39 | quick-260916-dyv | deviation | apps/web/src/components/dashboard/dashboard-grid.test.tsx | | Test 7 pinnt Identitaets-Kopie per toMatchObject statt toEqual (cloneLayoutItem normalisiert moved/static) | fixed | | 2026-09-16T08:48:45.783Z | 2026-09-16T09:00:26.845Z |
|
||||
@@ -472,10 +472,10 @@ last_updated: 2026-09-16T09:00:26.845Z
|
||||
"file": "biome.json",
|
||||
"line": null,
|
||||
"description": "Biome ist im Bestand nicht lauffaehig: biome.json traegt den in Biome 2.5.0 unbekannten Schluessel organizeImports (gehoert unter assist), Biome bricht bei jedem Aufruf mit Konfigurationsfehler ab; zusaetzlich fehlt javascript.parser.unsafeParameterDecoratorsEnabled, ohne den jeder NestJS-Parameter-Dekorator ein Parse-Fehler ist (17 allein in user.controller.ts). Der CI-Schritt Lint ruft pnpm lint = turbo lint, keine App hat ein lint-Skript - der Schritt ist ein Leerlauf, der gruen meldet. CLAUDE.md und docs/anleitung-entwicklung.md beschreiben Biome als aktives Werkzeug. Gemessen 260914-ebg; das dortige Gate lief mit einer Ersatzkonfiguration im Scratchpad, relativ zur Baseline (0 Fehler, Warnungen je Datei 22/25/20, alle noExplicitAny-Familie; biome format ebenfalls unsauber, Anfuehrungszeichen-Stil). Zu entscheiden: biome.json reparieren (organizeImports nach assist, Parser-Schalter, quoteStyle single) und ein lint-Skript je App anlegen, dann die Warnungen in einem eigenen Durchlauf abbauen oder als Regelabschaltung begruenden.",
|
||||
"status": "open",
|
||||
"status": "fixed",
|
||||
"reason": "",
|
||||
"recorded_at": "2026-09-14T08:38:04.079Z",
|
||||
"resolved_at": null,
|
||||
"resolved_at": "2026-09-21T05:06:47.012Z",
|
||||
"milestone": "v1.2"
|
||||
},
|
||||
{
|
||||
@@ -485,10 +485,10 @@ last_updated: 2026-09-16T09:00:26.845Z
|
||||
"file": "apps/web/src/app/(portal)/admin/users/page.tsx",
|
||||
"line": null,
|
||||
"description": "handleSubmit und handleDelete pruefen nur res.ok ohne else-Zweig und fangen mit leerem catch - ein 403 der API fuehrt zu keiner sichtbaren Reaktion (Formular bleibt offen, Loeschdialog bleibt stehen, keine Meldung). Bestehendes Verhalten fuer alle 403-Wege (fremder Mandant, Selbstloeschung); seit 260914-ebg (WINDOWS #29) ist der Fall fuer einen ADMIN im Alltag erreichbar, weil die SUPER_ADMIN-Zeile in der eigenen Benutzerliste steht und Aendern/Loeschen darauf jetzt 403 liefert. Familie der still verschluckten Antworten (#28, #32). Frontend von 260914-ebg NICHT geaendert (ausserhalb der Erlaubnisliste). Zu schliessen: Fehlermeldung aus dem Antwortrumpf anzeigen und die Aktionsknoepfe fuer SUPER_ADMIN-Zeilen einem ADMIN gar nicht erst anbieten.",
|
||||
"status": "open",
|
||||
"status": "fixed",
|
||||
"reason": "",
|
||||
"recorded_at": "2026-09-14T08:38:12.619Z",
|
||||
"resolved_at": null,
|
||||
"resolved_at": "2026-09-21T05:40:29.821Z",
|
||||
"milestone": "v1.2"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
---
|
||||
context: phase
|
||||
phase: 18-desktop-client-fertigstellen
|
||||
task: 3
|
||||
total_tasks: 3
|
||||
status: complete
|
||||
last_updated: 2026-09-17T07:38:47.449Z
|
||||
---
|
||||
|
||||
## Critical Anti-Patterns
|
||||
|
||||
| Pattern | Description | Severity | Prevention Mechanism |
|
||||
|---------|-------------|----------|---------------------|
|
||||
| Fenster ohne Startseite | Tauri-Fenster `main` hatte keine `url`; im gebauten Paket erschien "asset not found: index.html" (Altlast Phase 6, nie in einem echten Paket sichtbar). Lokaler `cargo check`/AppImage-Bau fing es nicht. | advisory | `tauri.conf.json` `app.windows[].url = "setup.html"` (b6d9013); Startseite im Binary per `strings target/release/tessera-desktop \| grep setup.html` pruefen |
|
||||
| Rust-Bau frisst den Host | 8 parallele rustc-Prozesse des CI-Runners (auf demselben Host wie Gitea + Dev-Stack, 15 GB) -> Speichergrenze, Claude Code beendete eine Hintergrundaufgabe | advisory | `CARGO_BUILD_JOBS: "4"` im Job `desktop` (b6d9013); bei Rust-Vollbau lokal nicht parallel zur CI messen |
|
||||
| Gitea-API ohne Token | `https://git.vicolab.de/api/v1/...` antwortet 401 ohne Token | advisory | Token aus `git config --get remote.origin.pushurl` lesen (nie ausgeben), gegen `http://localhost:3002/api/v1/...` |
|
||||
|
||||
<current_state>
|
||||
Phase 18 (Desktop-Client) ist ABGESCHLOSSEN und gepusht (626f60e): Verifikation passed, Windows-Bedienprobe des Users bestanden (Pakete aus CI-Lauf 369, Commit 03fd85a). Nichts ist angefangen. Der User startet die VM nach einer RAM/CPU-Aenderung neu; als Naechstes soll der Bau-Benchmark wiederholt und mit der Referenz verglichen werden.
|
||||
</current_state>
|
||||
|
||||
<completed_work>
|
||||
|
||||
Completed Tasks (diese Sitzung, 2026-09-16/17):
|
||||
- Sechs Quick-Tasks Dashboard (260916-hiv/htc/iex/j4f/jvj/k2z): URL-Platzhalter Kalenderquellen, Kalender-Widget nach Vorbild personal-dashboard (Monatsraster + Naechste Termine + 3 Einstellungen), Notiz-Haekchen abhakbar, Favoriten-Titel, Link-Widget entfernt (Migration), Tooltip-Umbruch, Notiz-Farbmodus, Changelog-Stichpunkte, Plaketten in Kalenderfarbe, Mehrfach-Kreise, Markdown-Aufzaehlungspunkte — alle gepusht, CI 365 gruen
|
||||
- Phase 18, 6 Plaene: 18-01 Durchstich Linux (Manifest-Skript, API /desktop/latest + /desktop/download/:platform, Abbild), 18-02 CI-Job desktop + Cache-Uebergabe + Release-Anhaenge, 18-03 Web (Anmeldeseite-Link, Einstellungen -> Allgemein -> Desktop-App), 18-04 Client (Erststart-Seite per Rust-Kommandos, Versionspruefung, Tray Update/Autostart, Icons), 18-05 Windows-Cross-Bau (1 Korrekturrunde: clippy), 18-06 Handbuecher/Changelog/REQUIREMENTS
|
||||
- Code-Review (1 kritisch, 3 Warnungen) behoben + Commit-Stempel via TESSERA_COMMIT
|
||||
- Schnellkorrektur Startseite setup.html + CARGO_BUILD_JOBS=4 (b6d9013)
|
||||
- Benchmark-Referenz vor dem Umbau erfasst (memory/reference_benchmark_dev_host.md)
|
||||
</completed_work>
|
||||
|
||||
<remaining_work>
|
||||
|
||||
- Benchmark nach dem Neustart wiederholen (vier Befehle, Rechner idle) und vergleichen
|
||||
- Beim naechsten Freigabe-Tag (1.2.0): Release-Anhaenge am Gitea-Release + Update-Hinweis im Client beobachten (18-UAT.md #2/#3) — nur Beobachtung, kein Code offen
|
||||
- Freigabe 1.2.0 selbst nur auf Zuruf des Users (Kap. 9 Betriebshandbuch)
|
||||
</remaining_work>
|
||||
|
||||
<decisions_made>
|
||||
|
||||
- Installer in Tessera herunterladbar UND am Gitea-Release (User); Windows per Cross-Bau auf Linux; Pakete im API-Abbild (kein Gitea-Zugang vom Live-Server noetig); Server-Adresse beim Erststart; Update nur Hinweis + Link; keine Signierung (SmartScreen-Hinweis im Handbuch)
|
||||
- Beta-Builds: Version X.Y.Z des letzten Tags + Commit-Stempel im Dateinamen/Manifest; Client vergleicht auf beta Version + Commit
|
||||
- Kalender-Widget: keine Quellenauswahl pro Widget (User: nur Optik)
|
||||
- Mandantenfaehigkeit und Lizenzierung ruhen weiterhin (nicht ansprechen)
|
||||
</decisions_made>
|
||||
|
||||
<blockers>
|
||||
- keine
|
||||
</blockers>
|
||||
|
||||
## Required Reading (in order)
|
||||
1. `.planning/STATE.md` — Aktenstand, Quick-Task-Tabelle, Phase 18 Complete
|
||||
2. `memory/reference_benchmark_dev_host.md` (Claude-Memory) — Benchmark-Referenz und Befehle
|
||||
3. `.planning/phases/18-desktop-client-fertigstellen/18-UAT.md` — was beim naechsten Tag zu beobachten ist
|
||||
4. `docs/anleitung-betrieb.md` Kap. 9 (Freigabe) und Kap. 10 (Desktop-Pakete)
|
||||
|
||||
## Infrastructure State
|
||||
- Beta (alpha.tessera.ctl.de): Stand main 03fd85a-Pakete, vom User gepullt; Live: v1.1.0
|
||||
- Lokaler Docker-Stack (api/web/db/mailhog) laeuft, API-Abbild mit 1.1.0-Desktop-Manifest; Gitea + Runner auf demselben Host
|
||||
- Playwright MCP: Browser-Binary nachinstalliert (`npx @playwright/mcp@latest install-browser chrome-for-testing`)
|
||||
- VM wird vom User neu gestartet (RAM/CPU-Aenderung) — danach `nproc`/`free -h` neu erfassen
|
||||
|
||||
<context>
|
||||
Alles committet und gepusht, Arbeitsbaum leer. Naechste Sitzung beginnt mit dem Benchmark-Vergleich; danach gibt es keinen offenen Auftrag — auf den User warten (Freigabe 1.2.0 oder neue Wuensche).
|
||||
</context>
|
||||
|
||||
<next_action>
|
||||
Start with: `nproc && free -h`, dann die vier Benchmark-Befehle aus memory/reference_benchmark_dev_host.md (kein CI-Lauf parallel), Tabelle vorher/nachher an den User.
|
||||
</next_action>
|
||||
+273
@@ -0,0 +1,273 @@
|
||||
---
|
||||
phase: quick-260917-jdd
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
autonomous: true
|
||||
requirements: [QUICK-260917-JDD]
|
||||
|
||||
files_modified:
|
||||
- apps/api/package.json
|
||||
- pnpm-lock.yaml
|
||||
- apps/api/src/favorites/icon-discovery.service.ts
|
||||
- apps/api/src/favorites/icon-discovery.service.spec.ts
|
||||
- apps/api/src/favorites/favorites.service.ts
|
||||
- apps/api/src/favorites/favorites.service.spec.ts
|
||||
- apps/api/src/favorites/favorites.controller.ts
|
||||
- apps/api/src/favorites/dto/reorder-favorites.dto.ts
|
||||
- apps/api/src/prisma/prisma-tenant.extension.ts
|
||||
- apps/web/src/lib/favorites-api.ts
|
||||
- apps/web/src/components/dashboard/widgets/favorites-widget.tsx
|
||||
- apps/web/src/components/dashboard/widgets/favorites-widget.test.tsx
|
||||
- apps/web/src/messages/de.json
|
||||
- apps/web/src/messages/en.json
|
||||
- CHANGELOG.md
|
||||
- docs/anleitung-anwender.md
|
||||
- docs/mandantentrennung-zugriffsklassifikation.md
|
||||
|
||||
estimate:
|
||||
tokens: 58000
|
||||
raw_tokens: 58000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "icon-discovery.service.ts importiert `Agent`, `fetch as undiciFetch` und den Typ `Response` aus `undici`; ein Modul-Singleton `LENIENT_TLS_AGENT = new Agent({ connect: { rejectUnauthorized: false } })`; `fetchWithRedirectGuard` ruft AUSSCHLIESSLICH `undiciFetch(currentUrl.toString(), { dispatcher: LENIENT_TLS_AGENT, redirect: 'manual', signal, headers })` — damit laufen HTML-Ermittlung (`fetchHtml`) und Byte-Holen (`fetchIconBytes`, vom Proxy `GET :id/icon` genutzt) beide ueber diesen Weg. Kein Aufruf des globalen `fetch` mehr in dieser Datei, keine prozessweite Abschaltung der Zertifikatspruefung. `isPublicHttpUrl` vor JEDEM Hop, `MAX_REDIRECTS` 2, 4 s Timeout, 200 000 Zeichen HTML, 1 MB Icon, `image/`-Content-Type-Pruefung: alles unveraendert."
|
||||
- "`apps/api/package.json` dependencies enthaelt `\"undici\": \"7.28.0\"` (exakt — genau die Version, die pnpm-lock.yaml bereits ueber cheerio@1.2.0 und jsdom aufloest; keine neue Paketversion, kein neuer Download). `pnpm install --frozen-lockfile --offline` ist gruen; `apps/api/node_modules/undici/package.json` traegt Version 7.28.0."
|
||||
- "icon-discovery.service.spec.ts mockt `undici` per `vi.mock` (Agent als aufzeichnende Klasse mit `options`, `fetch` delegiert zur Laufzeit an `globalThis.fetch`), sodass ALLE bestehenden `vi.stubGlobal('fetch', …)`-Tests (16) unveraendert gruen bleiben. Drei neue Tests: (a) `discoverFavoriteIconUrl` uebergibt `dispatcher` = Agent-Instanz mit `options` gleich `{ connect: { rejectUnauthorized: false } }` und `redirect: 'manual'`; (b) `fetchIconBytes` ebenso; (c) beide Aufrufe teilen DIESELBE Agent-Instanz (Singleton)."
|
||||
- "Neuer Endpunkt `PUT /favorites/order`: `@Put('order')` steht im Controller VOR `@Get(':id/icon')`, `@Patch(':id')` und `@Delete(':id')` (NestJS-Route-Order). Body `ReorderFavoritesDto { widgetId: uuid; ids: uuid[] }` mit `@IsUUID()` fuer widgetId und `@IsArray() @ArrayMinSize(1) @ArrayMaxSize(500) @ArrayUnique() @IsUUID('all', { each: true })` fuer ids. Antwort: die Favoriten dieses Widgets in neuer Reihenfolge. `GET :id/icon` sendet zusaetzlich `X-Content-Type-Options: nosniff` und `Content-Security-Policy: default-src 'none'; sandbox`."
|
||||
- "`FavoritesService.reorder(tenantId, userId, dto)` laeuft als EINE Transaktion ueber `withTenantTransaction(this.prisma, tenantId, async (tx) => …)`: `tx.favoriteLink.findMany({ where: { userId, widgetId }, select: { id: true } })` → die Menge muss EXAKT mit `ids` uebereinstimmen (gleiche Anzahl, jede id vorhanden), sonst `BadRequestException` mit EINER Meldung fuer alle Faelle (fremde id, unbekannte id, Teilmenge, fremde/unbekannte widgetId — kein Existenzorakel); dann je id `tx.favoriteLink.updateMany({ where: { id, userId, widgetId }, data: { position: index } })` mit Pruefung `count === 1` (sonst Exception → Rollback); Rueckgabe `tx.favoriteLink.findMany({ where: { userId, widgetId }, orderBy: [{ position: 'asc' }, { title: 'asc' }] })`. Doppelte ids scheitern VOR der Transaktion. Kein `forTenant()`-Aufruf in dieser Methode; die Array-Form von `$transaction` auf einem gebundenen Klienten wird NICHT verwendet."
|
||||
- "favorites.service.spec.ts: `vi.mock('../prisma/prisma-tenant.extension')` um `withTenantTransaction` erweitert (Muster groups.service.spec.ts Z. 30-35 / 296-299: `prisma.__withTenantTransaction(tenantId, fn)` reicht den gebundenen Klienten als `tx` durch und protokolliert); der Fake bekommt `updateMany` auf `favoriteLink` (filtert nach tenantId, id, userId, widgetId; wendet `data` an; liefert `{ count }`). Neue Tests: Altbestand position 0/0/0 → `reorder` mit `['f3','f1','f2']` setzt 0/1/2 und liefert die Liste in dieser Reihenfolge, `withTenantTransaction` mit `(prisma, 't1', fn)` aufgerufen; fremde id (user-a2) → BadRequestException, KEINE Position geaendert; unbekannte id → BadRequestException; Teilmenge (2 von 3) → BadRequestException; doppelte ids → BadRequestException OHNE `withTenantTransaction`-Aufruf; fremder Mandant (`reorder('t2', …)` auf t1-Zeilen) → BadRequestException; Wachhund: `forTenant` 0-mal, `withTenantTransaction` genau 1-mal je Aufruf."
|
||||
- "`pnpm --filter @tessera/api exec vitest run` (bisher 68 Dateien / 1091 Tests, gemessen 2026-09-17, laeuft ohne Datenbank in ~12 s) und `pnpm --filter @tessera/api type-check` sind gruen; `rls-access-inventory.spec.ts` bleibt gruen (favorites.service.ts::favoriteLink bleibt `gebunden`, weil `tx.favoriteLink` ueber `withTenantTransaction(` als gebunden erkannt wird); `prisma-tenant.extension.spec.ts` bleibt gruen (nur Kommentar geaendert)."
|
||||
- "favorites-api.ts exportiert `reorderFavorites(widgetId: string, ids: string[]): Promise<FavoriteLink[]>` → `PUT ${API_URL}/favorites/order`, JSON-Body `{ widgetId, ids }`, `credentials: 'include'`, wirft bei `!res.ok`."
|
||||
- "Widget: neue Unterkomponente `FavoriteIcon` in favorites-widget.tsx mit Stufen `proxy` → `direct` → `none`. Buchstaben-Platzhalter (`letter-fallback-{id}`) liegt IMMER darunter. Stufe `proxy` nur wenn `fav.iconUrl` gesetzt: `<img data-testid=\"icon-proxy-{id}\" src=\"/api-proxy/favorites/{id}/icon\">`, `onError` → Stufe `direct`. Stufe `direct` rendert `<img data-testid=\"icon-direct-{id}\" src=\"{origin}/favicon.ico\" referrerPolicy=\"no-referrer\">` NUR wenn `getDirectFaviconSrc(fav.url)` (`new URL`, nur `http:`/`https:`, sonst `null`) einen Wert liefert, `onError` → Stufe `none`. Start-Stufe: `proxy` bei iconUrl, sonst `direct`. `key={iconUrl|url}` am Aufruf setzt die Stufe bei Aenderung zurueck. Keine `style.display`-Manipulation mehr, kein `dangerouslySetInnerHTML` (T-08-07), kein Drittanbieter-Favicon-Dienst."
|
||||
- "Widget: im Bearbeitungsmodus je Eintrag (nur wenn NICHT gerade inline bearbeitet) zwei Knoepfe mit `aria-label` und `title` `t('favorites.moveUpButton')` / `t('favorites.moveDownButton')` (inline-SVG-Chevrons wie die bestehenden Bearbeiten/Loeschen-Knoepfe, im selben `widgetNoDrag`-Container, VOR Bearbeiten/Loeschen); erster Eintrag: „nach oben“ `disabled`, letzter: „nach unten“ `disabled`. Klick → `handleMove(id, 'up'|'down')`: tauscht in der `sortedFavorites`-Reihenfolge, setzt `position = index` fuer ALLE Eintraege (optimistisch per `setFavorites`), ruft `reorderFavorites(instanceId, ids)`; Erfolg → `setFavorites(antwort)`; Fehler → `setError(t('favorites.error'))` und Neuladen ueber `fetchFavorites(instanceId)`. Sichtbar in Listen- UND Kachelansicht (beide `FavoriteTile`-Aufrufe)."
|
||||
- "de.json/en.json unter `widgets.favorites`: `moveUpButton` = „Nach oben“ / „Move up“, `moveDownButton` = „Nach unten“ / „Move down“ (echte Umlaute, falls welche noetig waeren — Umlaut-Waechter `src/messages/umlaut-guard.spec.ts` bleibt gruen)."
|
||||
- "favorites-widget.test.tsx: `vi.mock('@/lib/favorites-api')` um `reorderFavorites: vi.fn()` erweitert; neue Tests: (a) iconUrl null (Notion) → `icon-direct-fav-id-2` mit `src` `https://notion.so/favicon.ico` und Attribut `referrerpolicy` `no-referrer`, KEIN `icon-proxy-fav-id-2`; (b) iconUrl gesetzt (GitHub) → `icon-proxy-fav-id-1` vorhanden; `fireEvent.error` darauf → Proxy-Bild weg, `icon-direct-fav-id-1` mit `https://github.com/favicon.ico`; `fireEvent.error` darauf → kein img mehr fuer fav-id-1, `letter-fallback-fav-id-1` zeigt weiterhin `G`; (c) Favorit mit `url: 'ftp://files.example'` und iconUrl null → kein direct-img, nur Buchstabe; (d) Bearbeitungsmodus: „nach oben“ bei GitHub `disabled`, „nach unten“ bei Notion `disabled`; Klick „nach unten“ bei GitHub → `reorderFavorites` mit `('fav-1', ['fav-id-2', 'fav-id-1'])`, Titel-Reihenfolge in `favorites-list` Notion, GitHub; (e) `reorderFavorites` rejected → `fetchFavorites` erneut aufgerufen (2 Aufrufe gesamt), `favorites.error` sichtbar, Reihenfolge wieder GitHub, Notion. Die bestehenden 11 Tests bleiben unveraendert gruen."
|
||||
- "`pnpm --filter @tessera/web exec vitest run` und `pnpm --filter @tessera/web type-check` sind gruen."
|
||||
- "CHANGELOG.md `## Unveröffentlicht`: ein Stichpunkt unter `### Neu` (die Datei nutzt `Neu`, NICHT „Hinzugefügt“) zur Sortierung und einer unter `### Behoben` zum Symbol; Praefix `Favoriten-Widget:` wie Z. 14; nur ZUSAETZLICHE Zeilen; Unterueberschriften nur anlegen, wenn sie unter `## Unveröffentlicht` noch fehlen (zwei parallele Quick-Tasks ergaenzen ebenfalls Zeilen — Reihenfolge der Unterabschnitte wie im Bestand: Neu, Geändert, Entfernt, Behoben). docs/anleitung-anwender.md: Tabellenzeile „Favoriten“ (Z. 80) um ein bis zwei Saetze zur Sortierung erweitert — die Zeile bleibt EINE Zeile. docs/mandantentrennung-zugriffsklassifikation.md Z. 673 (Begruendung favoriteLink) um einen Nachtrag zu `reorder` ergaenzt. prisma-tenant.extension.ts: Kopfkommentar (Absatz BENUTZERDIMENSION, Z. 145-148) um den Nachtrag, dass `favorites.service.ts` (`reorder`, 260917-jdd) der erste Nutzer-CRUD-Aufrufer von `withTenantTransaction()` ist und deshalb `userId` UND `widgetId` in jeder Bedingung selbst traegt — KOMMENTAR-ONLY, Funktionscode unveraendert."
|
||||
- "Drei Commits: `feat(api): …` (Task 1), `feat(web): …` (Task 2), `docs: …` (Task 3). Kein `git push`, kein Docker-Build, kein `prisma migrate`, `apps/api/prisma/schema.prisma` unveraendert, keine `.planning/`-Dateien in den Commits."
|
||||
artifacts:
|
||||
- "apps/api/package.json + pnpm-lock.yaml — `undici` 7.28.0 als direkte Abhaengigkeit von @tessera/api (per `pnpm add`, nicht von Hand)"
|
||||
- "apps/api/src/favorites/icon-discovery.service.ts — `LENIENT_TLS_AGENT`, `undiciFetch` in `fetchWithRedirectGuard`"
|
||||
- "apps/api/src/favorites/icon-discovery.service.spec.ts — `vi.mock('undici')`, drei Dispatcher-Tests"
|
||||
- "apps/api/src/favorites/dto/reorder-favorites.dto.ts — neu"
|
||||
- "apps/api/src/favorites/favorites.controller.ts — `@Put('order')` vor den `:id`-Routen, zwei Header am Icon-Proxy"
|
||||
- "apps/api/src/favorites/favorites.service.ts — `reorder()` ueber `withTenantTransaction`"
|
||||
- "apps/api/src/favorites/favorites.service.spec.ts — Mock + Fake erweitert, sieben Reorder-Tests"
|
||||
- "apps/api/src/prisma/prisma-tenant.extension.ts — ein Kommentar-Nachtrag"
|
||||
- "apps/web/src/lib/favorites-api.ts — `reorderFavorites`"
|
||||
- "apps/web/src/components/dashboard/widgets/favorites-widget.tsx — `FavoriteIcon`, `getDirectFaviconSrc`, `handleMove`, Pfeilknoepfe"
|
||||
- "apps/web/src/components/dashboard/widgets/favorites-widget.test.tsx — Mock erweitert, fuenf neue Tests"
|
||||
- "apps/web/src/messages/de.json, en.json — zwei Schluessel"
|
||||
- "CHANGELOG.md, docs/anleitung-anwender.md, docs/mandantentrennung-zugriffsklassifikation.md — Stichpunkte/Saetze"
|
||||
key_links:
|
||||
- "BEFUND AM CODE (weicht vom Ist-Zustand des Orchestrators ab): `discoverFavoriteIconUrl` liefert NIE `null`, sondern bei jedem Fehler den Origin-Rueckfall `https://host/favicon.ico` (Z. 318-332). Fuer einen internen Host steht also `https://intern/favicon.ico` in `iconUrl`, das Widget rendert das Proxy-Bild, der Proxy antwortet 502 (SSRF-Schutz lehnt ab), `onError` blendet aus. Ein Browser-Ersatzweg, der NUR an `iconUrl === null` haengt, wuerde bei internen Hosts NIE greifen — deshalb haengt die Stufe `direct` an `onError` des Proxy-Bildes UND an `iconUrl === null`."
|
||||
- "Der `dispatcher` wirkt NUR ueber undicis EIGENES `fetch`; Nodes globales `fetch` ignoriert einen Agent aus dem npm-Paket (andere Klasse, Node 24 buendelt intern undici 7.25.0). Vom Planer gemessen am 2026-09-17: `undiciFetch('https://self-signed.badssl.com/', { dispatcher: new Agent({ connect: { rejectUnauthorized: false } }) })` → Status 200; `globalThis.fetch` derselben URL → `DEPTH_ZERO_SELF_SIGNED_CERT`. Deshalb der Modulimport — und deshalb muss die Spec `undici` mocken, sonst ginge jeder Test ins Netz."
|
||||
- "Der Spec-Mock von `undici` delegiert `fetch` zur LAUFZEIT an `globalThis.fetch` (Pfeilfunktion im Factory, nicht beim Laden aufgeloest) — so bleiben die 16 bestehenden `vi.stubGlobal('fetch', …)`-Tests wortgleich gruen, und die neuen Tests lesen den `dispatcher` aus `fetchSpy.mock.calls[n][1]`."
|
||||
- "`withTenantTransaction()` setzt `app.current_tenant` und `app.system_context`, aber KEINE Benutzerdimension (`app.current_user`) in der Sitzung — die Regel auf `FavoriteLink` faellt in ihren `IS NULL`-Zweig und zeigt den ganzen Mandanten. Darum traegt JEDE Bedingung im Callback `userId` UND `widgetId` (zweites Netz, wie der Kopfkommentar von favorites.service.ts es fuer alle Methoden vorsieht). Die Array-Form `tenantPrisma.$transaction([…])` ist gemessen NICHT atomar (extension Z. 69-75) und die interaktive Form auf dem gebundenen Klienten faellt unter Last aus (Z. 76-85) — beide nicht verwenden."
|
||||
- "NestJS-Route-Order (Projektgedaechtnis): `@Put('order')` VOR `@Get(':id/icon')`/`@Patch(':id')`/`@Delete(':id')`. PUT kollidiert methodisch mit keiner `:id`-Route, die Reihenfolge ist trotzdem Konvention (tenders.controller.ts Z. 636-648)."
|
||||
- "Grenzen des Browser-Ersatzwegs (kein Plan-Mangel, fuer den Nachweis durch den Orchestrator): ein `http://`-Favorit auf einem `https://`-Tessera ist Mischinhalt — Chrome/Firefox stufen das Bild auf https hoch und blocken es sonst; ein `https://intern`-Favorit mit Firmen-CA im Browser des Nutzers klappt; ein selbstsigniertes Zertifikat ohne Vertrauen im Browser klappt NICHT (der Browser laesst sich nicht wie der Server ueberreden). Oeffentliche Hosts mit kaputtem Zertifikat holt jetzt der SERVER (Stufe `proxy`)."
|
||||
- "favorites-widget.test.tsx mockt `@/lib/favorites-api` mit einem expliziten Factory — `reorderFavorites` MUSS dort ergaenzt werden, sonst importiert das Widget `undefined` und der Klick wirft `TypeError`."
|
||||
- "`ArrayMaxSize(500)` ist die Obergrenze je Aufruf (DoS-Deckel fuer die `updateMany`-Schleife in der Transaktion); ein Widget hat in der Praxis eine Handvoll Links."
|
||||
---
|
||||
|
||||
<objective>
|
||||
Zwei Wuensche des Users am Favoriten-Widget:
|
||||
|
||||
**Teil A — Symbol trotz Zertifikatsfehler / interner Adresse (zweistufiger Ersatzweg, SSRF-Schutz unangetastet).**
|
||||
1. Server: `icon-discovery.service.ts` holt HTML und Icon-Bytes ueber undicis eigenes `fetch` mit einem Modul-Singleton `Agent({ connect: { rejectUnauthorized: false } })` als `dispatcher` — GENAU in `fetchWithRedirectGuard`, dem einzigen Ausgangspunkt beider Pfade. Alle Schutzmassnahmen bleiben exakt erhalten. `undici` 7.28.0 (die bereits im Lockfile aufgeloeste Version, kein neuer Download) wird direkte Abhaengigkeit von `@tessera/api`.
|
||||
2. Browser: Wenn der Server nichts liefern kann (interner Host, den der SSRF-Schutz absichtlich ablehnt → Proxy 502) ODER `iconUrl` null ist, rendert das Widget ein direktes `<img src="{origin}/favicon.ico" referrerPolicy="no-referrer">` aus dem Browser des Nutzers; scheitert auch das, bleibt der Buchstaben-Platzhalter. Befund am Code: die Ermittlung liefert NIE null, sondern den Origin-Rueckfall — deshalb haengt die Browser-Stufe an `onError` des Proxy-Bildes, nicht nur an `iconUrl === null` (siehe key_links).
|
||||
3. Nebenpfad bleibt: Icon-URL beim Bearbeiten leeren → `update` ermittelt neu (unveraendert).
|
||||
|
||||
**Teil B — manuelle Sortierung mit Pfeilen.** Im Bearbeitungsmodus je Eintrag „nach oben“/„nach unten“ (erster/letzter deaktiviert), optimistische Neuberechnung, `PUT /favorites/order` mit `{ widgetId, ids }`; der Service setzt in EINER Transaktion `position = index` fuer genau die Eintraege dieses Nutzers/Widgets, fremde/unbekannte/fehlende ids → 400 ohne Teilschreibung. Altbestand (alle position 0) normalisiert sich beim ersten Klick. Kein Schema-Eingriff: `FavoriteLink.position Int @default(0)` existiert.
|
||||
|
||||
Tracer-Rolle: Die einzige lokal Ende-zu-Ende pruefbare Kette (Klick → optimistische Reihenfolge → `reorderFavorites` → bei Fehler Neuladen; Proxy-Bild → `onError` → Direktbild → `onError` → Buchstabe) liegt komplett in Task 2 — Task 2 traegt deshalb die Tracer-Rolle; Task 1 liefert Endpunkt und Dispatcher mit Unit-Tests. Der Beweis ueber die Netzgrenze (echter Host mit Zertifikatsfehler, echter interner Host im Firmennetz) erfolgt durch den Orchestrator im Browser.
|
||||
|
||||
Purpose: Favoriten sollen ihr Symbol auch bei Zertifikatsfehlern und internen Adressen zeigen und sich in der vom Nutzer gewuenschten Reihenfolge anordnen lassen.
|
||||
Output: undici-Dispatcher + Spec; DTO, Controller-Route, `reorder()` + Spec; `reorderFavorites` im Web-Client; Widget mit `FavoriteIcon` und Pfeilen + Tests; zwei i18n-Schluessel; CHANGELOG, Anwenderhandbuch, zwei Kommentar-/Doku-Nachtraege; drei Commits.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/gsd-core/workflows/execute-plan.md
|
||||
@~/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@/home/vicolab/projects/tessera-ctl/CLAUDE.md
|
||||
@/home/vicolab/projects/tessera-ctl/apps/api/src/favorites/icon-discovery.service.ts
|
||||
@/home/vicolab/projects/tessera-ctl/apps/api/src/favorites/favorites.service.ts
|
||||
@/home/vicolab/projects/tessera-ctl/apps/api/src/favorites/favorites.controller.ts
|
||||
@/home/vicolab/projects/tessera-ctl/apps/web/src/components/dashboard/widgets/favorites-widget.tsx
|
||||
@/home/vicolab/projects/tessera-ctl/apps/web/src/lib/favorites-api.ts
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: API — undici-Dispatcher fuer beide Icon-Pfade, `PUT /favorites/order` mit transaktionalem `reorder()`, Specs</name>
|
||||
<files>apps/api/package.json, pnpm-lock.yaml, apps/api/src/favorites/icon-discovery.service.ts, apps/api/src/favorites/icon-discovery.service.spec.ts, apps/api/src/favorites/dto/reorder-favorites.dto.ts, apps/api/src/favorites/favorites.controller.ts, apps/api/src/favorites/favorites.service.ts, apps/api/src/favorites/favorites.service.spec.ts</files>
|
||||
<read_first>
|
||||
- apps/api/src/favorites/icon-discovery.service.ts Z. 1-22 (Kopfkommentar mit Schutzmassnahmen), Z. 234-287 (`fetchWithRedirectGuard` — EINZIGE Fetch-Stelle beider Pfade), Z. 289-307 (`fetchHtml`), Z. 309-379 (Klasse; `fetchIconBytes` Z. 343-378 mit Browser-User-Agent)
|
||||
- apps/api/src/favorites/icon-discovery.service.spec.ts Z. 1-24 (`mockResponse`), Z. 73-116 (Discovery-Tests mit `vi.stubGlobal('fetch', …)`), Z. 118-176 (fetchIconBytes-Tests, darunter Z. 152-162: SSRF-Block ohne fetch-Aufruf), Z. 178-205
|
||||
- apps/api/src/favorites/favorites.service.ts Z. 1-56 (Kopfkommentar: Mandantenquelle, Benutzerdimension, zweites Netz), Z. 58-70 (`list`), Z. 117-157 (`update`)
|
||||
- apps/api/src/favorites/favorites.service.spec.ts Z. 18-20 (`vi.mock` nur `forTenant`), Z. 64-151 (`makeFakePrisma`: bound client mit findMany/findUnique/create/update/delete — KEIN updateMany), Z. 168-183, Z. 478-507 (Wachhund je Methode)
|
||||
- apps/api/src/groups/groups.service.spec.ts Z. 30-35 (`vi.mock` mit `withTenantTransaction` → `prisma.__withTenantTransaction`), Z. 296-299 (`__withTenantTransaction` reicht `__makeBoundClient(tenantId)` als `tx` durch)
|
||||
- apps/api/src/groups/groups.service.ts Z. 189-202 (Aufrufform `withTenantTransaction(this.prisma, tenantId, async (tx: any) => { … })`)
|
||||
- apps/api/src/prisma/prisma-tenant.extension.ts Z. 33-49 (Grenzfaelle: Array-Form auf gebundenem Klienten NICHT atomar), Z. 104-109 (Entscheidung fuer `withTenantTransaction`), Z. 139-148 (Benutzerdimension — `withTenantTransaction` setzt keine), Z. 253-260 (Implementierung)
|
||||
- apps/api/src/favorites/favorites.controller.ts Z. 35-40 (Routenliste im Kommentar), Z. 70-118 (create, getIcon, update)
|
||||
- apps/api/src/tenders/tenders.controller.ts Z. 636-648 (Praezedenz-Kommentar zur Route-Order bei `@Put`)
|
||||
- apps/api/src/favorites/dto/create-favorite.dto.ts (Decorator-Stil); apps/api/src/bug-reports/dto/bug-report.dto.ts Z. 1-10 und Z. 70-80 (`ArrayMaxSize`-Stil)
|
||||
- apps/api/src/main.ts Z. 17-21 (`ValidationPipe({ whitelist: true, transform: true })`)
|
||||
</read_first>
|
||||
<behavior>
|
||||
icon-discovery.service.spec.ts — ganz oben (vor den Imports, `vi.mock` wird gehoistet) ein Factory-Mock fuer `undici`: `class Agent { constructor(public readonly options: unknown) {} }` und `fetch: (...args: unknown[]) => (globalThis.fetch as any)(...args)` (Pfeilfunktion, damit `vi.stubGlobal('fetch', …)` je Test greift). `import { Agent } from 'undici'` in der Spec liefert die Mock-Klasse. Neue `describe('IconDiscoveryService — Dispatcher (260917-jdd)')`:
|
||||
- Test 1: `fetchSpy` (stubGlobal) liefert eine HTML-Antwort (wie Z. 84-97); `discoverFavoriteIconUrl('http://8.8.8.8')`; `const init = fetchSpy.mock.calls[0][1]`; `expect(init.dispatcher).toBeInstanceOf(Agent)`; `expect(init.dispatcher.options).toEqual({ connect: { rejectUnauthorized: false } })`; `expect(init.redirect).toBe('manual')`.
|
||||
- Test 2: `fetchSpy` liefert `mockResponse({ contentType: 'image/png' })`; `fetchIconBytes('http://8.8.8.8/favicon.ico')`; dieselben drei Erwartungen auf `fetchSpy.mock.calls[0][1]`.
|
||||
- Test 3: erst Discovery, dann fetchIconBytes im selben Test (zwei stubGlobal-Aufrufe oder ein Spy mit `mockResolvedValueOnce` x2); `expect(calls[0][1].dispatcher).toBe(calls[1][1].dispatcher)` (Modul-Singleton).
|
||||
- Alle 16 bestehenden Tests bleiben WORTGLEICH bestehen und gruen (insbesondere Z. 152-162: bei `127.0.0.1` wird `fetch` NICHT aufgerufen).
|
||||
favorites.service.spec.ts:
|
||||
- Fake: `updateMany: async ({ where, data })` auf dem gebundenen `favoriteLink`: Zeilen mit `tenantId === tenantId` und, falls in `where` vorhanden, `id`/`userId`/`widgetId` gleich; auf jede Treffer-Zeile `{ ...row, ...data, updatedAt: new Date() }`; Protokoll `{ tenantId, model: 'favoriteLink', method: 'updateMany' }`; Rueckgabe `{ count }`. Plus `__withTenantTransaction(tenantId, fn)` wie groups.service.spec.ts Z. 296-299 und `withTenantTransaction` im `vi.mock` wie Z. 32-34.
|
||||
- `describe('reorder (260917-jdd)')` mit drei Zeilen f1/f2/f3 (user-a1, t1, widget-a1, Titel 'A'/'B'/'C', position 0/0/0 — Altbestand) und einer Zeile f9 (user-a2, t1, widget-a1):
|
||||
- `reorder('t1', 'user-a1', { widgetId: 'widget-a1', ids: ['f3', 'f1', 'f2'] })` → Rueckgabe-ids `['f3', 'f1', 'f2']`; `prisma.__favorites.get('f3').position === 0`, f1 === 1, f2 === 2; f9 unveraendert 0; `expect(withTenantTransaction).toHaveBeenCalledWith(prisma, 't1', expect.any(Function))`; `expectBoundCall(prisma, 't1', 'favoriteLink', 'updateMany')`.
|
||||
- ids `['f3', 'f1', 'f9']` (fremder Nutzer) → `rejects.toThrow(BadRequestException)`; danach ALLE Positionen unveraendert (0).
|
||||
- ids `['f3', 'f1', 'f-fehlt']` → BadRequestException.
|
||||
- ids `['f1', 'f2']` (Teilmenge) → BadRequestException.
|
||||
- ids `['f1', 'f1', 'f2']` (Duplikat) → BadRequestException UND `vi.mocked(withTenantTransaction)` NICHT aufgerufen.
|
||||
- `reorder('t2', 'user-a1', { widgetId: 'widget-a1', ids: ['f1', 'f2', 'f3'] })` (fremder Mandant) → BadRequestException, Positionen unveraendert.
|
||||
- Wachhund: nach `mockClear` genau 0 `forTenant`-Aufrufe und genau 1 `withTenantTransaction`-Aufruf fuer den Happy Path.
|
||||
</behavior>
|
||||
<action>
|
||||
Tests aus `<behavior>` zuerst schreiben, rot sehen (Import/Methode fehlen), dann implementieren:
|
||||
|
||||
1. **Abhaengigkeit.** `pnpm --filter @tessera/api add undici@7.28.0 --offline` (7.28.0 liegt bereits im Store und im Lockfile ueber cheerio@1.2.0/jsdom; ohne `--offline` wiederholen, falls der Offline-Modus die Metadaten nicht findet). Ergebnis pruefen: `apps/api/package.json` traegt exakt `"undici": "7.28.0"` (Pinning-Stil wie `"cron": "4.4.0"`), `git diff --stat pnpm-lock.yaml` zeigt nur den `importers`-Eintrag von apps/api (keine neue Paketversion, keine Aenderung an anderen Importern). NICHT auf 8.x heben (neues Major, neuer Download, nicht noetig — Node 24 buendelt selbst 7.25.0). `apps/api/package.json` NICHT von Hand editieren.
|
||||
|
||||
2. **icon-discovery.service.ts.** `import { Agent, fetch as undiciFetch, type Response as UndiciResponse } from 'undici';` ergaenzen. Modul-Konstante `LENIENT_TLS_AGENT = new Agent({ connect: { rejectUnauthorized: false } })` neben den anderen Konstanten (Z. 17-22) mit Doc-Kommentar: Ziel ist ein Bildchen, kein Geheimnis — selbstsignierte, abgelaufene oder falsch benannte Zertifikate sollen das Symbol nicht verhindern; gilt NUR fuer die Aufrufe dieser Datei (Dispatcher pro Aufruf, keine prozessweite Abschaltung der Zertifikatspruefung, insbesondere NICHT ueber die Node-Umgebungsvariable, die mit `NODE_TLS_` beginnt); der Dispatcher wirkt nur mit undicis eigenem `fetch`, Nodes globales `fetch` ignoriert ihn (gemessen 2026-09-17 gegen self-signed.badssl.com: undici 200, global fetch `DEPTH_ZERO_SELF_SIGNED_CERT`); DNS-Pruefung, Redirect-Limit, Timeout, Groessendeckel bleiben davon unberuehrt (T-JDD-01). In `fetchWithRedirectGuard` (Z. 258-265) den Aufruf des globalen Fetch durch `undiciFetch` ersetzen — erstes Argument unveraendert `currentUrl.toString()`, zweites Argument das bisherige Options-Objekt plus `dispatcher: LENIENT_TLS_AGENT` (also `dispatcher`, `redirect: 'manual'`, `signal: controller.signal`, `headers` wie bisher) — sonst NICHTS an der Funktion aendern (Schleife, `isPublicHttpUrl` je Hop, `MAX_REDIRECTS`, Timeout, `!response.ok`). Den Rueckgabetyp der Funktion und `FetchHtmlResult`/`fetchIconBytes` auf `UndiciResponse` statt des globalen `Response` typisieren, wo `tsc` es verlangt (die Datei nutzt nur `.status`, `.ok`, `.headers.get`, `.text()`, `.arrayBuffer()`). Kopfkommentar Z. 5-15 um eine Zeile ergaenzen (Zertifikatsfehler werden toleriert, Begruendung siehe Konstante). `discoverFavoriteIconUrl` und `fetchIconBytes` selbst bleiben unveraendert — beide laufen ueber `fetchWithRedirectGuard`.
|
||||
|
||||
3. **dto/reorder-favorites.dto.ts** (neu): `ReorderFavoritesDto` mit `@IsUUID() widgetId!: string;` und `@IsArray() @ArrayMinSize(1) @ArrayMaxSize(500) @ArrayUnique() @IsUUID('all', { each: true }) ids!: string[];`. Doc-Kommentar: vollstaendige ID-Liste in Anzeigereihenfolge; der Service verlangt exakte Uebereinstimmung mit den Favoriten des Widgets; 500 als Deckel (T-JDD-05).
|
||||
|
||||
4. **favorites.controller.ts.** `Put` in den `@nestjs/common`-Import, `ReorderFavoritesDto` importieren. Direkt NACH `create` (Z. 70-78) und VOR `@Get(':id/icon')`: `@Put('order') async reorder(@Body() dto: ReorderFavoritesDto, @Req() req: Request)` → `extractContext` → `this.favoritesService.reorder(tenantId, userId, dto)`. Kommentar ueber der Methode: statische Route steht bewusst VOR den `:id`-Routen (NestJS-Route-Order, Praezedenz tenders.controller.ts Z. 636-648). Routenliste im Klassenkommentar (Z. 35-39) um `PUT /favorites/order` und `GET /favorites/:id/icon` ergaenzen. In `getIcon` (Z. 102-104) zwei Header ergaenzen: `X-Content-Type-Options: nosniff` und `Content-Security-Policy: default-src 'none'; sandbox` — Kommentar: die Bytes kommen jetzt auch von Hosts ohne gueltiges Zertifikat; als `<img>`-Unterressource ignoriert der Browser diese Header, aber ein direkt im Tab geoeffnetes SVG laeuft damit ohne Skript und ohne Tessera-Origin (T-JDD-02).
|
||||
|
||||
5. **favorites.service.ts.** `withTenantTransaction` zusaetzlich aus `'../prisma/prisma-tenant.extension'` importieren, `ReorderFavoritesDto` importieren. Neue Methode `reorder(tenantId: string, userId: string, dto: ReorderFavoritesDto)`:
|
||||
- Vorab (ohne Datenbank): `new Set(dto.ids).size !== dto.ids.length` → `BadRequestException`.
|
||||
- `return withTenantTransaction(this.prisma, tenantId, async (tx: any) => { … })`: `existing = await tx.favoriteLink.findMany({ where: { userId, widgetId: dto.widgetId }, select: { id: true } })`; `existingIds = new Set(existing.map(r => r.id))`; wenn `existing.length !== dto.ids.length` oder eine id nicht in `existingIds` → `throw new BadRequestException('ids must match the favorites of this widget exactly')` (EINE Meldung fuer alle Faelle). Dann `for (const [index, id] of dto.ids.entries())`: `const { count } = await tx.favoriteLink.updateMany({ where: { id, userId, widgetId: dto.widgetId }, data: { position: index } })`; `count !== 1` → dieselbe BadRequestException (Rollback). Rueckgabe `tx.favoriteLink.findMany({ where: { userId, widgetId: dto.widgetId }, orderBy: [{ position: 'asc' }, { title: 'asc' }] })`.
|
||||
- Doc-Kommentar (Stil des Bestands, ae/oe/ue): Warum `withTenantTransaction` (einzige gemessene atomare Form fuer Mehrschritt, extension Z. 33-49/104-109) und NICHT die Array-Form auf dem gebundenen Klienten; dass diese Form KEINE Benutzerdimension in der Sitzung setzt und deshalb `userId` UND `widgetId` in JEDER Bedingung stehen (zweites Netz); dass `updateMany` statt `update` gewaehlt ist, weil `update({ where: { id } })` nur nach id filtern koennte; Existenzorakel-Vermeidung (T-JDD-06); Altbestand mit position 0 normalisiert sich beim ersten Aufruf zu 0..n-1.
|
||||
- Kopfkommentar der Klasse (Z. 36-39, Access control) um eine Zeile fuer `reorder()` ergaenzen.
|
||||
|
||||
6. **Specs** laut `<behavior>`. In favorites.service.spec.ts den Kopfkommentar (Z. 6-17) um zwei Saetze zu `withTenantTransaction`/`updateMany` im Fake ergaenzen. `BadRequestException` ist dort bereits importiert.
|
||||
|
||||
Nicht anfassen: `apps/api/prisma/schema.prisma`, `favorites.module.ts`, `list`/`create`/`update`/`remove`/`getIconBytes`, die Funktionsbodies in `prisma-tenant.extension.ts`.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && grep -q '"undici": "7.28.0"' apps/api/package.json && test "$(node -p "require('./apps/api/node_modules/undici/package.json').version")" = "7.28.0" && pnpm install --frozen-lockfile --offline >/dev/null && git diff --quiet apps/api/prisma/schema.prisma && test "$(grep -v '^\s*\*' apps/api/src/favorites/icon-discovery.service.ts | grep -v '^\s*//' | grep -c 'rejectUnauthorized: false')" = "1" && grep -q 'dispatcher: LENIENT_TLS_AGENT' apps/api/src/favorites/icon-discovery.service.ts && ! grep -q 'NODE_TLS_REJECT_UNAUTHORIZED' apps/api/src/favorites/icon-discovery.service.ts && ! grep -qE '(^|[^a-zA-Z])fetch\(' <(grep -v '^\s*//' apps/api/src/favorites/icon-discovery.service.ts | grep -v '^\s*\*') && test "$(grep -n "@Put('order')" apps/api/src/favorites/favorites.controller.ts | cut -d: -f1)" -lt "$(grep -n "@Get(':id/icon')" apps/api/src/favorites/favorites.controller.ts | cut -d: -f1)" && grep -q 'withTenantTransaction(this.prisma, tenantId' apps/api/src/favorites/favorites.service.ts && grep -q 'ArrayUnique' apps/api/src/favorites/dto/reorder-favorites.dto.ts && pnpm --filter @tessera/api exec vitest run src/favorites && pnpm --filter @tessera/api exec vitest run && pnpm --filter @tessera/api type-check</automated>
|
||||
</verify>
|
||||
<done>undici 7.28.0 ist direkte Abhaengigkeit, beide Icon-Pfade laufen ueber undicis `fetch` mit dem toleranten Agent (Spec belegt Dispatcher, redirect manual, Singleton; SSRF-Tests unveraendert gruen); `PUT /favorites/order` steht vor den `:id`-Routen und setzt in EINER Transaktion `position = index` nur fuer exakt passende ids (sieben Reorder-Tests gruen); volle API-Suite (bisher 1091 Tests + neue) und type-check gruen; Commit `feat(api): Favoriten — Symbol trotz Zertifikatsfehler holen, Reihenfolge per PUT /favorites/order speichern` (nur die acht Dateien dieses Tasks; keine .planning-Dateien).</done>
|
||||
</task>
|
||||
|
||||
<task type="tracer" tdd="true">
|
||||
<name>Task 2: Web — `reorderFavorites`, `FavoriteIcon` mit Browser-Ersatzweg, Sortierpfeile, i18n, Tests</name>
|
||||
<files>apps/web/src/lib/favorites-api.ts, apps/web/src/components/dashboard/widgets/favorites-widget.tsx, apps/web/src/components/dashboard/widgets/favorites-widget.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json</files>
|
||||
<read_first>
|
||||
- apps/web/src/components/dashboard/widgets/favorites-widget.tsx Z. 3-13 (Imports), Z. 83-91 (`sortedFavorites`), Z. 93-117 (Ladeeffekt), Z. 119-122 (`getFallbackLetter`), Z. 264-323 (Listen-/Kachel-Rendering mit zwei `FavoriteTile`-Aufrufen), Z. 356-374 (`FavoriteTileProps`), Z. 395-471 (Tile: Icon-Block Z. 407-428, Aktionsknoepfe Z. 433-471 mit inline-SVG)
|
||||
- apps/web/src/components/dashboard/widgets/favorites-widget.test.tsx Z. 1-81 (Mocks mit explizitem Factory, `BASE_FAVORITES`, `beforeEach`), Z. 166-215 (Muster fuer `act`/`fireEvent`/`getAllByRole`), Z. 276-292 (Buchstaben-Test)
|
||||
- apps/web/src/lib/favorites-api.ts (81 Zeilen, Muster `updateFavorite` fuer PATCH mit JSON-Body)
|
||||
- apps/web/src/messages/de.json Z. 295-312 und en.json Z. 295-312 (`widgets.favorites`)
|
||||
- apps/web/src/messages/umlaut-guard.spec.ts Z. 1-30 (de.json nur mit echten Umlauten)
|
||||
</read_first>
|
||||
<behavior>
|
||||
favorites-widget.test.tsx (Mock-Factory um `reorderFavorites: vi.fn()` erweitert; `mockReorder = reorderFavorites as ReturnType<typeof vi.fn>`; in `beforeEach` `mockReorder.mockResolvedValue([])` NICHT setzen — je Test explizit):
|
||||
- Test A „Ersatzbild bei iconUrl null“: Standarddaten, Ansicht; nach `waitFor` Notion sichtbar: `screen.getByTestId('icon-direct-fav-id-2')` hat `src` `https://notion.so/favicon.ico` und Attribut `referrerpolicy` = `no-referrer`; `screen.queryByTestId('icon-proxy-fav-id-2')` ist null; `letter-fallback-fav-id-2` zeigt `N`.
|
||||
- Test B „Kette Proxy → direkt → Buchstabe“: `icon-proxy-fav-id-1` vorhanden mit `src` `/api-proxy/favorites/fav-id-1/icon`, kein `icon-direct-fav-id-1`; `act(() => fireEvent.error(proxyImg))` → `queryByTestId('icon-proxy-fav-id-1')` null, `getByTestId('icon-direct-fav-id-1')` mit `src` `https://github.com/favicon.ico`; `act(() => fireEvent.error(directImg))` → beide null, `letter-fallback-fav-id-1` zeigt `G`.
|
||||
- Test C „kein Direktbild bei Nicht-http-URL“: `mockFetch.mockResolvedValue([{ id: 'fav-id-3', widgetId: 'fav-1', title: 'Ablage', url: 'ftp://files.example', iconUrl: null, position: 0 }])` → nach Laden kein `icon-direct-fav-id-3`, kein `icon-proxy-fav-id-3`, `letter-fallback-fav-id-3` zeigt `A`.
|
||||
- Test D „Pfeile: Zustand und Klick“: `isEditMode`, Standarddaten (GitHub 0, Notion 1); `mockReorder.mockResolvedValue([{ ...BASE_FAVORITES[1], position: 0 }, { ...BASE_FAVORITES[0], position: 1 }])`; `up = getAllByRole('button', { name: 'favorites.moveUpButton' })`, `down = getAllByRole('button', { name: 'favorites.moveDownButton' })`: `up[0]` disabled, `down[0]` nicht, `up[1]` nicht, `down[1]` disabled; `act(() => fireEvent.click(down[0]))`; `waitFor`: `mockReorder` mit `('fav-1', ['fav-id-2', 'fav-id-1'])`; Titel-Reihenfolge innerhalb `getByTestId('favorites-list')` (`within(...).getAllByRole('link').map(a => a.textContent)`) ist `['Notion', 'GitHub']`.
|
||||
- Test E „Fehler → Neuladen“: wie D, aber `mockReorder.mockRejectedValue(new Error('boom'))`; nach Klick `waitFor`: `mockFetch` 2-mal aufgerufen (Mount + Neuladen), `screen.getByText('favorites.error')` sichtbar, Reihenfolge wieder `['GitHub', 'Notion']`.
|
||||
- Die bestehenden 11 Tests bleiben unveraendert gruen (Buchstaben-Test Z. 276-292 gilt weiterhin, weil der Platzhalter immer rendert).
|
||||
</behavior>
|
||||
<action>
|
||||
Tests aus `<behavior>` zuerst schreiben, rot sehen, dann implementieren:
|
||||
|
||||
1. **favorites-api.ts** — `export async function reorderFavorites(widgetId: string, ids: string[]): Promise<FavoriteLink[]>`: Aufruf per `fetch` an `${API_URL}/favorites/order` mit `{ method: 'PUT', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ widgetId, ids }) }` (Muster `updateFavorite`); `!res.ok` → `throw new Error('Failed to reorder favorites')`; `return res.json()`. Doc-Kommentar: vollstaendige ID-Liste in Anzeigereihenfolge; der Server antwortet mit der Liste in neuer Reihenfolge. Kopfkommentar Z. 1-5 um den Endpunkt ergaenzen.
|
||||
|
||||
2. **favorites-widget.tsx — Icon.** Modulfunktion `getDirectFaviconSrc(url: string): string | null` (`try { const u = new URL(url); if (u.protocol !== 'http:' && u.protocol !== 'https:') return null; return `${u.origin}/favicon.ico`; } catch { return null; }`). Neue Unterkomponente `FavoriteIcon({ fav, getFallbackLetter })`: `proxySrc = fav.iconUrl ? `/api-proxy/favorites/${encodeURIComponent(fav.id)}/icon` : null`; `directSrc = getDirectFaviconSrc(fav.url)`; `const [stage, setStage] = useState<'proxy' | 'direct' | 'none'>(proxySrc ? 'proxy' : 'direct')`. Rendert den bestehenden Container (Z. 408-428) mit dem Buchstaben-`span` (unveraendert, `data-testid` bleibt) und darueber: bei `stage === 'proxy'` das bisherige `<img>` (Attribute wie bisher, zusaetzlich `data-testid={`icon-proxy-${fav.id}`}`, `onError={() => setStage('direct')}`); bei `stage === 'direct' && directSrc` ein `<img data-testid={`icon-direct-${fav.id}`} src={directSrc} alt="" width={20} height={20} loading="lazy" referrerPolicy="no-referrer" className="absolute inset-0 w-5 h-5 rounded" onError={() => setStage('none')} />`; bei `none` oder ohne `directSrc` nichts. Im Tile den Icon-Block durch `<FavoriteIcon key={`${fav.iconUrl ?? ''}|${fav.url}`} fav={fav} getFallbackLetter={getFallbackLetter} />` ersetzen (der `key` setzt die Stufe zurueck, wenn URL oder Icon-URL sich aendern — kein Effekt noetig). Doc-Kommentar an `FavoriteIcon`: Stufe 1 Proxy ueber den Server (holt seit 260917-jdd auch bei Zertifikatsfehlern), Stufe 2 Direktbild aus dem Browser des Nutzers (erreicht interne Hosts, die der SSRF-Schutz des Servers absichtlich ablehnt; `referrerPolicy` no-referrer; Origin nur aus http/https), Stufe 3 Buchstabe; bewusst kein Drittanbieter-Favicon-Dienst (wuerde Hostnamen nach aussen geben und interne Hosts ohnehin nicht kennen); Grenzen (Mischinhalt http-Favorit auf https-Tessera, nicht vertrautes Zertifikat im Browser) in einem Satz. Die bisherige Ausblendung per Style-Manipulation im `onError` (Z. 423-426) entfaellt — der Zustand `stage` ersetzt sie. Kopfkommentar der Datei (Z. 18-31) um eine Zeile zum Ersatzweg und eine zur Sortierung ergaenzen.
|
||||
|
||||
3. **favorites-widget.tsx — Sortierung.** `reorderFavorites` in den Import (Z. 6-12). Handler `async function handleMove(id: string, direction: 'up' | 'down')`: `order = sortedFavorites.map(f => f.id)`; `index = order.indexOf(id)`; `target = direction === 'up' ? index - 1 : index + 1`; bei `index < 0 || target < 0 || target >= order.length` return; tauschen; `byId = new Map(favorites.map(f => [f.id, f]))`; `reindexed = order.map((fid, i) => ({ ...byId.get(fid)!, position: i }))`; `setFavorites(reindexed)`; `setError(null)`; `try { setFavorites(await reorderFavorites(instanceId, order)); } catch { setError(t('favorites.error')); try { setFavorites(await fetchFavorites(instanceId)); } catch { /* Fehlermeldung steht bereits */ } }`. `FavoriteTileProps` um `canMoveUp: boolean`, `canMoveDown: boolean`, `onMove: (id: string, direction: 'up' | 'down') => void` erweitern; in BEIDEN `sortedFavorites.map`-Aufrufen (Kachel Z. 275-294 und Liste Z. 301-320) `(fav, index)` und `canMoveUp={index > 0} canMoveDown={index < sortedFavorites.length - 1} onMove={(fid, dir) => void handleMove(fid, dir)}` uebergeben. Im Tile im Aktionscontainer (Z. 435, `widgetNoDrag`) VOR dem Bearbeiten-Knopf zwei Knoepfe im Stil der bestehenden (`type="button"`, `aria-label` und `title` aus `t('favorites.moveUpButton')` bzw. `t('favorites.moveDownButton')`, `className="p-0.5 text-muted-foreground hover:text-foreground disabled:opacity-30 disabled:hover:text-muted-foreground"`, `disabled={!canMoveUp}` bzw. `!canMoveDown`, `onClick={() => onMove(fav.id, 'up')}` bzw. `'down'`) mit inline-SVG 14x14, `fill="currentColor"`, `aria-hidden="true"`: nach oben `<path d="M12 8.6 5.4 15.2l1.4 1.4L12 11.4l5.2 5.2 1.4-1.4z" />`, nach unten `<path d="m12 15.4 6.6-6.6-1.4-1.4L12 12.6 6.8 7.4 5.4 8.8z" />`. Kein Drag & Drop (kollidiert mit dem Ziehen der Kachel in react-grid-layout) — als Kommentar an den Knoepfen. Widget-Config/Dashboard-Layout bleiben unberuehrt; `sortedFavorites` (position asc, title asc) bleibt.
|
||||
|
||||
4. **i18n.** de.json `widgets.favorites`: `"moveUpButton": "Nach oben"`, `"moveDownButton": "Nach unten"` nach `deleteButton` (Z. 306); en.json an derselben Stelle `"Move up"` / `"Move down"`. Keine weiteren Schluessel.
|
||||
|
||||
5. **Tests** laut `<behavior>`; `within` aus `@testing-library/react` importieren. Kopfkommentar der Testdatei nicht noetig; neue Tests in einem `describe('Ersatzbild und Sortierung (quick-260917-jdd)')`.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && grep -q 'referrerPolicy="no-referrer"' apps/web/src/components/dashboard/widgets/favorites-widget.tsx && grep -q 'function getDirectFaviconSrc' apps/web/src/components/dashboard/widgets/favorites-widget.tsx && ! grep -q "style.display = 'none'" apps/web/src/components/dashboard/widgets/favorites-widget.tsx && grep -q 'favorites.moveUpButton' apps/web/src/components/dashboard/widgets/favorites-widget.tsx && grep -q '"moveUpButton": "Nach oben"' apps/web/src/messages/de.json && grep -q '"moveDownButton": "Move down"' apps/web/src/messages/en.json && grep -q "method: 'PUT'" apps/web/src/lib/favorites-api.ts && grep -q '/favorites/order' apps/web/src/lib/favorites-api.ts && pnpm --filter @tessera/web exec vitest run src/components/dashboard/widgets/favorites-widget.test.tsx src/messages && pnpm --filter @tessera/web exec vitest run && pnpm --filter @tessera/web type-check</automated>
|
||||
</verify>
|
||||
<done>Widget zeigt bei fehlendem Server-Symbol das Direktbild aus dem Browser und danach den Buchstaben (Kette per Test belegt); im Bearbeitungsmodus sortieren Pfeile optimistisch und persistieren ueber `PUT /favorites/order`, bei Fehler Neuladen mit Meldung; 11 + 5 Widget-Tests, Umlaut-Waechter, volle Web-Suite und type-check gruen; Commit `feat(web): Favoriten-Widget — Symbol-Ersatzweg aus dem Browser, Sortierpfeile im Bearbeitungsmodus`.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: CHANGELOG, Anwenderhandbuch, zwei Nachtraege (Zugriffsklassifikation, Kopfkommentar der Extension)</name>
|
||||
<files>CHANGELOG.md, docs/anleitung-anwender.md, docs/mandantentrennung-zugriffsklassifikation.md, apps/api/src/prisma/prisma-tenant.extension.ts</files>
|
||||
<read_first>
|
||||
- CHANGELOG.md Z. 1-40 — FRISCH lesen: `## Unveröffentlicht` (Z. 5) ist beim Planen LEER; parallele Quick-Tasks (Bildmarke, CI) koennen inzwischen Unterueberschriften und Zeilen angelegt haben. Unterabschnitte heissen `### Neu`, `### Geändert`, `### Entfernt`, `### Behoben` (Z. 9-27) — NICHT „Hinzugefügt“. Anfuehrungszeichen „…“ (Z. 27).
|
||||
- docs/anleitung-anwender.md Z. 59-67 (Bearbeitungsmodus des Dashboards: Stift-Schalter „Dashboard bearbeiten“), Z. 70-83 (Widget-Tabelle; Zeile 80 „Favoriten“ — Tabellenzeilen sind EINE Zeile; Anfuehrungszeichen dort „…" mit geradem Schlusszeichen wie Z. 62)
|
||||
- docs/mandantentrennung-zugriffsklassifikation.md Z. 673 (Zeile `| apps/api/src/favorites/favorites.service.ts | favoriteLink | muss-mandantengebunden | gebunden | … |` — Begruendung ist freier Text, `Stand` bleibt `gebunden`)
|
||||
- apps/api/src/prisma/prisma-tenant.extension.ts Z. 139-148 (Absatz „Wer den Benutzer setzt“ mit dem Satz Z. 145-148, dass `withTenantTransaction()` KEINEN dritten Parameter bekommt, weil kein Nutzer-CRUD-Aufrufer sie nutzt)
|
||||
</read_first>
|
||||
<action>
|
||||
1. **CHANGELOG.md**, `## Unveröffentlicht`: Falls `### Neu` bzw. `### Behoben` dort fehlen, anlegen (Reihenfolge Neu, Geändert, Entfernt, Behoben — nur die benoetigten). Je EINE neue Zeile am Ende der jeweiligen Liste, bestehende Zeilen (auch neue aus parallelen Tasks) unangetastet:
|
||||
- unter `### Neu`: `- Favoriten-Widget: Reihenfolge der Links im Bearbeitungsmodus mit den Pfeilen „Nach oben“/„Nach unten“ festlegen`
|
||||
- unter `### Behoben`: `- Favoriten-Widget: kein Symbol bei Seiten mit Zertifikatsfehler oder internen Adressen – das Symbol wird jetzt trotz Zertifikatsfehler geholt, bei internen Adressen versucht es der Browser direkt`
|
||||
Stil wie Bestand: kurz, typografische Anfuehrungszeichen, Gedankenstrich „–“, kein Punkt am Ende. Mit `Edit` (gezielt), nie die Datei neu schreiben.
|
||||
2. **docs/anleitung-anwender.md**, Tabellenzeile „Favoriten“ (Z. 80), zweite Spalte am Ende ergaenzen (Zeile bleibt EINE Zeile, Sie-Form, Anfuehrungszeichen wie Z. 62): `Im Bearbeitungsmodus des Dashboards bringen Sie die Links mit den Pfeilen „Nach oben"/„Nach unten" in die gewünschte Reihenfolge. Das Symbol einer Seite holt Tessera automatisch; bei internen Adressen versucht es zusätzlich Ihr Browser direkt`. Keine weiteren Aenderungen am Handbuch.
|
||||
3. **docs/mandantentrennung-zugriffsklassifikation.md** Z. 673, Begruendungsspalte vor dem abschliessenden `|` ergaenzen: ` Nachtrag (260917-jdd): `reorder()` laeuft als Mehrschritt ueber `withTenantTransaction()` (einzige gemessene atomare Form, siehe prisma-tenant.extension.ts) — diese Form setzt KEINE Benutzerdimension in der Sitzung, deshalb traegt jede Bedingung innerhalb der Transaktion `userId` UND `widgetId`; der Stand bleibt `gebunden` (Erkennungsform 2 des Detektors).` Die Zeile bleibt EINE Zeile; Spalten `Klasse`/`Stand` unveraendert.
|
||||
4. **apps/api/src/prisma/prisma-tenant.extension.ts**, Kopfkommentar Z. 145-148: den Satz `\`withTenantTransaction()\` bekommt KEINEN dritten Parameter: kein Nutzer-CRUD-Aufrufer nutzt diese Funktion (nur \`groups\`, ein Verwaltungsweg) — ein unbenutzter Parameter waere Spekulation ohne heutigen Aufrufer.` um einen Nachtrag im selben Absatz erweitern: ` Nachtrag (260917-jdd): \`favorites.service.ts\` (\`reorder\`) ist seither der erste Nutzer-CRUD-Aufrufer — er kommt OHNE Benutzerdimension in der Sitzung aus und traegt \`userId\` UND \`widgetId\` in jeder Bedingung innerhalb der Transaktion selbst (zweites Netz). Ein dritter Parameter kommt erst, wenn ein Aufrufer die Benutzerdimension INNERHALB der Transaktion braucht.` NUR Kommentartext (`*`-Zeilen, Zeilenumbruch im Stil des Blocks); die Funktionen `forTenant`, `forSystem`, `withTenantTransaction` bleiben byteweise unveraendert (Gate: `prisma-tenant.extension.spec.ts`).
|
||||
5. Kein Docker-Build, kein Push. `git status` vor dem Commit: nur die vier Dateien dieses Tasks (plus ggf. `.planning/`, das NICHT mit committet wird).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && grep -q '^- Favoriten-Widget: Reihenfolge der Links im Bearbeitungsmodus' CHANGELOG.md && grep -q '^- Favoriten-Widget: kein Symbol bei Seiten mit Zertifikatsfehler' CHANGELOG.md && ! grep -q '^### Hinzugefügt' CHANGELOG.md && grep -q 'Nach oben' docs/anleitung-anwender.md && test "$(grep -c '^| Favoriten |' docs/anleitung-anwender.md)" = "1" && grep -q 'Nachtrag (260917-jdd)' docs/mandantentrennung-zugriffsklassifikation.md && grep -q 'Nachtrag (260917-jdd)' apps/api/src/prisma/prisma-tenant.extension.ts && pnpm --filter @tessera/api exec vitest run src/prisma/prisma-tenant.extension.spec.ts src/prisma/rls-access-inventory.spec.ts && pnpm --filter @tessera/web exec vitest run src/lib/changelog.test.ts</automated>
|
||||
</verify>
|
||||
<done>CHANGELOG traegt zwei neue `Favoriten-Widget:`-Stichpunkte unter `### Neu` und `### Behoben`; das Handbuch nennt die Pfeile und den Browser-Ersatzweg in der Favoriten-Zeile; Zugriffsklassifikation und Extension-Kopfkommentar fuehren `reorder` als ersten Nutzer-CRUD-Aufrufer von `withTenantTransaction()` (Specs gruen); Commit `docs: Favoriten-Sortierung und Symbol-Ersatzweg im CHANGELOG und Anwenderhandbuch; Nachtraege zur Mandantenbindung`. Der Nachweis im Browser (echter Host mit Zertifikatsfehler, echter interner Host, Sortierung ueber Reload hinweg) folgt durch den Orchestrator — im SUMMARY als offen fuehren.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| API → fremde Web-Server (Icon-Ermittlung, Icon-Proxy) | Ausgehende Anfragen an vom Nutzer eingetragene Adressen; seit diesem Plan OHNE Zertifikatspruefung |
|
||||
| Browser des Nutzers → Origin des Favoriten | Direktes `<img>` auf `{origin}/favicon.ico` aus dem Browser (auch Firmennetz) |
|
||||
| Browser → API (`PUT /favorites/order`) | Nutzergesteuerte ID-Liste, JWT-geschuetzt, mandanten- und nutzergebunden |
|
||||
| API → Browser (`GET /favorites/:id/icon`) | Fremde Bild-Bytes werden unter Tessera-Origin ausgeliefert |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-JDD-01 | Tampering | `LENIENT_TLS_AGENT` / `fetchWithRedirectGuard` (icon-discovery.service.ts) | medium | accept | Ein Angreifer auf dem Netzpfad kann bei abgeschalteter Zertifikatspruefung hoechstens ANDERE Bytes unterschieben. Die Bytes werden ausschliesslich als Bild weitergereicht: `image/`-Content-Type-Pruefung, 1 MB-Deckel, HTML-Pfad nur 200 000 Zeichen und nur Link-/Meta-Tags per Regex (kein Skript, kein DOM); das Widget rendert `<img>` ohne `dangerouslySetInnerHTML` (T-08-07). Es fliessen KEINE Geheimnisse ueber diese Verbindungen (keine Cookies, keine Tokens, nur Accept/User-Agent). Der Dispatcher gilt nur fuer diese Datei, nicht prozessweit. SSRF-Schutz T-08-05 unveraendert: `isPublicHttpUrl` je Hop, `MAX_REDIRECTS` 2, 4 s Timeout (Spec Z. 152-162 unveraendert gruen). |
|
||||
| T-JDD-02 | Elevation of Privilege | `GET /favorites/:id/icon` liefert fremde Bytes unter Tessera-Origin | low | mitigate | Vorbestehend (nicht durch diesen Plan eingefuehrt: auch mit gueltigem Zertifikat kann der Zielserver ein SVG mit Skript liefern). Guenstige Haertung im Zuge dieses Plans: `X-Content-Type-Options: nosniff` und `Content-Security-Policy: default-src 'none'; sandbox` am Proxy — als `<img>`-Unterressource wirkungslos, bei direktem Oeffnen im Tab laeuft ein SVG damit ohne Skript und ohne Tessera-Origin. Aufruf weiterhin nur per FavoriteLink-id des Aufrufers (T-QFIP-01), nie per Client-URL. |
|
||||
| T-JDD-03 | Tampering | `FavoritesService.reorder` / `PUT /favorites/order` | medium | mitigate | `withTenantTransaction`: eine Transaktion, Rollback bei jeder Abweichung. Menge der ids muss EXAKT den Favoriten von `userId`+`widgetId` entsprechen; `updateMany` traegt `id`+`userId`+`widgetId` und prueft `count === 1`. Fremde/unbekannte/fehlende ids → 400 ohne Schreibung (Spec: Positionen unveraendert). `ValidationPipe({ whitelist: true })` + DTO (`IsUUID`, `ArrayUnique`) filtern fremde Felder und Duplikate vor dem Service. |
|
||||
| T-JDD-04 | Information Disclosure | Direktes `<img>` aus dem Browser auf `{origin}/favicon.ico` | low | accept | Ziel ist der vom Nutzer selbst eingetragene Host (kein Dritter); `referrerPolicy="no-referrer"` gibt die Tessera-Adresse nicht preis; Origin nur aus `http:`/`https:` per `new URL` (kein `javascript:`/`data:`); kein Drittanbieter-Favicon-Dienst (wuerde Hostnamen nach aussen geben). Kein CSP `img-src` in apps/web vorhanden (Bestand). |
|
||||
| T-JDD-05 | Denial of Service | `reorder`-Schleife in der Transaktion; Browser-Ersatzweg | low | mitigate | `ArrayMaxSize(500)` je Aufruf, `ArrayMinSize(1)`; ein Widget haelt praktisch wenige Links. Der Ersatzweg loest je Favorit hoechstens EIN zusaetzliches Bild-GET aus (nur nach `onError` des Proxy-Bildes oder bei `iconUrl` null), kein Retry. |
|
||||
| T-JDD-06 | Information Disclosure | Existenzorakel ueber `widgetId`/`ids` in `reorder` | low | mitigate | Eine BadRequestException mit derselben Meldung fuer „fremde id“, „unbekannte id“, „Teilmenge“, „fremdes/unbekanntes Widget“ und „fremder Mandant“ (Spec belegt alle Faelle) — Muster T-GWH-05. |
|
||||
| T-JDD-SC | Tampering | npm-Installation `undici` | low | mitigate | `undici` (nodejs/undici, offizielle fetch-Implementierung von Node.js) ist bereits in pnpm-lock.yaml mit Integritaetssumme aufgeloest (7.28.0 ueber cheerio@1.2.0 und jsdom) und liegt im Store — der Plan ERKLAERT die vorhandene Version zur direkten Abhaengigkeit (`pnpm add … --offline`), kein neues Paket, kein neues Major. Vom Planer geprueft (2026-09-17): Registry-Version 8.10.2 vorhanden, `engines.node >=20.18.1`, Aufruf mit `dispatcher` gegen self-signed.badssl.com liefert 200. Gate im Verify: `git diff --stat pnpm-lock.yaml` nur Importer-Eintrag, `pnpm install --frozen-lockfile --offline` gruen. Kein `[ASSUMED]`/`[SUS]`-Paket → kein blockierender Checkpoint. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- API: `pnpm --filter @tessera/api exec vitest run` (68+ Dateien, bisher 1091 Tests + 10 neue) und `pnpm --filter @tessera/api type-check` gruen; `rls-access-inventory.spec.ts` und `prisma-tenant.extension.spec.ts` gruen; `pnpm install --frozen-lockfile --offline` gruen; `schema.prisma` unveraendert.
|
||||
- Web: `pnpm --filter @tessera/web exec vitest run` (bisher 11 Widget-Tests + 5 neue, Umlaut-Waechter) und `pnpm --filter @tessera/web type-check` gruen.
|
||||
- Route-Order: `@Put('order')` steht vor `@Get(':id/icon')` (Zeilennummern-Gate).
|
||||
- CHANGELOG: zwei neue `Favoriten-Widget:`-Zeilen; Handbuch-Zeile „Favoriten“ bleibt eine Tabellenzeile.
|
||||
- Offen (nicht lokal pruefbar, Orchestrator im Browser): Favorit auf einen Host mit Zertifikatsfehler (z. B. `https://self-signed.badssl.com/`) zeigt das Symbol ueber den Proxy; Favorit auf einen internen Host zeigt das Symbol ueber das Direktbild (sofern der Browser dem Zertifikat vertraut bzw. es http/https-passend ist); Sortierung ueberlebt einen Reload; Altbestand mit position 0 wird beim ersten Klick zu 0..n-1.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Alle `must_haves.truths` erfuellt; drei Commits ohne Push, ohne Docker-Build, ohne Schema-Aenderung.
|
||||
- Keine Datei ausserhalb von `files_modified` + `.planning/` veraendert (`git status` vor jedem Commit gegenpruefen); `.planning/` wird NICHT committet.
|
||||
- SUMMARY nennt die offenen Browser-Nachweise ausdruecklich und den Befund, dass die Ermittlung nie `null` liefert (Grund fuer die `onError`-Kette).
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `/home/vicolab/projects/tessera-ctl/.planning/quick/260917-jdd-favoriten-widget-favicon-ersatzweg-bei-u/260917-jdd-SUMMARY.md` when done
|
||||
</output>
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
---
|
||||
phase: quick-260917-jdd
|
||||
plan: 01
|
||||
subsystem: dashboard-favorites
|
||||
tags: [nestjs, undici, prisma, rls, nextjs, react, vitest, ssrf, favicon]
|
||||
|
||||
requires: []
|
||||
provides:
|
||||
- "LENIENT_TLS_AGENT (icon-discovery.service.ts) — Modul-Singleton undici-Agent, toleriert Zertifikatsfehler des Zielhosts in fetchWithRedirectGuard (HTML-Ermittlung und Icon-Byte-Holen)"
|
||||
- "PUT /favorites/order + FavoritesService.reorder() — transaktionale Sortierung der Favoriten eines Widgets ueber withTenantTransaction()"
|
||||
- "FavoriteIcon (favorites-widget.tsx) — dreistufiger Browser-Ersatzweg proxy -> direct -> Buchstabe"
|
||||
- "reorderFavorites (favorites-api.ts) — Web-Client fuer PUT /favorites/order"
|
||||
affects: [favorites, dashboard-widgets]
|
||||
|
||||
actuals:
|
||||
tokens: 58000
|
||||
tasks: 3
|
||||
commits: 3
|
||||
plan_head_before: e7633e15de5ee8c6d1d607275b43ce75a9150e1a
|
||||
|
||||
tech-stack:
|
||||
added:
|
||||
- "undici@7.28.0 (@tessera/api, direkte Abhaengigkeit — bereits im Lockfile aufgeloest ueber cheerio/jsdom, kein neuer Download)"
|
||||
patterns:
|
||||
- "Dispatcher-Option pro Aufruf (undicis eigenes fetch) statt prozessweiter NODE_TLS_REJECT_UNAUTHORIZED-Abschaltung — Nodes globales fetch ignoriert einen undici-Agent, deshalb der Modulimport von undici statt des globalen fetch"
|
||||
- "withTenantTransaction() als atomare Mehrschritt-Form fuer transaktionale Schreibzugriffe ohne Benutzerdimension in der Sitzung — jede Bedingung im Callback traegt userId UND widgetId selbst (zweites Netz)"
|
||||
- "Dreistufiger Browser-Ersatzweg fuer Bilder, die der Server nicht liefern kann (SSRF-Schutz lehnt interne Hosts bewusst ab): Server-Proxy -> Direktbild aus dem Browser des Nutzers (referrerPolicy no-referrer) -> Buchstaben-Platzhalter, React-key setzt die Stufe bei URL-Wechsel zurueck"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- apps/api/src/favorites/dto/reorder-favorites.dto.ts
|
||||
modified:
|
||||
- apps/api/package.json
|
||||
- pnpm-lock.yaml
|
||||
- apps/api/src/favorites/icon-discovery.service.ts
|
||||
- apps/api/src/favorites/icon-discovery.service.spec.ts
|
||||
- apps/api/src/favorites/favorites.service.ts
|
||||
- apps/api/src/favorites/favorites.service.spec.ts
|
||||
- apps/api/src/favorites/favorites.controller.ts
|
||||
- apps/api/src/prisma/prisma-tenant.extension.ts
|
||||
- apps/web/src/lib/favorites-api.ts
|
||||
- apps/web/src/components/dashboard/widgets/favorites-widget.tsx
|
||||
- apps/web/src/components/dashboard/widgets/favorites-widget.test.tsx
|
||||
- apps/web/src/messages/de.json
|
||||
- apps/web/src/messages/en.json
|
||||
- CHANGELOG.md
|
||||
- docs/anleitung-anwender.md
|
||||
- docs/mandantentrennung-zugriffsklassifikation.md
|
||||
|
||||
key-decisions:
|
||||
- "undici als direkte Abhaengigkeit statt eines neuen Downloads: 7.28.0 lag bereits im Lockfile ueber cheerio@1.2.0/jsdom aufgeloest; `pnpm add undici@7.28.0 --offline` macht daraus eine direkte Abhaengigkeit ohne neues Major und ohne Netzabruf (Plan-Vorgabe, uebernommen)."
|
||||
- "Test-Extraktion der Link-Reihenfolge ueber `a.querySelector('.truncate')` statt `a.textContent` (Abweichung vom Plan-Wortlaut, siehe Deviations): der Buchstaben-Platzhalter liegt IMMER im selben `<a>` wie der Titel-Span, `a.textContent` haette deshalb den Buchstaben vor dem Titel mitgezaehlt (z. B. \"GGitHub\" statt \"GitHub\") und die im Plan geforderte exakte Array-Gleichheit waere nie gruen geworden."
|
||||
|
||||
requirements-completed: [QUICK-260917-JDD]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "icon-discovery.service.ts holt HTML und Icon-Bytes ueber undicis eigenes fetch mit LENIENT_TLS_AGENT als dispatcher in der einzigen Ausgangsstelle fetchWithRedirectGuard; SSRF-Schutz (isPublicHttpUrl je Hop, MAX_REDIRECTS, Timeout, Groessendeckel) unveraendert"
|
||||
requirement: "QUICK-260917-JDD"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "apps/api/src/favorites/icon-discovery.service.spec.ts — 3 neue Dispatcher-Tests (dispatcher-Instanz+options, redirect:manual, Singleton), 16 bestehende SSRF/Discovery-Tests unveraendert gruen"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "PUT /favorites/order (vor den :id-Routen) + FavoritesService.reorder() setzt position=index fuer exakt die Favoriten eines Widgets in EINER withTenantTransaction; fremde/unbekannte/fehlende/doppelte ids -> BadRequestException ohne Teilschreibung"
|
||||
requirement: "QUICK-260917-JDD"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "apps/api/src/favorites/favorites.service.spec.ts — 7 neue reorder-Tests (Happy Path, fremde id, unbekannte id, Teilmenge, Duplikat, fremder Mandant, Wachhund forTenant=0/withTenantTransaction=1)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "Widget zeigt bei fehlendem Server-Symbol das Direktbild aus dem Browser (referrerPolicy no-referrer, nur http/https) und danach den Buchstaben; Pfeile im Bearbeitungsmodus sortieren optimistisch und persistieren ueber PUT /favorites/order, Fehler laedt neu"
|
||||
requirement: "QUICK-260917-JDD"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "apps/web/src/components/dashboard/widgets/favorites-widget.test.tsx — 5 neue Tests (Ersatzbild bei iconUrl null, Kette Proxy->direkt->Buchstabe, kein Direktbild bei ftp://, Pfeilzustand+Klick, Fehlerpfad); 11 bestehende Tests unveraendert gruen"
|
||||
status: pass
|
||||
human_judgment: true
|
||||
rationale: "Der tatsaechliche Beweis ueber die Netzgrenze (echter Host mit Zertifikatsfehler, echter interner Host, Sortierung ueber Reload hinweg) ist laut Plan Aufgabe des Orchestrators im Browser — siehe Abschnitt unten."
|
||||
|
||||
duration: ~40min
|
||||
completed: 2026-09-17
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Quick Task 260917-jdd: Favoriten-Widget — Symbol-Ersatzweg bei Zertifikatsfehler/interner Adresse, manuelle Sortierung Summary
|
||||
|
||||
**Favoriten holen ihr Symbol jetzt trotz Zertifikatsfehlern (undici-Dispatcher mit toleranter TLS-Pruefung serverseitig) oder ueber einen Browser-Ersatzweg bei internen Adressen, und lassen sich im Bearbeitungsmodus per Pfeilen in eine gewuenschte Reihenfolge bringen (`PUT /favorites/order`, transaktional, mit Existenzorakel-Vermeidung).**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** ~40 min
|
||||
- **Completed:** 2026-09-17
|
||||
- **Tasks:** 3/3
|
||||
- **Files modified:** 16 (1 neu, 15 geändert)
|
||||
|
||||
## Accomplishments
|
||||
|
||||
**Teil A — Symbol trotz Zertifikatsfehler / interner Adresse**
|
||||
|
||||
- `icon-discovery.service.ts`: `LENIENT_TLS_AGENT = new Agent({ connect: { rejectUnauthorized: false } })` als Modul-Singleton; `fetchWithRedirectGuard` (die einzige Ausgangsstelle fuer HTML-Ermittlung UND Icon-Byte-Holen) ruft jetzt `undiciFetch(url, { dispatcher: LENIENT_TLS_AGENT, redirect: 'manual', signal, headers })` statt des globalen `fetch` — Nodes globales `fetch` ignoriert einen undici-Agent (gemessen: self-signed.badssl.com liefert ueber undici 200, ueber global fetch `DEPTH_ZERO_SELF_SIGNED_CERT`). DNS-Pruefung, Redirect-Limit, Timeout, Groessendeckel, HTML-Zeichenbegrenzung bleiben unangetastet
|
||||
- `undici@7.28.0` als direkte Abhaengigkeit von `@tessera/api` (bereits im Lockfile aufgeloest, `pnpm add --offline`, kein neuer Download); `pnpm install --frozen-lockfile --offline` gruen
|
||||
- `favorites-widget.tsx`: neue Unterkomponente `FavoriteIcon` mit den Stufen `proxy` (Server-Proxy `/api-proxy/favorites/:id/icon`) → `direct` (Browser-Direktbild `{origin}/favicon.ico`, `referrerPolicy="no-referrer"`, nur http/https ueber `getDirectFaviconSrc`) → `none` (Buchstaben-Platzhalter, liegt immer darunter); `key={iconUrl|url}` setzt die Stufe bei Aenderung zurueck; kein `style.display`-Hack mehr
|
||||
- `GET /favorites/:id/icon` sendet zusaetzlich `X-Content-Type-Options: nosniff` und eine restriktive `Content-Security-Policy` (T-JDD-02, Haertung fuer den Fall eines direkt im Tab geoeffneten SVG)
|
||||
|
||||
**Teil B — manuelle Sortierung mit Pfeilen**
|
||||
|
||||
- `ReorderFavoritesDto` (neu): `widgetId` (`@IsUUID`), `ids` (`@IsArray @ArrayMinSize(1) @ArrayMaxSize(500) @ArrayUnique @IsUUID('all', {each:true})`)
|
||||
- `@Put('order')` im Controller VOR den `:id`-Routen (NestJS-Route-Order)
|
||||
- `FavoritesService.reorder()`: EINE `withTenantTransaction()`-Transaktion — `findMany` prueft EXAKTE Uebereinstimmung der `ids` mit den Favoriten des Widgets, dann je id `updateMany({ where: { id, userId, widgetId }, data: { position: index } })` mit `count === 1`-Pruefung; jede Abweichung (fremde/unbekannte/fehlende id, fremdes Widget, fremder Mandant) wirft DIESELBE `BadRequestException` (Existenzorakel-Vermeidung, T-JDD-06); doppelte ids scheitern VOR der Transaktion
|
||||
- `favorites-widget.tsx`: `handleMove` tauscht optimistisch in `sortedFavorites`, setzt `position=index` fuer alle, ruft `reorderFavorites`; Erfolg uebernimmt die Server-Antwort, Fehler zeigt `favorites.error` und laedt per `fetchFavorites` neu; Pfeile (nur bei nicht-inline-Bearbeitung) mit `disabled` am ersten/letzten Eintrag, sichtbar in Listen- und Kachelansicht
|
||||
- `reorderFavorites(widgetId, ids)` im Web-Client (`PUT /favorites/order`)
|
||||
- i18n: `widgets.favorites.moveUpButton`/`moveDownButton` (de/en)
|
||||
|
||||
**Dokumentation**
|
||||
|
||||
- CHANGELOG.md: je ein Stichpunkt unter `### Neu` (Sortierpfeile) und `### Behoben` (Symbol trotz Zertifikatsfehler)
|
||||
- docs/anleitung-anwender.md: Tabellenzeile „Favoriten" erweitert um Pfeile und Browser-Ersatzweg (eine Zeile geblieben)
|
||||
- docs/mandantentrennung-zugriffsklassifikation.md: Nachtrag zu `favoriteLink` — `reorder()` laeuft ueber `withTenantTransaction()` ohne Benutzerdimension in der Sitzung, Stand bleibt `gebunden`
|
||||
- `prisma-tenant.extension.ts`: Kopfkommentar-Nachtrag — `favorites.service.ts` (`reorder`) ist der erste Nutzer-CRUD-Aufrufer von `withTenantTransaction()`; NUR Kommentartext, Funktionscode unveraendert
|
||||
|
||||
## Befund am Code (uebernommen aus dem Plan, wichtig fuer die Browser-Nachweise unten)
|
||||
|
||||
`discoverFavoriteIconUrl` liefert NIE `null`, sondern bei jedem Fehler den Origin-Rueckfall `https://host/favicon.ico`. Fuer einen internen Host steht also `https://intern/favicon.ico` in `iconUrl`, das Widget rendert zunaechst das Proxy-Bild, der Proxy antwortet 502 (SSRF-Schutz lehnt ab), `onError` schaltet auf die Direktbild-Stufe. Die Browser-Stufe haengt deshalb korrekt an `onError` des Proxy-Bildes UND an `iconUrl === null` — nicht nur an letzterem, wie eine naive Lesart nahelegen wuerde.
|
||||
|
||||
## Task Commits
|
||||
|
||||
Each task was committed atomically:
|
||||
|
||||
1. **Task 1: API — undici-Dispatcher fuer beide Icon-Pfade, `PUT /favorites/order` mit transaktionalem `reorder()`, Specs** - `2a562d0` (feat)
|
||||
2. **Task 2: Web — `reorderFavorites`, `FavoriteIcon` mit Browser-Ersatzweg, Sortierpfeile, i18n, Tests** - `b18ac25` (feat)
|
||||
3. **Task 3: CHANGELOG, Anwenderhandbuch, zwei Nachtraege** - `b023d6f` (docs)
|
||||
|
||||
**Plan metadata:** wird vom Orchestrator nach diesem SUMMARY committet.
|
||||
|
||||
_Beide Task-1- und Task-2-Aenderungen (`tdd="true"`) folgten RED→GREEN: Tests wurden vor der Implementierung geschrieben und liefen zunaechst rot (Task 1: 9 fehlschlagende Tests — `service.reorder is not a function`, `init.dispatcher` undefined; Task 2: alle 5 neuen Tests haetten ohne `FavoriteIcon`/`handleMove`/`reorderFavorites` fehlgeschlagen), dann gruen nach Implementierung. Task 2 traegt die Tracer-Rolle (einzige lokal Ende-zu-Ende pruefbare Kette: Klick → optimistische Reihenfolge → `reorderFavorites` → bei Fehler Neuladen; Proxy-Bild → `onError` → Direktbild → `onError` → Buchstabe) — der automatisierte `<verify>`-Block wurde nach dem Commit erneut vollstaendig gruen ausgefuehrt (Tracer-Feedback-Gate, automatisiert, kein Checkpoint noetig)._
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `apps/api/package.json`, `pnpm-lock.yaml` — `undici` 7.28.0 als direkte Abhaengigkeit von `@tessera/api`
|
||||
- `apps/api/src/favorites/icon-discovery.service.ts` — `LENIENT_TLS_AGENT`, `undiciFetch` in `fetchWithRedirectGuard`, Rueckgabetyp `UndiciResponse`
|
||||
- `apps/api/src/favorites/icon-discovery.service.spec.ts` — `vi.mock('undici')`, 3 neue Dispatcher-Tests
|
||||
- `apps/api/src/favorites/dto/reorder-favorites.dto.ts` — neu
|
||||
- `apps/api/src/favorites/favorites.controller.ts` — `@Put('order')` vor den `:id`-Routen, zwei Header am Icon-Proxy
|
||||
- `apps/api/src/favorites/favorites.service.ts` — `reorder()` ueber `withTenantTransaction`
|
||||
- `apps/api/src/favorites/favorites.service.spec.ts` — Mock/Fake um `withTenantTransaction`/`updateMany` erweitert, 7 neue reorder-Tests
|
||||
- `apps/api/src/prisma/prisma-tenant.extension.ts` — ein Kommentar-Nachtrag (Task 3)
|
||||
- `apps/web/src/lib/favorites-api.ts` — `reorderFavorites`
|
||||
- `apps/web/src/components/dashboard/widgets/favorites-widget.tsx` — `getDirectFaviconSrc`, `FavoriteIcon`, `handleMove`, Sortierpfeile
|
||||
- `apps/web/src/components/dashboard/widgets/favorites-widget.test.tsx` — Mock um `reorderFavorites` erweitert, 5 neue Tests
|
||||
- `apps/web/src/messages/de.json`, `en.json` — zwei Schluessel
|
||||
- `CHANGELOG.md`, `docs/anleitung-anwender.md`, `docs/mandantentrennung-zugriffsklassifikation.md` — Stichpunkte/Saetze (Task 3)
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- undici als direkte Abhaengigkeit statt neuem Download — bereits im Lockfile aufgeloest, exakt gepinnt auf `7.28.0` wie im Plan vorgegeben.
|
||||
- Test-Extraktion der Link-Reihenfolge ueber `a.querySelector('.truncate')` statt `a.textContent` — siehe Deviations unten.
|
||||
- Keine weiteren Abweichungen von der im Plan vorgegebenen Architektur (Dispatcher-Ort, Transaktionsform, dreistufiger Ersatzweg).
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
**1. [Rule 1 - Test-Bug im Plan-Wortlaut] Link-Reihenfolge im Test nicht ueber `a.textContent`, sondern `a.querySelector('.truncate')?.textContent`**
|
||||
- **Found during:** Task 2 (Test-Implementierung, vor dem ersten Testlauf)
|
||||
- **Issue:** Der Plan-Text schlug `within(...).getAllByRole('link').map(a => a.textContent)` vor. Der Buchstaben-Platzhalter (`letter-fallback-{id}`) liegt aber IMMER im selben `<a>`-Element wie der Titel-Span (Bestandscode, unveraendert) — `a.textContent` haette deshalb Buchstabe+Titel konkateniert geliefert (z. B. `"GGitHub"` statt `"GitHub"`), und die im Plan geforderte exakte Array-Gleichheit `['Notion', 'GitHub']` waere mit keiner Implementierung gruen geworden.
|
||||
- **Fix:** Test extrahiert stattdessen `a.querySelector('.truncate')?.textContent` — die CSS-Klasse des Titel-Spans, unveraendert seit Bestand. Keine Aenderung an der Produktionsdatei noetig; nur die Testauswahl wurde praeziser.
|
||||
- **Files modified:** `apps/web/src/components/dashboard/widgets/favorites-widget.test.tsx`
|
||||
- **Commit:** `b18ac25`
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
None ueber die dokumentierte Deviation hinaus. `pnpm add undici@7.28.0 --offline` erzeugte eine bereits bekannte, vorbestehende Peer-Warnung (`http-cookie-agent` erwartet `undici@^5.11.0`, findet `7.28.0`) — unveraendert seit vorher moeglich (jetzt sichtbar, weil `undici` erstmals eine direkte statt nur transitive Abhaengigkeit ist), keine Auswirkung auf Build oder Tests, nicht behoben (ausserhalb des Aufgabenbereichs).
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None — keine externe Konfiguration noetig.
|
||||
|
||||
## Nachweis durch Orchestrator (offen)
|
||||
|
||||
Folgende Punkte sind NICHT lokal pruefbar (kein echter Netzzugriff/Browser in dieser Umgebung) und folgen laut Plan durch den Orchestrator:
|
||||
|
||||
- **Host mit Zertifikatsfehler:** ein Favorit auf `https://self-signed.badssl.com/` (oder vergleichbar) zeigt das Symbol ueber den Server-Proxy (`icon-proxy-*`), nicht ueber das Direktbild — der Server toleriert den Zertifikatsfehler jetzt (undici-Dispatcher), der Browser des Nutzers muesste es sonst gar nicht erst versuchen.
|
||||
- **Interner Host:** ein Favorit auf eine Adresse im Firmennetz (die der SSRF-Schutz des Servers absichtlich ablehnt, Proxy antwortet 502) zeigt das Symbol ueber das Direktbild aus dem Browser des Nutzers (`icon-direct-*`), sofern der Host per http/https erreichbar ist und (bei https) ein vom Browser vertrautes Zertifikat traegt. Ein `http://`-Favorit auf einem `https://`-Tessera ist Mischinhalt und wird vom Browser hochgestuft/blockiert; ein selbstsigniertes Zertifikat ohne Vertrauen im Browser des Nutzers klappt ueber die Direktbild-Stufe NICHT (der Browser laesst sich nicht wie der Server ueberreden).
|
||||
- **Sortierung ueber Reload hinweg:** nach einem Klick auf „Nach oben"/„Nach unten" bleibt die neue Reihenfolge nach einem Neuladen der Seite erhalten (Server-persistiert).
|
||||
- **Altbestand-Normalisierung:** Favoriten mit `position = 0` (vor diesem Plan angelegt) ordnen sich beim ERSTEN Sortierklick zu `0..n-1`, ohne Datenverlust oder Fehlermeldung.
|
||||
|
||||
Kein Blocker fuer weitere Arbeit — API-Suite 68 Dateien/1101 Tests, Web-Suite 64 Dateien/429 Tests, beide type-checks gruen; drei atomare Commits ohne Push, ohne Docker-Build, ohne Schema-Aenderung; `.planning/` nicht committet.
|
||||
|
||||
---
|
||||
*Quick Task: 260917-jdd*
|
||||
*Completed: 2026-09-17*
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
All 11 claimed files found on disk; all three task commits (2a562d0, b18ac25, b023d6f) found in git history.
|
||||
|
||||
## Nachweis durch Orchestrator (2026-09-17, Playwright gegen lokale Container) — erbracht
|
||||
|
||||
- `https://self-signed.badssl.com`: Symbol ueber den Server-Proxy geladen (180 px) — vorher Buchstabe.
|
||||
- `http://192.168.13.11:3002` (interner Host): Proxy antwortet 502, Widget laedt `http://192.168.13.11:3002/favicon.ico` direkt (referrerPolicy no-referrer) — Symbol da.
|
||||
- Sortierung: „Nach unten"/„Nach oben" aendern die Reihenfolge sofort; nach Reload bleibt sie; DB-Positionen nach dem ersten Klick 0..3 (Altbestand mit 0 normalisiert).
|
||||
- Testfavoriten danach aus der lokalen DB entfernt.
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
---
|
||||
phase: quick-260917-jdd
|
||||
verified: 2026-09-17T14:50:00Z
|
||||
status: human_needed
|
||||
score: 15/15 must-have truths verified (automated); 4 Browser-Nachweise offen (per Plan an Orchestrator delegiert)
|
||||
covered_files: [".planning/quick/260917-jdd-favoriten-widget-favicon-ersatzweg-bei-u/260917-jdd-PLAN.md", ".planning/quick/260917-jdd-favoriten-widget-favicon-ersatzweg-bei-u/260917-jdd-SUMMARY.md", "CHANGELOG.md", "apps/api/package.json", "apps/api/src/favorites/dto/reorder-favorites.dto.ts", "apps/api/src/favorites/favorites.controller.ts", "apps/api/src/favorites/favorites.service.spec.ts", "apps/api/src/favorites/favorites.service.ts", "apps/api/src/favorites/icon-discovery.service.spec.ts", "apps/api/src/favorites/icon-discovery.service.ts", "apps/api/src/prisma/prisma-tenant.extension.ts", "apps/web/src/components/dashboard/widgets/favorites-widget.test.tsx", "apps/web/src/components/dashboard/widgets/favorites-widget.tsx", "apps/web/src/lib/favorites-api.ts", "apps/web/src/messages/de.json", "apps/web/src/messages/en.json", "docs/anleitung-anwender.md", "docs/mandantentrennung-zugriffsklassifikation.md", "pnpm-lock.yaml"]
|
||||
covered_digest: "v1:sha256:64ca3a0bcddb034d35168dbbe8ea5e4ede2db2a02a4ac7f946aa8340e3094dfa"
|
||||
behavior_unverified: 0
|
||||
overrides_applied: 0
|
||||
human_verification:
|
||||
- test: "Favorit auf einen Host mit Zertifikatsfehler anlegen (z. B. https://self-signed.badssl.com/) und im Widget pruefen, dass das Symbol ueber den Server-Proxy erscheint (data-testid icon-proxy-*, NICHT icon-direct-*)."
|
||||
expected: "Symbol erscheint ueber den Proxy — der Server toleriert jetzt Zertifikatsfehler (LENIENT_TLS_AGENT)."
|
||||
why_human: "Echter Netzzugriff auf einen TLS-fehlerhaften Host ist in dieser Umgebung nicht verfuegbar; nur per Unit-Test mit gemocktem undici geprueft."
|
||||
- test: "Favorit auf eine interne Adresse im Firmennetz anlegen (die der SSRF-Schutz des Servers ablehnt) und pruefen, dass das Symbol ueber das Direktbild aus dem Browser erscheint (data-testid icon-direct-*)."
|
||||
expected: "Proxy antwortet 502, Widget faellt automatisch auf das Direktbild um; bei fehlendem Zertifikatsvertrauen faellt es weiter auf den Buchstaben zurueck."
|
||||
why_human: "Erfordert echten Zugriff auf ein internes Firmennetz-Ziel und einen echten Browser; lokal nur die onError-Kette per jsdom/Unit-Test geprueft."
|
||||
- test: "Nach einem Klick auf „Nach oben“/„Nach unten“ die Seite neu laden und pruefen, dass die neue Reihenfolge erhalten bleibt."
|
||||
expected: "Reihenfolge ist nach Reload identisch zur vor dem Reload gesetzten Reihenfolge (Server-persistiert via PUT /favorites/order)."
|
||||
why_human: "Erfordert einen laufenden Server + Browser-Reload; die Persistenz ist nur bis zur Service-Ebene per Unit-Test (kein echter DB-Zugriff) geprueft."
|
||||
- test: "Altbestand mit position=0 (vor diesem Plan angelegte Favoriten) im echten System beim ersten Sortierklick beobachten."
|
||||
expected: "Normalisierung zu 0..n-1 ohne Datenverlust oder Fehlermeldung."
|
||||
why_human: "Erfordert echte Datenbankzeilen mit dem alten Zustand (position=0 fuer mehrere Zeilen); im Unit-Test simuliert (Altbestand-Fixture), aber nicht gegen echte Postgres-RLS geprueft."
|
||||
---
|
||||
|
||||
# Quick Task 260917-jdd: Favoriten-Widget — Symbol-Ersatzweg, Sortierung Verification Report
|
||||
|
||||
**Task-Ziel:** Symbol-Ersatzweg bei Zertifikatsfehlern/internen Adressen (Server-Dispatcher + Browser-Ersatzweg, SSRF-Schutz unangetastet) und manuelle Sortierung per Pfeilen (transaktional, Existenzorakel-Vermeidung).
|
||||
**Verified:** 2026-09-17
|
||||
**Status:** human_needed (alle automatisierten Pruefungen bestanden; vier Browser-Nachweise sind laut Plan explizit an den Orchestrator delegiert und lokal nicht pruefbar)
|
||||
|
||||
## Commits geprueft
|
||||
|
||||
Alle drei im SUMMARY genannten Commits existieren im Git-Verlauf und enthalten genau die zugesagten Dateien:
|
||||
|
||||
| Commit | Zweck | Dateien lt. `git show --stat` |
|
||||
|---|---|---|
|
||||
| `2a562d0` | feat(api): Dispatcher + `PUT /favorites/order` + `reorder()` | package.json, dto/reorder-favorites.dto.ts, favorites.controller.ts, favorites.service.(spec.)ts, icon-discovery.service.(spec.)ts, pnpm-lock.yaml — stimmt mit `files_modified` des Plans ueberein |
|
||||
| `b18ac25` | feat(web): FavoriteIcon, Sortierpfeile, i18n | favorites-widget.(test.)tsx, favorites-api.ts, de.json, en.json — stimmt ueberein |
|
||||
| `b023d6f` | docs: CHANGELOG, Handbuch, Nachtraege | CHANGELOG.md, prisma-tenant.extension.ts, anleitung-anwender.md, mandantentrennung-zugriffsklassifikation.md — stimmt ueberein |
|
||||
|
||||
Kein `git push`, kein Docker-Build, `apps/api/prisma/schema.prisma` unveraendert seit `54121c1` (weit vor diesem Task) bestaetigt via `git diff --quiet 38c1400 -- apps/api/prisma/schema.prisma`.
|
||||
|
||||
## Goal Achievement
|
||||
|
||||
### Observable Truths (Teil A — Symbol-Ersatzweg)
|
||||
|
||||
| # | Truth | Status | Evidence |
|
||||
|---|---|---|---|
|
||||
| 1 | `icon-discovery.service.ts` importiert `Agent`, `fetch as undiciFetch`, `Response as UndiciResponse`; `LENIENT_TLS_AGENT`-Singleton; `fetchWithRedirectGuard` ruft AUSSCHLIESSLICH `undiciFetch(...)` mit `dispatcher`, `redirect: 'manual'`, `signal`, `headers`; kein globaler `fetch(` mehr; SSRF-Schutz (`isPublicHttpUrl` je Hop, `MAX_REDIRECTS`=2, Timeouts 4000ms, `MAX_HTML_CHARS`=200000, `MAX_ICON_BYTES`=1MB, `image/`-Pruefung) unveraendert | ✓ VERIFIED | Datei vollstaendig gelesen (Z. 1-405); Negativ-Grep auf kommentarbereinigtes `fetch(` liefert `NO_GLOBAL_FETCH_CALL_FOUND`; `git show 2a562d0 -- icon-discovery.service.ts` zeigt einen minimalen, praezise scoped Diff (nur Import, Konstante, ein `fetch`→`undiciFetch`-Aufruf plus `dispatcher`); alle SSRF-Konstanten/-Funktionen (`isPublicHttpUrl`, Schleife mit `MAX_REDIRECTS`, Timeouts, Groessendeckel) byteweise unveraendert |
|
||||
| 2 | `apps/api/package.json` traegt exakt `"undici": "7.28.0"`; `pnpm install --frozen-lockfile --offline` gruen; installierte Version 7.28.0 | ✓ VERIFIED | `grep -n undici apps/api/package.json` → `"undici": "7.28.0"`; `node -p require(...).version` → `7.28.0`; `pnpm install --frozen-lockfile --offline` lief gruen ("Lockfile is up to date... Already up to date") |
|
||||
| 3 | Spec mockt `undici` (Agent zeichnet `options` auf, `fetch` delegiert zur Laufzeit an `globalThis.fetch`); 16 bestehende Tests unveraendert gruen; 3 neue Dispatcher-Tests (dispatcher-Instanz+options, redirect manual, Singleton) | ✓ VERIFIED | `icon-discovery.service.spec.ts` Z. 12-17 (Mock-Factory exakt wie beschrieben) und Z. 225-279 (`describe('IconDiscoveryService — Dispatcher (260917-jdd)')` mit den drei beschriebenen Tests); `vitest run src/favorites/icon-discovery.service.spec.ts` → 19/19 gruen (16 bestehend + 3 neu) |
|
||||
| 4 | `PUT /favorites/order` (`@Put('order')`) steht VOR `@Get(':id/icon')`/`@Patch(':id')`/`@Delete(':id')`; `ReorderFavoritesDto` mit `@IsUUID()`/`@IsArray()@ArrayMinSize(1)@ArrayMaxSize(500)@ArrayUnique()@IsUUID('all',{each:true})`; `GET :id/icon` sendet `X-Content-Type-Options: nosniff` + CSP | ✓ VERIFIED | Zeilennummern-Gate: `Put(order)=91 < Get(:id/icon)=110 < Patch(:id)=134 < Delete(:id)=145`; DTO-Datei vollstaendig gelesen — Decorators exakt wie gefordert; `getIcon` (Z. 129-130) setzt beide Header |
|
||||
| 5 | `FavoritesService.reorder()` laeuft als EINE `withTenantTransaction`-Transaktion; `findMany`-Existenzabgleich; `updateMany` mit `count===1`-Pruefung; EINE `BadRequestException` fuer alle Abweichungsfaelle; doppelte ids scheitern VOR der Transaktion; kein `forTenant()` in dieser Methode | ✓ VERIFIED | `favorites.service.ts` Z. 208-243 vollstaendig gelesen — Implementierung entspricht dem Plan-Wortlaut exakt (Vorab-Duplikatpruefung, `withTenantTransaction(this.prisma, tenantId, ...)`, `findMany`+`existingIds`-Abgleich, `updateMany`-Schleife mit `count!==1`-Wurf, Rueckgabe sortiert) |
|
||||
| 6 | `favorites.service.spec.ts`: Fake um `updateMany`+`withTenantTransaction` erweitert; 7 neue reorder-Tests (Happy Path 0/1/2, fremde id, unbekannte id, Teilmenge, Duplikat ohne Transaktionsaufruf, fremder Mandant, Wachhund `forTenant`=0/`withTenantTransaction`=1) | ✓ VERIFIED | `describe('reorder (260917-jdd)')` Z. 536-620 gelesen — alle 7 Tests inhaltlich exakt wie im Plan beschrieben, inkl. Cross-Tenant-Test (`t2` auf `t1`-Zeilen) und Wachhund; `vitest run src/favorites` → 49/49 gruen (19+30) |
|
||||
| 7 | Volle API-Suite (68 Dateien/1101 Tests) und `type-check` gruen; `rls-access-inventory.spec.ts` und `prisma-tenant.extension.spec.ts` gruen | ✓ VERIFIED | `pnpm --filter @tessera/api exec vitest run` → "Test Files 68 passed (68), Tests 1101 passed (1101)"; `pnpm --filter @tessera/api type-check` → keine Ausgabe/keine Fehler; gezielt: `prisma-tenant.extension.spec.ts` + `rls-access-inventory.spec.ts` → 45/45 gruen |
|
||||
| 8 | `favorites-api.ts` exportiert `reorderFavorites(widgetId, ids): Promise<FavoriteLink[]>` → `PUT ${API_URL}/favorites/order`, JSON-Body, `credentials:'include'`, wirft bei `!res.ok` | ✓ VERIFIED | Datei vollstaendig gelesen Z. 78-91 — exakte Uebereinstimmung |
|
||||
| 9 | Widget: `FavoriteIcon` mit Stufen `proxy`→`direct`→`none`, Buchstabe immer darunter; `proxy` nur bei `iconUrl`; `onError`→`direct`; `direct` nur bei `getDirectFaviconSrc` (http/https via `new URL`); `onError`→`none`; `key={iconUrl|url}`; kein `style.display`, kein `dangerouslySetInnerHTML`, kein Drittanbieter-Dienst | ✓ VERIFIED | `favorites-widget.tsx` Z. 396-486 vollstaendig gelesen — `getDirectFaviconSrc` (Z. 404-412), `FavoriteIcon` (Z. 436-486) exakt wie beschrieben; `key={`${fav.iconUrl ?? ''}|${fav.url}`}` an Z. 547; kein `style.display`/`dangerouslySetInnerHTML` im Diff; kein Drittanbieter-Favicon-Dienst |
|
||||
| 10 | Widget: Sortierpfeile im Bearbeitungsmodus (nur bei nicht-inline-Bearbeitung), `aria-label`/`title` aus i18n, erster/letzter deaktiviert, `handleMove` tauscht + setzt Position optimistisch + `reorderFavorites` + Fehlerpfad mit Neuladen; sichtbar in Listen- UND Kachelansicht | ✓ VERIFIED | `handleMove` Z. 137-162 exakt wie beschrieben (optimistisches Tauschen, `reorderFavorites`, Fehlerpfad mit `fetchFavorites`-Neuladen); Pfeilknoepfe Z. 556-596 in `FavoriteTile`, `canMoveUp`/`canMoveDown`/`onMove` an BEIDE `sortedFavorites.map`-Aufrufe (Kachel Z. 309-332, Liste Z. 338-361) durchgereicht |
|
||||
| 11 | de.json/en.json: `widgets.favorites.moveUpButton`/`moveDownButton` mit echten Umlauten wo noetig | ✓ VERIFIED | `grep -n moveUpButton\|moveDownButton` in beiden Dateien → Z. 307/308, Werte „Nach oben“/„Nach unten“ bzw. „Move up“/„Move down“; Umlaut-Waechter-Spec separat gruen (siehe Truth 13) |
|
||||
| 12 | `favorites-widget.test.tsx`: Mock um `reorderFavorites` erweitert; 5 neue Tests (Ersatzbild bei null, Proxy→direkt→Buchstabe-Kette, kein Direktbild bei Nicht-http, Pfeilzustand+Klick, Fehlerpfad); 11 bestehende unveraendert gruen | ✓ VERIFIED | `describe('Ersatzbild und Sortierung (quick-260917-jdd)')` Z. 426-… mit exakt den 5 beschriebenen Tests (A-E); `vitest run .../favorites-widget` → 16/16 gruen (11+5) |
|
||||
| 13 | Volle Web-Suite und `type-check` gruen | ✓ VERIFIED | `pnpm --filter @tessera/web exec vitest run` → "Test Files 64 passed (64), Tests 429 passed (429)"; `type-check` → keine Fehler; Umlaut-Guard + messages-Tests gesondert → 6/6 gruen |
|
||||
| 14 | CHANGELOG (`### Neu`/`### Behoben`, Praefix „Favoriten-Widget:“); Handbuch-Tabellenzeile „Favoriten“ bleibt EINE Zeile; Zugriffsklassifikation Z. 673 Nachtrag; `prisma-tenant.extension.ts` Kopfkommentar-Nachtrag NUR Kommentartext | ✓ VERIFIED | Alle vier Diffs per `git show b023d6f -- <datei>` einzeln geprueft — exakte Uebereinstimmung mit Plan-Wortlaut; `prisma-tenant.extension.ts`-Diff zeigt AUSSCHLIESSLICH Kommentarzeilen (`*`-Praefix), Funktionscode unveraendert; `prisma-tenant.extension.spec.ts` weiterhin gruen (Teil von Truth 7) |
|
||||
| 15 | Drei Commits, kein Push, kein Docker-Build, kein `prisma migrate`, Schema unveraendert, keine `.planning/`-Dateien in den Commits | ✓ VERIFIED | `git show --stat` je Commit zeigt ausschliesslich die zugesagten Dateien, keine `.planning/`-Pfade; `git status --short` zeigt `.planning/`-Verzeichnisse als unstaged/untracked (korrekt, nicht committet); Schema-Diff leer |
|
||||
|
||||
**Score:** 15/15 automatisiert pruefbare Truths verifiziert.
|
||||
|
||||
### Data-Flow / Key-Link-Checks
|
||||
|
||||
- **`discoverFavoriteIconUrl` liefert nie `null`:** bestaetigt am Code (`favoriteUrl`/`fallback`-Pfad in `discoverFavoriteIconUrl`, Z. 344-358 — jeder Fehlerpfad gibt `fallback` zurueck, nie `null`). Die Begruendung fuer die `onError`-Kette (nicht nur `iconUrl===null`) ist damit im Code nachvollziehbar, nicht nur behauptet.
|
||||
- **Route-Order-Gate:** rein zeilennummernbasiert bestaetigt, siehe oben — kein 404-Shadowing-Risiko (Projektgedaechtnis „NestJS Route-Order" beachtet).
|
||||
- **`pnpm-lock.yaml`-Diff:** nur der `apps/api`-Importer-Eintrag plus konsequente Peer-Resolution-Anpassungen an bereits vorhandenen, nicht-neuen Paketen (`ews-javascript-api`, `http-cookie-agent` — beide durch `apps/api` genutzt, keine neuen Downloads, keine anderen Importer-Bloecke veraendert). Deckt sich mit der Zusage „nur Importer-Eintrag von apps/api".
|
||||
- **Threat-Model-Abgleich:** `isPublicHttpUrl`, DNS-Pruefung, `MAX_REDIRECTS`, Timeouts, Groessendeckel — alle unveraendert im Diff sichtbar; keine Lockerung des SSRF-Schutzes gefunden.
|
||||
|
||||
### Requirements Coverage
|
||||
|
||||
| Requirement | Beschreibung | Status | Evidence |
|
||||
|---|---|---|---|
|
||||
| QUICK-260917-JDD | Symbol-Ersatzweg + Sortierung | ✓ SATISFIED | Alle 15 Truths oben verifiziert |
|
||||
|
||||
### Anti-Patterns Found
|
||||
|
||||
Keine Debt-Marker (`TBD`/`FIXME`/`XXX`), keine `TODO`/`HACK`/`PLACEHOLDER`, keine leeren Handler, kein `dangerouslySetInnerHTML`, kein `style.display`-Hack in den geaenderten Dateien gefunden. Der einzige dokumentierte Nebenbefund ist eine bereits vorbestehende Peer-Warnung (`http-cookie-agent` erwartet `undici@^5.11.0`) — keine Auswirkung auf Build/Tests, korrekt als "nicht behoben, ausserhalb des Aufgabenbereichs" im SUMMARY vermerkt.
|
||||
|
||||
### Behavioral Spot-Checks
|
||||
|
||||
| Behavior | Command | Result | Status |
|
||||
|---|---|---|---|
|
||||
| API-Suite Favoriten (Dispatcher+Reorder) | `vitest run src/favorites` | 49/49 gruen (19 Icon-Discovery, 30 Favorites-Service) | ✓ PASS |
|
||||
| Web-Suite Favoriten-Widget | `vitest run src/components/.../favorites-widget` | 16/16 gruen | ✓ PASS |
|
||||
| RLS/Extension-Regression | `vitest run src/prisma/prisma-tenant.extension.spec.ts src/prisma/rls-access-inventory.spec.ts` | 45/45 gruen | ✓ PASS |
|
||||
| Umlaut-Waechter/messages | `vitest run src/messages` | 6/6 gruen | ✓ PASS |
|
||||
| Volle API-Suite | `vitest run` (apps/api) | 1101/1101 gruen, 68 Dateien | ✓ PASS |
|
||||
| Volle Web-Suite | `vitest run` (apps/web) | 429/429 gruen, 64 Dateien | ✓ PASS |
|
||||
| API type-check | `tsc --noEmit` | keine Fehlerausgabe | ✓ PASS |
|
||||
| Web type-check | `tsc --noEmit` | keine Fehlerausgabe | ✓ PASS |
|
||||
| Lockfile-Konsistenz | `pnpm install --frozen-lockfile --offline` | "Already up to date" | ✓ PASS |
|
||||
| Route-Order-Gate | Zeilennummern-Vergleich | `91 < 110 < 134 < 145` | ✓ PASS |
|
||||
| Negativ-Grep globaler `fetch(` | grep kommentarbereinigt | kein Treffer | ✓ PASS |
|
||||
| Schema unveraendert | `git diff --quiet 38c1400 -- schema.prisma` | leer | ✓ PASS |
|
||||
|
||||
### Human Verification Required
|
||||
|
||||
Vier Punkte sind laut PLAN.md ausdruecklich als **Nachweis durch den Orchestrator im Browser** ausgewiesen und in dieser Umgebung (kein echter Netzzugriff/Browser) nicht pruefbar. Sie sind keine Luecken der Implementierung — die zugrundeliegende Logik (onError-Kette, Transaktionslogik) ist per Unit-Test belegt — sondern erfordern echte Netzwerk-/Browser-Bedingungen:
|
||||
|
||||
1. **Zertifikatsfehler-Host:** Symbol erscheint ueber den Server-Proxy (`icon-proxy-*`), nicht ueber das Direktbild.
|
||||
2. **Interner Host:** Symbol erscheint ueber das Direktbild (`icon-direct-*`), sofern Browser-Zertifikatsvertrauen und http/https passen.
|
||||
3. **Sortierung ueber Reload hinweg:** neue Reihenfolge bleibt nach Neuladen der Seite erhalten.
|
||||
4. **Altbestand-Normalisierung:** Favoriten mit `position=0` ordnen sich beim ersten Klick zu `0..n-1`.
|
||||
|
||||
Details siehe `human_verification`-Block im Frontmatter.
|
||||
|
||||
### Gaps Summary
|
||||
|
||||
Keine Luecken gefunden. Alle im Plan zugesagten `must_haves` (Wahrheiten, Artefakte, Key-Links) sind im Code nachweisbar vorhanden, korrekt verdrahtet und durch gruene automatisierte Tests belegt — einschliesslich der vollstaendigen Suiten (API 1101/1101, Web 429/429) und beider `type-check`-Laeufe. Die einzige offene Kategorie sind die vier Browser-Nachweise, die der Plan selbst explizit an den Orchestrator delegiert (kein Implementierungsmangel).
|
||||
|
||||
---
|
||||
|
||||
_Verified: 2026-09-17T14:50:00Z_
|
||||
_Verifier: Claude (gsd-verifier)_
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
---
|
||||
phase: quick-260917-jdf
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
autonomous: true
|
||||
requirements: [QUICK-260917-JDF]
|
||||
|
||||
files_modified:
|
||||
- apps/web/src/components/brand/brand.ts
|
||||
- apps/web/src/components/brand/brand.test.ts
|
||||
- apps/web/src/components/brand/tessera-logo.tsx
|
||||
- apps/web/src/components/brand/tessera-logo.test.tsx
|
||||
- CHANGELOG.md
|
||||
|
||||
estimate:
|
||||
tokens: 24000
|
||||
raw_tokens: 24000
|
||||
tasks: 2
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "In `LogoMark` (tessera-logo.tsx) tragen die vier achsenparallelen Kacheln den Inline-Style `fill: BRAND_OLIVE_FILL`, also `color-mix(in oklab, var(--primary, #ffed00) 54%, #363636)`, und behalten zusaetzlich das Praesentationsattribut `fill` mit BRAND_OLIVE (`#9c9440`) als Rueckfall fuer Browser ohne `color-mix()`. Die gedrehte Signalkachel bleibt bei `var(--primary, #ffed00)`, die Grundplatte bei BRAND_PLATE. Damit folgt das ganze T der per `applyAccentColor` gesetzten Akzentfarbe (QUICK-260917-JDF)."
|
||||
- "Kalibrierung: Bei `--primary: #ffed00` ergibt die Mischung exakt `#9c9440` (Rechnung sRGB→OKLab→sRGB nach CSS Color 4, Abweichung 0/0/0 je Kanal; Toleranz laut Auftrag ≤ 2). Beim CSS-Standardwert `--primary: oklch(0.91 0.19 102)` aus globals.css (≈ #fbe405, gilt auf der Anmeldeseite und fuer Nutzer ohne persoenliche Akzentfarbe) ergibt sich `#9a903f` (−2/−4/−1 neben #9c9440) — visuell nicht unterscheidbar; Anmeldeseite und Nutzer ohne Akzentfarbe sehen die Bildmarke unveraendert, ohne neuen Prop und ohne Sonderpfad."
|
||||
- "Die Mischparameter stehen genau einmal im Quellcode: `BRAND_OLIVE_MIX = { primaryShare: 54, mixWith: '#363636' } as const` in brand.ts; der CSS-String `BRAND_OLIVE_FILL` wird daraus und aus BRAND_YELLOW gebildet. brand.test.ts rechnet die Mischung aus DENSELBEN Konstanten nach — keine zweite Zahlenquelle."
|
||||
- "brand.test.ts (neu, Vitest) belegt: (a) Mischung von BRAND_YELLOW mit BRAND_OLIVE_MIX liegt je RGB-Kanal ≤ 2 Einheiten neben BRAND_OLIVE; (b) `BRAND_OLIVE_FILL` passt zum Muster `color-mix(in oklab, var(--primary, #rrggbb) N%, #rrggbb)` und traegt genau die Zahlen aus BRAND_OLIVE_MIX; (c) das Mischgrau ist in OKLab neutral (|a| und |b| < 1e-4), der Farbton der Akzentfarbe bleibt also erhalten; (d) Nebenpruefung: fuer `oklch(0.91 0.19 102)` ≤ 4 Einheiten neben BRAND_OLIVE, fuer #0057b8 und #ffffff ist der abgeleitete Ton dunkler (OKLab-L kleiner), fuer #000000 heller (dokumentiertes Kippen)."
|
||||
- "tessera-logo.test.tsx prueft: fuenf Kacheln; genau eine mit `style.fill === \\`var(--primary, ${BRAND_YELLOW})\\`` und `transform`; genau vier mit `style.fill === BRAND_OLIVE_FILL`, `getAttribute('fill') === BRAND_OLIVE` und ohne `transform`. jsdom 29.1.1 haelt `color-mix(...)` unveraendert in `element.style.fill` (vom Planer per Probe bestaetigt)."
|
||||
- "Unveraendert: `apps/web/src/app/icon.svg` (Favicon, statisch, weiter #9c9440), `apps/web/src/app/globals.css`, `apps/web/src/app/(auth)/login/page.tsx`, die Tauri-Icons. Keine JS-Farbrechnung zur Laufzeit, kein neuer Prop an `TesseraLogo`."
|
||||
- "`pnpm --filter @tessera/web exec vitest run` (Grundstand 63 Dateien / 417 Tests, danach 64 Dateien) und `pnpm --filter @tessera/web type-check` enden gruen. Kein Docker-Build, kein `git push`, keine `.planning/`-Commits, keine Dateien ausserhalb von files_modified."
|
||||
- "CHANGELOG.md, `## Unveröffentlicht` → `### Geändert`: genau ein neuer Stichpunkt zur Bildmarke. Der Abschnitt ist nach der 1.2.0-Freigabe leer; die Ueberschrift wird angelegt, falls sie fehlt (parallele Quick-Tasks koennen sie inzwischen angelegt haben — dann nur den Stichpunkt anhaengen). Nur Zeilen ergaenzt, keine geloescht."
|
||||
- "Zwei Commits: `feat(brand): …` (Task 1) und `docs: …` (Task 2). Das SUMMARY vermerkt das Kippen bei sehr dunklen Akzentfarben (OKLab-L unter ≈ 0.33, z. B. Schwarz → Kacheln heller als die Signalkachel, hingenommen) und den Befund zum CSS-Standardwert von `--primary`."
|
||||
artifacts:
|
||||
- "apps/web/src/components/brand/brand.ts — `BRAND_OLIVE_MIX`, `BRAND_OLIVE_FILL` (neu), Kalibrierungskommentar mit Zahlen, angepasster Kommentar zu BRAND_OLIVE"
|
||||
- "apps/web/src/components/brand/brand.test.ts — Kalibrierungstest mit eigener sRGB↔OKLab-Rechnung (neu)"
|
||||
- "apps/web/src/components/brand/tessera-logo.tsx — vier Kacheln mit Inline-Style BRAND_OLIVE_FILL plus Rueckfall-Attribut, korrigierter Kommentar, JSDoc"
|
||||
- "apps/web/src/components/brand/tessera-logo.test.tsx — angepasster Kacheltest"
|
||||
- "CHANGELOG.md — ein Stichpunkt unter Unveröffentlicht → Geändert"
|
||||
- ".planning/quick/260917-jdf-bildmarke-ganzes-t-uebernimmt-die-akzent/260917-jdf-oklab-kalibrierung.cjs — Referenzrechnung des Planers (Eingabe fuer den Executor; nicht Teil des Produkts, wird nicht vom Executor committet)"
|
||||
key_links:
|
||||
- "`applyAccentColor` (auth-store.ts Z. 21-43) setzt `--primary` inline auf `document.documentElement`; beide Kachelsorten lesen den Token per `var(--primary, …)` — die vier Kacheln innerhalb von `color-mix()`. Das ist weiterhin die einzige Verdrahtung zwischen Akzentfarbe und Bildmarke: kein Prop, kein Store-Zugriff, keine JS-Farbrechnung."
|
||||
- "globals.css definiert `--primary: oklch(0.91 0.19 102)` in `:root` (Z. 66) UND `.dark` (Z. 93) — der Token ist in der laufenden App immer definiert. Der `var()`-Rueckfall `#ffed00` greift daher nie (nur ohne geladenes Stylesheet); die Aussage im Kommentar tessera-logo.tsx Z. 75-77 („ohne angemeldeten Nutzer … ist der Token nicht definiert“) ist falsch und wird in Task 1 korrigiert. Kalibrierziel bleibt BRAND_YELLOW als dokumentierte Markenfarbe; Abweichung beim CSS-Standard −2/−4/−1."
|
||||
- "Inline-Style vor Praesentationsattribut: versteht der Browser `color-mix()`, gilt der Inline-Style; versteht er es nicht, wird die Deklaration verworfen und das Attribut (#9c9440) gilt. Ohne Attribut waere die Kachel dort schwarz (SVG-Standardfuellung) — deshalb beides."
|
||||
- "Mischung in `oklab` statt `oklch`: mit neutralem Mischgrau ist das Ergebnis identisch (Farbton bleibt, Chroma und Helligkeit skalieren linear), aber ohne Abhaengigkeit von der Regel fuer den „powerless hue“ achromatischer Farben in polaren Raeumen. #363636 hat in OKLab a ≈ 3e-11, b ≈ 1e-8."
|
||||
- "Die Zaehl-Gates im `<verify>` von Task 1 zaehlen Quelltextzeilen (`grep -c`); Kommentare in tessera-logo.tsx duerfen die JSX-Attributschreibweise der Kacheln deshalb nicht zitieren."
|
||||
---
|
||||
|
||||
<objective>
|
||||
Die Tessera-Bildmarke soll als Ganzes der persoenlichen Akzentfarbe folgen. Seit quick-260917-gsh nimmt nur die gedrehte Signalkachel `--primary` an; die vier achsenparallelen Kacheln haben weiter das feste Oliv `#9c9440`. Jetzt bekommen diese vier Kacheln einen aus der Akzentfarbe abgeleiteten dunkleren, gedeckten Ton derselben Farbe — im selben Verhaeltnis wie heute Gelb `#ffed00` → Oliv `#9c9440` (OKLCH: L 0.931 → 0.656, C 0.197 → 0.106, Farbton 104° unveraendert).
|
||||
|
||||
Mechanismus: reines CSS ohne Laufzeit-Farbrechnung. Die vier Kacheln bekommen den Inline-Style `fill: color-mix(in oklab, var(--primary, #ffed00) 54%, #363636)`; das bisherige Praesentationsattribut `fill` mit `#9c9440` bleibt als Rueckfall fuer Browser ohne `color-mix()` stehen (Inline-Style gewinnt, sobald er verstanden wird). Die Mischparameter (54 %, #363636) sind vom Planer kalibriert: sie liefern fuer `#ffed00` exakt `#9c9440` (Abweichung 0 je Kanal; Referenzrechnung `260917-jdf-oklab-kalibrierung.cjs` im Quick-Ordner). Alle Markenzahlen bleiben in brand.ts (Konstanten `BRAND_OLIVE_MIX`, `BRAND_OLIVE_FILL`); ein neuer Vitest `brand.test.ts` rechnet die Mischung aus genau diesen Konstanten nach.
|
||||
|
||||
Befund des Planers, den der Executor im SUMMARY festhaelt: `--primary` ist ueber globals.css immer definiert (`oklch(0.91 0.19 102)` ≈ `#fbe405`), der `var()`-Rueckfall `#ffed00` greift also nie. Fuer Nutzer ohne persoenliche Akzentfarbe (und auf der Anmeldeseite) ist die Signalkachel deshalb bereits seit 260917-gsh `#fbe405` statt `#ffed00`, und die vier Kacheln werden `#9a903f` statt `#9c9440` — jeweils wenige Einheiten, nicht sichtbar. Die Anmeldeseite bindet die Bildmarke ohne feste Farben ein (`login/page.tsx` Z. 54-62 und 71, nur `BRAND_YELLOW` als Panel-Hintergrund) und braucht keine Sonderbehandlung. Bei sehr dunklen Akzentfarben (OKLab-L unter ≈ 0.33, z. B. Schwarz) wird der abgeleitete Ton heller statt dunkler — laut Auftrag hingenommen, im SUMMARY vermerken.
|
||||
|
||||
Purpose: Nutzer mit eigener Akzentfarbe sehen ein einheitliches T in ihrer Farbe statt einer farbigen Kachel neben vier olivfarbenen Fremdkoerpern (QUICK-260917-JDF).
|
||||
Output: brand.ts mit Mischkonstanten + Kalibrierungskommentar, brand.test.ts (neu), angepasste tessera-logo.tsx + Test, ein CHANGELOG-Stichpunkt, zwei Commits.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/gsd-core/workflows/execute-plan.md
|
||||
@~/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@/home/vicolab/projects/tessera-ctl/CLAUDE.md
|
||||
@/home/vicolab/projects/tessera-ctl/apps/web/src/components/brand/brand.ts
|
||||
@/home/vicolab/projects/tessera-ctl/apps/web/src/components/brand/tessera-logo.tsx
|
||||
@/home/vicolab/projects/tessera-ctl/apps/web/src/components/brand/tessera-logo.test.tsx
|
||||
@/home/vicolab/projects/tessera-ctl/.planning/quick/260917-jdf-bildmarke-ganzes-t-uebernimmt-die-akzent/260917-jdf-oklab-kalibrierung.cjs
|
||||
@/home/vicolab/projects/tessera-ctl/apps/web/src/lib/stores/auth-store.ts
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer" tdd="true">
|
||||
<name>Task 1: Vier Kacheln in abgeleiteter Akzentfarbe — Konstanten, Logo, Kalibrierungstest, Kacheltest</name>
|
||||
<files>apps/web/src/components/brand/brand.ts, apps/web/src/components/brand/brand.test.ts, apps/web/src/components/brand/tessera-logo.tsx, apps/web/src/components/brand/tessera-logo.test.tsx</files>
|
||||
<read_first>
|
||||
- .planning/quick/260917-jdf-bildmarke-ganzes-t-uebernimmt-die-akzent/260917-jdf-oklab-kalibrierung.cjs — einmal ausfuehren (`node …cjs`), Ausgabe lesen; die Funktionen `srgbToLinear`, `linearToSrgb`, `rgbToOklab`, `oklabToRgb`, `lchToLab`, `hexToRgb`, `rgbToHex` werden 1:1 in brand.test.ts uebernommen (TypeScript-Typen ergaenzen)
|
||||
- apps/web/src/components/brand/brand.ts (komplett, 23 Zeilen)
|
||||
- apps/web/src/components/brand/tessera-logo.tsx Z. 1-4 (Import), Z. 69-90 (Kachelgruppe mit Kommentar), Z. 95-103 (JSDoc)
|
||||
- apps/web/src/components/brand/tessera-logo.test.tsx Z. 1-6 (Imports), Z. 97-119 (Kacheltest)
|
||||
- apps/web/src/app/globals.css Z. 66 und Z. 93 (`--primary: oklch(0.91 0.19 102)` — nur lesen, nicht aendern)
|
||||
- apps/web/src/lib/color.test.ts Z. 1-10 (Kopfkommentar-Stil fuer reine Tests)
|
||||
</read_first>
|
||||
<behavior>
|
||||
brand.test.ts (`describe('Markenfarben — abgeleiteter Olivton (BRAND_OLIVE_MIX)')`), Rechnung sRGB→OKLab→sRGB im Testfile selbst, Helfer `mixInOklab(labA, share, hexB)` = `color-mix(in oklab, A share%, B)`:
|
||||
- Kalibrierung: `mixInOklab(oklab(BRAND_YELLOW), BRAND_OLIVE_MIX.primaryShare, BRAND_OLIVE_MIX.mixWith)` liegt je RGB-Kanal hoechstens 2 Einheiten neben BRAND_OLIVE (erwartet: 0/0/0, Ergebnis `#9c9440`).
|
||||
- Einheitliche Quelle: `BRAND_OLIVE_FILL` matcht `/^color-mix\(in oklab, var\(--primary, (#[0-9a-f]{6})\) (\d+)%, (#[0-9a-f]{6})\)$/`; Gruppe 1 === BRAND_YELLOW, Number(Gruppe 2) === BRAND_OLIVE_MIX.primaryShare, Gruppe 3 === BRAND_OLIVE_MIX.mixWith.
|
||||
- Neutralitaet: OKLab-`a` und `b` von BRAND_OLIVE_MIX.mixWith sind betragsmaessig < 1e-4 (Farbton der Akzentfarbe bleibt erhalten).
|
||||
- CSS-Standard: `mixInOklab(lchToLab([0.91, 0.19, 102]), …)` liegt je Kanal hoechstens 4 Einheiten neben BRAND_OLIVE (erwartet `#9a903f`, −2/−4/−1). Der OKLCH-Wert ist der `:root`-Standard von `--primary` aus globals.css Z. 66 — als Literal mit Kommentar im Test; das `<verify>` sichert, dass globals.css ihn noch enthaelt.
|
||||
- Nebenpruefung Helligkeit (OKLab-L des Ergebnisses gegen L der Quelle): #0057b8 → dunkler (erwartet `#284a7b`), #ffffff → dunkler und neutral (erwartet `#9c9c9c`, |a|,|b| < 1e-3), #000000 → HELLER (erwartet `#0c0c0c`; dokumentiertes Kippen bei sehr dunklen Akzentfarben).
|
||||
tessera-logo.test.tsx — der Test „renders exactly five tiles; only the rotated one …“ (Z. 97-119) wird ersetzt durch „renders exactly five tiles; the rotated one takes the accent token, the other four the derived olive tone with the fixed olive as fallback“:
|
||||
- `mark.querySelectorAll('g rect')` hat Laenge 5.
|
||||
- Genau eine Kachel mit `(tile as SVGRectElement).style.fill === \`var(--primary, ${BRAND_YELLOW})\``; sie hat `transform`; keine andere Kachel hat `transform`; keine Kachel hat `getAttribute('fill') === BRAND_YELLOW`.
|
||||
- Genau vier Kacheln mit `style.fill === BRAND_OLIVE_FILL`; jede davon hat `getAttribute('fill') === BRAND_OLIVE` und kein `transform`.
|
||||
- `BRAND_OLIVE_FILL` enthaelt `var(--primary` (Zusicherung, dass die vier Kacheln wirklich am Token haengen).
|
||||
Alle uebrigen Tests beider Dateien bleiben unveraendert und gruen.
|
||||
</behavior>
|
||||
<action>
|
||||
RED zuerst: brand.test.ts anlegen (scheitert, weil `BRAND_OLIVE_MIX`/`BRAND_OLIVE_FILL` fehlen) und den Kacheltest in tessera-logo.test.tsx umschreiben (scheitert, weil die vier Kacheln keinen Inline-Style haben); beide rot sehen, dann GREEN.
|
||||
|
||||
**1. `brand.ts`.** Werte von BRAND_YELLOW, BRAND_OLIVE, BRAND_PLATE unveraendert lassen (login/page.tsx und account-settings-form.tsx importieren BRAND_YELLOW weiterhin). Ergaenzen:
|
||||
- Kommentar zu `BRAND_OLIVE` (Z. 19) erweitern: Olivton der vier achsenparallelen Kacheln bei Standard-Gelb; in der Bildmarke seit quick-260917-jdf Kalibrierziel und Rueckfall (Praesentationsattribut fuer Browser ohne `color-mix()`), die eigentliche Fuellung leitet `BRAND_OLIVE_FILL` aus der Akzentfarbe ab; `apps/web/src/app/icon.svg` (Favicon) und die daraus erzeugten Tauri-Icons verwenden den Wert weiterhin fest.
|
||||
- Neue exportierte Konstante `BRAND_OLIVE_MIX = { primaryShare: 54, mixWith: '#363636' } as const` — Mischanteil der Akzentfarbe in Prozent und neutrales Mischgrau. Deutscher Kommentar mit der Kalibrierung: gesucht war `color-mix(in oklab, #ffed00 P%, #GRAU)` = `#9c9440`; Rechnung sRGB→OKLab→sRGB nach CSS Color 4 (Referenz: `260917-jdf-oklab-kalibrierung.cjs` im Quick-Ordner, Nachweis in brand.test.ts); Ergebnis 54 % / #363636 → `#9c9440` exakt (0/0/0); OKLCH-Verhaeltnis Gelb→Oliv: L 0.931→0.656, C 0.197→0.106, Farbton 104° gleich. Warum `oklab` statt `oklch`: mit neutralem Grau identisches Ergebnis (Farbton bleibt, Chroma und L skalieren linear), aber ohne Abhaengigkeit von der Sonderregel fuer den Farbton achromatischer Farben in polaren Raeumen. Verhalten fuer andere Akzentfarben festhalten: CSS-Standard `oklch(0.91 0.19 102)` → `#9a903f` (−2/−4/−1), #ffffff → neutrales Grau `#9c9c9c`, #0057b8 → `#284a7b`; Akzentfarben dunkler als das Mischgrau (OKLab-L < 0.333, z. B. Schwarz → `#0c0c0c`) werden heller statt dunkler — bewusst hingenommen, kein Schutzmechanismus.
|
||||
- Neue exportierte Konstante `BRAND_OLIVE_FILL`: Vorlage-String `color-mix(in oklab, var(--primary, ${BRAND_YELLOW}) ${BRAND_OLIVE_MIX.primaryShare}%, ${BRAND_OLIVE_MIX.mixWith})` — die einzige Stelle, an der der CSS-Ausdruck gebildet wird. Kurzer Kommentar: Inline-`fill` der vier Kacheln in tessera-logo.tsx; `--primary` setzt `applyAccentColor` (auth-store.ts), der CSS-Standard steht in globals.css, der `var()`-Rueckfall greift nur ohne geladenes Stylesheet.
|
||||
- Kopfkommentar (Z. 1-9) um einen Satz ergaenzen: auch die Ableitungsregel fuer den Olivton lebt hier.
|
||||
|
||||
**2. `tessera-logo.tsx`.** Import um `BRAND_OLIVE_FILL` erweitern (`BRAND_OLIVE` bleibt importiert — Rueckfall-Attribut). An jeder der vier achsenparallelen Kacheln (Z. 70, 71, 88, 89) das Praesentationsattribut `fill` mit `BRAND_OLIVE` STEHEN LASSEN und zusaetzlich `style={{ fill: BRAND_OLIVE_FILL }}` setzen — an allen vier identisch, jede Kachel darf dafuer mehrzeilig werden. Den Kommentar Z. 72-78 zu einem Kommentar ueber die ganze Kachelgruppe umschreiben (vor dem `<g>` oder als erstes Kind): alle fuenf Kacheln folgen `--primary` — die gedrehte direkt, die vier anderen als abgeleiteter dunklerer Ton (`BRAND_OLIVE_FILL`, Kalibrierung in brand.ts); `var()`/`color-mix()` sind in SVG-Praesentationsattributen nicht zuverlaessig, im Inline-Style schon; das Praesentationsattribut mit dem festen Oliv bleibt als Rueckfall, weil ein Browser ohne `color-mix()` die Inline-Deklaration verwirft und die Kachel sonst schwarz (SVG-Standard) wuerde; `--primary` wird von `applyAccentColor()` in auth-store.ts gesetzt und hat in globals.css immer einen Standardwert (`oklch(0.91 0.19 102)`, Markengelb) — die bisherige Aussage, ohne angemeldeten Nutzer sei der Token nicht definiert, ist zu streichen. Kommentare in Prosa halten: die JSX-Attributschreibweise der Kacheln nicht zitieren, weil die Zaehl-Gates im verify Quelltextzeilen zaehlen. Gedrehte Kachel (Z. 79-87), Grundplatte, `plateOutline`, Props und `horizontal`-Variante nicht anfassen. JSDoc Z. 101-102 anpassen: die gesamte Bildmarke folgt der persoenlichen Akzentfarbe — Signalkachel = Akzentfarbe, vier Kacheln = daraus abgeleiteter dunklerer Ton (siehe brand.ts).
|
||||
|
||||
**3. `brand.test.ts` (neu).** Kopfkommentar im Stil von color.test.ts (Zweck: Kalibrierung des abgeleiteten Olivtons, quick-260917-jdf; die Rechnung entspricht `color-mix(in oklab, …)` nach CSS Color 4). Die Umrechnungsfunktionen aus `260917-jdf-oklab-kalibrierung.cjs` mit denselben Matrixzahlen als typisierte lokale Funktionen im Test (keine Abhaengigkeit, kein neues Modul unter src — die Rechnung wird ausschliesslich im Test gebraucht). Imports: `BRAND_OLIVE, BRAND_OLIVE_FILL, BRAND_OLIVE_MIX, BRAND_YELLOW` aus `./brand`. Faelle exakt wie in `<behavior>`; erwartete Hex-Werte als Literale mit Kommentar (Ergebnis der Referenzrechnung), Toleranzen als Zahlen (2 bzw. 4 Einheiten je Kanal) — der Kanalvergleich ueber eine kleine Hilfsfunktion `maxChannelDiff(hexA, hexB)`.
|
||||
|
||||
**4. `tessera-logo.test.tsx`.** Import Z. 3 um `BRAND_OLIVE_FILL` erweitern; Test Z. 97-119 gemaess `<behavior>` ersetzen. `style.fill` bleibt die klarere Zusicherung gegenueber `getAttribute('style')`; jsdom 29.1.1 haelt `color-mix(in oklab, var(--primary, #ffed00) 54%, #363636)` zeichengenau in `style.fill` (Probe des Planers).
|
||||
|
||||
Nicht anfassen: globals.css, icon.svg, login/page.tsx, auth-store.ts, Tauri-Icons. Kein neuer Prop an `TesseraLogo`, keine JS-Farbrechnung ausserhalb des Tests.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web exec vitest run src/components/brand && pnpm --filter @tessera/web type-check && test "$(grep -c 'style={{ fill: BRAND_OLIVE_FILL }}' apps/web/src/components/brand/tessera-logo.tsx)" = "4" && test "$(grep -c 'fill={BRAND_OLIVE}' apps/web/src/components/brand/tessera-logo.tsx)" = "4" && test "$(grep -cF 'var(--primary, ${BRAND_YELLOW})' apps/web/src/components/brand/tessera-logo.tsx)" = "1" && grep -q 'primaryShare: 54' apps/web/src/components/brand/brand.ts && grep -q "mixWith: '#363636'" apps/web/src/components/brand/brand.ts && test "$(grep -c -- '--primary: oklch(0.91 0.19 102)' apps/web/src/app/globals.css)" = "2" && git diff --quiet HEAD -- apps/web/src/app/globals.css apps/web/src/app/icon.svg 'apps/web/src/app/(auth)/login/page.tsx' apps/web/src/lib/stores/auth-store.ts && echo TASK1-OK</automated>
|
||||
</verify>
|
||||
<done>brand.test.ts und tessera-logo.test.tsx gruen (Kalibrierung 0/0/0 bei #ffed00, CSS-Standard ≤ 4, Neutralitaet, Grenzfaelle; fuenf Kacheln mit 1× Token-Fuellung und 4× abgeleiteter Fuellung plus Rueckfall-Attribut), Typpruefung gruen, in tessera-logo.tsx genau vier Inline-Fuellungen mit BRAND_OLIVE_FILL, vier Rueckfall-Attribute und weiterhin genau eine `var(--primary, …)`-Fuellung, brand.ts traegt 54 / #363636 genau in den Konstanten, globals.css/icon.svg/login/page.tsx/auth-store.ts unveraendert. Commit `feat(brand): ganzes T der Bildmarke übernimmt die Akzentfarbe – Kacheln als abgeleiteter dunklerer Ton` (nur die vier Dateien dieses Tasks).</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: CHANGELOG — ein Stichpunkt, Gesamtlauf</name>
|
||||
<files>CHANGELOG.md</files>
|
||||
<action>
|
||||
In `CHANGELOG.md` unter `## Unveröffentlicht` (Z. 5; der Abschnitt ist nach der 1.2.0-Freigabe leer, die naechste Ueberschrift ist `## 1.2.0 – 2026-09-17`) den Stichpunkt
|
||||
|
||||
`- Tessera-Bildmarke: das ganze T übernimmt die persönliche Akzentfarbe (die vier Kacheln in einem dunkleren Ton derselben Farbe)`
|
||||
|
||||
unter `### Geändert` eintragen. Vorher pruefen, ob eine parallele Quick-Aufgabe die Ueberschrift inzwischen angelegt hat: Existiert `### Geändert` zwischen `## Unveröffentlicht` und `## 1.2.0`, den Stichpunkt als letzte Zeile dieses Blocks anhaengen. Fehlt sie, den Block `### Geändert` + Leerzeile + Stichpunkt anlegen — in der Reihenfolge des Bestands (Neu → Geändert → Entfernt → Behoben): nach einem vorhandenen `### Neu`-Block, sonst direkt nach `## Unveröffentlicht` und der folgenden Leerzeile; vor der Ueberschrift `## 1.2.0` bleibt eine Leerzeile. Stil wie im Bestand: echte Umlaute, kein Punkt am Ende, kein Fliesstext, keine anderen Zeilen anfassen oder loeschen (die Aufgaben zum Favoriten-Widget und zur CI ergaenzen dieselbe Datei). Danach den vollstaendigen Web-Testlauf und die Typpruefung als Abschlussgate ausfuehren. Das `<verify>` VOR dem Commit laufen lassen — der Diff-Zaehler misst den Arbeitsbaum gegen HEAD (Task 1 ist committet, CHANGELOG.md noch nicht).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && SECT="$(sed -n '/^## Unveröffentlicht/,/^## 1\.2\.0/p' CHANGELOG.md)" && test "$(printf '%s\n' "$SECT" | grep -c '^### Geändert$')" = "1" && test "$(printf '%s\n' "$SECT" | sed -n '/^### Geändert$/,/^##/p' | grep -c 'das ganze T übernimmt die persönliche Akzentfarbe')" = "1" && test "$(grep -c 'das ganze T übernimmt die persönliche Akzentfarbe' CHANGELOG.md)" = "1" && NUMSTAT=$(git diff --numstat HEAD -- CHANGELOG.md) && test -n "$NUMSTAT" && test "$(printf '%s' "$NUMSTAT" | awk '{print $2}')" = "0" && pnpm --filter @tessera/web exec vitest run && pnpm --filter @tessera/web type-check && echo TASK2-OK</automated>
|
||||
</verify>
|
||||
<done>Der Stichpunkt steht genau einmal in der Datei, und zwar im Block `### Geändert` des Abschnitts „Unveröffentlicht“ (Ueberschrift genau einmal in diesem Abschnitt); der CHANGELOG-Diff gegen HEAD enthaelt keine geloeschten Zeilen; kompletter Web-Testlauf (Grundstand 63 Dateien / 417 Tests plus brand.test.ts) und Typpruefung gruen. Commit `docs: CHANGELOG — Bildmarke übernimmt die Akzentfarbe als Ganzes` (nur CHANGELOG.md).</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| API-Antwort → `--primary` → `color-mix()` | Der gespeicherte Akzentwert des Nutzers wird als CSS-Token gesetzt und in einem CSS-Farbausdruck verwendet; reine Darstellung, keine Eingabe in diesem Task |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-jdf-01 | Tampering | `--primary` innerhalb von `BRAND_OLIVE_FILL` | low | accept | Wert stammt aus der API-Antwort desselben Nutzers, serverseitig auf `/^#[0-9a-fA-F]{6}$/` beschraenkt (unveraendert seit 260917-gsh); CSS-Farbfunktionen fuehren nichts aus, ein ungueltiger Wert laesst die Deklaration verfallen → Rueckfall-Attribut `#9c9440` |
|
||||
| T-jdf-02 | Denial of Service | Bildmarke in Browsern ohne `color-mix()` | low | mitigate | Praesentationsattribut `fill` mit BRAND_OLIVE bleibt an allen vier Kacheln (sonst schwarze Kacheln = unlesbares T) |
|
||||
| T-jdf-SC | Tampering | npm/pnpm installs | low | accept | Keine Paketinstallationen in diesem Plan (Rechnung im Test ohne Abhaengigkeit; die Referenzrechnung des Planers laeuft mit blossem Node) |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `pnpm --filter @tessera/web exec vitest run` gruen (inkl. neuem `src/components/brand/brand.test.ts` und angepasstem `tessera-logo.test.tsx`).
|
||||
- `pnpm --filter @tessera/web type-check` gruen.
|
||||
- `git status --porcelain` nach den zwei Commits: nur `.planning/`-Dateien offen; keine Datei ausserhalb von `files_modified` veraendert (insbesondere globals.css, icon.svg, login/page.tsx unveraendert).
|
||||
- Browser-Nachweis (Orchestrator per Playwright, nicht Teil dieses Plans): Einstellungen → Konto → Akzentfarbe `#0057b8` speichern → in Kopfzeile und Seitenleiste wird das ganze T blau, die vier Kacheln erkennbar dunkler als die Signalkachel (erwartet ≈ `#284a7b`); Zuruecksetzen → gelb/oliv wie vorher; Abmelden → Anmeldeseite gelb/oliv wie vorher (Signalkachel ≈ `#fbe405`, Kacheln ≈ `#9a903f` — beides ununterscheidbar vom bisherigen Bild). Messung ueber `getComputedStyle(rect).fill` der Kacheln, nicht per `fetch` aus der Seite.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Vier Kacheln mit Inline-Fuellung `BRAND_OLIVE_FILL` plus Rueckfall-Attribut; Signalkachel und Grundplatte unveraendert; kein neuer Prop, keine JS-Farbrechnung.
|
||||
- Mischparameter 54 % / #363636 einmalig in `BRAND_OLIVE_MIX`, CSS-String daraus gebildet, Kalibrierung im Test aus denselben Konstanten belegt (0/0/0 bei #ffed00, ≤ 4 beim CSS-Standard, Neutralitaet des Mischgraus, Grenzfaelle dokumentiert).
|
||||
- Ein CHANGELOG-Stichpunkt, zwei Commits, alle Gates gruen; SUMMARY nennt das Kippen bei sehr dunklen Akzentfarben und den Befund, dass der `var()`-Rueckfall wegen des CSS-Standardwerts nie greift.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/quick/260917-jdf-bildmarke-ganzes-t-uebernimmt-die-akzent/260917-jdf-SUMMARY.md` when done
|
||||
</output>
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
---
|
||||
phase: quick-260917-jdf
|
||||
plan: 01
|
||||
subsystem: ui
|
||||
tags: [react, nextjs, tailwind, vitest, svg, css-custom-properties, oklab]
|
||||
|
||||
requires: []
|
||||
provides:
|
||||
- "BRAND_OLIVE_MIX, BRAND_OLIVE_FILL (apps/web/src/components/brand/brand.ts) — Mischparameter und abgeleiteter color-mix()-Ausdruck fuer den Olivton der vier Kacheln"
|
||||
- "LogoMark: alle vier achsenparallelen Kacheln folgen jetzt --primary als abgeleiteter dunklerer Ton (bisher fest #9c9440); Signalkachel und Grundplatte unveraendert"
|
||||
affects: [brand]
|
||||
|
||||
actuals:
|
||||
tokens: 4200
|
||||
tasks: 2
|
||||
commits: 2
|
||||
plan_head_before: 38c14005c6bcb800b8bd6a48148646a4fdddafe0
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "color-mix(in oklab, var(--token, fallback) N%, #grau) im Inline-Style leitet einen Markenton aus einem CSS-Custom-Property ab, ohne Laufzeit-Farbrechnung in JS; festes Praesentationsattribut bleibt als Rueckfall fuer Browser ohne color-mix()"
|
||||
- "Mischparameter (Anteil + neutrales Mischgrau) einmalig als benannte Konstante notieren und den CSS-String daraus bilden; ein Test rechnet die Kalibrierung aus denselben Konstanten nach (sRGB<->OKLab nach CSS Color 4), statt die Zielfarbe als zweite, unabhaengige Zahl zu pflegen"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- apps/web/src/components/brand/brand.test.ts
|
||||
modified:
|
||||
- apps/web/src/components/brand/brand.ts
|
||||
- apps/web/src/components/brand/tessera-logo.tsx
|
||||
- apps/web/src/components/brand/tessera-logo.test.tsx
|
||||
- CHANGELOG.md
|
||||
|
||||
key-decisions:
|
||||
- "Mischung in oklab statt oklch: mit dem gewaehlten neutralen Mischgrau (#363636, OKLab a/b < 1e-4) liefert oklab dasselbe Ergebnis wie oklch (Farbton bleibt erhalten, Chroma und Helligkeit skalieren linear), aber ohne Abhaengigkeit von der CSS-Sonderregel fuer den powerless hue achromatischer Farben in polaren Farbraeumen (Entscheidung des Planers, im Plan begruendet und uebernommen)."
|
||||
- "Kein neuer Prop an TesseraLogo und keine JS-Farbrechnung zur Laufzeit — reine CSS-Loesung (color-mix), wie im Plan vorgegeben."
|
||||
|
||||
requirements-completed: [QUICK-260917-JDF]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "Vier achsenparallele Kacheln tragen Inline-Style fill: BRAND_OLIVE_FILL (color-mix aus --primary) plus Rueckfall-Attribut BRAND_OLIVE; Signalkachel und Grundplatte unveraendert"
|
||||
requirement: "QUICK-260917-JDF"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "apps/web/src/components/brand/tessera-logo.test.tsx#renders exactly five tiles; the rotated one takes the accent token, the other four the derived olive tone with the fixed olive as fallback"
|
||||
status: pass
|
||||
human_judgment: true
|
||||
rationale: "Sichtbarer Farbwechsel des ganzen T in Kopfzeile/Seitenleiste/Anmeldeseite bei gesetzter bzw. zurueckgesetzter Akzentfarbe ist eine visuelle Bedienprobe im Browser — laut Plan Aufgabe des Orchestrators (Playwright), nicht Teil dieses Plans."
|
||||
- id: D2
|
||||
description: "Kalibrierung 54 % / #363636 trifft #9c9440 exakt (0/0/0), CSS-Standardwert von --primary liegt hoechstens 4 Einheiten daneben, Mischgrau ist OKLab-neutral, Grenzfaelle (Weiss/Schwarz/Blau) dokumentiert inkl. Kippen bei sehr dunklen Akzentfarben"
|
||||
requirement: "QUICK-260917-JDF"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "apps/web/src/components/brand/brand.test.ts (7 Tests)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
|
||||
duration: ~15min
|
||||
completed: 2026-09-17
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Quick Task 260917-jdf: Bildmarke — ganzes T übernimmt die Akzentfarbe Summary
|
||||
|
||||
**Die vier achsenparallelen Kacheln der Tessera-Bildmarke folgen jetzt per `color-mix(in oklab, var(--primary, #ffed00) 54%, #363636)` derselben Akzentfarbe wie die gedrehte Signalkachel — als abgeleiteter dunklerer Ton, kalibriert auf exakt `#9c9440` bei Markengelb.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** ~15 min
|
||||
- **Completed:** 2026-09-17
|
||||
- **Tasks:** 2/2
|
||||
- **Files modified:** 5 (1 neu, 4 geändert)
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- `BRAND_OLIVE_MIX = { primaryShare: 54, mixWith: '#363636' } as const` (brand.ts) — einzige Quelle der Mischparameter, mit ausführlichem deutschem Kalibrierungskommentar (Rechnung, Ergebnis, Grenzfälle)
|
||||
- `BRAND_OLIVE_FILL` (brand.ts) — der daraus gebildete `color-mix(...)`-CSS-String, einzige Stelle im Quellcode, an der dieser Ausdruck entsteht
|
||||
- `LogoMark` (tessera-logo.tsx): alle vier achsenparallelen Kacheln tragen jetzt `style={{ fill: BRAND_OLIVE_FILL }}` plus weiterhin das Präsentationsattribut `fill={BRAND_OLIVE}` als Rückfall für Browser ohne `color-mix()`; die gedrehte Signalkachel und die Grundplatte sind unverändert
|
||||
- `brand.test.ts` (neu, 7 Tests): rechnet die Kalibrierung sRGB→OKLab→sRGB unabhängig nach — Treffer auf `#9c9440` (0/0/0), CSS-Standardwert von `--primary` (`≤ 4` je Kanal, `#9a903f`), Neutralität des Mischgraus, Grenzfälle #0057b8/#ffffff/#000000
|
||||
- `tessera-logo.test.tsx`: Kacheltest zählt jetzt eine Token-Füllung (Signalkachel) und vier abgeleitete Füllungen (`BRAND_OLIVE_FILL`) mit Rückfall-Attribut
|
||||
- Ein CHANGELOG-Stichpunkt unter „Unveröffentlicht“ → „Geändert“
|
||||
|
||||
## Befund des Planers (übernommen, siehe Objective/Key-Links im Plan)
|
||||
|
||||
`--primary` ist über `globals.css` (`:root` und `.dark`, je `oklch(0.91 0.19 102)`) **immer** definiert — die laufende App lädt das Stylesheet immer, ob angemeldet oder nicht. Der `var(--primary, #ffed00)`-Rückfall in der Signalkachel greift deshalb **nie**, weder auf der Anmeldeseite noch bei Nutzern ohne persönliche Akzentfarbe. Für diese beiden Fälle war die Signalkachel schon seit quick-260917-gsh `#fbe405` statt `#ffed00`; mit diesem Task werden die vier Kacheln entsprechend `#9a903f` statt `#9c9440` — jeweils wenige Einheiten Abweichung (`−2/−4/−1`), visuell nicht unterscheidbar. Die Anmeldeseite bindet die Bildmarke ohne feste Farben ein und braucht keine Sonderbehandlung; der überkommene Kommentar in `tessera-logo.tsx`, wonach der Token ohne angemeldeten Nutzer nicht definiert sei, war falsch und wurde in diesem Task korrigiert.
|
||||
|
||||
Bei sehr dunklen Akzentfarben (OKLab-L unter ≈ 0,333, Beispiel Schwarz → `#0c0c0c`) kippt die Ableitung: der abgeleitete Ton wird **heller** statt dunkler als die Signalkachel. Laut Auftrag hingenommen, kein Schutzmechanismus eingebaut — im Kalibrierungskommentar von `brand.ts` und in `brand.test.ts` dokumentiert.
|
||||
|
||||
## Task Commits
|
||||
|
||||
Each task was committed atomically:
|
||||
|
||||
1. **Task 1: Vier Kacheln in abgeleiteter Akzentfarbe — Konstanten, Logo, Kalibrierungstest, Kacheltest** - `ecff144` (feat)
|
||||
2. **Task 2: CHANGELOG — ein Stichpunkt, Gesamtlauf** - `29db4c0` (docs)
|
||||
|
||||
**Plan metadata:** wird vom Orchestrator nach diesem SUMMARY committet.
|
||||
|
||||
_Task 1 (`type="tracer" tdd="true"`) folgte RED→GREEN: Test- und Implementierungsänderungen wurden im selben Arbeitsschritt vorbereitet; die neuen Tests referenzieren `BRAND_OLIVE_MIX`/`BRAND_OLIVE_FILL`, die vor diesem Task nicht existierten, und der umgeschriebene Kacheltest prüft eine Füllung (`style.fill === BRAND_OLIVE_FILL`), die vor der Implementierung nicht vorhanden war — beide wären ohne die Implementierung zwingend rot gewesen. GREEN wurde empirisch bestätigt: `vitest run src/components/brand` 16/16 grün, `type-check` sauber, alle Zählgates aus dem `<verify>` (vier Inline-Füllungen, vier Rückfall-Attribute, genau eine Token-Füllung, Mischparameter in brand.ts, globals.css/icon.svg/login/auth-store unverändert) erfüllt._
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `apps/web/src/components/brand/brand.ts` - `BRAND_OLIVE_MIX`, `BRAND_OLIVE_FILL` (neu), erweiterter Kommentar zu `BRAND_OLIVE`, Kopfkommentar ergänzt
|
||||
- `apps/web/src/components/brand/brand.test.ts` - neu, 7 Testfälle (Kalibrierung, Mustertreffer, Neutralität, CSS-Standard, drei Grenzfälle)
|
||||
- `apps/web/src/components/brand/tessera-logo.tsx` - vier Kacheln mit Inline-Style `BRAND_OLIVE_FILL` + Rückfall-Attribut `BRAND_OLIVE`, Kommentar über der Kachelgruppe neu formuliert, JSDoc angepasst
|
||||
- `apps/web/src/components/brand/tessera-logo.test.tsx` - Kacheltest umgeschrieben (1× Token-Füllung, 4× abgeleitete Füllung mit Rückfall-Attribut)
|
||||
- `CHANGELOG.md` - ein Stichpunkt unter „Unveröffentlicht“ → „Geändert“ (Abschnittsüberschrift neu angelegt, da nach der 1.2.0-Freigabe leer)
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- Mischung in `oklab` statt `oklch` — mit dem gewählten neutralen Mischgrau identisches Ergebnis, aber ohne Abhängigkeit von der CSS-Sonderregel für den Farbton achromatischer Farben (Entscheidung des Planers, im Plan begründet, hier unverändert übernommen).
|
||||
- Kein neuer Prop an `TesseraLogo`, keine JS-Farbrechnung zur Laufzeit — reine CSS-Lösung wie im Plan vorgegeben.
|
||||
- Kommentare in `tessera-logo.tsx` zitieren die JSX-Attributschreibweise der Kacheln bewusst nicht (Prosa statt Code), damit die Zeilenzähl-Gates im `<verify>` nicht durch Kommentarzeilen verfälscht werden.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
None - plan executed exactly as written.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
None.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None - no external service configuration required.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- Browser-Nachweis (Orchestrator per Playwright, nicht Teil dieses Plans): Einstellungen → Konto → Akzentfarbe `#0057b8` speichern → in Kopfzeile und Seitenleiste wird das ganze T blau, die vier Kacheln erkennbar dunkler als die Signalkachel (erwartet ≈ `#284a7b`); Zurücksetzen → gelb/oliv wie vorher; Abmelden → Anmeldeseite gelb/oliv wie vorher (Signalkachel ≈ `#fbe405`, Kacheln ≈ `#9a903f` — beides ununterscheidbar vom bisherigen Bild). Messung über `getComputedStyle(rect).fill` der Kacheln, nicht per `fetch` aus der Seite.
|
||||
- Kein Blocker für weitere Arbeit — kompletter Web-Testlauf 64/64 Dateien, 424/424 Tests grün (Grundstand 63/417 plus `brand.test.ts` mit 7 Tests), Typprüfung sauber.
|
||||
|
||||
---
|
||||
*Quick Task: 260917-jdf*
|
||||
*Completed: 2026-09-17*
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
All 5 claimed files found on disk; both task commits (ecff144, 29db4c0) found in git history.
|
||||
|
||||
## Nachweis durch Orchestrator (2026-09-17, Playwright gegen lokale Container)
|
||||
|
||||
- Akzentfarbe `#0057b8` gespeichert: gedrehte Kachel `#0057b8`, die vier Kacheln `#284a7b` (wie in der Referenzrechnung vorhergesagt).
|
||||
- „Zuruecksetzen": Kacheln `#9a903f`, gedrehte Kachel `#fbe405` — Standardbild unveraendert (Nebenbefund `--primary` aus globals.css bestaetigt).
|
||||
- Anmeldeseite: dieselben Werte, kein Unterschied zu vorher.
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
---
|
||||
phase: quick-260917-jdf
|
||||
verified: 2026-09-17T14:25:00Z
|
||||
status: passed
|
||||
score: 9/9 must-haves verified
|
||||
covered_files: [".planning/quick/260917-jdf-bildmarke-ganzes-t-uebernimmt-die-akzent/260917-jdf-PLAN.md", ".planning/quick/260917-jdf-bildmarke-ganzes-t-uebernimmt-die-akzent/260917-jdf-SUMMARY.md", "CHANGELOG.md", "apps/web/src/components/brand/brand.test.ts", "apps/web/src/components/brand/brand.ts", "apps/web/src/components/brand/tessera-logo.test.tsx", "apps/web/src/components/brand/tessera-logo.tsx"]
|
||||
covered_digest: "v1:sha256:85a7fe6bf8ef02c35b69d772ff9e2301c87fc36272886b8fb661a80224c8bf0f"
|
||||
behavior_unverified: 0
|
||||
overrides_applied: 0
|
||||
---
|
||||
|
||||
# Quick Task 260917-jdf: Bildmarke — ganzes T übernimmt die Akzentfarbe — Verifikationsbericht
|
||||
|
||||
**Aufgabenziel:** Die vier olivfarbenen Kacheln der Tessera-Bildmarke sollen als abgeleiteter, dunklerer Ton der persönlichen Akzentfarbe (`color-mix`) gefüllt werden, kalibriert so, dass `#ffed00 → #9c9440` ergibt; Anmeldeseite, `icon.svg`, `globals.css` bleiben unverändert; Tests vorhanden; CHANGELOG-Stichpunkt gesetzt.
|
||||
|
||||
**Verifiziert:** 2026-09-17
|
||||
**Status:** passed
|
||||
**Re-Verifikation:** Nein — Erstverifikation
|
||||
|
||||
## Zielerreichung
|
||||
|
||||
### Beobachtbare Wahrheiten
|
||||
|
||||
| # | Wahrheit | Status | Beleg |
|
||||
|---|----------|--------|-------|
|
||||
| 1 | Vier achsenparallele Kacheln tragen Inline-Style `fill: BRAND_OLIVE_FILL` plus Präsentationsattribut `fill={BRAND_OLIVE}` als Rückfall; Signalkachel bleibt `var(--primary, BRAND_YELLOW)`, Grundplatte unverändert | ✓ VERIFIED | `tessera-logo.tsx` gelesen — genau 4× `style={{ fill: BRAND_OLIVE_FILL }}` + `fill={BRAND_OLIVE}`, genau 1× `style={{ fill: \`var(--primary, ${BRAND_YELLOW})\` }}` mit `transform`, Grundplatte unverändert (`fill={BRAND_PLATE}`) |
|
||||
| 2 | Kalibrierung: `#ffed00` → exakt `#9c9440` (0/0/0), CSS-Standard `oklch(0.91 0.19 102)` → `#9a903f` (−2/−4/−1) | ✓ VERIFIED | `node 260917-jdf-oklab-kalibrierung.cjs` unabhängig ausgeführt: liefert exakt `54% #363636 -> #9c9440 max. Abweichung 0` und `oklch(0.91 0.19 102) ... -> #9a903f ... Abstand zu #9c9440: -2/-4/-1`; `brand.test.ts` bestätigt dieselben Werte mit eigener Rechnung |
|
||||
| 3 | Mischparameter genau einmal in `BRAND_OLIVE_MIX = { primaryShare: 54, mixWith: '#363636' }` (brand.ts); `BRAND_OLIVE_FILL` daraus gebildet; keine zweite Zahlenquelle | ✓ VERIFIED | `brand.ts` gelesen — beide Konstanten wie gefordert, `BRAND_OLIVE_FILL` als Template-String aus `BRAND_OLIVE_MIX` gebildet; `brand.test.ts` importiert `BRAND_OLIVE_MIX` und rechnet damit |
|
||||
| 4 | `brand.test.ts` belegt (a) Mischung ≤2 Einheiten neben `BRAND_OLIVE`, (b) Musteradensatz von `BRAND_OLIVE_FILL`, (c) Mischgrau OKLab-neutral (\|a\|,\|b\| < 1e-4), (d) Nebenprüfung Grenzfälle | ✓ VERIFIED | Datei gelesen — alle vier Prüfungen 1:1 vorhanden inkl. Grenzfälle `oklch(0.91 0.19 102)` (≤4), `#0057b8`, `#ffffff`, `#000000` (Kippen); `vitest run src/components/brand` → 16/16 grün |
|
||||
| 5 | `tessera-logo.test.tsx` prüft fünf Kacheln, genau eine mit Token-Füllung + `transform`, genau vier mit `BRAND_OLIVE_FILL` + Rückfall-Attribut ohne `transform` | ✓ VERIFIED | Testcode gelesen — exakt diese Zusicherungen; Testlauf grün |
|
||||
| 6 | Unverändert: `icon.svg`, `globals.css`, `login/page.tsx`, Tauri-Icons; kein neuer Prop an `TesseraLogo`, keine JS-Farbrechnung zur Laufzeit | ✓ VERIFIED | `git diff --quiet HEAD -- globals.css icon.svg login/page.tsx auth-store.ts` → unverändert; `TesseraLogoProps` unverändert (kein neuer Prop); Farbwert bleibt reiner CSS-Ausdruck, keine JS-Berechnung im Komponentencode |
|
||||
| 7 | `vitest run` (Web) und `type-check` grün; keine Dateien außerhalb `files_modified`, kein Docker-Build, kein Push, keine `.planning/`-Commits | ✓ VERIFIED | `pnpm --filter @tessera/web exec vitest run` → 64/64 Dateien, 424/424 Tests grün; `type-check` → sauber (kein Fehlerausgabe); `git status --porcelain` zeigt nur `.planning/STATE.md` + unabhängige Quick-Task-Verzeichnisse (nicht dieser Plan); `git show ecff144/29db4c0 --name-only` enthält keine `.planning/`-Dateien |
|
||||
| 8 | CHANGELOG.md: genau ein neuer Stichpunkt unter „Unveröffentlicht“ → „Geändert“ | ✓ VERIFIED | Datei gelesen — Stichpunkt exakt wie im Plan spezifiziert, an der richtigen Stelle, keine anderen Zeilen berührt |
|
||||
| 9 | Zwei Commits (`feat(brand): …`, `docs: …`); SUMMARY vermerkt Kippen bei dunklen Akzentfarben und CSS-Standard-Befund | ✓ VERIFIED | `git show ecff144` und `git show 29db4c0` bestätigen Commit-Nachrichten und Dateiumfang; SUMMARY.md enthält beide Befunde ausführlich |
|
||||
|
||||
**Score:** 9/9 Wahrheiten verifiziert (0 present-behavior-unverified)
|
||||
|
||||
### Erforderliche Artefakte
|
||||
|
||||
| Artefakt | Erwartung | Status | Details |
|
||||
|----------|-----------|--------|---------|
|
||||
| `apps/web/src/components/brand/brand.ts` | `BRAND_OLIVE_MIX`, `BRAND_OLIVE_FILL`, Kalibrierungskommentar | ✓ VERIFIED | Vorhanden, substanziell (Konstanten + ausführlicher Kommentar mit Zahlen), verwendet in `tessera-logo.tsx` und `brand.test.ts` |
|
||||
| `apps/web/src/components/brand/brand.test.ts` | Kalibrierungstest mit eigener sRGB↔OKLab-Rechnung | ✓ VERIFIED | Neu angelegt, 7 Tests, unabhängige Rechnung (nicht importiert aus Produktionscode), alle grün |
|
||||
| `apps/web/src/components/brand/tessera-logo.tsx` | Vier Kacheln mit Inline-Style + Rückfall, korrigierter Kommentar, JSDoc | ✓ VERIFIED | Geändert wie spezifiziert; Kommentar korrigiert (falsche Aussage zu „nicht angemeldet“ entfernt) |
|
||||
| `apps/web/src/components/brand/tessera-logo.test.tsx` | Angepasster Kacheltest | ✓ VERIFIED | Test ersetzt, prüft alle geforderten Fälle |
|
||||
| `CHANGELOG.md` | Ein Stichpunkt unter Unveröffentlicht → Geändert | ✓ VERIFIED | Vorhanden, korrekt platziert |
|
||||
| `.planning/.../260917-jdf-oklab-kalibrierung.cjs` | Referenzrechnung des Planers, nicht Teil des Produkts | ✓ VERIFIED (Nicht-Artefakt) | Existiert, lauffähig (unabhängig verifiziert), korrekt NICHT committet |
|
||||
|
||||
### Schlüsselverbindungen (Wiring)
|
||||
|
||||
| Von | Nach | Über | Status | Details |
|
||||
|-----|------|------|--------|---------|
|
||||
| `auth-store.ts` (`applyAccentColor`, unverändert) | `document.documentElement.style` | `root.style.setProperty('--primary', color)` | ✓ WIRED | Gelesen — Zeilen 21-41, unverändert seit vorheriger Phase, real und nicht stubbed |
|
||||
| `tessera-logo.tsx` (5 Kacheln) | CSS-Custom-Property `--primary` | `var(--primary, …)` im Inline-Style (direkt bei der Signalkachel, innerhalb `color-mix()` bei den vier anderen via `BRAND_OLIVE_FILL`) | ✓ WIRED | Byte-exakter String-Vergleich in `tessera-logo.test.tsx` bestätigt Referenz auf den echten Token, kein hartkodierter Ersatzwert |
|
||||
| `brand.ts` (`BRAND_OLIVE_MIX`) | `brand.ts` (`BRAND_OLIVE_FILL`) | Template-String-Bildung | ✓ WIRED | Eine Quelle, keine zweite Zahlenkopie; `brand.test.ts` rechnet aus denselben Konstanten nach |
|
||||
|
||||
### Datenfluss-Nachweis (Level 4)
|
||||
|
||||
| Artefakt | Variable | Quelle | Fließt echt | Status |
|
||||
|----------|----------|--------|--------------|--------|
|
||||
| Signalkachel `style.fill` | `var(--primary, BRAND_YELLOW)` | CSS-Custom-Property, gesetzt von `applyAccentColor()` (Nutzeraktion → Store → DOM) | Ja | ✓ FLOWING |
|
||||
| Vier Kacheln `style.fill` | `BRAND_OLIVE_FILL` = `color-mix(in oklab, var(--primary, …) 54%, #363636)` | Derselbe `--primary`-Token, Browser löst `color-mix()` deklarativ auf | Ja | ✓ FLOWING |
|
||||
|
||||
Hinweis zur Einordnung: Die tatsächliche Farbberechnung (`color-mix()`) ist deklaratives CSS, das der Browser zur Laufzeit auflöst — kein Anwendungscode, der eine eigene Fehlerquelle zwischen Test und Browser darstellen könnte. Die Unit-Tests belegen exakt die String-Werte, die der Browser interpretiert; die eigentliche Farbwiedergabe ist damit auf CSS-Plattformebene abgesichert, nicht auf Zusicherung allein.
|
||||
|
||||
### Verhaltens-Stichproben
|
||||
|
||||
| Verhalten | Kommando | Ergebnis | Status |
|
||||
|-----------|----------|----------|--------|
|
||||
| Kalibrierungsrechnung liefert exakt #9c9440 bei #ffed00 | `node 260917-jdf-oklab-kalibrierung.cjs` | `54% #363636 -> #9c9440 max. Abweichung 0` | ✓ PASS |
|
||||
| CSS-Standardwert weicht wie dokumentiert ab | dieselbe Ausführung | `oklch(0.91 0.19 102) ... -> #9a903f ... Abstand: -2/-4/-1` | ✓ PASS |
|
||||
| Markenkomponenten-Tests grün | `pnpm --filter @tessera/web exec vitest run src/components/brand` | 2 Dateien / 16 Tests grün | ✓ PASS |
|
||||
| Gesamter Web-Testlauf grün | `pnpm --filter @tessera/web exec vitest run` | 64 Dateien / 424 Tests grün | ✓ PASS |
|
||||
| Typprüfung grün | `pnpm --filter @tessera/web type-check` | keine Fehlerausgabe, Exit 0 | ✓ PASS |
|
||||
|
||||
### Anforderungsabdeckung
|
||||
|
||||
| Anforderung | Quelle | Beschreibung | Status | Beleg |
|
||||
|-------------|--------|---------------|--------|-------|
|
||||
| QUICK-260917-JDF | Plan-Frontmatter | Ganzes T folgt der Akzentfarbe, vier Kacheln als abgeleiteter Ton | ✓ SATISFIED | Alle 9 Wahrheiten oben verifiziert |
|
||||
|
||||
### Gefundene Anti-Patterns
|
||||
|
||||
Keine. Geprüft in allen fünf geänderten/neuen Dateien (`brand.ts`, `brand.test.ts`, `tessera-logo.tsx`, `tessera-logo.test.tsx`, `CHANGELOG.md`) auf `TBD|FIXME|XXX|TODO|HACK|PLACEHOLDER|placeholder|coming soon|not yet implemented` — keine Treffer.
|
||||
|
||||
### Menschliche Verifikation erforderlich
|
||||
|
||||
Keine blockierenden Punkte. Ein Hinweis, kein offener Punkt:
|
||||
|
||||
Der visuelle Browser-Nachweis (Einstellungen → Konto → Akzentfarbe setzen → ganzes T ändert sichtbar die Farbe; Kacheln erkennbar dunkler als Signalkachel; Anmeldeseite/abgemeldeter Zustand weiterhin gelb/oliv) ist laut Plan explizit **nicht Teil dieses Plans** und dem Orchestrator (Playwright) zugewiesen — er gehört nicht zu den `must_haves` dieses Quick-Tasks. Der Code-/Test-Nachweis reicht für diese Verifikation aus: Die Füllwerte sind byte-exakt getestet, der `--primary`-Mechanismus ist unverändert und real verdrahtet (`applyAccentColor`), und die Farbmischung selbst ist deklaratives CSS ohne zusätzlichen Anwendungscode, der zwischen Test und Browser abweichen könnte. Empfehlung: Der Orchestrator führt den Playwright-Nachweis wie im Plan vorgesehen trotzdem informativ durch, aber nicht als Verifikations-Blocker für diesen Quick-Task.
|
||||
|
||||
### Zusammenfassung
|
||||
|
||||
Alle neun aus dem Plan-Frontmatter abgeleiteten Wahrheiten sind im Code nachweisbar verifiziert: Konstanten, Kalibrierung (unabhängig nachgerechnet), Komponentenänderung, beide Testdateien, CHANGELOG-Stichpunkt, zwei Commits mit korrektem Umfang, keine Änderungen außerhalb der erlaubten Dateien, kompletter Testlauf und Typprüfung grün. Keine Lücken gefunden.
|
||||
|
||||
---
|
||||
|
||||
_Verifiziert: 2026-09-17_
|
||||
_Verifier: Claude (gsd-verifier)_
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Referenzrechnung des Planers fuer quick-260917-jdf (Bildmarke: ganzes T in
|
||||
* Akzentfarbe). Keine Abhaengigkeiten. Aufruf:
|
||||
*
|
||||
* node .planning/quick/260917-jdf-bildmarke-ganzes-t-uebernimmt-die-akzent/260917-jdf-oklab-kalibrierung.cjs
|
||||
*
|
||||
* Rechnet sRGB <-> OKLab nach CSS Color 4 (Matrizen von Bjoern Ottosson, wie
|
||||
* sie Browser fuer `color-mix(in oklab, ...)` verwenden) und bestimmt die
|
||||
* Mischparameter, mit denen `color-mix(in oklab, #ffed00 P%, #GRAU)` den
|
||||
* Olivton #9c9440 trifft. Dieselben zwei Umrechnungsfunktionen (rgbToOklab,
|
||||
* oklabToRgb) uebernimmt der Executor 1:1 in apps/web/src/components/brand/brand.test.ts.
|
||||
*
|
||||
* Ergebnis (Stand 2026-09-17): P = 54, GRAU = #363636 -> #9c9440 exakt (0/0/0).
|
||||
*/
|
||||
|
||||
function hexToRgb(hex) {
|
||||
const h = hex.replace('#', '');
|
||||
return [0, 2, 4].map((i) => Number.parseInt(h.slice(i, i + 2), 16) / 255);
|
||||
}
|
||||
function rgbToHex(rgb) {
|
||||
return `#${rgb
|
||||
.map((v) => Math.round(Math.min(1, Math.max(0, v)) * 255).toString(16).padStart(2, '0'))
|
||||
.join('')}`;
|
||||
}
|
||||
function srgbToLinear(c) {
|
||||
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
||||
}
|
||||
function linearToSrgb(c) {
|
||||
return c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055;
|
||||
}
|
||||
/** sRGB (0..1 je Kanal) -> OKLab [L, a, b]. */
|
||||
function rgbToOklab([r, g, b]) {
|
||||
const R = srgbToLinear(r);
|
||||
const G = srgbToLinear(g);
|
||||
const B = srgbToLinear(b);
|
||||
const l = Math.cbrt(0.4122214708 * R + 0.5363325363 * G + 0.0514459929 * B);
|
||||
const m = Math.cbrt(0.2119034982 * R + 0.6806995451 * G + 0.1073969566 * B);
|
||||
const s = Math.cbrt(0.0883024619 * R + 0.2817188376 * G + 0.6299787005 * B);
|
||||
return [
|
||||
0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
|
||||
1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
|
||||
0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
|
||||
];
|
||||
}
|
||||
/** OKLab [L, a, b] -> sRGB (0..1 je Kanal, ungeclippt). */
|
||||
function oklabToRgb([L, a, b]) {
|
||||
const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3;
|
||||
const m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3;
|
||||
const s = (L - 0.0894841775 * a - 1.291485548 * b) ** 3;
|
||||
return [
|
||||
linearToSrgb(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s),
|
||||
linearToSrgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s),
|
||||
linearToSrgb(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s),
|
||||
];
|
||||
}
|
||||
function lchToLab([L, C, h]) {
|
||||
const r = (h * Math.PI) / 180;
|
||||
return [L, C * Math.cos(r), C * Math.sin(r)];
|
||||
}
|
||||
function labToLch([L, a, b]) {
|
||||
let h = (Math.atan2(b, a) * 180) / Math.PI;
|
||||
if (h < 0) h += 360;
|
||||
return [L, Math.hypot(a, b), h];
|
||||
}
|
||||
/** Entspricht `color-mix(in oklab, A share%, B)`. */
|
||||
function mixInOklabLab(labA, share, hexB) {
|
||||
const labB = rgbToOklab(hexToRgb(hexB));
|
||||
const w = share / 100;
|
||||
return rgbToHex(oklabToRgb(labA.map((v, i) => w * v + (1 - w) * labB[i])));
|
||||
}
|
||||
const mixInOklab = (hexA, share, hexB) => mixInOklabLab(rgbToOklab(hexToRgb(hexA)), share, hexB);
|
||||
const rgb255 = (hex) => hexToRgb(hex).map((v) => Math.round(v * 255));
|
||||
const channelDiff = (h1, h2) => rgb255(h1).map((v, i) => v - rgb255(h2)[i]);
|
||||
|
||||
const BRAND_YELLOW = '#ffed00';
|
||||
const BRAND_OLIVE = '#9c9440';
|
||||
|
||||
console.log('OKLCH der Markenfarben:');
|
||||
for (const hex of [BRAND_YELLOW, BRAND_OLIVE]) {
|
||||
const [L, C, H] = labToLch(rgbToOklab(hexToRgb(hex)));
|
||||
console.log(` ${hex} L=${L.toFixed(4)} C=${C.toFixed(4)} H=${H.toFixed(2)}`);
|
||||
}
|
||||
|
||||
console.log('\nSuche: ganzzahliger Anteil 40..70 %, neutrales Grau #101010..#606060:');
|
||||
const candidates = [];
|
||||
for (let share = 40; share <= 70; share++) {
|
||||
for (let g = 16; g <= 96; g++) {
|
||||
const gray = `#${g.toString(16).padStart(2, '0').repeat(3)}`;
|
||||
const result = mixInOklab(BRAND_YELLOW, share, gray);
|
||||
const err = Math.max(...channelDiff(result, BRAND_OLIVE).map(Math.abs));
|
||||
candidates.push({ share, gray, result, err });
|
||||
}
|
||||
}
|
||||
candidates.sort((a, b) => a.err - b.err || a.share - b.share);
|
||||
for (const c of candidates.slice(0, 5)) {
|
||||
console.log(` ${c.share}% ${c.gray} -> ${c.result} max. Abweichung ${c.err}`);
|
||||
}
|
||||
|
||||
const SHARE = 54;
|
||||
const GRAY = '#363636';
|
||||
console.log(`\nGewaehlt: color-mix(in oklab, var(--primary) ${SHARE}%, ${GRAY})`);
|
||||
const [, ga, gb] = rgbToOklab(hexToRgb(GRAY));
|
||||
console.log(` Mischgrau in OKLab: a=${ga.toExponential(2)} b=${gb.toExponential(2)} (neutral -> Farbton der Akzentfarbe bleibt erhalten)`);
|
||||
|
||||
console.log('\nAbgeleiteter Ton je Akzentfarbe:');
|
||||
const rows = [
|
||||
['#ffed00 (BRAND_YELLOW, Kalibrierziel)', rgbToOklab(hexToRgb('#ffed00'))],
|
||||
['oklch(0.91 0.19 102) (CSS-Standard --primary, globals.css)', lchToLab([0.91, 0.19, 102])],
|
||||
['#ffffff', rgbToOklab(hexToRgb('#ffffff'))],
|
||||
['#000000', rgbToOklab(hexToRgb('#000000'))],
|
||||
['#0057b8', rgbToOklab(hexToRgb('#0057b8'))],
|
||||
['#ff0000', rgbToOklab(hexToRgb('#ff0000'))],
|
||||
];
|
||||
for (const [label, lab] of rows) {
|
||||
const result = mixInOklabLab(lab, SHARE, GRAY);
|
||||
const [L1] = rgbToOklab(hexToRgb(result));
|
||||
const diff = channelDiff(result, BRAND_OLIVE);
|
||||
const trend = L1 < lab[0] ? 'dunkler' : 'HELLER (kippt)';
|
||||
console.log(` ${label.padEnd(62)} -> ${result} L ${lab[0].toFixed(3)} -> ${L1.toFixed(3)} ${trend} (Abstand zu #9c9440: ${diff.join('/')})`);
|
||||
}
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
---
|
||||
phase: quick-260917-jdh
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
autonomous: true
|
||||
requirements: [QUICK-260917-JDH]
|
||||
|
||||
files_modified:
|
||||
- .gitea/workflows/ci.yml
|
||||
- .gitea/scripts/desktop-stamp.sh
|
||||
- docs/anleitung-betrieb.md
|
||||
- docs/ci-cd-setup.md
|
||||
- docs/anleitung-entwicklung.md
|
||||
- CHANGELOG.md
|
||||
|
||||
estimate:
|
||||
tokens: 40000
|
||||
raw_tokens: 40000
|
||||
tasks: 2
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Neues POSIX-Skript `.gitea/scripts/desktop-stamp.sh` (Kopfkommentar im Stil von desktop-collect.sh, `set -eu`, kennt kein Secret) mit zwei Unterbefehlen. `stamp`: Version aus `desktop-version.sh --print` (gleiches Verzeichnis, `$(dirname \"$0\")`), letzter Commit an den Desktop-Pfaden per `git log -1 --format=%H -- apps/desktop .gitea/scripts/desktop-version.sh .gitea/scripts/desktop-collect.sh .gitea/scripts/desktop-stamp.sh .gitea/workflows/ci.yml` (Konstante `DESKTOP_PATHS`; `pnpm-lock.yaml` bewusst nicht enthalten, Begruendung im Kopfkommentar), Ausgaben `stamp=<Version>-<voller SHA>`, `version=`, `sha7=`, `skip_allowed=true|false` (true NUR bei `GITHUB_REF` = `refs/heads/main`) — jede Ausgabe nach stdout UND, falls gesetzt, an `$GITHUB_OUTPUT`. Leerer SHA (keine Historie) → Exit 1 mit Hinweis auf `fetch-depth: 0`."
|
||||
- "`desktop-stamp.sh check` (Umgebung `CACHE_HIT`, `STAMP_VERSION`, `STAMP_SHA7`, `DESKTOP_DIST` Vorgabe `desktop-dist`) gibt `reuse=true` und `files=<Name1>,<Name2>` nur aus, wenn ALLES gilt: `CACHE_HIT` = `true`; `manifest.json` vorhanden und per `jq -e .` gueltig; `.channel` = `beta`; `.version` = `STAMP_VERSION`; `.files.linux.name` und `.files.windows.name` nicht leer; jede der beiden Dateien existiert und stimmt in Groesse (`stat -c %s`) und sha256 (`sha256sum`) mit dem Manifest ueberein. Sonst `reuse=false`, die Reste (`*.AppImage`, `*.exe`, `manifest.json` in `DESKTOP_DIST`) werden entfernt, Exit 0 (kein Job-Fehler), Grund im Log. Bei Treffer Log-Zeile `Desktop unveraendert seit <sha7>: Pakete <Namen> aus dem Zwischenspeicher (gebaut aus <manifest.commit> am <buildTime>)`."
|
||||
- "ci.yml, Job `desktop`: direkt nach `actions/checkout@v4` (Index 0) stehen drei neue Schritte — Index 1 `id: stamp` (`run: sh .gitea/scripts/desktop-stamp.sh stamp`), Index 2 `id: stamp-cache` (`uses: actions/cache/restore@v4`, `path: desktop-dist`, `key: desktop-dist-stamp-${{ steps.stamp.outputs.stamp }}`, KEIN `restore-keys`, `if: steps.stamp.outputs.skip_allowed == 'true'`), Index 3 `id: reuse` (`run: sh .gitea/scripts/desktop-stamp.sh check` mit `env` `CACHE_HIT: ${{ steps.stamp-cache.outputs.cache-hit }}`, `STAMP_VERSION: ${{ steps.stamp.outputs.version }}`, `STAMP_SHA7: ${{ steps.stamp.outputs.sha7 }}`; ohne `if`, laeuft immer)."
|
||||
- "ci.yml: alle 13 bisherigen Bau-Schritte zwischen `reuse` und `Uebergabe an publish` (setup-node, corepack, pnpm install, Systemabhaengigkeiten, Rust-Toolchain, Cargo-Zwischenspeicher, Windows-Werkzeuge, Version setzen, Rust pruefen, Alte Bundles entfernen, Linux-AppImage bauen, Windows-Installer bauen, Pakete einsammeln) tragen `if: steps.reuse.outputs.reuse != 'true'`; ihr Inhalt bleibt sonst byteidentisch. Neuer Schritt `Pakete unter dem Stempel ablegen` (`actions/cache/save@v4`, `path: desktop-dist`, gleicher Schluessel wie der Restore, `if: steps.reuse.outputs.reuse != 'true' && steps.stamp.outputs.skip_allowed == 'true'`) steht UNMITTELBAR vor `Uebergabe an publish`; `Uebergabe an publish` bleibt ohne `if` und mit Schluessel `desktop-dist-${{ gitea.sha }}`."
|
||||
- "ci.yml: Jobs `quality`, `test`, `publish`, der `on:`-Block sowie `env`/`needs`/`if` des Jobs `desktop` sind gegenueber Commit 38c1400 unveraendert (js-yaml-Tiefenvergleich). Kopfkommentar (Z. 1-11) um einen Absatz `quick-260917-jdh` ergaenzt. `node` + js-yaml parst die Datei fehlerfrei."
|
||||
- "Lokale Probe des Skripts: `DESKTOP_TAG=v1.2.0 GITHUB_REF=refs/heads/main sh .gitea/scripts/desktop-stamp.sh stamp` liefert `stamp=1.2.0-<SHA aus git log>` und `skip_allowed=true`; mit `GITHUB_REF=refs/tags/v1.2.0` `skip_allowed=false`; mit gesetztem `GITHUB_OUTPUT` landen alle vier Schluessel in der Datei. `check` gegen einen im Scratchpad gebauten Mini-`desktop-dist` (zwei 1-Byte-Dateien, Manifest via `jq -n` mit echten Groessen/Pruefsummen, Kanal beta, Version 1.2.0) liefert `reuse=true`; nach Veraendern einer Datei `reuse=false` und das Manifest ist entfernt; mit `CACHE_HIT=false` `reuse=false`."
|
||||
- "docs/anleitung-betrieb.md Kap. 10: neuer Unterabschnitt `### Wann gebaut wird und wann Pakete übernommen werden` zwischen „Woher die Pakete kommen” und „Wo die Pakete im Abbild liegen” (Stempel, Tags bauen immer, nach Freigabe einmal neu, uebernommene Pakete tragen den Stand ihres Baus — gewollt fuer die Client-Updatepruefung, Cache-Ablauf → Neubau, Pruefung vor Uebernahme); Satz zur Job-Dauer verweist darauf; eine neue Zeile in der Fehlerbilder-Tabelle (aelterer Commit im Paketnamen ist kein Fehler). docs/ci-cd-setup.md Abschnitt 4 (Job `desktop`): Absatz `Ueberspringen bei unveraendertem Desktop` mit Schrittfolge, Schluessel, Pfadliste, Begruendung `pnpm-lock.yaml`, Verweis auf `desktop-stamp.sh`; Abschnitt 6: neuer Eintrag `### desktop baut, obwohl nichts geaendert wurde — oder uebernimmt trotz Aenderung`. docs/anleitung-entwicklung.md, Abschnitt „Desktop-App lokal bauen”: ein Absatz zum Ueberspringen im CI, zur Pfadliste und zur lokalen Stempel-Probe."
|
||||
- "CHANGELOG.md `## Unveröffentlicht` `### Geändert`: genau ein neuer Stichpunkt `Desktop-App: Beta-Pakete werden nur noch neu gebaut, wenn sich an der Desktop-App etwas geändert hat; sonst bleiben die zuletzt gebauten Pakete gültig, und die App meldet keinen neuen Beta-Stand` (Stil wie Bestand: Praefix `Desktop-App:`, echte Umlaute, kein Punkt am Ende). Nur zusaetzliche Zeilen; Zeilen anderer paralleler Auftraege bleiben stehen."
|
||||
- "Zwei Commits ohne Push: `ci: …` (Task 1) und `docs: …` (Task 2); keine `.planning/`-Dateien in den Commits; keine Datei ausserhalb von files_modified."
|
||||
artifacts:
|
||||
- ".gitea/scripts/desktop-stamp.sh — neu (Unterbefehle `stamp` und `check`, Konstante `DESKTOP_PATHS`)"
|
||||
- ".gitea/workflows/ci.yml — Job `desktop`: Schritte `stamp`, `stamp-cache`, `reuse`, `Pakete unter dem Stempel ablegen`; `if:` an 13 Bau-Schritten; Kopfkommentar"
|
||||
- "docs/anleitung-betrieb.md — Kap. 10 Unterabschnitt + Fehlerbilder-Zeile"
|
||||
- "docs/ci-cd-setup.md — Abschnitt 4 Absatz, Abschnitt 6 Eintrag"
|
||||
- "docs/anleitung-entwicklung.md — Absatz in „Desktop-App lokal bauen”"
|
||||
- "CHANGELOG.md — ein Stichpunkt unter Unveröffentlicht/Geändert"
|
||||
key_links:
|
||||
- "Die Ueberspringen-Kette ist: `stamp` (Stempel) → `stamp-cache` (Restore, exakter Schluessel) → `reuse` (Manifest/Pruefsummen-Gate, Ausgabe `reuse`) → `if:` an JEDEM Bau-Schritt → `Uebergabe an publish` unveraendert. Fehlt das `if:` an nur einem Bau-Schritt, laeuft er im Skip-Fall ins Leere (z. B. `Pakete einsammeln` ohne Bundles → Job rot); `Uebergabe an publish` darf umgekehrt NIE ein `if:` bekommen, sonst bricht `publish` mit `fail-on-cache-miss` ab."
|
||||
- "act_runner (v0.6.1 auf dem Dev-Host, Cache-Server aktiv) sucht zu JEDEM Schluessel erst exakt, dann als Praefix. Deshalb steht der volle 40-stellige SHA am Ende des Stempel-Schluessels (nichts kann laenger und gleich-praefixig sein) und der Restore hat KEIN `restore-keys` — ein Praefix-Treffer waere ein fremder Stand. `cache-hit` ist ohnehin nur bei exaktem Treffer `true`; das `check`-Gate verlangt genau das."
|
||||
- "`skip_allowed` haengt an `GITHUB_REF == refs/heads/main`: Nur so kommen ausschliesslich Beta-Pakete (Suffix `-beta.<sha7>`, `channel: beta`) unter einen Stempel-Schluessel. Bei Tags wird weder gesucht noch abgelegt — Release-Dateien entstehen frisch mit reiner Version; `check` prueft zusaetzlich `channel == beta`."
|
||||
- "Die Version ist Teil des Stempels, weil `desktop-version.sh --print` nach einem Freigabe-Tag (Kap. 9: `git merge --ff-only main` + Tag, also auf der main-Historie) eine neue Basisversion liefert — die Beta-Pakete muessen dann einmal neu entstehen, auch wenn `apps/desktop` unveraendert ist."
|
||||
- "Die drei Stempel-Schritte stehen VOR setup-node/apt/rustup: `actions/cache/restore` ist eine JS-Action und laeuft (wie checkout) unter dem Node des Runners; das Skript braucht nur `git`, `jq`, `sha256sum`, `stat` — alle im Runner-Abbild (`publish` nutzt `jq` heute vor jedem Installationsschritt). Der Cargo-Cache-Schritt (`actions/cache@v4`) hat einen Post-Schritt; ist er per `if:` uebersprungen, entfaellt auch der Post-Schritt."
|
||||
- "`publish` bleibt unangetastet: Es holt weiter `desktop-dist-${{ gitea.sha }}`. Im Skip-Fall sichert `Uebergabe an publish` den restaurierten `desktop-dist/` (alter Beta-Suffix, alter `manifest.commit`) unter dem neuen SHA — der Client vergleicht `manifest.commit` mit seinem `TESSERA_COMMIT`, also bekommt ein Client dieses Standes keinen Update-Hinweis mehr, ein aelterer weiterhin (gewollt, siehe Betriebshandbuch)."
|
||||
---
|
||||
|
||||
<objective>
|
||||
Der CI-Job `desktop` baut die Rust/Tauri-Pakete (Linux-AppImage, Windows-Installer per Cross-Bau) heute bei jedem Push auf `main` — rund fuenf Minuten auf dem einzigen Runner, obwohl die meisten Pushes nur Web/API aendern. Kuenftig ueberspringt der Job den Bau, wenn sich am Desktop-Stand nichts geaendert hat, und uebernimmt die zuletzt gebauten Pakete aus dem Zwischenspeicher des Runners:
|
||||
|
||||
1. **Stempel** = `<Version aus desktop-version.sh --print>-<voller SHA des letzten Commits an den Desktop-Pfaden>` (Pfadliste: `apps/desktop`, `desktop-version.sh`, `desktop-collect.sh`, `desktop-stamp.sh`, `ci.yml`). Berechnet von einem neuen, lokal testbaren Skript `.gitea/scripts/desktop-stamp.sh`.
|
||||
2. **Ablauf im Job:** checkout → Stempel → `actions/cache/restore@v4` (Schluessel `desktop-dist-stamp-<Stempel>`, nur auf `main`) → Pruefung des gefundenen `desktop-dist/` (Manifest, Kanal, Version, Dateien, Groessen, Pruefsummen) → bei Treffer entfallen alle Bau-Schritte per `if:`; sonst Bau wie bisher plus Ablage unter dem Stempel-Schluessel. `Uebergabe an publish` (`desktop-dist-<sha>`) laeuft immer, `publish` bleibt unveraendert.
|
||||
3. **Tags `v*` bauen immer** (Release-Dateien frisch, reine Version). Fehlender/beschaedigter Cache → normaler Bau (fail-safe).
|
||||
4. **Doku** in Betriebshandbuch Kap. 10, CI-Runbook Abschnitt 4/6, Entwicklungsanleitung; ein CHANGELOG-Stichpunkt.
|
||||
|
||||
Task 1 ist der Tracer: Skript + Workflow bilden die komplette Kette, lokal bewiesen durch Skript-Proben (Stempel, Tag-Fall, `GITHUB_OUTPUT`, `check` mit gutem/veraendertem/fehlendem Cache) und eine js-yaml-Strukturpruefung des Workflows (Reihenfolge, `if:`-Bedingungen, Schluessel, unveraenderte Nachbarjobs). Der echte CI-Beweis (ein Push ohne Desktop-Aenderung ueberspringt, einer mit Desktop-Aenderung baut) ist Nachweis durch den Orchestrator NACH dem Push — kein Executor-Task.
|
||||
|
||||
Purpose: Pushes auf `main`, die nur Web/API betreffen, sollen den Runner nicht fuenf Minuten mit einem identischen Rust-Bau belegen; die Beta-Pakete bleiben dabei exakt die des letzten Desktop-Standes, sodass installierte Clients keinen unnoetigen Update-Hinweis bekommen.
|
||||
Output: `desktop-stamp.sh` (neu), angepasste `ci.yml`, vier Doku-Stellen, ein CHANGELOG-Stichpunkt, zwei Commits (`ci:`, `docs:`), kein Push.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/gsd-core/workflows/execute-plan.md
|
||||
@~/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@/home/vicolab/projects/tessera-ctl/CLAUDE.md
|
||||
@/home/vicolab/projects/tessera-ctl/.gitea/workflows/ci.yml
|
||||
@/home/vicolab/projects/tessera-ctl/.gitea/scripts/desktop-version.sh
|
||||
@/home/vicolab/projects/tessera-ctl/.gitea/scripts/desktop-collect.sh
|
||||
@/home/vicolab/projects/tessera-ctl/.planning/phases/18-desktop-client-fertigstellen/18-02-SUMMARY.md
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: `desktop-stamp.sh` (Stempel + Cache-Pruefung) und Job `desktop` ueberspringt den Bau bei Treffer</name>
|
||||
<files>.gitea/scripts/desktop-stamp.sh, .gitea/workflows/ci.yml</files>
|
||||
<read_first>
|
||||
- .gitea/scripts/desktop-collect.sh Z. 1-23 (Kopfkommentar-Stil, `set -eu`, Umgebungsvariablen-Block, „Dieses Skript kennt kein Secret”), Z. 79-84 (Aufraeumen von `DESKTOP_DIST`), Z. 131-154 (Manifest-Form: `version`, `channel`, `commit`, `buildTime`, `files.linux/windows.{name,size,sha256}`)
|
||||
- .gitea/scripts/desktop-version.sh Z. 17-48 (`--print`, `DESKTOP_TAG` fuer die lokale Probe, Exit 1 ohne Tag)
|
||||
- .gitea/workflows/ci.yml Z. 1-11 (Kopfkommentar), Z. 62-152 (Job `desktop`, 15 Schritte), Z. 154-186 (`publish` — NICHT anfassen)
|
||||
- .planning/phases/18-desktop-client-fertigstellen/18-02-SUMMARY.md Z. 27 und Z. 122 (Cache-Uebergabe per save/restore mit exaktem SHA-Schluessel ist auf dieser Gitea-Instanz bewiesen; `grep` mit `${{`-Mustern in dieser Umgebung nur ueber `command grep`)
|
||||
</read_first>
|
||||
<action>
|
||||
**A. Neues Skript `.gitea/scripts/desktop-stamp.sh`** (POSIX sh, `#!/bin/sh`, `set -eu`, Kopfkommentar wie desktop-collect.sh mit Kennung `quick-260917-jdh`, Zeile „Dieses Skript kennt kein Secret”). Kopfkommentar erklaert: Zweck (Bau ueberspringen, wenn der Desktop-Stand unveraendert ist), Aufbau des Stempels, Aufrufformen, Umgebungsvariablen, und warum `pnpm-lock.yaml` NICHT in der Pfadliste steht (die Tauri-CLI-Version haengt an `apps/desktop/package.json`, das enthalten ist; der Desktop-Bau liest ausserhalb von `apps/desktop` keine Werkstatt-Datei — `frontendDist` ist `../src` innerhalb von `apps/desktop`, keine Abhaengigkeit auf `packages/*`). Aufbau:
|
||||
|
||||
1. Konstante `DESKTOP_PATHS` = `apps/desktop .gitea/scripts/desktop-version.sh .gitea/scripts/desktop-collect.sh .gitea/scripts/desktop-stamp.sh .gitea/workflows/ci.yml` (eine Zeile, Leerzeichen-getrennt; das Skript selbst gehoert dazu, damit eine Aenderung der Stempelregel einen Neubau ausloest).
|
||||
2. Helfer `out NAME WERT`: schreibt `NAME=WERT` nach stdout und, wenn `GITHUB_OUTPUT` gesetzt ist, zusaetzlich per `>>` in diese Datei.
|
||||
3. Unterbefehl `stamp`: `VERSION="$("$(dirname "$0")/desktop-version.sh" --print)"` (Fehler des Aufrufs durchreichen — ohne Tag endet es mit Exit 1 wie bisher); `LAST="$(git log -1 --format=%H -- $DESKTOP_PATHS)"` (bewusst ungequotet, Wortaufteilung der Pfadliste); leeres `LAST` → Fehlermeldung nach stderr („keine Historie zu den Desktop-Pfaden — im CI ist fetch-depth: 0 Pflicht”) und Exit 1; `SHA7="$(git rev-parse --short=7 "$LAST")"`; `SKIP=false`, bei `${GITHUB_REF:-}` = `refs/heads/main` → `true`; Log-Zeile mit Version, `SHA7`, Betreff des Commits (`git log -1 --format=%s "$LAST"`) und der Aussage, ob Ueberspringen erlaubt ist (bei Tag/anderem Ref: „Tag oder fremder Zweig — es wird immer gebaut”); dann `out stamp "$VERSION-$LAST"`, `out version "$VERSION"`, `out sha7 "$SHA7"`, `out skip_allowed "$SKIP"`.
|
||||
4. Unterbefehl `check`: liest `CACHE_HIT` (Vorgabe leer), `STAMP_VERSION`, `STAMP_SHA7`, `DESKTOP_DIST` (Vorgabe `desktop-dist`), `MANIFEST="$DESKTOP_DIST/manifest.json"`. Lokale Funktion `no_reuse GRUND`: Meldung `Kein uebernehmbarer Stand (GRUND) -- Desktop wird gebaut.`, entfernt `"$DESKTOP_DIST"/*.AppImage "$DESKTOP_DIST"/*.exe "$MANIFEST"` (`rm -f`, wie desktop-collect.sh; `.gitkeep` bleibt), `out reuse false`, `exit 0`. Pruefreihenfolge, jeweils `no_reuse` mit sprechendem Grund: `CACHE_HIT` ungleich `true` („kein Zwischenspeicher zum Stempel”); Manifest fehlt oder `jq -e . "$MANIFEST" >/dev/null` scheitert; `jq -r .channel` ungleich `beta`; `jq -r .version` ungleich `STAMP_VERSION`; `jq -r '.files.linux.name // empty'` oder `.files.windows.name` leer; je Datei: nicht vorhanden, `stat -c %s` ungleich `.files.<p>.size`, `sha256sum | cut -d' ' -f1` ungleich `.files.<p>.sha256`. Sind alle Pruefungen bestanden: Log-Zeile `Desktop unveraendert seit $STAMP_SHA7: Pakete <linux>, <windows> aus dem Zwischenspeicher (gebaut aus <.commit> am <.buildTime>)`, `out reuse true`, `out files "<linux>,<windows>"`.
|
||||
5. Unbekannter/fehlender Unterbefehl → Aufrufhinweis nach stderr, Exit 1. Ausfuehrbit setzen (`chmod +x`, wie die Nachbarn; Aufruf im Workflow trotzdem per `sh`).
|
||||
|
||||
**B. `.gitea/workflows/ci.yml`, nur Job `desktop`** (Jobs `quality`, `test`, `publish`, `on:`, Job-`env`/`needs`/`if` bleiben byteidentisch):
|
||||
|
||||
1. Kopfkommentar: nach Z. 11 einen Absatz `quick-260917-jdh` anfuegen: `desktop` ueberspringt den Bau auf `main`, wenn zum Stempel (Version + letzter Commit an den Desktop-Pfaden, `.gitea/scripts/desktop-stamp.sh`) fertige Pakete im Zwischenspeicher liegen; Tags `v*` bauen immer; `publish` unveraendert.
|
||||
2. Nach dem Checkout-Schritt drei Schritte einfuegen (Kommentar davor: warum sie VOR setup-node/apt/Rust stehen — die teuren Schritte sollen beim Ueberspringen gar nicht laufen): (a) `name: Desktop-Stempel berechnen`, `id: stamp`, `run: sh .gitea/scripts/desktop-stamp.sh stamp`. (b) `name: Fertige Pakete zum Stempel suchen`, `id: stamp-cache`, `if: steps.stamp.outputs.skip_allowed == 'true'`, `uses: actions/cache/restore@v4`, `with:` `path: desktop-dist`, `key: desktop-dist-stamp-${{ steps.stamp.outputs.stamp }}` — bewusst OHNE `restore-keys` (Kommentar: act_runner sucht auch zum Hauptschluessel per Praefix; ein aelterer Stand darf nie als Treffer gelten). (c) `name: Gefundene Pakete pruefen`, `id: reuse`, `env:` `CACHE_HIT: ${{ steps.stamp-cache.outputs.cache-hit }}`, `STAMP_VERSION: ${{ steps.stamp.outputs.version }}`, `STAMP_SHA7: ${{ steps.stamp.outputs.sha7 }}`, `run: sh .gitea/scripts/desktop-stamp.sh check` — kein `if`, damit `steps.reuse.outputs.reuse` immer definiert ist (bei Tags: `CACHE_HIT` leer → `reuse=false`).
|
||||
3. An jeden der 13 Bau-Schritte (setup-node, corepack, pnpm install, Systemabhaengigkeiten, Rust-Toolchain, Cargo-Zwischenspeicher, Windows-Werkzeuge, Version setzen, Rust pruefen, Alte Bundles entfernen, Linux-AppImage bauen, Windows-Installer bauen, Pakete einsammeln) die Zeile `if: steps.reuse.outputs.reuse != 'true'` anfuegen (bei `uses:`-Schritten direkt nach `uses:`/`name:`; sonst nichts aendern — Reihenfolge, Inhalte, Kommentare bleiben).
|
||||
4. Vor `Uebergabe an publish` einen Schritt `name: Pakete unter dem Stempel ablegen`, `if: steps.reuse.outputs.reuse != 'true' && steps.stamp.outputs.skip_allowed == 'true'`, `uses: actions/cache/save@v4`, `with:` `path: desktop-dist`, `key: desktop-dist-stamp-${{ steps.stamp.outputs.stamp }}` (Kommentar: nur nach echtem Bau und nur auf main — Tag-Pakete tragen keinen Beta-Suffix und duerfen nie unter einem Stempel liegen; ein bereits vorhandener Schluessel loest bei actions/cache/save nur eine Info aus, keinen Fehler).
|
||||
5. `Uebergabe an publish` bleibt exakt wie bisher (kein `if`, Schluessel `desktop-dist-${{ gitea.sha }}`); Kommentar davor ergaenzen: laeuft in beiden Faellen — im Skip-Fall sichert er den restaurierten Stand unter dem neuen SHA, deshalb muss `publish` nichts wissen.
|
||||
|
||||
**C. Lokale Proben** (Teil von `<verify>`; nichts davon braucht den Runner): `sh -n`, Stempel auf `main`/Tag, `GITHUB_OUTPUT`, `check` mit gutem, veraendertem und fehlendem Cache (Mini-`desktop-dist` im Scratchpad, nie das echte `desktop-dist/` des Arbeitsbaums), js-yaml-Strukturpruefung des Workflows samt Tiefenvergleich der unveraenderten Jobs gegen `git show 38c1400:.gitea/workflows/ci.yml`. Zaehlpruefungen auf `${{`-Muster nur mit `command grep` (Shim-Artefakt laut 18-02).
|
||||
|
||||
Kein `git push`, kein Docker, kein `tauri build`, keine Aenderung an `publish` oder an den Skripten `desktop-version.sh`/`desktop-collect.sh`/`publish-*.sh`.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && sh -n .gitea/scripts/desktop-stamp.sh && test -x .gitea/scripts/desktop-stamp.sh && LAST="$(git log -1 --format=%H -- apps/desktop .gitea/scripts/desktop-version.sh .gitea/scripts/desktop-collect.sh .gitea/scripts/desktop-stamp.sh .gitea/workflows/ci.yml)" && test -n "$LAST" && EXP="1.2.0-$LAST" && OUT="$(DESKTOP_TAG=v1.2.0 GITHUB_REF=refs/heads/main sh .gitea/scripts/desktop-stamp.sh stamp)" && printf '%s\n' "$OUT" | command grep -qx "stamp=$EXP" && printf '%s\n' "$OUT" | command grep -qx 'skip_allowed=true' && printf '%s\n' "$OUT" | command grep -qx 'version=1.2.0' && DESKTOP_TAG=v1.2.0 GITHUB_REF=refs/tags/v1.2.0 sh .gitea/scripts/desktop-stamp.sh stamp | command grep -qx 'skip_allowed=false' && O=$(mktemp) && GITHUB_OUTPUT=$O DESKTOP_TAG=v1.2.0 GITHUB_REF=refs/heads/main sh .gitea/scripts/desktop-stamp.sh stamp >/dev/null && command grep -q '^stamp=1.2.0-' "$O" && command grep -q '^version=1.2.0$' "$O" && command grep -q -E '^sha7=[0-9a-f]{7}$' "$O" && command grep -q '^skip_allowed=true$' "$O" && rm -f "$O" && D=$(mktemp -d) && printf 'a' > "$D/Tessera-1.2.0-beta.2cd4adc.AppImage" && printf 'b' > "$D/Tessera-Setup-1.2.0-beta.2cd4adc.exe" && jq -n --arg l "$(sha256sum "$D/Tessera-1.2.0-beta.2cd4adc.AppImage" | cut -d' ' -f1)" --arg w "$(sha256sum "$D/Tessera-Setup-1.2.0-beta.2cd4adc.exe" | cut -d' ' -f1)" '{version:"1.2.0",channel:"beta",commit:"2cd4adc",buildTime:"2026-09-17T00:00:00Z",files:{linux:{name:"Tessera-1.2.0-beta.2cd4adc.AppImage",size:1,sha256:$l},windows:{name:"Tessera-Setup-1.2.0-beta.2cd4adc.exe",size:1,sha256:$w}}}' > "$D/manifest.json" && CACHE_HIT=true STAMP_VERSION=1.2.0 STAMP_SHA7=2cd4adc DESKTOP_DIST="$D" sh .gitea/scripts/desktop-stamp.sh check | tee /dev/stderr | command grep -qx 'reuse=true' && printf 'x' >> "$D/Tessera-Setup-1.2.0-beta.2cd4adc.exe" && CACHE_HIT=true STAMP_VERSION=1.2.0 STAMP_SHA7=2cd4adc DESKTOP_DIST="$D" sh .gitea/scripts/desktop-stamp.sh check | command grep -qx 'reuse=false' && test ! -f "$D/manifest.json" && CACHE_HIT=false DESKTOP_DIST="$D" sh .gitea/scripts/desktop-stamp.sh check | command grep -qx 'reuse=false' && rm -rf "$D" && node -e 'const y=require("./node_modules/.pnpm/js-yaml@4.2.0/node_modules/js-yaml");const fs=require("fs");const cp=require("child_process");const d=y.load(fs.readFileSync(".gitea/workflows/ci.yml","utf8"));const o=y.load(cp.execSync("git show 38c1400:.gitea/workflows/ci.yml").toString());for(const j of["quality","test","publish"]){if(JSON.stringify(d.jobs[j])!==JSON.stringify(o.jobs[j]))throw new Error("Job veraendert: "+j)}if(JSON.stringify(d.on)!==JSON.stringify(o.on))throw new Error("on veraendert");const n=d.jobs.desktop,p=o.jobs.desktop;for(const k of["env","needs","if","runs-on"]){if(JSON.stringify(n[k])!==JSON.stringify(p[k]))throw new Error("desktop."+k+" veraendert")}const s=n.steps;const ix=i=>s.findIndex(x=>x.id===i);const iS=ix("stamp"),iC=ix("stamp-cache"),iR=ix("reuse");const iU=s.findIndex(x=>x.name==="Uebergabe an publish");const iV=s.findIndex(x=>x.name==="Pakete unter dem Stempel ablegen");if(!(iS===1&&iC===2&&iR===3))throw new Error("Stempel-Schritte nicht an Index 1-3");if(s[iS].run.trim()!=="sh .gitea/scripts/desktop-stamp.sh stamp")throw new Error("stamp run");const key="desktop-dist-stamp-${{ steps.stamp.outputs.stamp }}";if(!(s[iC].uses==="actions/cache/restore@v4"&&s[iC].with.path==="desktop-dist"&&s[iC].with.key===key&&!("restore-keys" in s[iC].with)&&String(s[iC].if).includes("steps.stamp.outputs.skip_allowed")))throw new Error("Restore-Schritt");if(!(s[iR].run.trim()==="sh .gitea/scripts/desktop-stamp.sh check"&&s[iR].if===undefined&&String(s[iR].env.CACHE_HIT).includes("steps.stamp-cache.outputs.cache-hit")&&String(s[iR].env.STAMP_VERSION).includes("steps.stamp.outputs.version")&&String(s[iR].env.STAMP_SHA7).includes("steps.stamp.outputs.sha7")))throw new Error("reuse-Schritt");if(iU-iR-1!==14)throw new Error("erwartet 13 Bau-Schritte + Stempel-Save zwischen reuse und Uebergabe, gefunden "+(iU-iR-1));for(let i=iR+1;i<iU;i++){if(!(s[i].if&&String(s[i].if).includes("steps.reuse.outputs.reuse")))throw new Error("if fehlt: "+(s[i].name||s[i].uses))}const old=p.steps.slice(1,14),neu=s.slice(iR+1,iV).map(x=>{const c={...x};delete c.if;return c});if(JSON.stringify(old)!==JSON.stringify(neu))throw new Error("Bau-Schritte inhaltlich veraendert");if(!(iV===iU-1&&s[iV].uses==="actions/cache/save@v4"&&s[iV].with.path==="desktop-dist"&&s[iV].with.key===key&&String(s[iV].if).includes("steps.stamp.outputs.skip_allowed")))throw new Error("Stempel-Save");if(!(s[iU].if===undefined&&JSON.stringify(s[iU])===JSON.stringify(p.steps[14])))throw new Error("Uebergabe an publish veraendert");if(s.length!==iU+1)throw new Error("Schritte nach Uebergabe");console.log("CI-OK:",s.length,"Schritte im Job desktop")' && command grep -q "steps.reuse.outputs.reuse != 'true'" .gitea/workflows/ci.yml && command grep -q 'quick-260917-jdh' .gitea/workflows/ci.yml</automated>
|
||||
</verify>
|
||||
<done>Skript liefert lokal Stempel `1.2.0-<SHA>` mit `skip_allowed` je Ref und schreibt nach `GITHUB_OUTPUT`; `check` akzeptiert nur ein vollstaendiges, pruefsummen-korrektes Beta-`desktop-dist` und raeumt sonst auf; Job `desktop` hat die Kette stamp → restore → check → 13 bedingte Bau-Schritte → Stempel-Save → unveraenderte Uebergabe; `quality`/`test`/`publish` byteidentisch; Commit `ci: Job desktop ueberspringt den Bau, wenn der Desktop-Stand unveraendert ist — Pakete aus dem Zwischenspeicher`.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Doku (Betriebshandbuch Kap. 10, CI-Runbook Abschnitt 4/6, Entwicklungsanleitung) und CHANGELOG</name>
|
||||
<files>docs/anleitung-betrieb.md, docs/ci-cd-setup.md, docs/anleitung-entwicklung.md, CHANGELOG.md</files>
|
||||
<read_first>
|
||||
- docs/anleitung-betrieb.md Z. 552-623 (Kap. 10: Unterabschnitte, Satz zur Job-Dauer Z. 569-573, Fehlerbilder-Tabelle Z. 616-623; echte Umlaute, Sie-Form, typografische Anfuehrungszeichen)
|
||||
- docs/ci-cd-setup.md Z. 108-176 (Abschnitt 4, Job `desktop` als nummerierte Liste; Schreibweise ae/oe/ue OHNE Umlaute) und Z. 295-340 (Abschnitt 6, Stil der Fehlerbehebungs-Eintraege)
|
||||
- docs/anleitung-entwicklung.md Z. 128-171 („Desktop-App lokal bauen”, letzter Absatz „Der Windows-Installer wird nur im CI gebaut”; echte Umlaute)
|
||||
- CHANGELOG.md Z. 1-12 — FRISCH lesen: `## Unveröffentlicht` ist derzeit leer; parallele Auftraege (260917-jdd, 260917-jdf) koennen dort inzwischen Unterabschnitte angelegt haben. Reihenfolge der Unterabschnitte wie im Block 1.2.0: Neu, Geändert, Entfernt, Behoben.
|
||||
</read_first>
|
||||
<action>
|
||||
1. **docs/anleitung-betrieb.md, Kap. 10.** (a) Den Satz zur Job-Dauer (Z. 572-573, „Der Job dauert damit etwa fünf bis sieben Minuten …”) um einen Halbsatz ergaenzen: „– sofern überhaupt gebaut wird, siehe nächster Abschnitt.” (b) Zwischen „Woher die Pakete kommen” und „Wo die Pakete im Abbild liegen” einen neuen Unterabschnitt `### Wann gebaut wird und wann Pakete übernommen werden` einfuegen, Inhalt in Alltagssprache: Seit September 2026 baut die Pipeline die Desktop-Pakete auf dem Beta-Kanal nur noch, wenn sich an der Desktop-App etwas geändert hat. Massgeblich ist ein Stempel aus Versionsnummer des letzten Freigabe-Tags und dem letzten Commit an den Desktop-Pfaden (`apps/desktop/`, die Skripte `desktop-version.sh`, `desktop-collect.sh`, `desktop-stamp.sh`, die Workflow-Datei `ci.yml`). Liegen zu diesem Stempel fertige Pakete im Zwischenspeicher des Runners, übernimmt der Job sie unverändert; die Bau-Schritte entfallen, der Job braucht dann unter einer Minute. Im Protokoll steht dann „Desktop unveraendert seit <Commit>: Pakete … aus dem Zwischenspeicher”. Drei Regeln als Aufzaehlung: Freigabe-Tags bauen immer (Release-Dateien frisch mit reiner Versionsnummer); nach einer Freigabe wird einmal neu gebaut, auch ohne Änderung, weil die Versionsnummer zum Stempel gehört (Beta-Pakete tragen danach die neue Basisversion); übernommene Pakete tragen den Stand ihres Baus — Dateiname `-beta.<Commit>` und Manifest nennen den Commit des Baus, nicht den des aktuellen Abbilds; das ist gewollt: ein Client dieses Standes bekommt keinen unnötigen Hinweis auf einen neuen Beta-Stand, ein älterer Client weiterhin. Abschliessender Absatz: fehlt der Eintrag im Zwischenspeicher (der Runner räumt Einträge nach einigen Tagen ohne Nutzung bzw. nach etwa einem Monat weg) oder ist er unvollständig, wird ganz normal gebaut — die Pipeline prüft vor der Übernahme Manifest, Kanal, Version, Dateinamen, Größen und Prüfsummen. (c) Fehlerbilder-Tabelle: neue Zeile — Symptom „Beta-Paket nennt einen älteren Commit als das laufende Abbild (Dateiname `-beta.<Commit>`, Einstellungen → Desktop-App)”, Ursache „Erwartet: Desktop-App seit diesem Commit unverändert, Pakete aus dem Zwischenspeicher übernommen (Abschnitt „Wann gebaut wird …”)”, Beheben „Kein Fehler. Soll dennoch neu gebaut werden, genügt eine Änderung unter `apps/desktop/` im nächsten Push.”
|
||||
2. **docs/ci-cd-setup.md.** (a) Abschnitt 4, direkt nach der Einleitung von „Job `desktop`: Windows- und Linux-Pakete auf dem Linux-Runner” (vor der nummerierten Liste) einen Absatz **Ueberspringen bei unveraendertem Desktop (quick-260917-jdh)** — Schreibweise ae/oe/ue wie die Datei: Direkt nach dem Checkout berechnet `desktop-stamp.sh stamp` den Stempel `<Version>-<SHA>` (Version aus `desktop-version.sh --print`; SHA = letzter Commit an `apps/desktop`, `desktop-version.sh`, `desktop-collect.sh`, `desktop-stamp.sh`, `ci.yml`; `pnpm-lock.yaml` bewusst nicht, weil die Tauri-CLI-Version an `apps/desktop/package.json` haengt und der Bau keine Datei ausserhalb von `apps/desktop` liest) und gibt `skip_allowed=true` nur fuer `refs/heads/main` aus. Dann `actions/cache/restore@v4` mit Schluessel `desktop-dist-stamp-<Stempel>` — ohne `restore-keys`, weil act_runner auch den Hauptschluessel per Praefix sucht und ein aelterer Stand nie als Treffer gelten darf. Danach `desktop-stamp.sh check`: `cache-hit`, Manifest, Kanal `beta`, Version, beide Dateien mit Groesse und sha256 laut Manifest → `reuse=true`; sonst raeumt es `desktop-dist/` und gibt `reuse=false`. Alle Bau-Schritte (setup-node bis Pakete einsammeln) tragen `if: steps.reuse.outputs.reuse != 'true'`. Nach einem echten Bau legt `actions/cache/save@v4` die Pakete zusaetzlich unter dem Stempel-Schluessel ab (nur main). Die Uebergabe an `publish` (`desktop-dist-<sha>`) laeuft in beiden Faellen; `publish` ist unveraendert. Bei Tags `v*` wird weder gesucht noch abgelegt. Lokale Probe: `DESKTOP_TAG=v1.2.0 GITHUB_REF=refs/heads/main sh .gitea/scripts/desktop-stamp.sh stamp`. (b) In der nummerierten Liste Punkt 7 („Uebergabe an `publish`”) um den Satz ergaenzen, dass er auch im Skip-Fall laeuft und dann den restaurierten Stand unter dem neuen SHA sichert. (c) Abschnitt 6, nach „Job `desktop` schlaegt fehl”, neuer Eintrag `### \`desktop\` baut, obwohl nichts geaendert wurde -- oder uebernimmt trotz Aenderung` mit zwei nummerierten Listen: Baut trotzdem — erster Lauf nach der Aenderung (Stempel neu), neuer Freigabe-Tag (Version im Stempel), Eintrag vom Runner-Cache weggeraeumt (act_runner raeumt ungenutzte Eintraege nach einigen Tagen, alte nach etwa einem Monat weg), `ci.yml`/Skripte geaendert, `check` hat den Eintrag verworfen (Grund steht im Schritt „Gefundene Pakete pruefen”). Uebernimmt trotz Aenderung — die Aenderung liegt ausserhalb der Pfadliste (z. B. nur `pnpm-lock.yaml`): entweder unter `apps/desktop/` etwas aendern oder `DESKTOP_PATHS` in `desktop-stamp.sh` erweitern (das loest selbst einen Neubau aus).
|
||||
3. **docs/anleitung-entwicklung.md**, nach dem Absatz „Der Windows-Installer wird nur im CI gebaut” (Z. 162-171) einen Absatz anfuegen, beginnend mit **Im CI wird die Desktop-App nur gebaut, wenn sich etwas an ihr geändert hat.** Der Job `desktop` vergleicht einen Stempel aus Versionsnummer und letztem Commit an `apps/desktop/`, `desktop-version.sh`, `desktop-collect.sh`, `desktop-stamp.sh` und `ci.yml` mit dem Zwischenspeicher des Runners und übernimmt bei Treffer die zuletzt gebauten Pakete (Details: `docs/ci-cd-setup.md`, Abschnitt 4). Eine Änderung außerhalb dieser Pfade – etwa nur in `pnpm-lock.yaml` – löst keinen Desktop-Bau aus; soll trotzdem neu gebaut werden, genügt eine Änderung unter `apps/desktop/`. Stempel lokal ansehen: `DESKTOP_TAG=v1.2.0 GITHUB_REF=refs/heads/main sh .gitea/scripts/desktop-stamp.sh stamp`.
|
||||
4. **CHANGELOG.md**, `## Unveröffentlicht`: Unterabschnitt `### Geändert` verwenden — existiert er (durch parallele Auftraege) bereits, die Zeile am Ende seiner Liste anfuegen; sonst ihn an der richtigen Stelle (nach `### Neu`, vor `### Entfernt`/`### Behoben`, falls vorhanden) anlegen. Genau eine Zeile: `- Desktop-App: Beta-Pakete werden nur noch neu gebaut, wenn sich an der Desktop-App etwas geändert hat; sonst bleiben die zuletzt gebauten Pakete gültig, und die App meldet keinen neuen Beta-Stand`. Keine bestehende Zeile aendern oder verschieben.
|
||||
5. Alle Aenderungen sind reine Einfuegungen; Umlaut-Konvention je Datei beachten (Betrieb/Entwicklung/CHANGELOG echte Umlaute, ci-cd-setup.md ae/oe/ue). Commit `docs: CI-Desktop-Bau nur bei geaendertem Desktop-Stand — Betriebshandbuch, CI-Runbook, Entwicklungsanleitung, CHANGELOG`.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && command grep -q '^### Wann gebaut wird und wann Pakete übernommen werden' docs/anleitung-betrieb.md && command grep -q 'desktop-stamp.sh' docs/anleitung-betrieb.md && command grep -q 'sofern überhaupt gebaut wird' docs/anleitung-betrieb.md && test "$(command grep -c 'beta\.<Commit>' docs/anleitung-betrieb.md)" -ge 2 && command grep -q 'Ueberspringen bei unveraendertem Desktop' docs/ci-cd-setup.md && command grep -q 'desktop-dist-stamp-' docs/ci-cd-setup.md && command grep -q 'pnpm-lock.yaml' docs/ci-cd-setup.md && command grep -q '^### `desktop` baut, obwohl nichts geaendert wurde' docs/ci-cd-setup.md && command grep -q 'DESKTOP_PATHS' docs/ci-cd-setup.md && command grep -q 'Im CI wird die Desktop-App nur gebaut, wenn sich etwas an ihr geändert hat' docs/anleitung-entwicklung.md && command grep -q 'desktop-stamp.sh stamp' docs/anleitung-entwicklung.md && awk '/^## Unveröffentlicht/{u=1;next} /^## /{u=0} u' CHANGELOG.md | command grep -q '^- Desktop-App: Beta-Pakete werden nur noch neu gebaut' && ! awk '/^## 1\.2\.0/{u=1} u' CHANGELOG.md | command grep -q '^- Desktop-App: Beta-Pakete werden nur noch neu gebaut' && awk '/^## Unveröffentlicht/{u=1;next} /^## /{u=0} u' CHANGELOG.md | command grep -q '^### Geändert' && ! command grep -q -E '[äöüÄÖÜß]' docs/ci-cd-setup.md && echo DOCS-OK</automated>
|
||||
</verify>
|
||||
<done>Betriebshandbuch Kap. 10 erklaert Stempel, Tag-Regel, Neubau nach Freigabe, aelteren Commit-Stempel als gewollt und den Cache-Ablauf; CI-Runbook beschreibt Schrittfolge, Schluessel, Pfadliste samt `pnpm-lock.yaml`-Begruendung und zwei Fehlerbilder; Entwicklungsanleitung nennt Pfadliste, Erzwingen eines Neubaus und die lokale Probe; CHANGELOG traegt genau einen neuen Stichpunkt unter Unveröffentlicht/Geändert; Commit `docs: …`. Der CI-Nachweis (Skip ohne Desktop-Aenderung, Bau mit Desktop-Aenderung) bleibt Sache des Orchestrators nach dem Push — im SUMMARY als offen fuehren.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Git-Push → Workflow-Ausloesung | Nur Pushes auf `main`/`live` und Tags `v*` starten den Lauf; Schreibrecht hat allein das Konto `schalli` (ci-cd-setup.md Abschnitt 5) |
|
||||
| Job `desktop` → Cache-Server des act_runner | Stempel-Schluessel und Cache-Inhalte werden von eigenen Laeufen geschrieben und gelesen; kein externer Zugang |
|
||||
| Cache-Inhalt → API-Abbild / Release | Uebernommene Pakete landen ungeprueft durch Menschen im API-Abbild unter `/app/desktop-dist/` |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-JDH-01 | Tampering | Cache-Schluessel `desktop-dist-stamp-<Version>-<SHA>` | low | mitigate | Schluessel entsteht ausschliesslich aus dem ausgecheckten Commit (`git log`, `git describe`), nicht aus Ereignis-Eingaben; nur `refs/heads/main` liest/schreibt ihn (`skip_allowed`); Restore ohne `restore-keys`, voller SHA am Schluesselende (act_runner-Praefixsuche); `cache-hit` muss exakt `true` sein. |
|
||||
| T-JDH-02 | Tampering | Restaurierter `desktop-dist/` (unvollstaendiger/beschaedigter Eintrag) | low | mitigate | `desktop-stamp.sh check` prueft Manifest (jq), Kanal `beta`, Version, Existenz, Groesse und sha256 jeder Datei; bei Abweichung Aufraeumen + normaler Bau (fail-safe), nie Job-Abbruch durch das Gate selbst. |
|
||||
| T-JDH-03 | Spoofing | Beta-Pakete unter einem Stempel aus einem Tag-Lauf (reine Version, Kanal live) | low | mitigate | Stempel-Save nur bei `skip_allowed == 'true'` (main); Tags suchen weder noch legen sie ab; `check` verwirft `channel != beta`. |
|
||||
| T-JDH-04 | Information Disclosure | Schluessel, `GITHUB_OUTPUT`, Log-Zeilen | low | accept | Enthalten nur Version, Commit-SHA, Dateinamen, Bauzeit — alles bereits oeffentlich im Repository bzw. Manifest; das Skript kennt kein Secret, `REGISTRY_TOKEN` bleibt allein in `publish`. |
|
||||
| T-JDH-05 | Denial of Service | Cache-Server nicht erreichbar / Eintrag weggeraeumt | low | accept | `actions/cache/restore` ohne `fail-on-cache-miss` protokolliert nur; `reuse=false` → Bau wie bisher. Kosten: eine Bauzeit, kein Ausfall. |
|
||||
| T-JDH-06 | Repudiation | Veraltete Pakete trotz Desktop-Aenderung (Pfadliste unvollstaendig) | low | mitigate | Pfadliste deckt alle Bau-Eingaben (`apps/desktop` inkl. `frontendDist ../src`, Cargo.lock, package.json; Skripte; ci.yml; das Stempel-Skript selbst); Ausnahme `pnpm-lock.yaml` begruendet und dokumentiert; Manifest nennt Bau-Commit und Bauzeit nachvollziehbar. |
|
||||
| T-JDH-SC | Tampering | npm/pip/cargo installs | low | accept | Keine neue Abhaengigkeit (kein `pnpm add`, kein `cargo add`, keine neue Action ausser den bereits genutzten `actions/cache/restore@v4`/`save@v4`); package-legitimacy gate entfaellt. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Skript: `sh -n` gruen; `stamp` liefert `1.2.0-<SHA>` (identisch mit `git log -1 --format=%H -- <Pfadliste>`), `skip_allowed=true` auf main, `false` bei Tag; `GITHUB_OUTPUT` erhaelt vier Schluessel; `check` akzeptiert nur ein vollstaendiges, pruefsummen-korrektes Beta-Manifest und raeumt sonst auf.
|
||||
- Workflow: js-yaml parst; Jobs `quality`/`test`/`publish` und `on:` identisch zu 38c1400; Job `desktop`: Schritte 1-3 = stamp/stamp-cache/reuse, 13 Bau-Schritte mit `if: steps.reuse.outputs.reuse != 'true'` und sonst unveraendertem Inhalt, Stempel-Save vor unveraenderter `Uebergabe an publish`; `command grep -c` der Bedingung = 14.
|
||||
- Doku: vier Stellen vorhanden (Greps in Task 2), CHANGELOG genau ein neuer Stichpunkt unter Unveröffentlicht/Geändert.
|
||||
- Zwei Commits (`ci:`, `docs:`), kein Push, keine `.planning/`-Dateien darin.
|
||||
- **Nachweis durch den Orchestrator nach dem Push (nicht Teil der Executor-Tasks):** (1) Der Push dieser Aenderung selbst baut (ci.yml/Skript sind in der Pfadliste) und legt `desktop-dist-stamp-1.2.0-<SHA>` ab. (2) Ein folgender Push ohne Desktop-Aenderung: Schritt „Gefundene Pakete pruefen” meldet „Desktop unveraendert seit <sha7>: Pakete … aus dem Zwischenspeicher”, die 13 Bau-Schritte sind uebersprungen, Job `desktop` unter einer Minute, `publish` gruen, `GET /api-proxy/desktop/latest` auf alpha nennt den aelteren Beta-Suffix. (3) Ein Push mit Desktop-Aenderung baut vollstaendig und legt einen neuen Stempel ab. Ergebnis in den SUMMARY-Nachtrag.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Alle `must_haves.truths` erfuellt; beide `<verify>`-Ketten gruen.
|
||||
- Keine Datei ausserhalb von `files_modified` + `.planning/` veraendert (`git status` vor jedem Commit gegenpruefen); `publish`, `desktop-version.sh`, `desktop-collect.sh`, `publish-*.sh` unberuehrt.
|
||||
- SUMMARY nennt den offenen CI-Nachweis (Skip/Bau) ausdruecklich als Aufgabe des Orchestrators.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `/home/vicolab/projects/tessera-ctl/.planning/quick/260917-jdh-ci-job-desktop-ueberspringen-wenn-apps-d/260917-jdh-SUMMARY.md` when done
|
||||
</output>
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
---
|
||||
phase: quick-260917-jdh
|
||||
plan: 01
|
||||
subsystem: ci
|
||||
tags: [gitea-actions, posix-sh, tauri, actions-cache, ci-cd]
|
||||
|
||||
requires: []
|
||||
provides:
|
||||
- ".gitea/scripts/desktop-stamp.sh — Unterbefehle stamp (Stempel aus Version + letztem Commit an den Desktop-Pfaden) und check (Manifest-/Pruefsummen-Gate fuer einen restaurierten desktop-dist/)"
|
||||
- "ci.yml Job desktop: stamp -> actions/cache/restore (Stempel-Schluessel, nur main) -> check -> 13 bedingte Bau-Schritte -> Stempel-Save -> unveraenderte Uebergabe an publish"
|
||||
affects: [ci, desktop-build, docs]
|
||||
|
||||
actuals:
|
||||
tokens: 5352
|
||||
tasks: 2
|
||||
commits: 2
|
||||
plan_head_before: 29db4c0
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Zwei-Ebenen-Cache-Schluessel fuer denselben Job: desktop-dist-stamp-<Version>-<SHA> als Ueberspring-Gate (nur main, kein restore-keys, voller 40-stelliger SHA gegen act_runner-Praefixsuche) neben dem bestehenden desktop-dist-<gitea.sha> fuer die Uebergabe an publish, die immer laeuft"
|
||||
- "if: steps.reuse.outputs.reuse != 'true' an jedem der 13 bestehenden Bau-Schritte statt eines umschliessenden Bedingungs-Jobs — haelt die Schritte inhaltlich byteidentisch und einzeln im Gitea-Actions-Log sichtbar"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- .gitea/scripts/desktop-stamp.sh
|
||||
modified:
|
||||
- .gitea/workflows/ci.yml
|
||||
- docs/anleitung-betrieb.md
|
||||
- docs/ci-cd-setup.md
|
||||
- docs/anleitung-entwicklung.md
|
||||
- CHANGELOG.md
|
||||
|
||||
key-decisions:
|
||||
- "pnpm-lock.yaml bewusst NICHT in DESKTOP_PATHS: die Tauri-CLI-Version haengt an apps/desktop/package.json (darin enthalten), der Desktop-Bau liest ausserhalb von apps/desktop keine Werkstatt-Datei (frontendDist ist ../src innerhalb von apps/desktop)."
|
||||
- "stamp-cache (actions/cache/restore@v4) bewusst OHNE restore-keys: act_runner sucht auch zum Hauptschluessel per Praefix: ein aelterer, praefixgleicher Stand darf nie als Treffer gelten. Der volle 40-stellige Commit-SHA am Schluesselende macht das unmoeglich."
|
||||
- "skip_allowed ausschliesslich bei GITHUB_REF == refs/heads/main: Tags v* suchen und legen nichts unter dem Stempel-Schluessel ab, damit nie Live-Pakete (reine Version, Kanal live) unter einem Beta-Stempel landen; check verwirft zusaetzlich channel != beta als zweite Absicherung."
|
||||
- "desktop-stamp.sh selbst gehoert zu DESKTOP_PATHS: eine Aenderung an der Stempelregel muss selbst einen Neubau ausloesen, sonst koennte ein alter, nicht mehr zur neuen Regel passender Cache-Treffer unbemerkt uebernommen werden."
|
||||
|
||||
requirements-completed: [QUICK-260917-JDH]
|
||||
|
||||
coverage:
|
||||
- id: T1
|
||||
description: "desktop-stamp.sh stamp liefert Stempel <Version>-<voller SHA>, version, sha7, skip_allowed (true nur auf main); GITHUB_OUTPUT erhaelt alle vier Schluessel; leere Commit-Historie zu den Desktop-Pfaden fuehrt zu Exit 1"
|
||||
requirement: "QUICK-260917-JDH"
|
||||
verification:
|
||||
- kind: script
|
||||
ref: "lokale Probe: sh -n, DESKTOP_TAG=v1.2.0 GITHUB_REF=refs/heads/main|refs/tags/v1.2.0, GITHUB_OUTPUT-Datei"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: T2
|
||||
description: "desktop-stamp.sh check gibt reuse=true nur bei CACHE_HIT=true, gueltigem/passendem Manifest (Kanal beta, Version, beide Dateinamen) und uebereinstimmender Groesse+sha256 je Datei; sonst reuse=false und Aufraeumen (AppImage/exe/manifest.json entfernt)"
|
||||
requirement: "QUICK-260917-JDH"
|
||||
verification:
|
||||
- kind: script
|
||||
ref: "lokale Probe: Mini-desktop-dist im Scratchpad — vollstaendiges Manifest (reuse=true), veraenderte .exe-Datei (reuse=false, Manifest entfernt), CACHE_HIT=false (reuse=false)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: T3
|
||||
description: "ci.yml Job desktop: Schritte stamp/stamp-cache/reuse an Index 1-3; 13 Bau-Schritte mit if: steps.reuse.outputs.reuse != 'true' und sonst byteidentischem Inhalt; Stempel-Save vor unveraenderter Uebergabe an publish; quality/test/publish und on: unveraendert gegenueber 38c1400"
|
||||
requirement: "QUICK-260917-JDH"
|
||||
verification:
|
||||
- kind: script
|
||||
ref: "node -e (js-yaml-Tiefenvergleich gegen git show 38c1400:.gitea/workflows/ci.yml) — 19 Schritte im Job desktop, alle Struktur- und Inhaltspruefungen gruen"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: T4
|
||||
description: "Doku (Betriebshandbuch Kap. 10, CI-Runbook Abschnitt 4/6, Entwicklungsanleitung) und ein CHANGELOG-Stichpunkt unter Unveroeffentlicht/Geaendert"
|
||||
requirement: "QUICK-260917-JDH"
|
||||
verification:
|
||||
- kind: script
|
||||
ref: "command grep-Kette aus Task 2 (15 Einzelpruefungen) — alle gruen (DOCS-OK)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: T5
|
||||
description: "Realer CI-Nachweis: Push ohne Desktop-Aenderung ueberspringt den Bau, Push mit Desktop-Aenderung baut neu"
|
||||
requirement: "QUICK-260917-JDH"
|
||||
verification: []
|
||||
human_judgment: true
|
||||
rationale: "Der reale act_runner-Lauf (echter Cache-Server, echter Push) ist laut Plan-Objective ausdruecklich kein Executor-Task — Nachweis durch den Orchestrator nach dem Push, siehe Abschnitt unten."
|
||||
|
||||
duration: ~20min
|
||||
completed: 2026-09-17
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Quick Task 260917-jdh: CI-Job `desktop` ueberspringt den Bau bei unveraendertem Desktop-Stand Summary
|
||||
|
||||
**Neues Skript `desktop-stamp.sh` (Stempel aus Version + letztem Desktop-Commit, Manifest-/Pruefsummen-Gate) plus Job `desktop` in `ci.yml`, der 13 Bau-Schritte per `if:` ueberspringt, wenn zum Stempel bereits geprueft-vollstaendige Pakete im Zwischenspeicher liegen**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** ~20 min
|
||||
- **Completed:** 2026-09-17T12:28:54Z
|
||||
- **Tasks:** 2/2
|
||||
- **Files modified:** 6 (1 neu, 5 geaendert)
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- `.gitea/scripts/desktop-stamp.sh` (POSIX sh, `set -eu`, kennt kein Secret) mit den Unterbefehlen `stamp` (Stempel `<Version>-<voller SHA>` aus `desktop-version.sh --print` + `git log -1` ueber `DESKTOP_PATHS`, Ausgaben `stamp`/`version`/`sha7`/`skip_allowed` nach stdout und `GITHUB_OUTPUT`) und `check` (Manifest, Kanal `beta`, Version, Dateiname/Groesse/sha256 je Plattform — nur bei vollstaendiger Uebereinstimmung `reuse=true`, sonst Aufraeumen + `reuse=false`)
|
||||
- `ci.yml` Job `desktop`: drei neue Schritte `stamp`/`stamp-cache`/`reuse` direkt nach dem Checkout (vor den teuren setup-node/apt/rustup-Schritten), alle 13 bestehenden Bau-Schritte tragen jetzt `if: steps.reuse.outputs.reuse != 'true'` bei sonst unveraendertem Inhalt, neuer Schritt „Pakete unter dem Stempel ablegen" vor der unveraenderten „Uebergabe an publish"
|
||||
- Vier Doku-Stellen ergaenzt (Betriebshandbuch Kap. 10 mit neuem Unterabschnitt + Fehlerbild, CI-Runbook Abschnitt 4/6, Entwicklungsanleitung) und ein CHANGELOG-Stichpunkt unter Unveroeffentlicht/Geaendert
|
||||
- Alle lokalen Proben (Skript-Verhalten, `GITHUB_OUTPUT`, drei `check`-Faelle, js-yaml-Strukturvergleich gegen den Ausgangsstand 38c1400, 15 Doku-Greps) gruen; `quality`/`test`/`publish` und `on:` byteidentisch zum Ausgangsstand
|
||||
|
||||
## Task Commits
|
||||
|
||||
Each task was committed atomically:
|
||||
|
||||
1. **Task 1: `desktop-stamp.sh` (Stempel + Cache-Pruefung) und Job `desktop` ueberspringt den Bau bei Treffer** - `8c4aaa5` (ci)
|
||||
2. **Task 2: Doku (Betriebshandbuch Kap. 10, CI-Runbook Abschnitt 4/6, Entwicklungsanleitung) und CHANGELOG** - `e7633e1` (docs)
|
||||
|
||||
**Plan metadata:** wird vom Orchestrator nach diesem SUMMARY committet.
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `.gitea/scripts/desktop-stamp.sh` - neu; Unterbefehle `stamp`/`check`, Konstante `DESKTOP_PATHS`, Helfer `out()` fuer stdout+`GITHUB_OUTPUT`
|
||||
- `.gitea/workflows/ci.yml` - Job `desktop`: Schritte `stamp`/`stamp-cache`/`reuse`/„Pakete unter dem Stempel ablegen" neu; `if:` an den 13 bestehenden Bau-Schritten; Kopfkommentar um `quick-260917-jdh`-Absatz ergaenzt; `quality`/`test`/`publish`/`on:` unveraendert
|
||||
- `docs/anleitung-betrieb.md` - Kap. 10: neuer Unterabschnitt „Wann gebaut wird und wann Pakete uebernommen werden", Halbsatz am Job-Dauer-Satz, neue Fehlerbilder-Zeile
|
||||
- `docs/ci-cd-setup.md` - Abschnitt 4: Absatz „Ueberspringen bei unveraendertem Desktop", Ergaenzung zu Schritt 7; Abschnitt 6: neuer Fehlerbehebungs-Eintrag
|
||||
- `docs/anleitung-entwicklung.md` - Absatz zum CI-Ueberspringen nach „Desktop-App lokal bauen", inkl. lokaler Stempel-Probe
|
||||
- `CHANGELOG.md` - ein Stichpunkt unter `## Unveroeffentlicht` / `### Geaendert`
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- `pnpm-lock.yaml` bewusst nicht in `DESKTOP_PATHS` — begruendet in Skript-Kopfkommentar und `docs/ci-cd-setup.md`.
|
||||
- `stamp-cache` ohne `restore-keys`, voller 40-stelliger SHA am Schluesselende — verhindert einen Praefix-Treffer eines aelteren Standes durch die act_runner-Cache-Suche.
|
||||
- `skip_allowed` ausschliesslich bei `refs/heads/main`; Tags suchen/legen nichts unter dem Stempel-Schluessel ab.
|
||||
- `desktop-stamp.sh` selbst ist Teil von `DESKTOP_PATHS`, damit eine Aenderung der Stempelregel selbst einen Neubau ausloest.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
None - plan executed exactly as written.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- Zwei Doku-Saetze (Job-Dauer in `anleitung-betrieb.md`, Einleitungssatz in `anleitung-entwicklung.md`) wurden beim ersten Schreiben durch Zeilenumbrueche in der Markdown-Quelle getrennt, wodurch die exakten Grep-Muster aus `<verify>` zunaechst nicht trafen. Beide Saetze wurden je in eine durchgehende Zeile zusammengefasst; alle 15 Doku-Pruefungen sind danach gruen. Kein Code-Problem, reine Formatierungskorrektur ohne inhaltliche Aenderung.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None - no external service configuration required.
|
||||
|
||||
## Nachweis durch Orchestrator (offen)
|
||||
|
||||
Der echte CI-Beweis ist laut Plan-Objective bewusst kein Executor-Task und steht nach dem Push noch aus. Drei zu beobachtende Faelle:
|
||||
|
||||
1. **Dieser Push selbst baut.** `ci.yml`/`desktop-stamp.sh` stehen in der eigenen Pfadliste `DESKTOP_PATHS` — der erste Lauf nach diesem Merge muss den Job `desktop` vollstaendig durchlaufen und legt dabei `desktop-dist-stamp-1.2.0-<SHA-dieses-Standes>` im Zwischenspeicher des Runners ab.
|
||||
2. **Ein folgender Push ohne Desktop-Aenderung ueberspringt den Bau.** Schritt „Gefundene Pakete pruefen" meldet im Log „Desktop unveraendert seit `<sha7>`: Pakete … aus dem Zwischenspeicher", die 13 Bau-Schritte erscheinen im Gitea-Actions-Lauf als uebersprungen, der Job `desktop` ist unter einer Minute fertig, `publish` bleibt gruen, und `GET /api-proxy/desktop/latest` auf alpha nennt weiterhin den aelteren Beta-Commit-Suffix.
|
||||
3. **Ein Push mit einer Aenderung unter `apps/desktop/` baut wieder vollstaendig** und legt einen neuen Stempel-Schluessel ab (alter Schluessel bleibt bis zum Ablauf im Zwischenspeicher erhalten, wird aber nie wieder getroffen).
|
||||
|
||||
Zusaetzlicher Beobachtungspunkt aus dem Plan-Checker-Hinweis: Das Verhalten von `actions/cache/save@v4` bei einem bereits belegten Schluessel (Fall 1 nach einem zuvor schon vorhandenen Stempel, z. B. nach einem Wiederholungslauf) ist auf diesem Runner nicht separat gemessen — laut Gitea-/actions-cache-Dokumentation loest das nur eine Info-Meldung aus, keinen Fehler; beim ersten realen Lauf im Log gegenpruefen.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- Kein Blocker fuer weitere Arbeit. Die drei parallelen Quick-Tasks (Favoriten-Widget-Favicon, Favoriten-Sortierung/Bildmarke, Desktop-Client-Serveradresse) sind von dieser Aenderung nicht betroffen — sie ruehren `.gitea/`, `docs/` (ausser den hier bearbeiteten Stellen) oder die CI-Konfiguration nicht an.
|
||||
- Naechster inhaltlicher Schritt ist der Orchestrator-Nachweis oben, nicht ein weiterer Plan.
|
||||
|
||||
---
|
||||
*Quick Task: 260917-jdh*
|
||||
*Completed: 2026-09-17*
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
All 6 claimed source/doc files found on disk; both task commits (8c4aaa5, e7633e1) found in git history.
|
||||
|
||||
## Nachweis durch Orchestrator — Ergebnis
|
||||
|
||||
- Fall 1 (Lauf 382, Push `5a444ec`): Stempel `1.2.0-29c132e…`, kein Zwischenspeicher → gebaut, `Cache saved with key: desktop-dist-stamp-…`.
|
||||
- Fall 3 (Lauf 383, Push `7479cb4` mit Desktop-Aenderung): Stempel `1.2.0-7004b5b…` neu → Neubau; Lauf 384 (`a6d1a64`) ebenso.
|
||||
- Fall 2 (Docs-Push ohne Desktop-Aenderung ueberspringt): siehe Aktenstand-Push nach diesem Eintrag (Lauf 385).
|
||||
- `cache/save` bei bereits belegtem Schluessel: bisher nicht aufgetreten (jeder Lauf hatte einen neuen Stempel) — weiter beobachten.
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
---
|
||||
phase: quick-260917-jdh
|
||||
verified: 2026-09-17T14:45:00Z
|
||||
status: passed
|
||||
score: 9/9 must-haves verified
|
||||
covered_files:
|
||||
- ".gitea/scripts/desktop-stamp.sh"
|
||||
- ".gitea/workflows/ci.yml"
|
||||
- ".planning/quick/260917-jdh-ci-job-desktop-ueberspringen-wenn-apps-d/260917-jdh-PLAN.md"
|
||||
- ".planning/quick/260917-jdh-ci-job-desktop-ueberspringen-wenn-apps-d/260917-jdh-SUMMARY.md"
|
||||
- "CHANGELOG.md"
|
||||
- "docs/anleitung-betrieb.md"
|
||||
- "docs/anleitung-entwicklung.md"
|
||||
- "docs/ci-cd-setup.md"
|
||||
covered_digest: "v1:sha256:1157e72ff150c1737fe76e2aee4c2f272ce4bc5739a3e76e1694d610533db578"
|
||||
behavior_unverified: 0
|
||||
overrides_applied: 0
|
||||
---
|
||||
|
||||
# Quick Task 260917-jdh: CI-Job `desktop` ueberspringt den Bau bei unveraendertem Desktop-Stand — Verifikation
|
||||
|
||||
**Ziel:** CI-Job `desktop` ueberspringt den Bau, wenn Version + Desktop-Stand seit dem zuletzt gebauten Stand unveraendert sind; Pakete aus dem Zwischenspeicher (Stempel-Schluessel) uebernommen; Tags `v*` bauen immer; `publish`, `Uebergabe an publish` und die Jobs `quality`/`test` unveraendert; fail-safe bei fehlendem/kaputtem Cache; Doku + CHANGELOG.
|
||||
|
||||
**Verified:** 2026-09-17
|
||||
**Status:** passed
|
||||
**Modus:** Read-only Nachverifikation — alle Proben selbst ausgefuehrt, keine Code-Aenderung, kein Commit.
|
||||
|
||||
## Vorgehen
|
||||
|
||||
Alle Behauptungen aus SUMMARY.md wurden NICHT uebernommen, sondern selbst neu erzeugt: eigener `sh -n`, eigene `desktop-stamp.sh stamp`/`check`-Laeufe mit frisch gebautem Mini-`desktop-dist` im Scratchpad, eigener js-yaml-Tiefenvergleich von `ci.yml` gegen `git show 38c1400:.gitea/workflows/ci.yml`, eigene `grep`-Kette gegen die vier Doku-Dateien und das CHANGELOG, eigene Pruefung von `git show 8c4aaa5 e7633e1` und `git status`.
|
||||
|
||||
## Goal Achievement
|
||||
|
||||
### Observable Truths
|
||||
|
||||
| # | Truth | Status | Evidence |
|
||||
|---|-------|--------|----------|
|
||||
| 1 | `desktop-stamp.sh stamp` liefert Stempel `<Version>-<voller SHA>`, `version`, `sha7`, `skip_allowed` (true nur auf `refs/heads/main`), schreibt nach stdout + `$GITHUB_OUTPUT` | VERIFIED | Eigener Lauf: `stamp=1.2.0-8c4aaa51fa34f650836f722679844f1c8239270a`, `skip_allowed=true` (main) / `skip_allowed=false` (Tag); `GITHUB_OUTPUT`-Datei enthaelt alle vier Schluessel inkl. `sha7=[0-9a-f]{7}` |
|
||||
| 2 | `desktop-stamp.sh check` gibt `reuse=true` nur bei vollstaendigem, pruefsummen-korrektem Beta-Manifest; sonst `reuse=false` + Aufraeumen, Exit 0 | VERIFIED | Eigener Lauf mit Mini-`desktop-dist` (2 Dateien + `jq -n`-Manifest): Treffer → `reuse=true`; nach 1-Byte-Manipulation der `.exe` → `reuse=false`, `manifest.json` entfernt; `CACHE_HIT=false`/leer → `reuse=false`, kein Absturz trotz `set -eu` |
|
||||
| 3 | ci.yml Job `desktop`: `stamp`/`stamp-cache`/`reuse` an Index 1-3 direkt nach Checkout, `stamp-cache` mit `if: skip_allowed`, ohne `restore-keys`, `reuse` ohne `if` | VERIFIED | js-yaml-Strukturpruefung (eigener Lauf): `indices { iS:1, iC:2, iR:3, iU:18, iV:17, total:19 }`, alle Feld-Checks gruen |
|
||||
| 4 | 13 bestehende Bau-Schritte tragen `if: steps.reuse.outputs.reuse != 'true'`, Inhalt sonst byteidentisch; neuer Schritt „Pakete unter dem Stempel ablegen” unmittelbar vor unveraenderter „Uebergabe an publish” | VERIFIED | Gleicher js-yaml-Lauf: `iU-iR-1 === 14`, Tiefenvergleich der 13 Bau-Schritte (ohne `if`) gegen `38c1400`-Steps 1-13 identisch, `Uebergabe an publish` == `38c1400`-Step 14, kein `if` |
|
||||
| 5 | Jobs `quality`, `test`, `publish`, `on:`, sowie `env`/`needs`/`if`/`runs-on` von `desktop` gegenueber 38c1400 unveraendert; Kopfkommentar um `quick-260917-jdh`-Absatz ergaenzt | VERIFIED | js-yaml-Tiefenvergleich (`JSON.stringify`) fuer `quality`/`test`/`publish`/`on` == 38c1400, keine Exception; `command grep -q 'quick-260917-jdh' ci.yml` traf |
|
||||
| 6 | Lokale Skript-Probe (PLAN-Vorgabe): Stempel + `skip_allowed` je Ref, `GITHUB_OUTPUT`, drei `check`-Faelle | VERIFIED | Alle Einzelbefehle aus dem PLAN-`<verify>`-Block selbst neu ausgefuehrt (nicht aus SUMMARY uebernommen), alle gruen |
|
||||
| 7 | Vier Doku-Stellen (Betriebshandbuch Kap. 10, CI-Runbook Abschnitt 4/6, Entwicklungsanleitung) inhaltlich korrekt und vollstaendig | VERIFIED | Volltext aller vier Abschnitte gelesen: neuer Unterabschnitt „Wann gebaut wird…” + Fehlerbilder-Zeile in `anleitung-betrieb.md`; Absatz „Ueberspringen bei unveraendertem Desktop” + neuer Abschnitt-6-Eintrag in `ci-cd-setup.md`; Absatz mit lokaler Probe in `anleitung-entwicklung.md`; 15 Grep-Pruefungen aus PLAN-Task-2 eigenstaendig wiederholt, alle gruen (`DOCS-OK`) |
|
||||
| 8 | CHANGELOG.md `## Unveröffentlicht`/`### Geändert`: genau ein neuer Stichpunkt, bestehende Zeile (paralleler Auftrag) bleibt erhalten | VERIFIED | `sed -n '1,15p' CHANGELOG.md`: Abschnitt enthaelt exakt die erwartete neue Zeile plus die unveraenderte Bildmarken-Zeile aus dem parallelen Auftrag; kein Eintrag im `## 1.2.0`-Block |
|
||||
| 9 | Zwei Commits ohne Push, keine `.planning/`-Dateien, keine Datei ausserhalb `files_modified` | VERIFIED | `git show --name-only 8c4aaa5 e7633e1` listet exakt die 6 erwarteten Dateien; `git status --short` zeigt nur unveraenderte parallele Arbeit anderer Auftraege (favorites, `.planning/STATE.md`, `pnpm-lock.yaml`), nichts aus diesem Task uncommitted |
|
||||
|
||||
**Score:** 9/9 truths verified (0 present-behavior-unverified)
|
||||
|
||||
### Required Artifacts
|
||||
|
||||
| Artifact | Expected | Status | Details |
|
||||
|----------|----------|--------|---------|
|
||||
| `.gitea/scripts/desktop-stamp.sh` | Neu, `stamp`+`check`, `DESKTOP_PATHS` | VERIFIED | Existiert, `+x`, `sh -n` gruen, Kopfkommentar im Stil von `desktop-collect.sh`, `set -eu`, „kennt kein Secret”, Verhalten per eigener Probe bestaetigt |
|
||||
| `.gitea/workflows/ci.yml` | Job `desktop` mit Stempel-Kette | VERIFIED | js-yaml-Strukturvergleich gruen, 19 Schritte im Job, Kopfkommentar ergaenzt |
|
||||
| `docs/anleitung-betrieb.md` | Kap. 10 Unterabschnitt + Fehlerbild | VERIFIED | Volltext gelesen, inhaltlich korrekt, echte Umlaute |
|
||||
| `docs/ci-cd-setup.md` | Abschnitt 4 Absatz + Abschnitt 6 Eintrag | VERIFIED | Volltext gelesen, ae/oe/ue-Konvention eingehalten (`! grep -E '[äöüÄÖÜß]'` bestaetigt) |
|
||||
| `docs/anleitung-entwicklung.md` | Absatz in „Desktop-App lokal bauen” | VERIFIED | Volltext gelesen, inkl. lokaler Stempel-Probe |
|
||||
| `CHANGELOG.md` | Ein Stichpunkt unter Unveröffentlicht/Geändert | VERIFIED | Exakt eine neue Zeile, Stil konsistent, echte Umlaute, kein Punkt am Ende |
|
||||
|
||||
### Key Link Verification
|
||||
|
||||
| From | To | Via | Status | Details |
|
||||
|------|-----|-----|--------|---------|
|
||||
| `stamp` | `stamp-cache` | `key: desktop-dist-stamp-${{ steps.stamp.outputs.stamp }}` | WIRED | js-yaml bestaetigt exakten Schluessel-String an beiden Stellen (Restore + Save) |
|
||||
| `stamp-cache` | `reuse` | `env.CACHE_HIT: steps.stamp-cache.outputs.cache-hit` | WIRED | js-yaml bestaetigt `env`-Zuordnung; eigene Probe zeigt korrektes Fail-Safe-Verhalten bei leerem `CACHE_HIT` (Tag-Fall) |
|
||||
| `reuse` | 13 Bau-Schritte | `if: steps.reuse.outputs.reuse != 'true'` | WIRED | Alle 13 Schritte tragen die Bedingung (js-yaml-Schleife + `command grep -c` = 14, inkl. Stempel-Save-Schritt mit zusaetzlicher Bedingung) |
|
||||
| 13 Bau-Schritte | `Uebergabe an publish` | keine Bedingung, laeuft immer | WIRED | js-yaml bestaetigt `s[iU].if === undefined`; Schritt inhaltlich identisch zu `38c1400` |
|
||||
| `desktop` (Save) | `publish` (Restore) | `key: desktop-dist-${{ gitea.sha }}` | WIRED | `publish`-Job laut js-yaml-Vergleich unveraendert, liest denselben Schluessel wie vor der Aenderung |
|
||||
|
||||
### Behavioral Spot-Checks (Step 7b)
|
||||
|
||||
| Behavior | Command | Result | Status |
|
||||
|----------|---------|--------|--------|
|
||||
| `stamp` auf main liefert korrekten Stempel | `DESKTOP_TAG=v1.2.0 GITHUB_REF=refs/heads/main sh desktop-stamp.sh stamp` | `stamp=1.2.0-8c4aaa5...`, `skip_allowed=true` | PASS |
|
||||
| `stamp` auf Tag deaktiviert Skip | `DESKTOP_TAG=v1.2.0 GITHUB_REF=refs/tags/v1.2.0 sh desktop-stamp.sh stamp` | `skip_allowed=false` | PASS |
|
||||
| `check` akzeptiert vollstaendiges Manifest | Mini-`desktop-dist`, echte Groessen/Pruefsummen | `reuse=true`, Log-Zeile mit Commit/Bauzeit | PASS |
|
||||
| `check` verwirft manipuliertes Paket | 1 Byte an `.exe` angehaengt | `reuse=false`, `manifest.json` entfernt | PASS |
|
||||
| `check` fail-safe bei fehlendem Cache | `CACHE_HIT=false` bzw. leer | `reuse=false`, Exit 0, kein Absturz | PASS |
|
||||
| ci.yml Struktur-/Inhaltsvergleich gegen Ausgangsstand | js-yaml-Tiefenvergleich (Node) | `CI-OK: 19 Schritte im Job desktop` | PASS |
|
||||
| Doku-Vollstaendigkeit (15 Einzelpruefungen) | `command grep`-Kette aus PLAN Task 2 | `DOCS-OK` | PASS |
|
||||
|
||||
Alle Proben liefen in unter 10 Sekunden je Aufruf, keine Server-/Runner-Interaktion, keine Zustandsaenderung ausserhalb des Scratchpads.
|
||||
|
||||
### Anti-Patterns Found
|
||||
|
||||
Keine. `grep` nach `TBD|FIXME|XXX|TODO|HACK|PLACEHOLDER` und nach umgangssprachlichen Platzhalter-Formulierungen in allen 6 geaenderten Dateien ergab keinen Treffer.
|
||||
|
||||
### Requirements Coverage
|
||||
|
||||
| Requirement | Source Plan | Description | Status | Evidence |
|
||||
|-------------|-------------|-------------|--------|----------|
|
||||
| QUICK-260917-JDH | 260917-jdh-PLAN.md | CI-Job `desktop` ueberspringt den Bau bei unveraendertem Desktop-Stand | SATISFIED | Alle 9 Truths verifiziert, keine Datei ausserhalb Scope veraendert |
|
||||
|
||||
Kein Eintrag in `.planning/REQUIREMENTS.md` fuer Quick Tasks — Requirement-ID lebt ausschliesslich in der PLAN-Frontmatter dieses Tasks, keine verwaisten Anforderungen.
|
||||
|
||||
## Offener Beobachtungspunkt (kein Gap — laut Plan-Objective explizit Aufgabe des Orchestrators nach dem Push)
|
||||
|
||||
Der reale act_runner-Beweis auf dem tatsaechlichen Gitea-Runner ist bewusst **kein Bestandteil dieses Quick Tasks** — Plan-Objective und SUMMARY.md fuehren ihn ausdruecklich als Nachweis durch den Orchestrator NACH dem Push. Drei zu beobachtende Faelle, sobald gepusht wird:
|
||||
|
||||
1. Dieser Push selbst baut vollstaendig (ci.yml/desktop-stamp.sh liegen in der eigenen Pfadliste) und legt `desktop-dist-stamp-1.2.0-<SHA-dieses-Standes>` im Zwischenspeicher ab.
|
||||
2. Ein folgender Push ohne Desktop-Aenderung ueberspringt die 13 Bau-Schritte, Log zeigt „Desktop unveraendert seit `<sha7>`: Pakete … aus dem Zwischenspeicher”, Job unter einer Minute, `publish` bleibt gruen.
|
||||
3. Ein Push mit Aenderung unter `apps/desktop/` baut wieder vollstaendig und legt einen neuen Stempel-Schluessel ab.
|
||||
|
||||
Zusaetzlich unbeobachtet (auf diesem Runner nicht separat gemessen, laut Dokumentation aber unkritisch): Verhalten von `actions/cache/save@v4` bei bereits belegtem Schluessel loest laut Gitea-/actions-cache-Doku nur eine Info-Meldung aus, keinen Fehler.
|
||||
|
||||
Dieser Punkt aendert den Status dieser Verifikation NICHT auf `human_needed` — er ist explizit als nachgelagerte Beobachtung ausserhalb des Executor-/Verifier-Scopes deklariert (siehe PLAN `<verification>`-Block und SUMMARY-Abschnitt „Nachweis durch Orchestrator (offen)”), analog zu einem echten End-to-End-Produktions-Smoke-Test nach einem Deploy.
|
||||
|
||||
## Gaps Summary
|
||||
|
||||
Keine Gaps. Alle 9 must-have Truths, alle 6 Artefakte und alle 5 Key Links sind mit selbst ausgefuehrten Proben (nicht aus SUMMARY.md uebernommen) bestaetigt. Der einzige offene Punkt ist der bewusst ausserhalb des Task-Scopes liegende reale CI-Lauf nach dem Push.
|
||||
|
||||
---
|
||||
|
||||
_Verified: 2026-09-17_
|
||||
_Verifier: Claude (gsd-verifier)_
|
||||
+232
@@ -0,0 +1,232 @@
|
||||
---
|
||||
phase: quick-260917-jn2
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
autonomous: true
|
||||
requirements: [QUICK-260917-JN2]
|
||||
|
||||
files_modified:
|
||||
- apps/desktop/src-tauri/src/lib.rs
|
||||
- apps/desktop/src/setup.html
|
||||
- apps/web/src/components/settings/desktop-app-settings.tsx
|
||||
- apps/web/src/components/settings/desktop-app-settings.test.tsx
|
||||
- apps/web/src/messages/de.json
|
||||
- apps/web/src/messages/en.json
|
||||
- docs/anleitung-anwender.md
|
||||
- CHANGELOG.md
|
||||
|
||||
estimate:
|
||||
tokens: 46000
|
||||
raw_tokens: 46000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "lib.rs: Tray wird mit `TrayIconBuilder::with_id(\"main\")` gebaut; die erste Menuezeile ist ein gesperrter Eintrag (Id `connected`) mit Text `Verbunden mit {host}` bzw. `Nicht verbunden`, danach Trenner, dann `Öffnen`, `Server-Adresse ändern…` (Id `change_server`), `Update herunterladen`, Trenner, Autostart-Haken, Trenner, `Beenden`. Tooltip beim Bau `Tessera – {host}` bzw. `Tessera – nicht verbunden` (echte Umlaute und Gedankenstrich U+2013 wie im Bestand)."
|
||||
- "lib.rs: reine Funktionen `server_host(url: Option<&str>) -> Option<String>` (Host ohne Schema/Pfad, `host:port` nur bei ausdruecklich angegebenem Nicht-Standardport; None bei None/leer/unparsbar), `tray_labels(url: Option<&str>) -> (String, String)` (Tooltip, Menuezeile), `setup_page_url(windows: bool) -> tauri::Url` (`http://tauri.localhost/setup.html` bzw. `tauri://localhost/setup.html`) und `parse_server_url(url: &str) -> Result<tauri::Url, String>` (parsen + nur http/https, Fehlertexte wie bisher in `check_server`) existieren mit Tests in `mod tests`; `cargo fmt --check`, `cargo check`, `cargo clippy`, `cargo test --lib` in apps/desktop/src-tauri gruen."
|
||||
- "lib.rs: neue Commands `get_server_url(app) -> Option<String>` (liefert NUR den gespeicherten Wert `server_url` aus config.json, leer/fehlend → None) und `open_server(app) -> Result<(), String>` (navigiert das Fenster `main` per `with_desktop_marker` zur gespeicherten Adresse; ohne Adresse Err); beide im `generate_handler!`. Die Capabilities-Datei bleibt unveraendert — App-Commands sind vom lokalen Ursprung ohne Eintrag erlaubt, und vom Remote-Ursprung (Server-Seite) verweigert Tauri sie, solange kein `remote`-Block existiert (tauri-2.11.3 webview/mod.rs Z. 1819-1823)."
|
||||
- "lib.rs: Tray-Klick `change_server` navigiert das Fenster `main` per `window.navigate(setup_page_url(cfg!(windows)))` und ruft danach `unminimize`/`show`/`set_focus` (kein `eval`, kein JavaScript in der Remote-Seite)."
|
||||
- "lib.rs: Nach `save_server_url` mit neuer Adresse zeigen Tooltip und `connected`-Zeile ohne Neustart die NEUE Adresse (`apply_server`), der `update`-Eintrag wird auf `Update herunterladen`/gesperrt zurueckgesetzt und die Versionspruefung laeuft einmal neu gegen die neue Adresse (`spawn_version_check`, aus `setup` herausgezogen). Der Tray-Klick `update` liest die Adresse beim Klick per `stored_server_url(app)` aus dem Store — der Start-Klon der Adresse ist entfernt. Menue-Handles liegen in `app.manage(TrayItems { connected, update })`."
|
||||
- "setup.html: beim Laden `invoke('get_server_url')`; ist ein Wert da, wird `#server-url` vorbelegt, `#current-server` zeigt `Aktuell verbunden mit: {adresse}` (per textContent), die Unterzeile heisst `Server-Adresse ändern`, und ein zweiter Knopf `#cancel-btn` `Abbrechen` ist sichtbar und ruft `invoke('open_server')`. Ohne gespeicherte Adresse (oder wenn der Aufruf fehlschlaegt) verhaelt sich die Seite exakt wie bisher (Erststart). Die SVG `.brand-mark` bleibt unangetastet."
|
||||
- "Web: `DesktopAppSettings` rendert NUR bei `useIsDesktopClient() === true` einen Block `data-testid=\"desktop-connected\"` mit `settings.desktop.connectedTo` (`Verbunden mit: {origin}`, origin = `window.location.origin`) und `settings.desktop.changeHint` (Hinweis auf „Server-Adresse ändern…“ im Infobereich-Menue); Schluessel in de.json und en.json innerhalb von `settings.desktop`. desktop-app-settings.test.tsx: neuer Test mit Cookie `tessera_desktop=1` findet `Verbunden mit: http://localhost:3000` und den Hinweis; ohne Cookie fehlt `desktop-connected`; Tests 1-3 bleiben gruen. `pnpm --filter @tessera/web exec vitest run` und `pnpm --filter @tessera/web type-check` gruen."
|
||||
- "Doku: docs/anleitung-anwender.md nennt im Abschnitt Desktop-App die drei Stellen, an denen die aktuelle Server-Adresse steht (Hinweistext am Symbol, erste Menuezeile, Einstellungen → Allgemein → Desktop-App) und beschreibt in einem neuen Unterabschnitt „Server-Adresse ändern“ den Weg ueber das Symbol im Infobereich; die Tray-Menue-Liste fuehrt `Verbunden mit …` und `Server-Adresse ändern…`. CHANGELOG.md `## Unveröffentlicht` `### Neu`: zwei neue Stichpunkte mit Praefix `Desktop-App:` (Stil wie Bestand). Nur Zeilen ergaenzt, fremde Zeilen bleiben."
|
||||
- "Drei Commits: `feat(desktop): …` (Task 1), `feat(web): …` (Task 2), `docs: …` (Task 3). Kein `git push`, keine `.planning/`-Commits, kein `tauri build`, kein Docker; `cargo` nur mit `CARGO_BUILD_JOBS=4`."
|
||||
artifacts:
|
||||
- "apps/desktop/src-tauri/src/lib.rs — `stored_server_url`, `server_host`, `tray_labels`, `setup_page_url`, `parse_server_url`, `TrayItems`, `apply_server`, `spawn_version_check`, Commands `get_server_url`/`open_server`, Tray mit `connected`/`change_server`, `mod tests` erweitert"
|
||||
- "apps/desktop/src/setup.html — Aenderungsmodus (`#current-server`, `#cancel-btn`, `#subtitle`), `init()`"
|
||||
- "apps/web/src/components/settings/desktop-app-settings.tsx + .test.tsx — Block `desktop-connected`, Test mit Cookie"
|
||||
- "apps/web/src/messages/de.json, en.json — `settings.desktop.connectedTo`, `settings.desktop.changeHint`"
|
||||
- "docs/anleitung-anwender.md — Unterabschnitt „Server-Adresse ändern“, Tray-Liste, Hinweis beim Erststart; CHANGELOG.md — zwei Stichpunkte"
|
||||
key_links:
|
||||
- "Die Kette ist: Tray `change_server` → `window.navigate(setup_page_url(cfg!(windows)))` → setup.html laedt lokal (`tauri://localhost` bzw. `http://tauri.localhost`) → `invoke('get_server_url')` (lokaler Ursprung, darum erlaubt) → Vorbelegung → `check_server` → `save_server_url` → Store + `apply_server` + `spawn_version_check` + Navigation zur neuen Adresse. Bricht `setup_page_url` (falsches Schema je Plattform), landet der Client auf einer Fehlerseite ohne Rueckweg — deshalb ist die Funktion rein und je Plattform getestet; sie spiegelt Tauris eigene, nicht oeffentliche `tauri_protocol_url` (manager/mod.rs Z. 339-346)."
|
||||
- "Die Server-Seite (Remote-Ursprung) kann die App-Commands nicht aufrufen: Tauri prueft bei `!is_local` die ACL, und capabilities/default.json hat keinen `remote`-Block (webview/mod.rs Z. 1819-1823). Genau deshalb braucht es keinen Capability-Eintrag fuer `get_server_url`/`open_server` — und deshalb darf auch KEIN `remote`-Block dazukommen."
|
||||
- "Tooltip und `connected`-Zeile haengen an `tray_labels(url)`; `apply_server` ist die EINZIGE Stelle, die beide setzt (Start und Wechsel) — sonst laufen die drei Anzeigen auseinander. `tray.set_tooltip` ist unter Linux ein No-Op (Tauri-Doku), die Menuezeile bleibt dort die Anzeige."
|
||||
- "Der `update`-Klick liest die Adresse jetzt beim Klick aus dem Store; wuerde er den Start-Klon behalten, oeffnete er nach einem Wechsel die ALTE Einstellungsseite."
|
||||
- "`prevent_exit`-Falle (260917-eta): der Run-Handler laesst `app.exit(0)` (code: Some) durch und verhindert nur `code: None`. Navigation zur Setup-Seite und zurueck schliesst kein Fenster und loest kein ExitRequested aus — Run-Handler und `on_window_event` bleiben unangetastet."
|
||||
- "Web-Block nur im Client: `useIsDesktopClient()` ist im Server-HTML und im ersten Client-Render false, `window.location.origin` wird deshalb nur nach der Hydration gelesen (kein Hydration-Fehler). jsdom-URL in vitest ist `http://localhost:3000`, darauf prueft der Test."
|
||||
---
|
||||
|
||||
<objective>
|
||||
Der Desktop-Client zeigt, mit welchem Tessera-Server er verbunden ist, und die Adresse laesst sich nachtraeglich aendern — ohne config.json zu loeschen:
|
||||
|
||||
1. **Sichtbar (drei Stellen).** Tray-Tooltip `Tessera – {host}`, erste (gesperrte) Menuezeile `Verbunden mit {host}` (ohne Adresse: `Tessera – nicht verbunden` / `Nicht verbunden`), und in der App unter Einstellungen → Allgemein → Desktop-App ein Block „Verbunden mit: {origin}“ mit Hinweis auf den Aenderungsweg.
|
||||
2. **Aendern.** Neuer Tray-Eintrag `Server-Adresse ändern…` (nach „Öffnen“) navigiert das Fenster zur gebuendelten Setup-Seite. Die Seite erkennt per neuem Command `get_server_url` den Aenderungsmodus (Feld vorbelegt, Zeile „Aktuell verbunden mit: …“, Knopf „Abbrechen“ → neues Command `open_server`). Ohne gespeicherte Adresse bleibt es der Erststart.
|
||||
3. **Konsistent ohne Neustart.** Nach `save_server_url` setzt `apply_server` Tooltip und Menuezeile neu, der Update-Klick liest die Adresse beim Klick aus dem Store, und die Versionspruefung (`spawn_version_check`, aus `setup` herausgezogen) laeuft einmal gegen den neuen Server.
|
||||
4. **Reine, getestete Helfer** `server_host`, `tray_labels`, `setup_page_url`, `parse_server_url` in `mod tests` (Stil wie `with_desktop_marker`-Tests).
|
||||
5. **Doku + CHANGELOG.**
|
||||
|
||||
Task-Zuschnitt: Task 1 ist der Tracer und traegt die vollstaendige Client-Kette Rust ↔ setup.html (Tray → Setup-Seite → Commands → Store → Tray-Auffrischung) — nur so ist die Kette in einem Commit `feat(desktop)` geschlossen. Task 2 ist der Web-Block (`feat(web)`), Task 3 Doku/CHANGELOG (`docs`). Der Windows-Nachweis (Tooltip, Menue, Wechsel, Abbrechen, Update-Link nach Wechsel) erfolgt durch den Orchestrator mit dem CI-Paket auf der Test-VM — im SUMMARY als „Nachweis durch Orchestrator“ ausweisen.
|
||||
|
||||
Purpose: Der Nutzer sieht auf einen Blick, gegen welchen Server der Client laeuft (Test- vs. Live-Server), und kann bei einem Serverwechsel die Adresse selbst umstellen.
|
||||
Output: lib.rs mit vier reinen Helfern + Tests, State `TrayItems`, zwei neuen Commands, neuem Tray-Aufbau; setup.html im Aenderungsmodus; Web-Block + zwei i18n-Schluessel + Test; Handbuch-Unterabschnitt; zwei CHANGELOG-Zeilen; drei Commits.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/gsd-core/workflows/execute-plan.md
|
||||
@~/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@/home/vicolab/projects/tessera-ctl/CLAUDE.md
|
||||
@/home/vicolab/projects/tessera-ctl/apps/desktop/src-tauri/src/lib.rs
|
||||
@/home/vicolab/projects/tessera-ctl/apps/desktop/src/setup.html
|
||||
@/home/vicolab/projects/tessera-ctl/apps/desktop/src-tauri/capabilities/default.json
|
||||
@/home/vicolab/projects/tessera-ctl/apps/web/src/components/settings/desktop-app-settings.tsx
|
||||
@/home/vicolab/projects/tessera-ctl/apps/web/src/components/settings/desktop-app-settings.test.tsx
|
||||
@/home/vicolab/projects/tessera-ctl/apps/web/src/lib/desktop-client.ts
|
||||
@/home/vicolab/projects/tessera-ctl/.planning/quick/260917-eta-desktop-client-tray-eintrag-beenden-been/260917-eta-SUMMARY.md
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer" tdd="true">
|
||||
<name>Task 1: Rust + setup.html — Verbunden-Zeile und Tooltip im Tray, „Server-Adresse ändern…“, Commands `get_server_url`/`open_server`, Tray-Auffrischung nach Wechsel, Tests</name>
|
||||
<files>apps/desktop/src-tauri/src/lib.rs, apps/desktop/src/setup.html</files>
|
||||
<read_first>
|
||||
- apps/desktop/src-tauri/src/lib.rs komplett (349 Zeilen): Z. 24-72 Stil dokumentierter reiner Helfer (`api_url`, `with_desktop_marker`, `update_labels`), Z. 74-121 `check_server`/`save_server_url`, Z. 133 `generate_handler!`, Z. 134-151 Startnavigation, Z. 153-241 Tray-Aufbau (Items, Menue, `on_menu_event`), Z. 243-277 Versionspruefung, Z. 290-302 Run-Handler (NICHT anfassen, siehe 260917-eta), Z. 305-349 `mod tests`
|
||||
- apps/desktop/src/setup.html Z. 145-174 (Markup: `.brand-mark`-SVG NICHT anfassen — 260917-jdf koennte sie parallel aendern; Aenderungen nur ab `<h1>`), Z. 176-306 (Script: `invoke`, `connect()`, Meldungsfunktionen)
|
||||
- apps/desktop/src-tauri/capabilities/default.json (nur `core:default` + Plugin-Rechte; `check_server`/`save_server_url` stehen NICHT darin — App-Commands brauchen keinen Eintrag; es gibt keinen `remote`-Block, und das bleibt so)
|
||||
- ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tauri-2.11.3/src/manager/mod.rs Z. 337-346 (`tauri_protocol_url`: Windows/Android `http(s)://tauri.localhost`, sonst `tauri://localhost`; pub(crate), darum eigener Helfer), tray/mod.rs Z. 222-234 (`TrayIconBuilder::with_id`) und Z. 518-526 (`set_tooltip`, Linux unsupported), app.rs Z. 826-834 (`tray_by_id`), webview/mod.rs Z. 1819-1823 (ACL-Pruefung fuer App-Commands bei Remote-Ursprung)
|
||||
</read_first>
|
||||
<behavior>
|
||||
Neue Tests in `#[cfg(test)] mod tests` (deutsche snake_case-Namen wie im Bestand):
|
||||
- `server_host(Some("https://tessera.ctl.de/"))` → `Some("tessera.ctl.de")`
|
||||
- `server_host(Some("http://localhost:3000/"))` → `Some("localhost:3000")`
|
||||
- `server_host(Some("https://host:443/pfad?x=1"))` → `Some("host")` (Standardport faellt weg, Pfad/Query auch)
|
||||
- `server_host(None)`, `server_host(Some(""))`, `server_host(Some("kein url"))` → jeweils `None`
|
||||
- `tray_labels(Some("https://tessera.ctl.de/"))` → `("Tessera – tessera.ctl.de", "Verbunden mit tessera.ctl.de")`
|
||||
- `tray_labels(None)` → `("Tessera – nicht verbunden", "Nicht verbunden")`
|
||||
- `setup_page_url(true).as_str()` → `"http://tauri.localhost/setup.html"`; `setup_page_url(false).as_str()` → `"tauri://localhost/setup.html"`
|
||||
- `parse_server_url("https://tessera.ctl.de")` → `Ok`, `as_str()` = `"https://tessera.ctl.de/"`
|
||||
- `parse_server_url("ftp://host")` → `Err("Es sind nur Adressen mit http oder https erlaubt.")`
|
||||
- `parse_server_url("kein url")` → `Err("Diese Adresse ist ungültig.")`
|
||||
Die fuenf bestehenden Tests bleiben unveraendert gruen.
|
||||
</behavior>
|
||||
<action>
|
||||
Zuerst die Tests aus `<behavior>` in `mod tests` schreiben und `CARGO_BUILD_JOBS=4 cargo test --lib` rot sehen. Dann in lib.rs:
|
||||
|
||||
1. **Reine Helfer** (neben `api_url`/`with_desktop_marker`, jeweils deutscher Doc-Kommentar mit ae/oe/ue wie im Bestand):
|
||||
- `parse_server_url(url: &str) -> Result<tauri::Url, String>`: `tauri::Url::parse` (Fehler → `Diese Adresse ist ungültig.`), Schema muss `http` oder `https` sein (sonst `Es sind nur Adressen mit http oder https erlaubt.`). Genau die beiden Texte, die heute in `check_server` inline stehen; `check_server` und `save_server_url` rufen kuenftig diesen Helfer (Verhalten unveraendert, Duplikat weg), ebenso `open_server`.
|
||||
- `server_host(url: Option<&str>) -> Option<String>`: `url` → leer/None → None; `tauri::Url::parse` fehlgeschlagen oder `host_str()` None → None; sonst `host` bzw. `host:port`, wenn `parsed.port()` Some ist (der url-Crate laesst Standardports weg). Doc: Anzeige im Tray soll kurz sein — nur der Host, kein Schema, kein Pfad.
|
||||
- `tray_labels(url: Option<&str>) -> (String, String)`: Rueckgabe (Tooltip, Menuezeile). Mit Host: `Tessera – {host}` / `Verbunden mit {host}`; ohne: `Tessera – nicht verbunden` / `Nicht verbunden`. Gedankenstrich U+2013 wie in `update_labels`.
|
||||
- `setup_page_url(windows: bool) -> tauri::Url`: `windows` → `http://tauri.localhost/setup.html`, sonst `tauri://localhost/setup.html`. Doc: spiegelt Tauris nicht oeffentliche `tauri_protocol_url` (WebView2 kennt kein eigenes Schema, deshalb `http://tauri.localhost`; `useHttpsScheme` ist in tauri.conf.json nicht gesetzt, darum `http`); `WebviewUrl::App` laesst sich nicht an `navigate` geben. Der `tauri dev`-Fall mit `devUrl` wird in diesem Projekt nicht genutzt (CI baut Release) und ist bewusst nicht abgebildet. Aufrufstelle uebergibt `cfg!(windows)`.
|
||||
- `stored_server_url(app: &AppHandle) -> Option<String>`: `app.store("config.json").ok()?.get("server_url")`, nur nicht-leere Strings. Das ist die EINZIGE Lesestelle des Store-Werts (Start, `get_server_url`, `open_server`, Tray-Klick `update`).
|
||||
2. **State und Auffrischung**: `struct TrayItems { connected: tauri::menu::MenuItem<tauri::Wry>, update: tauri::menu::MenuItem<tauri::Wry> }` (MenuItem ist Send + Sync, `app.manage` verlangt das). `fn apply_server(app: &AppHandle, url: Option<&str>)`: `let (tooltip, line) = tray_labels(url)`; `app.tray_by_id("main")` → `set_tooltip(Some(tooltip))`; `app.state::<TrayItems>().connected.set_text(line)`; Fehler ignorieren (`let _ =`) wie im Bestand. Doc: einzige Stelle, die Tooltip und Menuezeile setzt; `set_tooltip` ist unter Linux ein No-Op.
|
||||
3. **Versionspruefung herausziehen**: `fn spawn_version_check(app: AppHandle, server_url: String)`: holt `update` aus `app.state::<TrayItems>()` (Klon), setzt ihn auf `Update herunterladen` + `set_enabled(false)` zurueck (nach einem Serverwechsel darf kein Hinweis des alten Servers stehen bleiben), dann der bisherige `tauri::async_runtime::spawn`-Block aus `setup` (Z. 249-276) unveraendert samt Kommentar; `app_version`/`app_commit` (`env!`) wandern mit hinein. Den Menue-Standardtext als `const UPDATE_ITEM_DEFAULT: &str = "Update herunterladen";` anlegen und an beiden Stellen (Bau des Items, Reset) nutzen.
|
||||
4. **Commands**: `#[tauri::command] fn get_server_url(app: AppHandle) -> Option<String> { stored_server_url(&app) }` (Doc: liefert nur den gespeicherten Wert, nichts anderes; nur vom lokalen Ursprung aufrufbar, siehe Punkt 7). `#[tauri::command] fn open_server(app: AppHandle) -> Result<(), String>`: `stored_server_url` → ohne Wert `Err("Es ist keine Server-Adresse gespeichert.")`; `parse_server_url`; Fenster `main` → `navigate(with_desktop_marker(&parsed))`, Fehler per `map_err(|e| e.to_string())`. `save_server_url` umbauen: `parse_server_url(&url)?` → `normalized` → Store speichern → `apply_server(&app, Some(&normalized))` → `spawn_version_check(app.clone(), normalized.clone())` → Navigation wie bisher. `generate_handler![check_server, save_server_url, get_server_url, open_server]`.
|
||||
5. **`setup`**: `let server_url = stored_server_url(app.handle());` ersetzt das verschachtelte if-let; Startnavigation ueber `parse_server_url` + `with_desktop_marker` wie bisher. Tray-Aufbau: Item `connected` = `MenuItemBuilder::with_id("connected", tray_labels(server_url.as_deref()).1).enabled(false)`; Item `change_server` = `MenuItemBuilder::with_id("change_server", "Server-Adresse ändern…")` (echtes Auslassungszeichen U+2026); `update` mit `UPDATE_ITEM_DEFAULT`. Menue: `connected` · Trenner · `open` · `change_server` · `update` · Trenner · `autostart` · Trenner · `quit` (Kommentar Z. 153-155 entsprechend anpassen). Vor dem Tray-Bau `app.manage(TrayItems { connected: connected.clone(), update: update.clone() });`. Tray: `TrayIconBuilder::with_id("main")` statt `::new()`, `.tooltip(tray_labels(server_url.as_deref()).0)`. Nach `.build(app)?`: `apply_server(app.handle(), server_url.as_deref());` (setzt beide Anzeigen aus derselben Quelle) und `if let Some(url) = server_url.clone() { spawn_version_check(app.handle().clone(), url); }` — der bisherige Inline-Block Z. 243-277 entfaellt. Die Variable fuer den Start-Klon der Adresse im Menue-Closure entfaellt ersatzlos.
|
||||
6. **Menue-Handler**: neuer Zweig `"change_server"`: Fenster `main` → `let _ = w.navigate(setup_page_url(cfg!(windows)));` dann `unminimize`/`show`/`set_focus` (gleiche drei Zeilen wie bei `open`). Zweig `"update"`: `if let Some(server) = stored_server_url(app)` statt des Klons; Ziel-URL-Bildung unveraendert (ohne `desktop=1`, oeffnet im System-Browser). `open`, `autostart`, `quit`, Linksklick-Handler, `on_window_event`, Run-Handler: unveraendert.
|
||||
7. **Kein Capability-Eintrag, kein `remote`-Block**: App-Commands sind vom lokalen Ursprung (`tauri://localhost` / `http://tauri.localhost`) ohne ACL-Manifest erlaubt; vom Remote-Ursprung (Server-Seite) verweigert Tauri sie, weil capabilities/default.json keinen `remote`-Block hat (T-JN2-01). Die Datei bleibt unangetastet.
|
||||
8. `CARGO_BUILD_JOBS=4 cargo fmt` anwenden (Bestand ist rustfmt-konform; anders als bei 260917-eta gibt es hier kein Ein-Zeilen-Gate, das fmt zerstoeren koennte).
|
||||
|
||||
Dann **setup.html** (nur ab `<h1>` und im Script; SVG und CSS-Bestand unveraendert, neue CSS-Regeln anhaengen):
|
||||
9. Markup: `<p class="subtitle" id="subtitle">Desktop-App einrichten</p>`; nach der Unterzeile `<p id="current-server" class="current-server"></p>` (CSS: `display: none; text-align: left; font-size: 0.8125rem; color: oklch(0.75 0 0); margin: -20px 0 24px;` — sichtbar erst im Aenderungsmodus); nach `#connect-btn` ein `<button id="cancel-btn" type="button" class="secondary" hidden>Abbrechen</button>` (CSS `button.secondary { margin-top: 12px; background: transparent; color: oklch(0.85 0 0); border: 1px solid oklch(0.30 0.01 260); }` und `button[hidden] { display: none; }`, damit `hidden` gegen die `button`-Regel gewinnt).
|
||||
10. Script: Referenzen `subtitle`, `currentServer`, `cancelBtn`. `async function init()`: `try { const current = await invoke('get_server_url'); if (typeof current === 'string' && current) { enterChangeMode(current); } } catch { /* Erststart-Verhalten */ }`; am Ende des Moduls `init();`. `enterChangeMode(current)`: `urlInput.value = current`; `currentServer.textContent = 'Aktuell verbunden mit: ' + current`; `currentServer.style.display = 'block'`; `subtitle.textContent = 'Server-Adresse ändern'`; `cancelBtn.hidden = false`; `urlInput.focus(); urlInput.select();`. `cancelBtn`-Klick: `clearMessages(); cancelBtn.disabled = true; try { await invoke('open_server'); } catch (err) { showError(String(err)); cancelBtn.disabled = false; }`. Der Wert kommt per `textContent` in die Seite (kein HTML-Einfuegen — T-JN2-05). Kommentar im Script (deutsch): Aenderungsmodus, Erststart bleibt der Standardpfad. `connect()`, `validateUrl`, Enter-Taste, Meldungen: unveraendert.
|
||||
11. Gate laut `<verify>`. Kein `tauri build`, kein `tauri dev`.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl/apps/desktop/src-tauri && export CARGO_BUILD_JOBS=4 && cargo fmt --check && cargo check && cargo clippy && cargo test --lib && grep -q 'TrayIconBuilder::with_id("main")' src/lib.rs && grep -q 'tray_by_id("main")' src/lib.rs && grep -q 'with_id("connected"' src/lib.rs && grep -q 'with_id("change_server", "Server-Adresse ändern…")' src/lib.rs && grep -q 'generate_handler!\[check_server, save_server_url, get_server_url, open_server\]' src/lib.rs && grep -q 'fn spawn_version_check(' src/lib.rs && grep -q 'fn apply_server(' src/lib.rs && test "$(grep -c 'stored_server_url(' src/lib.rs)" -ge 4 && grep -q "setup_page_url(cfg!(windows))" src/lib.rs && grep -q "invoke('get_server_url')" ../src/setup.html && grep -q "invoke('open_server')" ../src/setup.html && grep -q 'id="cancel-btn"' ../src/setup.html && grep -q 'id="current-server"' ../src/setup.html && grep -q 'class="brand-mark"' ../src/setup.html && ! grep -q '"remote"' capabilities/default.json && git -C /home/vicolab/projects/tessera-ctl diff --quiet -- apps/desktop/src-tauri/capabilities/default.json apps/desktop/src-tauri/Cargo.lock</automated>
|
||||
</verify>
|
||||
<done>Tray zeigt `Verbunden mit {host}` (gesperrt) als erste Zeile und `Tessera – {host}` als Tooltip; `Server-Adresse ändern…` navigiert zur Setup-Seite, die im Aenderungsmodus vorbelegt ist und „Abbrechen“ anbietet; nach dem Speichern einer neuen Adresse sind Tooltip, Menuezeile und Update-Ziel ohne Neustart aktuell und die Versionspruefung laeuft neu; mindestens 10 neue + 5 alte Rust-Tests gruen, fmt/check/clippy sauber; capabilities/default.json unveraendert; Commit `feat(desktop): Verbundenen Server im Infobereich zeigen, Server-Adresse nachträglich änderbar`. Der Nachweis am Bildschirm (Tooltip, Menue, Wechsel, Abbrechen, Update-Link nach Wechsel) folgt durch den Orchestrator mit dem CI-Paket auf der Windows-VM — im SUMMARY als offen fuehren.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Web — Block „Verbunden mit: {origin}“ nur im Client auf Einstellungen → Desktop-App, i18n, Test</name>
|
||||
<files>apps/web/src/components/settings/desktop-app-settings.tsx, apps/web/src/components/settings/desktop-app-settings.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json</files>
|
||||
<read_first>
|
||||
- apps/web/src/components/settings/desktop-app-settings.tsx (komplett; die vier `<p>` mit `desktop.intro` … `desktop.update`, danach Version/Downloads)
|
||||
- apps/web/src/components/settings/desktop-app-settings.test.tsx (next-intl-Mock mit `{name}`-Ersetzung, `loadDesktopLatest`-Mock, `afterEach` mit `cleanup`)
|
||||
- apps/web/src/lib/desktop-client.ts (`useIsDesktopClient`, `DESKTOP_COOKIE_NAME`) und desktop-client.test.ts Z. 10-17 (Cookie setzen/loeschen im Test)
|
||||
- apps/web/src/messages/de.json und en.json, Block `settings.desktop` (Z. 161-173; letzter Schluessel `unavailable`) — FRISCH lesen, parallele Plaene ergaenzen andere Namensraeume
|
||||
</read_first>
|
||||
<behavior>
|
||||
desktop-app-settings.test.tsx (Cookie in `afterEach` wie in desktop-client.test.ts loeschen; `DESKTOP_COOKIE_NAME` importieren):
|
||||
- Test 4 (im Desktop-Client): `document.cookie = 'tessera_desktop=1; path=/'`, `loadDesktopLatest.mockResolvedValue(null)`, `render` → `await screen.findByText('Verbunden mit: http://localhost:3000')` vorhanden; `screen.getByText('Ändern über das Tessera-Symbol im Infobereich → „Server-Adresse ändern…“')` vorhanden; `screen.getByTestId('desktop-connected')` vorhanden.
|
||||
- Test 5 (im Browser): ohne Cookie, `loadDesktopLatest.mockResolvedValue(null)`, `render`, `await act(async () => {})` → `screen.queryByTestId('desktop-connected')` ist null.
|
||||
- Tests 1-3 unveraendert gruen (sie laufen ohne Cookie).
|
||||
</behavior>
|
||||
<action>
|
||||
Tests zuerst schreiben, rot sehen, dann:
|
||||
|
||||
1. **de.json / en.json**, innerhalb `settings.desktop` NACH `unavailable` zwei Schluessel ergaenzen (nur Zeilen anhaengen, Komma an `unavailable` nicht vergessen, sonst nichts anfassen):
|
||||
- de: `"connectedTo": "Verbunden mit: {origin}"`, `"changeHint": "Ändern über das Tessera-Symbol im Infobereich → „Server-Adresse ändern…“"`
|
||||
- en: `"connectedTo": "Connected to: {origin}"`, `"changeHint": "To change it, use the Tessera icon in the notification area → “Change server address…”"`
|
||||
2. **desktop-app-settings.tsx**: `import { useIsDesktopClient } from '@/lib/desktop-client';` `const isDesktop = useIsDesktopClient();` nach `useLocale()`. Nach den vier `<p>`-Saetzen und VOR dem `info === null`-Hinweis rendern: `{isDesktop && (<div data-testid="desktop-connected" className="mt-4 space-y-1 rounded border border-border bg-muted/30 p-3 text-sm"><p className="text-foreground">{t('desktop.connectedTo', { origin: window.location.origin })}</p><p className="text-muted-foreground">{t('desktop.changeHint')}</p></div>)}`. `window.location.origin` darf hier direkt gelesen werden: `isDesktop` ist im Server-HTML und im ersten Client-Render false (Hook mit useEffect), der Zweig rendert erst nach der Hydration — Satz dazu in den Komponenten-Doc-Kommentar (260917-jn2). Download-Knoepfe bleiben wie bisher (kein Ausblenden hier — der Update-Link aus dem Tray oeffnet diese Seite im System-Browser, dort bleiben sie sichtbar; im Client stoeren sie nicht).
|
||||
3. Keine weiteren Komponenten, keine Middleware-Aenderung, kein neues Paket.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web exec vitest run src/components/settings/desktop-app-settings.test.tsx && pnpm --filter @tessera/web exec vitest run && pnpm --filter @tessera/web type-check && node -e 'for (const l of ["de","en"]) { const m = require("./apps/web/src/messages/" + l + ".json").settings.desktop; if (!m.connectedTo || !m.changeHint || !m.connectedTo.includes("{origin}")) { console.error("i18n fehlt:", l); process.exit(1); } } console.log("i18n OK")' && grep -q 'data-testid="desktop-connected"' apps/web/src/components/settings/desktop-app-settings.tsx && grep -q 'useIsDesktopClient' apps/web/src/components/settings/desktop-app-settings.tsx</automated>
|
||||
</verify>
|
||||
<done>Im Client (Cookie `tessera_desktop=1`) zeigt Einstellungen → Allgemein → Desktop-App „Verbunden mit: {origin}“ plus Hinweis auf „Server-Adresse ändern…“; im Browser fehlt der Block; beide Sprachen; alle Web-Tests (bisher 417 + 2) und type-check gruen; Commit `feat(web): Einstellungen → Desktop-App zeigt in der App den verbundenen Server`.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Handbuch (Server-Adresse sehen und ändern) und CHANGELOG</name>
|
||||
<files>docs/anleitung-anwender.md, CHANGELOG.md</files>
|
||||
<read_first>
|
||||
- docs/anleitung-anwender.md Z. 184-197 („Erster Start: Server-Adresse“, „Fenster, Infobereich und Beenden“ mit Tray-Liste) und Z. 203-205 („Neue Version“); Anfuehrungszeichen in dieser Datei: oeffnend „ (U+201E), schliessend gerades " — so beibehalten
|
||||
- CHANGELOG.md Z. 1-8 — FRISCH lesen: `## Unveröffentlicht` ist nach der Freigabe 1.2.0 leer; parallele Plaene (Favoriten, Bildmarke, CI) koennen inzwischen Unterabschnitte angelegt haben. Anfuehrungszeichen hier „…“ (U+201E/U+201C), Praefix `Desktop-App:`, kein Punkt am Ende
|
||||
</read_first>
|
||||
<action>
|
||||
1. **docs/anleitung-anwender.md**:
|
||||
- Abschnitt „Erster Start: Server-Adresse“: am Ende des Absatzes den Satz anfuegen: „Die eingetragene Adresse können Sie später jederzeit ändern, siehe [Server-Adresse ändern](#server-adresse-ändern).“
|
||||
- Tray-Liste im Abschnitt „Fenster, Infobereich und Beenden“: als ERSTE Zeile `- **Verbunden mit …** — zeigt grau den Tessera-Server, mit dem die App verbunden ist (nicht anklickbar)`; nach **Öffnen** die Zeile `- **Server-Adresse ändern…** — siehe [Server-Adresse ändern](#server-adresse-ändern)`. Davor im Satz „Ein Rechtsklick zeigt ein Menü mit:“ nichts aendern. Nach der Liste (vor „Nur „Beenden" beendet …“) einen Satz: „Fahren Sie mit der Maus über das Symbol, nennt der Hinweistext ebenfalls den verbundenen Server (unter Windows).“
|
||||
- Neuer Unterabschnitt `### Server-Adresse ändern` direkt VOR `### Automatischer Start`: Absatz 1 — wo die aktuelle Adresse steht (Hinweistext am Symbol im Infobereich, erste Zeile des Rechtsklick-Menüs, in der App unter Einstellungen → Allgemein → Desktop-App als „Verbunden mit: …“). Absatz 2 — der Weg: Rechtsklick auf das Symbol → „Server-Adresse ändern…" → die Einrichtungsseite erscheint mit der aktuellen Adresse im Feld und der Zeile „Aktuell verbunden mit: …" → neue Adresse eintragen → „Verbinden" (Prüfung wie beim ersten Start) → die App wechselt sofort, Hinweistext und Menü nennen die neue Adresse, ein Neustart ist nicht nötig; „Abbrechen" bringt Sie ohne Änderung zurück. Absatz 3 (kurz) — Anmeldung: beim Wechsel auf einen anderen Server melden Sie sich dort wie gewohnt an. Sie-Form, Anfuehrungszeichen wie in der Datei.
|
||||
2. **CHANGELOG.md**, `## Unveröffentlicht`: Existiert darunter bereits `### Neu` (von einem parallelen Plan), die zwei Zeilen am ENDE dieser Liste anhaengen; sonst direkt unter `## Unveröffentlicht` (Leerzeile, `### Neu`, Leerzeile, Liste, Leerzeile vor `## 1.2.0 – 2026-09-17`) anlegen. Der Bestand nennt den Abschnitt `### Neu` (nicht „Hinzugefügt“) — Stil wie Bestand. Zeilen:
|
||||
- `- Desktop-App: das Symbol im Infobereich zeigt den verbundenen Tessera-Server – im Hinweistext und als erste Zeile des Menüs; in der App auch unter Einstellungen → Desktop-App als „Verbunden mit: …“`
|
||||
- `- Desktop-App: Server-Adresse nachträglich änderbar über „Server-Adresse ändern…“ im Menü des Infobereich-Symbols – ohne Neustart`
|
||||
Fremde Zeilen (auch neue aus parallelen Plaenen) unveraendert lassen; vor dem Commit `git diff CHANGELOG.md` gegenpruefen, dass nur diese Zeilen (und ggf. die Ueberschrift `### Neu`) hinzugekommen sind. Nur die zwei Dateien dieses Tasks per `git add` uebernehmen (`git add docs/anleitung-anwender.md CHANGELOG.md`), nie `git add -A`.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && grep -q '^### Server-Adresse ändern' docs/anleitung-anwender.md && grep -q 'Server-Adresse ändern…\*\*' docs/anleitung-anwender.md && grep -q 'Verbunden mit …\*\*' docs/anleitung-anwender.md && grep -q 'Aktuell verbunden mit' docs/anleitung-anwender.md && test "$(awk '/^## Unveröffentlicht/{f=1;next} /^## /{f=0} f' CHANGELOG.md | grep -c '^- Desktop-App: ')" -ge 2 && awk '/^## Unveröffentlicht/{f=1;next} /^## /{f=0} f' CHANGELOG.md | grep -q '^### Neu' && pnpm --filter @tessera/web exec vitest run src/lib/changelog.test.ts</automated>
|
||||
</verify>
|
||||
<done>Handbuch beschreibt, wo die Server-Adresse steht und wie sie geaendert wird (neuer Unterabschnitt, Tray-Liste, Erststart-Hinweis); CHANGELOG traegt zwei neue `Desktop-App:`-Stichpunkte unter Unveröffentlicht / Neu; Commit `docs: Desktop-App — Server-Adresse sehen und ändern (Handbuch, CHANGELOG)`.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Remote-Seite (Tessera-Server im WebView) → Tauri-IPC | Die Server-Seite laeuft im selben Fenster wie die lokale Setup-Seite; sie darf keine App-Commands erreichen |
|
||||
| Setup-Seite (lokal) → Rust-Commands | Nutzer-Eingabe (Adresse) wird geparst, gespeichert und als Navigationsziel genutzt |
|
||||
| Store (config.json) → Tray/Anzeige | Gespeicherter Wert wird in Tooltip, Menuezeile, Update-Ziel und Setup-Seite angezeigt |
|
||||
| Web-Client (Cookie) → Einstellungsseite | Cookie `tessera_desktop` ist frei setzbar; steuert nur die Anzeige eines Blocks |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-JN2-01 | Elevation of Privilege | `get_server_url`, `open_server`, `save_server_url` (IPC) | high | mitigate | Tauri verweigert App-Commands aus Remote-Ursprung, solange die Capability keinen `remote`-Block hat (tauri-2.11.3 webview/mod.rs Z. 1819-1823, `!is_local` erzwingt ACL). capabilities/default.json bleibt unveraendert; Gate in Task 1 bricht ab, falls ein `"remote"`-Schluessel auftaucht. Kein `dangerousRemoteDomainIpcAccess`. |
|
||||
| T-JN2-02 | Tampering | `setup_page_url`, `change_server`-Klick | medium | mitigate | Navigationsziel ist eine Konstante ohne Nutzer-Eingabe; `navigate` statt `eval` — die Remote-Seite fuehrt kein von Rust injiziertes JavaScript aus. Reine Funktion je Plattform getestet. |
|
||||
| T-JN2-03 | Tampering | `open_server`, `save_server_url` (Navigationsziel) | medium | mitigate | Beide gehen ausschliesslich ueber `parse_server_url` (nur http/https); `open_server` navigiert nur zum gespeicherten Wert, `save_server_url` nur zu dem Wert, den die Setup-Seite zuvor per `check_server` bestaetigt hat (wie heute). Kein anderes Schema, keine Datei-URLs. |
|
||||
| T-JN2-04 | Information Disclosure | `get_server_url`, Tooltip, Menuezeile | low | accept | Der Wert ist die vom Nutzer selbst eingetragene Server-Adresse (kein Geheimnis, keine Zugangsdaten). `get_server_url` liefert nur diesen einen Store-Schluessel und ist nur lokal aufrufbar (T-JN2-01). |
|
||||
| T-JN2-05 | Tampering | setup.html (`#current-server`, `#server-url`) | low | mitigate | Gespeicherter Wert wird per `textContent`/`value` eingesetzt, nie als HTML; CSP `default-src 'self'` bleibt. |
|
||||
| T-JN2-06 | Spoofing | Cookie `tessera_desktop` → Web-Block | low | accept | Der Block zeigt nur `window.location.origin` (vom Browser bestimmt) und einen Hinweistext; kein Auth-, Rechte- oder Datenpfad haengt daran (wie T-H2S-01). |
|
||||
| T-JN2-07 | Denial of Service | `spawn_version_check` nach Wechsel | low | accept | Genau eine zusaetzliche Anfrage je Wechsel; ein noch laufender alter Check kann im seltenen Fall spaeter antworten und den Update-Eintrag setzen — beim naechsten Start korrigiert sich das; bewusst hingenommen. |
|
||||
| T-JN2-SC | Tampering | npm/pip/cargo installs | low | accept | Keine neue Abhaengigkeit (kein `pnpm add`, kein `cargo add`, Cargo.lock unveraendert); package-legitimacy gate entfaellt. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Rust: `cargo fmt --check && cargo check && cargo clippy && cargo test --lib` (apps/desktop/src-tauri, `CARGO_BUILD_JOBS=4`) gruen, mindestens 15 Tests; Tray mit `with_id("main")`, Items `connected`/`change_server`, Handler `get_server_url`/`open_server` registriert, `spawn_version_check`/`apply_server` vorhanden, Store-Lesen nur ueber `stored_server_url`.
|
||||
- setup.html: `get_server_url` beim Laden, `open_server` am Abbrechen-Knopf, `#current-server`/`#cancel-btn` vorhanden, SVG unveraendert.
|
||||
- capabilities/default.json: unveraendert, kein `remote`-Block.
|
||||
- Web: `pnpm --filter @tessera/web exec vitest run` (alle Dateien) und `type-check` gruen; i18n-Schluessel in beiden Sprachen.
|
||||
- Doku: neuer Unterabschnitt „Server-Adresse ändern“, Tray-Liste erweitert; CHANGELOG zwei neue Zeilen unter Unveröffentlicht / Neu; changelog.test.ts gruen.
|
||||
- Offen (Nachweis durch Orchestrator mit dem CI-Paket auf der Windows-VM): Tooltip `Tessera – {host}`, Menuezeile, Klick auf „Server-Adresse ändern…“ zeigt die vorbelegte Setup-Seite, „Abbrechen“ fuehrt zurueck, Wechsel auf eine andere Adresse aktualisiert Tooltip/Menue/Update-Ziel ohne Neustart, Web-Block in der App sichtbar und im Browser nicht.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Alle `must_haves.truths` erfuellt; drei Commits (`feat(desktop)`, `feat(web)`, `docs`) ohne Push, ohne `tauri build`, ohne Docker, ohne `.planning/`-Commits.
|
||||
- Keine Datei ausserhalb von `files_modified` veraendert (vor jedem Commit `git status` gegenpruefen; parallele Plaene arbeiten im selben Baum — nur eigene Dateien per `git add` nennen).
|
||||
- SUMMARY nennt die offenen VM-Nachweise ausdruecklich und vermerkt, dass die Versionspruefung nach dem Wechsel neu angestossen wird.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `/home/vicolab/projects/tessera-ctl/.planning/quick/260917-jn2-desktop-client-aktuelle-server-adresse-s/260917-jn2-SUMMARY.md` when done
|
||||
</output>
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
---
|
||||
phase: quick-260917-jn2
|
||||
plan: 01
|
||||
subsystem: desktop-client
|
||||
tags: [tauri, rust, tray, nextjs, i18n, desktop]
|
||||
|
||||
# Dependency graph
|
||||
requires:
|
||||
- phase: 18-desktop-client-fertigstellen
|
||||
provides: Tauri-Desktop-Client mit Tray-Menue (open/update/autostart/quit), Setup-Seite (Erststart), Web-Einstellungsseite Desktop-App
|
||||
- phase: quick-260917-eta
|
||||
provides: Run-Handler-Muster (code: None vs. code: Some), unminimize() vor show() in "open" und Linksklick — unangetastet uebernommen
|
||||
provides:
|
||||
- "Tray zeigt den verbundenen Server an drei Stellen: Tooltip 'Tessera – {host}', gesperrte erste Menuezeile 'Verbunden mit {host}', Web-Einstellungsseite 'Verbunden mit: {origin}'"
|
||||
- "Neuer Tray-Eintrag 'Server-Adresse ändern…' navigiert zur gebuendelten Setup-Seite im Aenderungsmodus (vorbelegtes Feld, 'Aktuell verbunden mit: …', Knopf 'Abbrechen')"
|
||||
- "Nach einem Serverwechsel aktualisieren sich Tooltip, Menuezeile und Update-Ziel ohne Neustart; die Versionspruefung laeuft neu gegen den neuen Server"
|
||||
- "Vier reine, getestete Helfer server_host/tray_labels/setup_page_url/parse_server_url plus stored_server_url als einzige Store-Lesestelle"
|
||||
affects: [desktop-client, tray-verhalten, updater]
|
||||
|
||||
# Actuals (#2632)
|
||||
actuals:
|
||||
tokens: 8317
|
||||
tasks: 3
|
||||
commits: 3
|
||||
plan_head_before: b023d6f72655706167d72430337470ad3722144f
|
||||
|
||||
# Tech tracking
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "app.manage(TrayItems { connected, update }) haelt die Menue-Handles im State — einzige Stelle, die Tooltip/Menuezeile (apply_server) und den Update-Eintrag (spawn_version_check) ohne Neustart setzt"
|
||||
- "stored_server_url(app) als einzige Store-Lesestelle (Start, get_server_url, open_server, Tray-Klick 'update') statt mehrfacher Store-Zugriffe"
|
||||
- "App-Commands ohne Capability-Eintrag: lokale Herkunft (tauri://localhost / http://tauri.localhost) erlaubt sie implizit, Remote-Ursprung verweigert Tauri sie mangels remote-Block (T-JN2-01)"
|
||||
|
||||
key-files:
|
||||
created: []
|
||||
modified:
|
||||
- apps/desktop/src-tauri/src/lib.rs
|
||||
- apps/desktop/src/setup.html
|
||||
- apps/web/src/components/settings/desktop-app-settings.tsx
|
||||
- apps/web/src/components/settings/desktop-app-settings.test.tsx
|
||||
- apps/web/src/messages/de.json
|
||||
- apps/web/src/messages/en.json
|
||||
- docs/anleitung-anwender.md
|
||||
- CHANGELOG.md
|
||||
|
||||
key-decisions:
|
||||
- "Pruefpunkt 5 des Task-1-Gates (grep nach einzeiligem generate_handler!\\[...\\]) liess sich nicht woertlich erfuellen: rustfmt bricht die vier Handler-Namen (109 Zeichen) legitim auf mehrere Zeilen um. cargo fmt --check MUSS gruen bleiben (Bestand ist rustfmt-konform) — darum durch einen semantisch gleichwertigen mehrzeiligen Nachweis ersetzt (Rule 3, Detail unten unter Abweichungen)."
|
||||
- "Startnavigation zur gespeicherten Adresse bleibt VOR dem Tray-Aufbau (wie im Bestand); apply_server()/spawn_version_check() laufen NACH dem Tray-Aufbau, weil sie app.state::<TrayItems>() brauchen, das erst beim Tray-Aufbau gemanagt wird."
|
||||
- "spawn_version_check() setzt den Update-Eintrag zuerst synchron auf UPDATE_ITEM_DEFAULT zurueck, bevor der async-Block startet — verhindert, dass nach einem Wechsel ein veralteter Update-Hinweis des alten Servers sichtbar bleibt."
|
||||
|
||||
patterns-established:
|
||||
- "Tray-Menue-Struktur: connected (gesperrt) · — · open · change_server · update · — · autostart · — · quit; TrayIconBuilder::with_id(\"main\") + app.tray_by_id(\"main\") als Paar fuer spaetere Auffrischung"
|
||||
|
||||
requirements-completed: [QUICK-260917-JN2]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "Rust: vier reine Helfer (server_host, tray_labels, setup_page_url, parse_server_url) plus stored_server_url, TrayItems-State, apply_server, spawn_version_check, Commands get_server_url/open_server; capabilities/default.json unveraendert"
|
||||
requirement: "QUICK-260917-JN2"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "apps/desktop/src-tauri/src/lib.rs mod tests — 18 Tests (13 neu + 5 Bestand), cargo test --lib"
|
||||
status: pass
|
||||
- kind: other
|
||||
ref: "cargo fmt --check, cargo check, cargo clippy — je 0 Warnungen/Fehler"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "setup.html im Aenderungsmodus: get_server_url beim Laden, Feld vorbelegt, 'Aktuell verbunden mit: …', Knopf 'Abbrechen' → open_server; ohne gespeicherte Adresse Erststart-Verhalten unveraendert"
|
||||
requirement: "QUICK-260917-JN2"
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "Grep-Gate Task 1 (invoke('get_server_url'), invoke('open_server'), #cancel-btn, #current-server, brand-mark-SVG unveraendert)"
|
||||
status: pass
|
||||
human_judgment: true
|
||||
rationale: "Reale Bedienprobe (Tooltip, Menue, Klick auf 'Server-Adresse ändern…', vorbelegte Seite, 'Abbrechen', Wechsel aktualisiert Tooltip/Menue/Update-Ziel ohne Neustart) erfordert die Windows-Test-VM mit dem CI-Paket — macht laut Plan der Orchestrator im Anschluss, nicht dieser Ausfuehrungslauf."
|
||||
- id: D3
|
||||
description: "Web: DesktopAppSettings zeigt im Desktop-Client 'Verbunden mit: {origin}' plus Aenderungshinweis; im Browser fehlt der Block; beide Sprachen"
|
||||
requirement: "QUICK-260917-JN2"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "apps/web/src/components/settings/desktop-app-settings.test.tsx — Test 4 (Cookie) und Test 5 (ohne Cookie), 5/5 Tests gruen"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "pnpm --filter @tessera/web exec vitest run — 431/431 gruen; pnpm --filter @tessera/web type-check — 0 Fehler"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "Handbuch: neuer Unterabschnitt 'Server-Adresse ändern', erweiterte Tray-Liste, Erststart-Verweis; CHANGELOG zwei Desktop-App-Stichpunkte unter Unveröffentlicht/Neu"
|
||||
requirement: "QUICK-260917-JN2"
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "Grep-Gate Task 3 (Ueberschrift, Tray-Liste, 'Aktuell verbunden mit', CHANGELOG-Zeilenzahl) + git diff CHANGELOG.md gegengeprueft (nur die 2 Zeilen neu)"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "apps/web/src/lib/changelog.test.ts — 10/10 gruen"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
|
||||
duration: ~25min (nicht exakt gestoppt)
|
||||
completed: 2026-09-17
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Quick Task 260917-jn2: Desktop-Client — aktuelle Server-Adresse sehen und ändern Summary
|
||||
|
||||
**Tray zeigt an drei Stellen (Tooltip, gesperrte Menuezeile, Web-Einstellungsseite), mit welchem Tessera-Server der Desktop-Client verbunden ist, und ein neuer Tray-Eintrag „Server-Adresse ändern…" fuehrt zur Setup-Seite im Aenderungsmodus — ohne config.json zu loeschen und ohne Neustart der App.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** ~25 min (RED/GREEN-Zyklus fuer Rust-Helfer und Web-Block, drei Verifikations-Gates)
|
||||
- **Completed:** 2026-09-17T14:53:26+02:00
|
||||
- **Tasks:** 3/3
|
||||
- **Files modified:** 8
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- **Rust (`lib.rs`)**: vier reine, getestete Helfer `server_host`, `tray_labels`, `setup_page_url`, `parse_server_url` sowie `stored_server_url` als einzige Store-Lesestelle; neuer State `TrayItems { connected, update }`; `apply_server()` setzt Tooltip + Menuezeile aus derselben Quelle; `spawn_version_check()` (aus `setup` herausgezogen) laeuft nach jedem Wechsel neu und setzt den Update-Eintrag zuerst zurueck. Zwei neue Commands `get_server_url`/`open_server`, beide nur vom lokalen Ursprung aufrufbar (kein Capability-Eintrag noetig, `capabilities/default.json` unveraendert). Tray jetzt `TrayIconBuilder::with_id("main")` mit Zeilen „Verbunden mit …" (gesperrt), „Öffnen", „Server-Adresse ändern…", „Update herunterladen", Autostart, „Beenden".
|
||||
- **`setup.html`**: erkennt beim Laden per `get_server_url` den Aenderungsmodus (Feld vorbelegt, „Aktuell verbunden mit: …", Knopf „Abbrechen" → `open_server`); ohne gespeicherte Adresse bleibt der Erststart unveraendert. SVG-Bildmarke unangetastet.
|
||||
- **Web**: `DesktopAppSettings` zeigt im Desktop-Client (`useIsDesktopClient()`) einen Block „Verbunden mit: {origin}" plus Aenderungshinweis; im Browser fehlt er. Zwei neue i18n-Schluessel in beiden Sprachen.
|
||||
- **Doku**: neuer Handbuch-Unterabschnitt „Server-Adresse ändern", erweiterte Tray-Liste, zwei CHANGELOG-Stichpunkte unter Unveröffentlicht/Neu.
|
||||
|
||||
## Task Commits
|
||||
|
||||
Each task was committed atomically:
|
||||
|
||||
1. **Task 1: Rust + setup.html — Verbunden-Zeile/Tooltip, „Server-Adresse ändern…", Commands, Tray-Auffrischung, Tests** - `29c132e` (feat)
|
||||
2. **Task 2: Web — Block „Verbunden mit: {origin}" nur im Client, i18n, Test** - `4c79874` (feat)
|
||||
3. **Task 3: Handbuch und CHANGELOG** - `4d48543` (docs)
|
||||
|
||||
_Hinweis: kein separater `test(...)`-Commit trotz `tdd="true"` — die Orchestrator-Vorgabe fuer diesen Quick-Task lautet ausdruecklich drei Commits (einer je Task, `feat(desktop)`/`feat(web)`/`docs`). RED (Tests zuerst, Fehlschlag beobachtet) und GREEN (Implementierung, Tests gruen) liefen innerhalb jedes Tasks, aber nur ein Commit je Task wurde erzeugt — siehe „TDD Gate Compliance" unten._
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `apps/desktop/src-tauri/src/lib.rs` — vier reine Helfer + `mod tests` (18 Tests), `TrayItems`, `apply_server`, `spawn_version_check`, Commands `get_server_url`/`open_server`, Tray-Aufbau mit `connected`/`change_server`
|
||||
- `apps/desktop/src/setup.html` — Aenderungsmodus (`#current-server`, `#cancel-btn`, `#subtitle`, `init()`, `enterChangeMode()`)
|
||||
- `apps/web/src/components/settings/desktop-app-settings.tsx` — Block `data-testid="desktop-connected"` nur im Desktop-Client
|
||||
- `apps/web/src/components/settings/desktop-app-settings.test.tsx` — Test 4 (Cookie) und Test 5 (ohne Cookie)
|
||||
- `apps/web/src/messages/de.json`, `en.json` — `settings.desktop.connectedTo`, `settings.desktop.changeHint`
|
||||
- `docs/anleitung-anwender.md` — Unterabschnitt „Server-Adresse ändern", erweiterte Tray-Liste, Erststart-Verweis
|
||||
- `CHANGELOG.md` — zwei `Desktop-App:`-Stichpunkte unter Unveröffentlicht/Neu
|
||||
|
||||
## Endgueltige Namen fuer den Folgeplan (Updater, `tauri-plugin-updater`)
|
||||
|
||||
Der naechste Task baut auf dieser Tray-Struktur auf — hier die endgueltigen Signaturen:
|
||||
|
||||
- `struct TrayItems { connected: tauri::menu::MenuItem<tauri::Wry>, update: tauri::menu::MenuItem<tauri::Wry> }`, gemanagt via `app.manage(TrayItems { connected: connected.clone(), update: update.clone() })` direkt nach dem Menue-Bau, vor `TrayIconBuilder::with_id("main")...build(app)?`.
|
||||
- `fn apply_server(app: &AppHandle, url: Option<&str>)` — einzige Stelle, die `app.tray_by_id("main")` (Tooltip) und `app.state::<TrayItems>().connected` (Menuezeile) setzt.
|
||||
- `fn spawn_version_check(app: AppHandle, server_url: String)` — nimmt `AppHandle` (nicht `&AppHandle`) und den Server-String per Wert entgegen; holt `update_item` aus `app.state::<TrayItems>()`, setzt ihn synchron auf `UPDATE_ITEM_DEFAULT`/`enabled(false)` zurueck, dann `tauri::async_runtime::spawn(...)` wie im Bestand. Der Updater-Task kann hier andocken (z. B. den Update-Klick auf `tauri-plugin-updater` statt `opener::open_url` umstellen) — `update_item` ist bereits das MenuItem-Handle, `UPDATE_ITEM_DEFAULT` (`&str`-Konstante) der Ruecksetz-Text.
|
||||
- `fn stored_server_url(app: &AppHandle) -> Option<String>` — einzige Store-Lesestelle; der Updater-Task sollte KEINE eigene Store-Lesung einfuehren, sondern diese Funktion wiederverwenden.
|
||||
- Tray-Menue-Reihenfolge: `connected` (gesperrt) · Trenner · `open` · `change_server` · `update` · Trenner · `autostart` · Trenner · `quit`. Ein neuer Updater-Eintrag würde vermutlich zwischen `update` und dem folgenden Trenner eingefuegt, oder `update` selbst würde umgewidmet — Entscheidung liegt beim Folgeplan.
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- **Pruefpunkt 5 des Task-1-Gates (Rule 3, Blocking):** Die Plan-Verifikation verlangt einen woertlichen Ein-Zeilen-Treffer `generate_handler!\[check_server, save_server_url, get_server_url, open_server\]`. Die vollstaendige Zeile ist 109 Zeichen lang, rustfmt (max_width 100, Standard) bricht sie beim Pflicht-Schritt `cargo fmt` legitim auf fuenf Zeilen um. `cargo fmt --check` MUSS gruen sein (im Bestand rustfmt-konform, keine Sonderregel wie bei 260917-eta) — das genannte Format war also nicht gleichzeitig mit einer bestandenen Formatpruefung erreichbar. Ich habe den betroffenen Pruefpunkt durch einen semantisch gleichwertigen mehrzeiligen Nachweis ersetzt (`perl -0777` Regex ueber den Macro-Block) und alle anderen 15 Pruefpunkte des Gates woertlich wie im Plan laufen lassen — alle gruen. Kein Code geaendert, nur die Pruefmethode fuer diesen einen Punkt.
|
||||
- **Reihenfolge im `setup`-Closure:** Startnavigation zur gespeicherten Adresse laeuft weiterhin VOR dem Tray-Aufbau (wie im Bestand), `apply_server`/`spawn_version_check` laufen NACH dem Tray-Aufbau, weil beide `app.state::<TrayItems>()` brauchen, das erst beim Tray-Aufbau gemanagt wird (`app.manage(...)` direkt vor `TrayIconBuilder::with_id("main")`).
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 3 - Blocking, Pruefmethode] Ein-Zeilen-Grep im Task-1-Gate gegen rustfmt-Umbruch ersetzt**
|
||||
- **Found during:** Task 1, Verifikationslauf
|
||||
- **Issue:** `grep -q 'generate_handler!\[check_server, save_server_url, get_server_url, open_server\]'` verlangt eine 109-Zeichen-Zeile; `cargo fmt` (Pflichtschritt im selben Gate) bricht sie legitim um, weil sie ueber `max_width = 100` liegt. Woertlich erfuellbar waren beide Anforderungen nicht gleichzeitig.
|
||||
- **Fix:** Den betroffenen Pruefpunkt durch eine mehrzeilige, semantisch gleichwertige Pruefung ersetzt (`perl -0777` Regex, das denselben `generate_handler!`-Block mit denselben vier Namen in beliebiger Zeilenaufteilung matcht). Der generierte Code selbst blieb unveraendert — `cargo fmt --check` ist gruen, die vier Handler sind registriert.
|
||||
- **Files modified:** keine (nur die Pruefmethode fuer diesen einen Punkt, kein Code)
|
||||
- **Verification:** `cargo fmt --check && cargo check && cargo clippy && cargo test --lib` gruen (18 Tests); alle 15 uebrigen Pruefpunkte des Gates woertlich wie im Plan, gruen; `perl`-Ersatzpruefung fuer Punkt 5 gruen.
|
||||
- **Committed in:** `29c132e` (Task-1-Commit)
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 1 auto-fixed (1 blocking/Pruefmethode)
|
||||
**Impact on plan:** Kein Codeverhalten geaendert, nur wie ein einzelner Gate-Punkt gegen den (korrekten) rustfmt-Output geprueft wurde. Keine Abweichung von den `must_haves.truths` oder vom Threat-Register.
|
||||
|
||||
## TDD Gate Compliance
|
||||
|
||||
Alle drei Tasks liefen als RED → GREEN innerhalb eines einzigen Commits je Task (Orchestrator-Vorgabe: genau drei Commits, `feat(desktop)`/`feat(web)`/`docs`):
|
||||
|
||||
- **Task 1 (Rust, tdd="true"):** RED — 13 neue Tests in `mod tests` ergaenzt, `cargo test --lib` schlug mit 13 Compile-Fehlern fehl (fehlende Funktionen `server_host`, `tray_labels`, `setup_page_url`, `parse_server_url`), exakt auf die neuen Tests zurueckfuehrbar. GREEN — Helfer implementiert, `cargo test --lib` lief mit 18/18 gruenen Tests durch. Ein Commit (`29c132e`).
|
||||
- **Task 2 (Web, tdd="true"):** RED — Test 4 ("Verbunden mit: http://localhost:3000") und Test 5 ergaenzt; Testlauf zeigte Test 4 fehlschlagend auf der Zielbehauptung (Block nicht gefunden), Tests 1-3 und 5 bereits gruen. GREEN — Block implementiert, i18n-Schluessel ergaenzt; 5/5 Tests gruen, danach 431/431 Web-Tests + type-check gruen. Ein Commit (`4c79874`).
|
||||
- **Task 3 (kein `tdd="true"`):** Standard-Task, kein RED/GREEN-Zyklus vorgesehen.
|
||||
|
||||
Kein separater `test(...)`-Commit vor dem jeweiligen `feat(...)`-Commit — bewusst, siehe Hinweis unter „Task Commits" oben; die formale RED/GREEN-Gate-Pruefung via `gsd_run check tdd-red-evidence` gilt fuer Plaene mit `type: tdd` in der Frontmatter, dieser Plan hat `type: execute` mit `tdd="true"` je Task.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
None ueber die oben dokumentierte Abweichung hinaus.
|
||||
|
||||
## Nachweis durch Orchestrator (offen)
|
||||
|
||||
Windows-VM-Bedienprobe mit dem CI-Paket steht noch aus:
|
||||
|
||||
- **Tooltip** beim Ueberfahren des Tray-Symbols zeigt `Tessera – {host}` (bzw. `Tessera – nicht verbunden` ohne Adresse).
|
||||
- **Menuezeile**: erste Zeile `Verbunden mit {host}` (gesperrt, nicht anklickbar).
|
||||
- **Adresse ändern**: Rechtsklick → „Server-Adresse ändern…" navigiert zur Setup-Seite; Feld ist mit der aktuellen Adresse vorbelegt, Zeile „Aktuell verbunden mit: …" sichtbar, Knopf „Abbrechen" vorhanden.
|
||||
- **Abbrechen**: fuehrt ohne Aenderung zurueck zur laufenden Verbindung (App navigiert zur bisherigen Adresse).
|
||||
- **Wechsel**: neue Adresse eintragen → „Verbinden" → Tooltip, Menuezeile und Update-Ziel zeigen sofort die neue Adresse, ohne Neustart der App.
|
||||
- **Update-Link nach Wechsel**: die Versionspruefung laeuft neu gegen den neuen Server; ein veralteter Update-Hinweis des alten Servers darf nicht mehr sichtbar sein (Update-Eintrag ist beim Wechsel zunaechst wieder gesperrt).
|
||||
|
||||
Alle automatisierten Verifikationen (Rust: fmt/check/clippy/test, Web: vitest/type-check, Doku-Gates) sind bereits gruen — siehe „Task Commits" und „Coverage" oben.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- Tray-Struktur (`TrayItems`, `apply_server`, `spawn_version_check`, `stored_server_url`) ist bereit fuer den Updater-Task (`tauri-plugin-updater`) — Signaturen siehe Abschnitt oben.
|
||||
- Windows-VM-Bedienprobe (Tooltip, Menue, Wechsel, Abbrechen, Update-Link) folgt durch den Orchestrator mit dem CI-Paket.
|
||||
|
||||
---
|
||||
*Quick Task: 260917-jn2*
|
||||
*Completed: 2026-09-17*
|
||||
|
||||
## Nachweis durch Orchestrator (2026-09-17, Windows-Test-VM 8233, Paket 1.2.0-beta.5a444ec) — erbracht
|
||||
|
||||
- Tooltip „Tessera – alpha.tessera.ctl.de", Menuezeile „Verbunden mit alpha.tessera.ctl.de" (gesperrt).
|
||||
- „Server-Adresse ändern…" oeffnet die Setup-Seite mit „Aktuell verbunden mit: https://alpha.tessera.ctl.de/", Adresse vorbelegt; „Abbrechen" fuehrt zur Server-Seite zurueck.
|
||||
- Wechsel auf `http://192.168.13.11:3000`: Anmeldeseite des neuen Servers, Tooltip/Menuezeile sofort „192.168.13.11:3000", Versionspruefung lief neu — ohne Neustart.
|
||||
- „Beenden": Fenster zu, Tray-Symbol weg, `tasklist` ohne `tessera-desktop.exe`.
|
||||
- Befund (Altlast, durch 260917-kgc behoben): gegen einen aelteren Server (1.1.0) bot der Client „Version 1.1.0 herunterladen" an.
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
---
|
||||
phase: quick-260917-jn2
|
||||
verified: 2026-09-17T15:00:00Z
|
||||
status: human_needed
|
||||
score: 6/9 must-haves verified
|
||||
behavior_unverified: 3
|
||||
covered_files: [".planning/quick/260917-jn2-desktop-client-aktuelle-server-adresse-s/260917-jn2-PLAN.md", ".planning/quick/260917-jn2-desktop-client-aktuelle-server-adresse-s/260917-jn2-SUMMARY.md", "CHANGELOG.md", "apps/desktop/src-tauri/capabilities/default.json", "apps/desktop/src-tauri/src/lib.rs", "apps/desktop/src/setup.html", "apps/web/src/components/settings/desktop-app-settings.test.tsx", "apps/web/src/components/settings/desktop-app-settings.tsx", "apps/web/src/messages/de.json", "apps/web/src/messages/en.json", "docs/anleitung-anwender.md"]
|
||||
covered_digest: "v1:sha256:fa46c309fdbf11c37328548b8330bf0b2cd672e44f8393dae8497c28f6d71fc1"
|
||||
overrides_applied: 0
|
||||
behavior_unverified_items:
|
||||
- truth: "Tray-Aufbau (Tooltip `Tessera – {host}`, gesperrte erste Menuezeile `Verbunden mit {host}`) rendert korrekt in einer laufenden App"
|
||||
test: "Windows-VM: Maus ueber Tray-Symbol halten und Rechtsklick-Menue oeffnen"
|
||||
expected: "Tooltip zeigt `Tessera – {host}` bzw. `Tessera – nicht verbunden`; erste Menuezeile zeigt `Verbunden mit {host}` (gesperrt, nicht anklickbar)"
|
||||
why_human: "Statischer Code (TrayIconBuilder, MenuItemBuilder, tray_labels()) ist vollstaendig geprueft und unit-getestet; ob Windows den Tooltip/die Menuezeile tatsaechlich so darstellt, ist natives GUI-Rendering, das nur eine laufende App zeigen kann"
|
||||
- truth: "Nach `save_server_url` mit neuer Adresse zeigen Tooltip, Menuezeile und Update-Ziel OHNE Neustart die neue Adresse; die Versionspruefung laeuft neu gegen den neuen Server"
|
||||
test: "Windows-VM: bestehende Verbindung per `Server-Adresse ändern…` auf eine andere Test-Adresse umstellen, danach Tray-Tooltip/-Menue und den `update`-Klick pruefen"
|
||||
expected: "Tooltip und `Verbunden mit …`-Zeile zeigen sofort die neue Adresse ohne App-Neustart; `Update herunterladen` ist zunaechst wieder gesperrt (kein veralteter Hinweis vom alten Server); ein Klick auf `update` fuehrt zur NEUEN Adresse"
|
||||
why_human: "Dies ist eine echte Laufzeit-Zustandsaenderung (State-Transition: alte Adresse → neue Adresse, ohne Prozess-Neustart); `apply_server`/`spawn_version_check` sind korrekt verdrahtet (Code gelesen, `app.state::<TrayItems>()` einzige Setzstelle), aber ob der native Tray tatsaechlich ohne Neustart auffrischt, laesst sich nur an der laufenden App beobachten"
|
||||
- truth: "setup.html zeigt im Aenderungsmodus das vorbelegte Feld, `Aktuell verbunden mit: …` und den Knopf `Abbrechen`, der per `open_server` zur bisherigen Adresse zurueckfuehrt; Klick auf `Server-Adresse ändern…` im Tray navigiert dorthin"
|
||||
test: "Windows-VM: Rechtsklick auf Tray-Symbol → `Server-Adresse ändern…` anklicken; danach `Abbrechen` anklicken"
|
||||
expected: "Setup-Seite erscheint mit vorbelegter aktueller Adresse, Zeile `Aktuell verbunden mit: …` und sichtbarem `Abbrechen`-Knopf; `Abbrechen` fuehrt ohne Aenderung zur laufenden Verbindung zurueck"
|
||||
why_human: "Skript-Logik (`init()`, `enterChangeMode()`, `cancelBtn`-Handler) ist vollstaendig gelesen und deckt sich mit der Spezifikation, ist aber nicht durch einen automatisierten Test abgedeckt (kein vitest/jsdom fuer setup.html) und haengt von echter Tauri-Webview-Navigation ab (`window.navigate`, `tauri://localhost` bzw. `http://tauri.localhost`), die nur auf der Ziel-Plattform beobachtbar ist"
|
||||
coincidental_reliance_items: []
|
||||
human_verification:
|
||||
- test: "Tooltip beim Ueberfahren des Tray-Symbols"
|
||||
expected: "`Tessera – {host}` bzw. `Tessera – nicht verbunden` ohne Adresse"
|
||||
why_human: "Natives GUI-Rendering (Windows-Tooltip), nicht automatisiert pruefbar"
|
||||
- test: "Erste (gesperrte) Menuezeile im Rechtsklick-Menue"
|
||||
expected: "`Verbunden mit {host}`, nicht anklickbar"
|
||||
why_human: "Natives Menue-Rendering"
|
||||
- test: "Klick auf `Server-Adresse ändern…`"
|
||||
expected: "Navigiert zur Setup-Seite; Feld vorbelegt mit aktueller Adresse, Zeile `Aktuell verbunden mit: …` sichtbar, Knopf `Abbrechen` vorhanden"
|
||||
why_human: "Echte Tauri-Webview-Navigation, nur auf Zielplattform beobachtbar"
|
||||
- test: "Klick auf `Abbrechen`"
|
||||
expected: "Fuehrt ohne Aenderung zur laufenden Verbindung zurueck (App navigiert zur bisherigen Adresse via `open_server`)"
|
||||
why_human: "Laufzeitverhalten der Navigation"
|
||||
- test: "Serverwechsel (neue Adresse eintragen → `Verbinden`)"
|
||||
expected: "Tooltip, Menuezeile und Update-Ziel zeigen sofort die neue Adresse, ohne Neustart der App"
|
||||
why_human: "State-Transition zur Laufzeit, nur an laufender App beobachtbar"
|
||||
- test: "Update-Link nach Wechsel"
|
||||
expected: "Versionspruefung laeuft neu gegen den neuen Server; ein veralteter Update-Hinweis des alten Servers ist nicht mehr sichtbar (Eintrag zunaechst wieder gesperrt)"
|
||||
why_human: "Haengt von echtem Netzwerk-Roundtrip gegen den (neuen) Server ab"
|
||||
---
|
||||
|
||||
# Quick Task 260917-jn2: Desktop-Client — aktuelle Server-Adresse sehen und ändern Verification Report
|
||||
|
||||
**Ziel:** Desktop-Client zeigt an drei Stellen (Tray-Tooltip, gesperrte Menuezeile, Web-Einstellungsseite), mit welchem Tessera-Server er verbunden ist, und die Adresse laesst sich ueber einen neuen Tray-Eintrag nachtraeglich aendern — ohne Neustart, mit erneuter Versionspruefung. Reine, getestete Helferfunktionen; Doku + CHANGELOG.
|
||||
|
||||
**Verified:** 2026-09-17T15:00:00Z
|
||||
**Status:** human_needed
|
||||
**Re-verification:** No — initial verification
|
||||
|
||||
## Goal Achievement
|
||||
|
||||
### Observable Truths
|
||||
|
||||
| # | Truth | Status | Evidence |
|
||||
|---|-------|--------|----------|
|
||||
| 1 | Tray gebaut mit `with_id("main")`, erste Zeile gesperrt `connected` (`Verbunden mit {host}`), danach Trenner/Öffnen/„Server-Adresse ändern…"/Update/Trenner/Autostart/Trenner/Beenden; Tooltip `Tessera – {host}` | ⚠️ PRESENT_BEHAVIOR_UNVERIFIED | Code exakt wie Plan (lib.rs Z. 291-366); `tray_labels()` unit-getestet (`tray_labels_mit_host`, `tray_labels_ohne_adresse`, gruen); reales Rendering nicht automatisiert pruefbar |
|
||||
| 2 | Vier reine Helfer (`server_host`, `tray_labels`, `setup_page_url`, `parse_server_url`) + `stored_server_url` mit Tests; `cargo fmt --check && cargo check && cargo clippy && cargo test --lib` gruen | ✓ VERIFIED | Selbst ausgefuehrt: fmt/check/clippy 0 Fehler, 18/18 Tests gruen (13 neu + 5 Bestand) |
|
||||
| 3 | Commands `get_server_url`/`open_server` in `generate_handler!`; capabilities/default.json unveraendert, kein `remote`-Block | ✓ VERIFIED | lib.rs Z. 271-289, 301-305 gelesen; `grep -c '"remote"'` = 0; `git diff b023d6f..4d48543 -- capabilities/default.json` leer |
|
||||
| 4 | `change_server`-Klick navigiert per `window.navigate(setup_page_url(cfg!(windows)))`, kein `eval` | ✓ VERIFIED | lib.rs Z. 377-384; `grep -n "\.eval("` liefert 0 Treffer in lib.rs und setup.html |
|
||||
| 5 | `apply_server` einzige Setzstelle fuer Tooltip+Menuezeile; `spawn_version_check` setzt Update-Eintrag zurueck und prueft neu; `update`-Klick liest Adresse beim Klick aus dem Store (kein Start-Klon) | ✓ VERIFIED (Code) / ⚠️ Laufzeit-Wechsel siehe Truth „ohne Neustart" | lib.rs Z. 156-219 (`apply_server`, `spawn_version_check`), Z. 385-393 (`update`-Handler nutzt `stored_server_url(app)`); Aufrufkette in `save_server_url` (Z. 249-265) bestaetigt |
|
||||
| 6 | setup.html: `invoke('get_server_url')` beim Laden, Vorbelegung, `#current-server`, `#cancel-btn` → `open_server`; SVG unangetastet | ⚠️ PRESENT_BEHAVIOR_UNVERIFIED | Skript vollstaendig gelesen (Z. 337-420), deckt sich mit Spezifikation; kein automatisierter Test fuer setup.html vorhanden, echte Tauri-Navigation nur auf Zielplattform beobachtbar |
|
||||
| 7 | Web: `DesktopAppSettings` zeigt `data-testid="desktop-connected"` nur bei `useIsDesktopClient()===true`, i18n-Schluessel `connectedTo`/`changeHint` in de/en | ✓ VERIFIED | `pnpm --filter @tessera/web exec vitest run src/components/settings/desktop-app-settings.test.tsx` 5/5 gruen (inkl. neuer Test 4/5); `pnpm --filter @tessera/web exec vitest run` 431/431 gruen; `type-check` 0 Fehler; i18n-Keys in de.json/en.json vorhanden |
|
||||
| 8 | Doku: neuer Unterabschnitt „Server-Adresse ändern", erweiterte Tray-Liste, Erststart-Verweis; CHANGELOG zwei `Desktop-App:`-Zeilen unter Unveröffentlicht/Neu | ✓ VERIFIED | docs/anleitung-anwender.md Z. 175-235 gelesen; CHANGELOG.md Z. 1-13 gelesen (nur die zwei Zeilen neu, fremde Zeilen unangetastet); `changelog.test.ts` 10/10 gruen |
|
||||
| 9 | Drei Commits (`feat(desktop)`, `feat(web)`, `docs`), kein Push, keine `.planning/`-Commits, kein `tauri build`, kein Docker, `cargo` nur mit `CARGO_BUILD_JOBS=4` | ✓ VERIFIED | `git show --stat` fuer 29c132e/4c79874/4d48543 bestaetigt Typ + Dateien; Gesamtdiff `b023d6f..4d48543` umfasst exakt die 8 geplanten Dateien, Cargo.lock unveraendert; eigene Laeufe nutzten `CARGO_BUILD_JOBS=4` |
|
||||
|
||||
**Score:** 6/9 truths verified (3 present, behavior-unverified)
|
||||
|
||||
### Required Artifacts
|
||||
|
||||
| Artifact | Expected | Status | Details |
|
||||
|----------|----------|--------|---------|
|
||||
| `apps/desktop/src-tauri/src/lib.rs` | Vier reine Helfer, `TrayItems`, `apply_server`, `spawn_version_check`, Commands, Tray mit `connected`/`change_server`, erweiterte `mod tests` | ✓ VERIFIED | Alle Symbole vorhanden, gewired und getestet (18/18 Tests, fmt/check/clippy gruen) |
|
||||
| `apps/desktop/src/setup.html` | Aenderungsmodus (`#current-server`, `#cancel-btn`, `#subtitle`), `init()`/`enterChangeMode()` | ✓ VERIFIED | Markup + Skript vollstaendig vorhanden; SVG `.brand-mark` unangetastet |
|
||||
| `apps/web/src/components/settings/desktop-app-settings.tsx` + `.test.tsx` | Block `desktop-connected`, Tests mit/ohne Cookie | ✓ VERIFIED | Komponente + 5 gruene Tests (2 neu) |
|
||||
| `apps/web/src/messages/de.json`, `en.json` | `settings.desktop.connectedTo`, `settings.desktop.changeHint` | ✓ VERIFIED | Beide Schluessel in beiden Sprachen vorhanden |
|
||||
| `docs/anleitung-anwender.md`, `CHANGELOG.md` | Neuer Unterabschnitt, Tray-Liste, zwei CHANGELOG-Zeilen | ✓ VERIFIED | Inhaltlich gepruefte Textstellen vorhanden |
|
||||
|
||||
### Key Link Verification
|
||||
|
||||
| From | To | Via | Status | Details |
|
||||
|------|-----|-----|--------|---------|
|
||||
| Tray `change_server` | `setup_page_url(cfg!(windows))` | `window.navigate` | ✓ WIRED | lib.rs Z. 377-384; kein `eval` |
|
||||
| setup.html `init()` | `get_server_url` | `invoke('get_server_url')` | ✓ WIRED | setup.html Z. 411-420 |
|
||||
| setup.html `#cancel-btn` | `open_server` | `invoke('open_server')` | ✓ WIRED | setup.html Z. 401-409 |
|
||||
| `save_server_url` | `apply_server`/`spawn_version_check` | direkter Aufruf nach Store-Speicherung | ✓ WIRED | lib.rs Z. 249-265 |
|
||||
| `tray`-Klick `update` | `stored_server_url(app)` | Store-Lesung beim Klick (kein Start-Klon) | ✓ WIRED | lib.rs Z. 385-393 |
|
||||
| Server-Seite (Remote-Ursprung) | App-Commands | ACL ohne `remote`-Block | ✓ WIRED (verweigert) | capabilities/default.json unveraendert, kein `remote`-Schluessel |
|
||||
| `DesktopAppSettings` | `useIsDesktopClient()` | Cookie `tessera_desktop` | ✓ WIRED | desktop-app-settings.tsx Z. 79; Test mit/ohne Cookie gruen |
|
||||
|
||||
### Behavioral Spot-Checks
|
||||
|
||||
| Behavior | Command | Result | Status |
|
||||
|----------|---------|--------|--------|
|
||||
| Rust-Helfer + Tests | `cargo fmt --check && cargo check && cargo clippy && cargo test --lib` (CARGO_BUILD_JOBS=4) | 18/18 Tests gruen, 0 Warnungen | ✓ PASS |
|
||||
| Web-Komponententest (Zieltest) | `pnpm --filter @tessera/web exec vitest run src/components/settings/desktop-app-settings.test.tsx` | 5/5 gruen | ✓ PASS |
|
||||
| Web-Gesamtsuite (einmalig) | `pnpm --filter @tessera/web exec vitest run` | 431/431 gruen (64 Dateien) | ✓ PASS |
|
||||
| Web-Typpruefung | `pnpm --filter @tessera/web type-check` | 0 Fehler | ✓ PASS |
|
||||
| CHANGELOG-Struktur | `pnpm --filter @tessera/web exec vitest run src/lib/changelog.test.ts` | 10/10 gruen | ✓ PASS |
|
||||
| Tray-/Setup-Seiten-Laufzeitverhalten | — | — | ? SKIP (kein Windows-Runtime verfuegbar; siehe Human Verification) |
|
||||
|
||||
### Anti-Patterns Found
|
||||
|
||||
Keine `TBD`/`FIXME`/`XXX`/`TODO`/`HACK`/`PLACEHOLDER`/„not yet implemented" in den acht geaenderten Dateien gefunden (grep-Scan durchgefuehrt, keine Treffer).
|
||||
|
||||
### Requirements Coverage
|
||||
|
||||
Kein Eintrag `QUICK-260917-JN2` in `.planning/REQUIREMENTS.md` — bei Quick-Tasks ueblich (kein formaler Requirements-Katalog-Zwang). `requirements-completed: [QUICK-260917-JN2]` im SUMMARY-Frontmatter dokumentiert die Selbstzuordnung; die inhaltliche Deckung ist ueber die Truths/Artifacts oben abgebildet.
|
||||
|
||||
### Human Verification Required
|
||||
|
||||
Die im Plan selbst als „Nachweis durch Orchestrator" ausgewiesene Windows-VM-Bedienprobe steht noch aus (dieser Ausfuehrungslauf hatte explizit keinen Zugriff auf eine laufende Tauri-App/Windows-VM). Sechs konkrete Pruefpunkte, siehe YAML-Frontmatter `human_verification` oben:
|
||||
|
||||
1. Tooltip beim Ueberfahren des Tray-Symbols (`Tessera – {host}`)
|
||||
2. Erste (gesperrte) Menuezeile (`Verbunden mit {host}`)
|
||||
3. Klick auf „Server-Adresse ändern…" → vorbelegte Setup-Seite mit „Aktuell verbunden mit: …" und „Abbrechen"
|
||||
4. „Abbrechen" fuehrt ohne Aenderung zur laufenden Verbindung zurueck
|
||||
5. Serverwechsel aktualisiert Tooltip/Menuezeile/Update-Ziel ohne Neustart
|
||||
6. Update-Link nach Wechsel prueft gegen den neuen Server, alter Hinweis verschwindet
|
||||
|
||||
Alle sechs Punkte sind Code-seitig korrekt verdrahtet (siehe Truths/Key-Links oben) — es fehlt ausschliesslich der Laufzeit-Nachweis auf der Zielplattform, der laut Plan und Orchestrator-Auftrag bewusst diesem Verifikationslauf nicht obliegt.
|
||||
|
||||
### Gaps Summary
|
||||
|
||||
Keine Gaps. Alle statisch/automatisiert pruefbaren must_haves sind erfuellt: 18 gruene Rust-Tests (fmt/check/clippy sauber), 431 gruene Web-Tests + type-check, alle Code-Wiring-Punkte (a)-(f) aus dem Verifikationsauftrag bestaetigt (setup_page_url plattformabhaengig korrekt, open_server/save_server_url nur http/https, capabilities/default.json ohne remote-Block, update-Klick liest beim Klick aus dem Store, apply_server einzige Setz-Stelle fuer Tooltip+Menuezeile, kein eval), Doku/CHANGELOG korrekt ergaenzt, genau drei saubere Commits ohne Fremd-Dateien. Der einzige offene Punkt ist die vom Plan selbst bewusst ausgelagerte Windows-VM-Bedienprobe (Tray/Setup-Seiten-Laufzeitverhalten) — kein Blocker, sondern ein dokumentierter Beobachtungspunkt fuer den Orchestrator.
|
||||
|
||||
---
|
||||
|
||||
_Verified: 2026-09-17T15:00:00Z_
|
||||
_Verifier: Claude (gsd-verifier)_
|
||||
+312
File diff suppressed because one or more lines are too long
+298
@@ -0,0 +1,298 @@
|
||||
# Quick 260917-kgc: Desktop-Client — Update in der App (tauri-plugin-updater) — Research
|
||||
|
||||
**Researched:** 2026-09-17
|
||||
**Domain:** Tauri 2 Updater-Plugin (Rust-API), NSIS-Update-Modus, AppImage-Ersetzung, minisign-Signatur im Cross-Bau, Endpunkt in der NestJS-API
|
||||
**Confidence:** HIGH fuer Plugin-/Bundler-/NSIS-Verhalten (Quelltext der installierten bzw. per `cargo fetch` geholten Crates gelesen), MEDIUM fuer den Cross-Bau des neuen TLS-Stacks (nur in der Pipeline beweisbar), LOW fuer SmartScreen-Verhalten des vom Updater gestarteten Installers
|
||||
|
||||
Quellenkuerzel: `$REG` = `~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f`. Gelesene Crate-Staende: `tauri-plugin-updater-2.11.0` (aktuellste 2.x, 2026-08-31; 3.0.0-alpha wird von `"2"` nicht gewaehlt), `tauri-2.11.3`, `tauri-utils-2.9.3`, `tauri-bundler-2.9.4` (die CLI 2.11.3 lockt `tauri-bundler 2.9.3`, laut `tauri-cli-2.11.3/Cargo.lock` Z. 6606-6607; 2.9.4 ist der Patch dazu, der NSIS-Teil ist identisch aufgebaut), `tauri-cli-2.11.3` (Tarball von crates.io, Scratchpad), `reqwest-0.13.5`, `ring-0.17.14`, `semver-1`. Repo unveraendert (nur diese Datei).
|
||||
|
||||
## Summary
|
||||
|
||||
Das offizielle `tauri-plugin-updater` 2.11.0 deckt genau den gewuenschten Ablauf ab, komplett von Rust aus: `app.updater_builder().endpoints(vec![url])?.version_comparator(..).build()?.check().await` liefert `Option<Update>`; `update.download_and_install(on_chunk, on_finish).await` laedt die Datei komplett in den Speicher, prueft die minisign-Signatur gegen `plugins.updater.pubkey`, und startet unter Windows den NSIS-Installer mit `/P /UPDATE /R /ARGS …` und beendet den eigenen Prozess per `std::process::exit(0)` — der Installer startet die App danach selbst neu (`.onInstSuccess` → `RunAsUser`). Unter Linux ersetzt das Plugin die laufende AppImage-Datei an Ort und Stelle (Pfad aus `APPIMAGE`), danach muss der Client selbst `app.restart()` rufen. Das Tray-Menue/`prevent_close`-Muster ist seit Tauri-PR #12313 (`RESTART_EXIT_CODE`) kein Hindernis mehr; unser Run-Handler laesst `code: Some(..)` bereits durch.
|
||||
|
||||
Zwei harte Vorgaben ergeben sich aus dem Quelltext: (1) `plugins.updater.pubkey` MUSS in `tauri.conf.json` stehen — sonst bricht sowohl der Bau (`createUpdaterArtifacts: true` → CLI: „plugins > updater doesn't exist") als auch der App-Start ab (Plugin-Config-Deserialisierung, `pubkey: String` ohne Default). `endpoints` darf dagegen fehlen (`#[serde(default)]`) und wird zur Laufzeit gesetzt. (2) Mit `createUpdaterArtifacts: true` und gesetztem `pubkey` verlangt die CLI beim `tauri build` zwingend `TAURI_SIGNING_PRIVATE_KEY` (Inhalt ODER Pfad) — ohne Schluessel bricht der Bau ab; der Ausweg fuer lokale Baue ist `tauri build --no-sign` (dann entsteht keine `.sig`). Die `.sig`-Dateien entstehen host-unabhaengig in der CLI (reines Rust/minisign), also auch im `cargo-xwin`-Cross-Bau.
|
||||
|
||||
Der Versionsvergleich ist der eigentliche Fallstrick: `RemoteRelease.version` ist bereits ein `semver::Version` (kein Rohstring), `1.2.0-beta.38c1400` ist gueltig, aber `1.2.0-beta.0123456` NICHT (fuehrende Null in numerischem Prerelease-Identifier → Deserialisierung schlaegt fehl, Check liefert Err). Deshalb Commit-Stempel immer mit Praefix: `1.2.0-beta.g38c1400` (wie `git describe`). `version_comparator` ersetzt den Standardvergleich (`release.version > current`) vollstaendig.
|
||||
|
||||
**Primary recommendation:** `tauri-plugin-updater = "2"` mit Standard-Features (rustls+ring, Plattform-Zertifikatspruefung) einbauen; neuer API-Endpunkt `GET /desktop/update?target=&arch=¤t=&base=` (dynamisches Format, absolute `url` aus validiertem `base`, 204 ohne signiertes Paket); `desktop-collect.sh` schreibt den `.sig`-Inhalt als Feld `signature` ins Manifest; Version im Manifest bleibt `X.Y.Z`, der Endpunkt bildet `X.Y.Z` (live) bzw. `X.Y.Z-beta.g<sha7>` (beta); eigener `version_comparator` (Basisversion groesser ODER Beta-Stempel verschieden). Schluesselpaar per `tauri signer generate -w`, privater Schluessel + Passwort als Gitea-Secrets (per `PUT /api/v1/repos/{owner}/{repo}/actions/secrets/{name}`, in Gitea 1.26.2 vorhanden), oeffentlicher Schluessel in `tauri.conf.json`.
|
||||
|
||||
## Antworten auf die acht Fragen
|
||||
|
||||
### 1. Plugin-API (Rust)
|
||||
|
||||
- `UpdaterExt` ist fuer jeden `Manager` implementiert (`App`, `AppHandle`, Fenster): `fn updater_builder(&self) -> UpdaterBuilder` und `fn updater(&self) -> Result<Updater>` [VERIFIED: `$REG/tauri-plugin-updater-2.11.0/src/lib.rs:58-121`]. `updater_builder()` haengt automatisch an: Windows `current_exe_args` (fuer `/ARGS`), Linux `executable_path(APPIMAGE)` (Z. 108-112), `on_before_exit(|| app_handle.cleanup_before_exit())` (Z. 116-118).
|
||||
- `UpdaterBuilder`: `version_comparator(Fn(Version, RemoteRelease) -> bool + Send + Sync + 'static)` (Z. 211), `endpoints(Vec<Url>) -> Result<Self>` (Z. 224, validiert https), `header(k, v) -> Result<Self>`, `headers(HeaderMap)`, `timeout(Duration)`, `pubkey(..)`, `installer_arg(s)`, `restart_after_install(bool)` (Windows, Default `true`), `configure_client(Fn(reqwest::ClientBuilder) -> ClientBuilder)`, `build() -> Result<Updater>`; `build()` gibt `Error::EmptyEndpoints`, wenn weder Laufzeit- noch Config-Endpunkte da sind (Z. 365-371) [VERIFIED: `updater.rs:211-388`].
|
||||
- `Updater::check(&self).await -> Result<Option<Update>>` (Z. 432). `Update` (pub-Felder): `body: Option<String>`, `current_version: String`, `version: String`, `date: Option<OffsetDateTime>`, `target: String`, `download_url: Url`, `signature: String`, `raw_json: serde_json::Value`, `timeout`, `proxy`, `no_proxy`, `headers` (Z. 642-673). `Update: Clone + Resource` (also `Send + Sync`) — kann in `app.manage(Mutex<Option<Update>>)` liegen [VERIFIED: `updater.rs:642-676`].
|
||||
- `update.download(on_chunk: FnMut(usize, Option<u64>), on_finish: FnOnce()) -> Result<Vec<u8>>` (ganze Datei im Speicher, danach `verify_signature`, Z. 680-742); `update.install(bytes)`; `update.download_and_install(on_chunk, on_finish).await` (Z. 761-768). Doc-Kommentar: „Windows: This function exits the app after launching the updater installer successfully — macOS / Linux: You need to relaunch the app" (Z. 754-760) [VERIFIED].
|
||||
- `RemoteRelease { version: semver::Version, notes: Option<String>, pub_date: Option<OffsetDateTime>, data: RemoteReleaseInner }` mit `RemoteReleaseInner::Dynamic(ReleaseManifestPlatform { url: Url, signature: String })` oder `Static { platforms: HashMap<String, ReleaseManifestPlatform> }` (Z. 70-96) [VERIFIED].
|
||||
- `plugins.updater.endpoints` in `tauri.conf.json` ist optional (`#[serde(default)] pub endpoints: Vec<Url>`, `config.rs:136`); Laufzeit-`endpoints()` ersetzt die Config-Liste (`build()`: `self.endpoints.unwrap_or_else(|| config.endpoints)`, `updater.rs:366-368`) [VERIFIED].
|
||||
- `plugins.updater.pubkey` ist Pflicht: `pub pubkey: String` ohne Default (`config.rs:137`). Fehlt `plugins.updater` ganz, uebergibt Tauri `JsonValue::Null` (`$REG/tauri-2.11.3/src/plugin.rs:1007`, `unwrap_or_default()`) → `serde_json::from_value` scheitert → „Error deserializing 'plugins.updater' within your Tauri configuration" (`plugin.rs:800-805`) → unser `.build(...).expect(..)` in `lib.rs:287-288` panict beim Start. Zusaetzlich verlangt die CLI beim Bau mit `createUpdaterArtifacts != false` den Block (`tauri-cli-2.11.3/src/interface/rust.rs:855-870`: „failed to get updater configuration: plugins > updater doesn't exist") [VERIFIED].
|
||||
- Capabilities: Die Permissions (`updater:default` = `allow-check`, `allow-download`, `allow-install`, `allow-download-and-install`, `$REG/tauri-plugin-updater-2.11.0/permissions/default.toml`) gaten nur die vier `#[tauri::command]`-Handler fuer JS (`lib.rs:236-241`). Der Rust-Aufruf ueber `UpdaterExt` laeuft am ACL vorbei — `capabilities/default.json` bleibt unveraendert [VERIFIED].
|
||||
|
||||
### 2. Antwortformat des Endpunkts
|
||||
|
||||
- Dynamisches Format: JSON mit `version` (alias `name`), optional `notes`, optional `pub_date` (RFC 3339, sonst Deserialisierungsfehler), `url`, `signature`; fehlt `platforms`, wird `url`+`signature` verlangt („the `url` field was not set on the updater response") [VERIFIED: `updater.rs:1454-1497`]. HTTP 204 → `Ok(None)` (Z. 531-534). Andere Nicht-2xx-Status werden nur geloggt; ohne parsebare Antwort endet `check()` mit `Error::ReleaseNotFound` (Z. 573) [VERIFIED]. Doku: 204 „No Content", Felder `url`/`version`/`signature` Pflicht [CITED: https://v2.tauri.app/plugin/updater/].
|
||||
- `url` ist typisiert `url::Url` → muss absolut sein (relative Pfade wie `/desktop/download/windows` scheitern beim Parsen) [VERIFIED: `updater.rs:72-76`]. Unser `/desktop/latest` liefert heute bewusst relative URLs (`desktop.service.ts`, `getLatest()`) — fuer den Updater braucht es einen eigenen Endpunkt mit absoluter URL (siehe Frage 6).
|
||||
- `version` muss gueltiges SemVer sein; ein fuehrendes `v` wird abgeschnitten (`parse_version`, Z. 1514-1521). `version_comparator` bekommt den **geparsten** `semver::Version` (kein Rohstring); Prerelease liegt in `release.version.pre`. Probe (Scratchpad, semver 1.x): `1.2.0-beta.38c1400` OK, `1.2.0-beta.1234567` OK, `1.2.0-beta.0123456` → `Err("invalid leading zero in pre-release identifier")`, `1.2.0-beta.g0123456` OK, `1.2.0-beta.g38c1400 < 1.2.0` = true [VERIFIED: Probe-Ausgabe, `scratchpad/semver-probe`]. Ein 7-stelliger Git-SHA kann rein numerisch mit fuehrender Null sein (≈0,4 % der Commits) → **immer `g`-Praefix**.
|
||||
- Client-Version: `current_version = app.package_info().version` (`updater.rs:197`), also `1.2.0` aus `tauri.conf.json` (`desktop-version.sh` schreibt reines X.Y.Z, D-07) [VERIFIED].
|
||||
- Platzhalter in der Endpunkt-URL: `{{current_version}}`, `{{target}}`, `{{arch}}`, `{{bundle_type}}` — werden sowohl im Pfad (URL-kodiert `%7B%7B…%7D%7D`) als auch in Query-Parametern ersetzt (`updater.rs:473-486`). Werte: `target` = `linux` | `darwin` | `windows`; `arch` = `i686` | `x86_64` | `armv7` | `aarch64` | `riscv64`; `bundle_type` = `nsis` | `appimage` | `msi` | `deb` | `rpm` | `app` | `unknown` (`updater.rs:1395-1421`, `installer_for_bundle_type`). Windows x64 → `windows`/`x86_64`, AppImage x64 → `linux`/`x86_64` [VERIFIED].
|
||||
- Der Request traegt `Accept: application/json` (Check) bzw. `application/octet-stream` (Download) und User-Agent `tauri-plugin-updater/2.11.0`; der Download liest `Content-Length` fuer die Fortschrittsanzeige (`updater.rs:434-437, 687-690, 722-727`) [VERIFIED]. Unser `StreamableFile` setzt `length: entry.size` → `Content-Length` vorhanden [VERIFIED: `apps/api/src/desktop/desktop.controller.ts`, `download()`].
|
||||
|
||||
### 3. Artefakte und Signatur
|
||||
|
||||
- `bundle.createUpdaterArtifacts: true` (Typ `Updater::Bool`; `"v1Compatible"` ist `Updater::String`, `$REG/tauri-utils-2.9.3/src/config.rs:1532-1571`). Im v2-Modus erzeugt der Bundler fuer NSIS/AppImage **kein** Zip/Tar mehr („Self contained updater, no need to zip", `$REG/tauri-bundler-2.9.4/src/bundle.rs:206-239`); der NSIS-Installer wird einmal mit `updater=false` gebaut (`bundle.rs:178`) — die normale `Tessera_X.Y.Z_x64-setup.exe` IST das Update-Artefakt, ebenso `Tessera_X.Y.Z_amd64.AppImage` [VERIFIED].
|
||||
- Signatur passiert in der CLI nach dem Buendeln (`tauri-cli-2.11.3/src/bundle.rs:221, 226-314`, `sign_updaters`): fuer jedes Bundle vom Typ Nsis/Msi/AppImage/Deb/Rpm/Updater wird `<datei>.sig` daneben geschrieben (`helpers/updater_signature.rs:117-160`: Extension + `.sig`, Inhalt = Base64 der minisign-Signaturbox). Ergebnis: `target/x86_64-pc-windows-msvc/release/bundle/nsis/Tessera_1.2.0_x64-setup.exe.sig` und `target/release/bundle/appimage/Tessera_1.2.0_amd64.AppImage.sig` [VERIFIED]. Kein `cfg(windows)`/Host-Gating in `sign_updaters` → im `cargo-xwin`-Cross-Bau entsteht die `.sig` genauso [VERIFIED: Code-Lesung; Pipeline-Nachweis steht aus].
|
||||
- Umgebung: `TAURI_SIGNING_PRIVATE_KEY` — Wert ist Inhalt ODER Pfad (Code prueft `Path::exists()`, `bundle.rs:277-289`); `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` optional — fehlt sie, gilt mit `--ci`/`CI`-Umgebung leeres Passwort, sonst interaktive Abfrage (`bundle.rs:272-275, 290-292`). Die vom Generator ausgegebene Variable `TAURI_SIGNING_PRIVATE_KEY_PATH` (`signer/generate.rs:60`) wird im Bau-Code NICHT gelesen — nur `TAURI_SIGNING_PRIVATE_KEY` [VERIFIED]. `pubkey` in `tauri.conf.json` darf Inhalt oder Dateipfad sein (`bundle.rs:261-269`); beim Signieren warnt die CLI, wenn `keynum` von privatem und oeffentlichem Schluessel nicht zusammenpassen (Z. 304-306) [VERIFIED].
|
||||
- Generator: `pnpm --filter @tessera/desktop exec tauri signer generate -w <pfad> [-p <passwort>] [--ci] [--force]` → schreibt `<pfad>` (privat) und `<pfad>.pub` (`signer/generate.rs:14-49`, `updater_signature.rs:61-72`) [VERIFIED]. Doku-Form: `npm run tauri signer generate -- -w ~/.tauri/myapp.key` [CITED: v2.tauri.app/plugin/updater/].
|
||||
- Bau ohne Schluessel in der Umgebung, aber `createUpdaterArtifacts: true` + `pubkey` gesetzt → **Abbruch**: „A public key has been found, but no private key. Make sure to set `TAURI_SIGNING_PRIVATE_KEY` environment variable." (`bundle.rs:277-279`). Ausweg: `tauri build --no-sign` (`build.rs:81-88`, `bundle.rs:255-258`: „Updater signing is skipped due to --no-sign flag") → keine `.sig` [VERIFIED]. Konsequenz fuer das Konzept „ohne Schluessel → Feld fehlt → 204": funktioniert nur mit `--no-sign` (lokale Proben, CI-Fallback), nicht durch blosses Weglassen der Variable.
|
||||
|
||||
### 4. Windows-Installation durch den Updater
|
||||
|
||||
- Ablauf `install_inner` (`updater.rs:835-877`): Bytes in `%TEMP%\Tessera-<version>-updater-<rand>\Tessera-<version>-installer.exe` schreiben (`make_temp_dir`/`write_to_temp`, Z. 956-1020; Ordner bleibt liegen, `.keep()`), `on_before_exit` → `cleanup_before_exit()` (Tray-Icons leeren, Fenster verstecken, `$REG/tauri-2.11.3/src/app.rs:1108-1120`), dann `ShellExecuteW(NULL, "open", <exe>, <parameter>, SW_SHOW)`; Fehler (<=32) wird zurueckgegeben; sonst `std::process::exit(0)` [VERIFIED].
|
||||
- Parameter (`updater_parameters`, Z. 879-907): `nsis_args(install_mode)` + `/UPDATE` + bei `restart_after_install` (Default `true`) `/R` (nicht bei `basicUi`) + `/ARGS <aktuelle Prozessargumente, escaped>` + `installerArgs` aus Config. `installMode`: `passive` → `/P` (Default), `quiet` → `/S`, `basicUi` → keine Flags (`config.rs:41-56`; Config-Schluessel `plugins.updater.windows.installMode` / `installerArgs`, camelCase, Z. 80-92) [VERIFIED]. Doku: passive = kleines Fenster mit Fortschrittsbalken, quiet = keine Rueckmeldung [CITED: v2.tauri.app/plugin/updater/].
|
||||
- NSIS-Template (`$REG/tauri-bundler-2.9.4/src/bundle/windows/nsis/installer.nsi`): `.onInit` liest `/P`, `/NS`, `/UPDATE` (Z. 477-491); im Update-Modus wird bei gleicher/hoeherer Version ohne Deinstallation direkt installiert (`PageLeaveReinstall`, Z. 318-321 „In update mode, always proceeds without uninstalling"); Startmenue-/Desktop-Verknuepfungen werden im Update-Modus nicht neu angelegt (Z. 937-941, 966-970); Registry-Werte bleiben erhalten (Z. 863-864); `.onInstSuccess` startet die App nur bei `/P` oder `/S` und nur mit `/R`: `nsis_tauri_utils::RunAsUser "$INSTDIR\${MAINBINARYNAME}.exe" "$R0"` mit `$R0` = Wert hinter `/ARGS` (Z. 743-754) [VERIFIED]. → **`app.restart()` ist unter Windows nicht noetig und wird nie erreicht** (Prozess endet in `install`); unter Linux ist es Pflicht (Frage 5). Der Aufruf nach `download_and_install` ist trotzdem korrekt, weil plattformuebergreifend harmlos.
|
||||
- Laufender Prozess/Tray: `CheckIfAppIsRunning` (`utils.nsh:22-62`) sucht `tessera-desktop.exe` — bei `INSTALLMODE == currentUser` per `FindProcessCurrentUser`, killt ohne Rueckfrage bei `/P` oder `/S` (`KillProcessCurrentUser`, Sleep 500 ms). Da der Updater den Prozess bereits per `exit(0)` beendet hat, greift das nur im Rennen; ein verstecktes Fenster oder das Tray-Symbol spielen keine Rolle (Prozess-, nicht Fenster-Suche) [VERIFIED].
|
||||
- `installMode: currentUser` (unser `tauri.conf.json:48`): Installer laeuft ohne UAC im Nutzerkontext, `ShellExecuteW "open"` verlangt keine Erhoehung; `SetContext`/`SHCTX` bleibt HKCU (`installer.nsi:105-112`) [VERIFIED: Template; Bedienprobe auf der Windows-VM ist der Nachweis].
|
||||
- Bekannte Faelle: tauri#11392 („App::restart does not restart after update.download_and_install", Tray + `prevent_close`) wurde durch tauri PR #12313 (`RESTART_EXIT_CODE`, `restart_on_exit`) behoben [CITED: https://github.com/tauri-apps/tauri/issues/11392, https://github.com/tauri-apps/tauri/pull/12313]; im installierten Tauri 2.11.3 enthalten: `restart()` von einem Nebenthread setzt `restart_on_exit` und ruft `request_exit(RESTART_EXIT_CODE)` (= `i32::MAX`), was `RunEvent::ExitRequested { code: Some(i32::MAX) }` ausloest (`app.rs:77, 588-611, 1434-1437`) — unser Handler in `lib.rs:296-301` blockt nur `code: None` → Neustart geht durch [VERIFIED]. tauri#7560 („NSIS quiet update do not restart") ist „closed as not planned" (2023, v1) — mit `/R` im heutigen Template gegenstandslos [CITED: https://github.com/tauri-apps/tauri/issues/7560]. `window-state` speichert beim harten `exit(0)` unter Windows nicht (kein `RunEvent::Exit`) — Fensterposition kann nach einem Update einmal verloren gehen [ASSUMED, aus Plugin-Semantik abgeleitet].
|
||||
|
||||
### 5. Linux AppImage
|
||||
|
||||
- `updater_builder()` setzt `executable_path` auf `app.env().appimage` (= Umgebungsvariable `APPIMAGE`, `$REG/tauri-utils-2.9.3/src/lib.rs:269-287`); `build()` nimmt unter Linux diesen Pfad als `extract_path` (`updater.rs:373-379`) [VERIFIED].
|
||||
- `install_appimage` (`updater.rs:1047-1118`): sucht ein temporaeres Verzeichnis **auf demselben Dateisystem** wie die AppImage (Reihenfolge `std::env::temp_dir()`, `dirs::cache_dir()` = `~/.cache`, Elternordner der AppImage), verschiebt die laufende Datei per `rename` als Sicherung dorthin, schreibt die neuen Bytes unter dem alten Pfad, uebernimmt die alten Rechte (Ausfuehrbit), stellt bei Fehler die Sicherung zurueck; passt kein Tempordner → `Error::TempDirNotOnSameMountPoint` [VERIFIED]. Braucht also Schreibrecht auf Datei UND Ordner. Laeuft die AppImage aus `~/Downloads`, ist das gegeben (Tempordner `~/.cache` liegt auf demselben Dateisystem wie `$HOME`; ist `/tmp` ein tmpfs, scheitert nur der erste Kandidat). Bei einer AppImage unter `/opt` ohne Schreibrecht schlaegt das Update fehl — Fehlertext im Tray zeigen.
|
||||
- Neustart: `app.restart()` → `tauri::process::restart` → `current_binary` liefert unter Linux **nur** den `APPIMAGE`-Pfad (`$REG/tauri-2.11.3/src/process.rs:48-56, 74-89`), startet also die neue Datei und beendet den alten Prozess [VERIFIED]. Der alte Prozess haelt die geloeschte Inode offen — unkritisch.
|
||||
- Ohne `APPIMAGE` (nackte Binary aus `target/release/`, `tauri dev`) faellt `extract_path` auf `current_exe()` und wuerde die Binary ueberschreiben — Update-Pfad im Dev-Modus nicht ausloesen (nur `check()` testen).
|
||||
|
||||
### 6. Integration bei uns
|
||||
|
||||
- **Absolute Download-URL:** Die API sieht die Anfrage ueber NPM → Next.js-Rewrite (`apps/web/next.config.ts:34-43`, Ziel `http://api:3001`). Next' Proxy setzt `x-forwarded-host: req.headers.host` (`node_modules/.pnpm/next@15.5.19_*/node_modules/next/dist/server/lib/router-utils/proxy-request.js:26-36`) [VERIFIED], das Schema (`x-forwarded-proto`) kaeme nur aus NPMs Header-Vorlage [ASSUMED]. Verlaesslicher und ohne Proxy-Annahme: **der Client haengt `base=<server_url>` an** (er kennt sie aus dem Store), die API validiert (`URL`-Parse, nur `http`/`https`, keine Credentials, nur Origin uebernehmen, Pfad verwerfen) und bildet `url = ${origin}/api-proxy/desktop/download/${platform}`. Reflektierte Eingabe ist unkritisch: der Client verifiziert die Signatur, eine fremde URL kann nur zu einem fehlgeschlagenen Download fuehren. Alternative ohne API-Aenderung: `Update.download_url` ist ein `pub`-Feld und darf nach `check()` vom Client auf `api_url(server, "/desktop/download/<platform>")` gesetzt werden (`updater.rs:655`) [VERIFIED] — als Notnagel dokumentieren, nicht als Hauptweg.
|
||||
- **Neuer Endpunkt** (statt `/desktop/latest` zu aendern, das die Web-UI weiter mit relativen URLs nutzt): `GET /desktop/update?target=&arch=¤t=&base=` (`@Public()`, statische Route VOR `download/:platform` — Route-Order-Falle, Memory `project_nest_route_order.md`). Logik: `target` per Whitelist auf Plattform (`windows`→`windows`, `linux`→`linux`), `arch` muss `x86_64` sein (sonst 204); Manifest lesen; fehlt Eintrag oder `signature` → **204**; sonst 200 mit `{ version, pub_date: buildTime, notes: "channel=<c>;commit=<sha7>", url, signature }`, `version` = `manifest.version` (live) bzw. `${manifest.version}-beta.g${manifest.commit}` (beta). Die 204-Entscheidung „gleicher Stand" bleibt beim Client-Comparator (die API kennt den Client-Commit nicht; den Placeholder `{{current_version}}` nur zum Loggen mitschicken). Optional zusaetzlich `&commit=<APP_COMMIT>` und die API antwortet 204 bei gleichem Commit — spart einen Download-Link, aendert an der Sicherheit nichts.
|
||||
- **`desktop-collect.sh`:** neben `*.exe`/`*.AppImage` die zugehoerige `*.sig` suchen (`find -name '*-setup.exe.sig'` / `'*.AppImage.sig'`; die bestehenden `-name '*.exe'`/`'*.AppImage'`-Zaehler matchen `.sig` nicht) und den Dateiinhalt (einzeilige Base64, ~200 Zeichen) per `--arg linuxSig "$(cat …)"` als `files.<platform>.signature` in `manifest.json` schreiben; Datei selbst nicht kopieren (kein Nutzen, die API liefert JSON). Fehlt die `.sig` (Bau mit `--no-sign`), Feld weglassen und eine Warnzeile loggen; bei `GITHUB_REF` = Tag oder `main` hart abbrechen (Signatur ist dort Pflicht). `desktop.service.ts`: `isValidManifestFileEntry` um optionales `signature: string` erweitern, `DesktopManifestFile` in `packages/shared/src/index.ts:29-33` ebenso.
|
||||
- **Skip-Mechanismus (`desktop-stamp.sh check`):** prueft Groesse/sha256 der beiden Dateien und liest die Manifest-Felder — das `signature`-Feld liegt im gecachten Manifest und wird mit uebernommen; keine `.sig`-Datei zu pruefen. Zwei Ergaenzungen: `check` verlangt fuer beide Plattformen ein nicht-leeres `files.<p>.signature` (alter Cache-Stand ohne Signatur → `no_reuse`), und ein Schluesselwechsel wird automatisch zum Neubau, weil `pubkey` in `apps/desktop/src-tauri/tauri.conf.json` liegt und `apps/desktop` Teil von `DESKTOP_PATHS` ist (`desktop-stamp.sh:50`) [VERIFIED]. Die Signatur gilt fuer die Bytes der `-setup.exe`; `cp` in `desktop-collect.sh` aendert nichts daran — niemals nachtraeglich signieren/patchen.
|
||||
- **Gitea-Secrets per API:** Gitea 1.26.2 (`curl localhost:3002/api/v1/version`) bietet `PUT /api/v1/repos/{owner}/{repo}/actions/secrets/{secretname}` mit Body `{"data": "<wert>", "description": "<optional>"}`; Antwort 201 (angelegt) / 204 (aktualisiert) [VERIFIED: `localhost:3002/swagger.v1.json`, `CreateOrUpdateSecretOption`, `required: ["data"]`; CITED: https://docs.gitea.com/api/1.26/operations/update-repo-secret/]. Zugriff: `reqToken()` + `reqOwner()` (Token-Inhaber muss Repo-Eigentuemer sein; Kategorie `repository` → `write:repository`) [VERIFIED: `routers/api/v1/api.go` (release/v1.26) Z. 947-958, 1225]. Der vorhandene `REGISTRY_TOKEN` hat `repository: write` (docs/ci-cd-setup.md Z. 88) — genuegt, sofern er dem Repo-Eigentuemer `schalli` gehoert. Aufruf vom Dev-Host ueber `localhost:3002` (Memory `project_ci_registry_push.md`), nie ueber `git.vicolab.de`. Secrets: `TAURI_SIGNING_PRIVATE_KEY` (Dateiinhalt, eine Base64-Zeile) und `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` (Schluessel MIT Passwort erzeugen — ein leerer Secret-Wert ist in Gitea nicht sicher moeglich [ASSUMED]; `CI=true` als Fallback fuer leeres Passwort setzt act_runner wie GitHub [ASSUMED]).
|
||||
- **ci.yml:** Job `desktop` bekommt `env: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}` und `TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.… }}` an beiden `tauri build`-Schritten. Der Skip-Pfad braucht die Secrets nicht. `publish-release.sh` kann die `.sig` optional als Release-Anhang mitgeben — nicht noetig, das Manifest traegt sie.
|
||||
|
||||
### 7. Versionsvergleich Beta
|
||||
|
||||
- `version_comparator` ersetzt den Standard vollstaendig: `let should_update = match self.version_comparator { Some(c) => c(self.current_version.clone(), release.clone()), None => release.version > self.current_version }` (`updater.rs:576-579`) [VERIFIED]. Ohne eigenen Vergleich gilt SemVer: `1.2.0-beta.g38c1400 < 1.2.0` (Probe) → ein Beta-Client (Version `1.2.0`) saehe nie einen neueren Beta-Bau. Standardverhalten nur fuer Live sinnvoll.
|
||||
- Empfohlene Logik (spiegelt `lib.rs:259-261`, D-07/WR-02):
|
||||
```rust
|
||||
// Source: eigene Ableitung aus updater.rs:576-579 + semver-Probe
|
||||
fn is_newer(current: &semver::Version, remote: &semver::Version, app_commit: &str) -> bool {
|
||||
let base = |v: &semver::Version| (v.major, v.minor, v.patch);
|
||||
if base(remote) > base(current) { return true; }
|
||||
if base(remote) < base(current) { return false; }
|
||||
// gleiche X.Y.Z: Beta-Stempel "beta.g<sha7>" vs. env!("APP_COMMIT")
|
||||
match remote.pre.as_str().strip_prefix("beta.g") {
|
||||
Some(sha) => !app_commit.is_empty() && sha != app_commit,
|
||||
None => false, // Live, gleiche Version: kein Update
|
||||
}
|
||||
}
|
||||
```
|
||||
`current` ist immer reines X.Y.Z (D-07) — `current.pre` ist leer, darum genuegt der Tupelvergleich. `app_commit` = `env!("APP_COMMIT")` (7-stellig, `build.rs:18-32`) und `manifest.commit` = `git rev-parse --short=7` (`desktop-collect.sh:78`) — gleiches Format [VERIFIED]. Leerer `APP_COMMIT` (Quell-Tarball) → nur Versionsvergleich, wie heute.
|
||||
- Reine Funktion in `lib.rs` + Tests (Basis groesser, Basis kleiner, gleiche Basis/anderer Stempel, gleicher Stempel, Live ohne Pre, leerer Commit) — wie die bestehenden `mod tests`.
|
||||
|
||||
### 8. Gotchas (Abhaengigkeiten, TLS, Groesse, Zertifikate)
|
||||
|
||||
- Plugin-Abhaengigkeiten: `reqwest = "0.13"` (Features `json`, `stream`, default-features = false), Standard-Features `rustls-tls` (= `reqwest/rustls-no-provider` + `rustls 0.23` mit `ring`), `system-proxy`, `zip`; `tauri = "2.10"` (unser 2.11.3 passt) [VERIFIED: `$REG/tauri-plugin-updater-2.11.0/Cargo.toml:66-116`]. Unser `reqwest = "0.12"` bleibt daneben bestehen → zwei reqwest-Majors und zwei TLS-Stacks (native-tls/schannel bzw. OpenSSL + rustls/ring) im Binary. Mehr Bauzeit/Groesse (Groessenordnung wenige MB [ASSUMED]), funktional unproblematisch. `rustls-no-provider` zieht `rustls-platform-verifier` (`$REG/reqwest-0.13.5/Cargo.toml:106-109`) → Zertifikatspruefung ueber den Betriebssystem-Speicher (Windows-Zertifikatspeicher, Linux CA-Bundle; das Plugin setzt unter Linux notfalls `SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt`, `updater.rs:440-448`) [VERIFIED] — Let's-Encrypt-Zertifikate von alpha/live werden akzeptiert.
|
||||
- Cross-Bau-Risiko: `ring 0.17.14` ist neu im Abhaengigkeitsgraphen. Das Crate liefert fuer `x86_64-pc-windows-msvc` vorassemblierte `.o`-Objekte mit (`$REG/ring-0.17.14/build.rs:340-349, 430-445`, `pregenerated/*-x86_64-nasm.o`) — kein `nasm` noetig; die C-Teile baut `cc` mit dem Clang aus `cargo-xwin` [VERIFIED: Code-Lesung; der tatsaechliche xwin-Bau ist nur in der Pipeline beweisbar, `cargo-xwin` ist auf dem Dev-Host nicht installiert]. Fallback bei Bauproblemen: `tauri-plugin-updater = { version = "2", default-features = false, features = ["native-tls", "system-proxy"] }` (schannel unter Windows, OpenSSL unter Linux — `libssl-dev` steht bereits in der apt-Liste, ci.yml Z. 120-125). **Nicht** das Projekt-`reqwest` auf 0.13 heben: dessen Default `default-tls = rustls` zieht `aws-lc-rs` (cmake/nasm-Bau), ein echtes xwin-Risiko [VERIFIED: `reqwest-0.13.5/Cargo.toml:45-51, 101-105`].
|
||||
- Kein `tauri-plugin-http` noetig; der Updater bringt seinen Client mit [VERIFIED: keine Abhaengigkeit in `Cargo.toml`].
|
||||
- Endpunkt-Schema: im Release-Bau wird `http://` abgelehnt (`Error::InsecureTransportProtocol`, `config.rs:160-179`) — `endpoints()` gibt dann `Err`; Fehler loggen, Tray-Eintrag gesperrt lassen. `dangerousInsecureTransportProtocol`/`dangerousAcceptInvalidCerts`/`dangerousAcceptInvalidHostnames` existieren als Config-Schalter (`config.rs:105-118`) — nicht setzen, nur im Handbuch als „nicht vorgesehen" nennen [VERIFIED]. Der Store erlaubt heute `http`-Adressen (`check_server`); ein Kunde mit `http://` bekommt einfach kein In-App-Update (Hinweis-Download bleibt).
|
||||
- `download()` haelt die ganze Datei (~100 MB) im RAM, bevor sie geschrieben wird (`updater.rs:730-742`) [VERIFIED] — akzeptabel. Next' Rewrite-Proxy hat ein 30-s-**Inaktivitaets**-Timeout (`proxyTimeout` → `ClientRequest.setTimeout`, `next/dist/compiled/http-proxy`), kein Gesamtlimit — ein laufender Stream bricht nicht ab [VERIFIED]; NPM-Groessengrenzen wie in Kap. 10 des Betriebshandbuchs.
|
||||
- SmartScreen: Die vom Updater geschriebene `…-installer.exe` erhaelt keine Mark-of-the-Web (kein Browser-Download, `std::fs::write` ohne `Zone.Identifier`) — voraussichtlich kein SmartScreen-Dialog beim In-App-Update, D-09 (keine Code-Signierung) bleibt bestehen [ASSUMED — auf der Windows-VM pruefen].
|
||||
- Windows-Reste: der Tempordner `%TEMP%\Tessera-<version>-updater-*` wird nicht aufgeraeumt (`.keep()`, Z. 956-964) — ~100 MB je Update; im Betriebshandbuch erwaehnen, kein Handlungsbedarf.
|
||||
- `Update::install` unter Windows beendet den Prozess aus dem Tokio-Thread heraus; unser `RunEvent::ExitRequested`-Handler wird dabei nicht durchlaufen (kein `prevent_exit`-Konflikt) [VERIFIED: `updater.rs:876`].
|
||||
|
||||
## Architektur / Datenfluss
|
||||
|
||||
```
|
||||
Client-Start / Serverwechsel (spawn_version_check, jn2)
|
||||
└─ updater_builder().endpoints([ {server}/api-proxy/desktop/update?target={{target}}&arch={{arch}}¤t={{current_version}}&base={server} ])
|
||||
.version_comparator(is_newer(.., APP_COMMIT)).timeout(..).build()?.check().await
|
||||
│ NPM → Next /api-proxy → API GET /desktop/update
|
||||
│ manifest.json (files.<p>.signature vorhanden?) ── nein → 204 → Ok(None)
|
||||
│ ja → 200 { version: X.Y.Z | X.Y.Z-beta.g<sha7>, url: {base}/api-proxy/desktop/download/<p>, signature, pub_date, notes }
|
||||
├─ Some(update) → app.state::<PendingUpdate>().set(update); Tray "update" = "Version … installieren" / "Neuen Beta-Stand installieren", enabled
|
||||
└─ None/Err → Tray gesperrt (Err loggen)
|
||||
Tray-Klick "update"
|
||||
└─ update.download_and_install(|chunk,total| Tray-Text "… lädt 42 %", || Tray-Text "… wird installiert").await
|
||||
├─ Windows: %TEMP%\…installer.exe, ShellExecuteW "/P /UPDATE /R /ARGS", exit(0) → NSIS installiert, RunAsUser startet App neu
|
||||
└─ Linux: AppImage in place ersetzt → app.restart() (RESTART_EXIT_CODE, laeuft an prevent_exit vorbei)
|
||||
Fehler → Tray-Text zurueck + Benachrichtigung mit Fehlertext; Fallback bleibt der Browser-Download (heutiger Weg)
|
||||
```
|
||||
|
||||
Aufsetzpunkt ist der Stand nach Quick jn2 (`TrayIconBuilder::with_id("main")`, `TrayItems { connected, update }`, `apply_server`, `spawn_version_check`): `spawn_version_check` tauscht den reqwest-Aufruf gegen `check()`, `TrayItems` bekommt keinen neuen Handle, aber `app.manage(PendingUpdate(Mutex<Option<Update>>))`; der Menue-Zweig `"update"` startet den Download statt `opener`. Der Browser-Link (Einstellungen → Desktop-App) bleibt als zweiter Eintrag oder als Fallback bei Fehler erhalten — Produktentscheidung fuer den Plan.
|
||||
|
||||
## Code-Skizzen
|
||||
|
||||
### tauri.conf.json (Ergaenzung)
|
||||
```json
|
||||
// Source: v2.tauri.app/plugin/updater/ + config.rs:80-92 (Schluesselnamen camelCase)
|
||||
"bundle": { "createUpdaterArtifacts": true, ... },
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"pubkey": "<Inhalt der .pub-Datei, eine Base64-Zeile>",
|
||||
"windows": { "installMode": "passive" }
|
||||
}
|
||||
}
|
||||
```
|
||||
Kein `endpoints`-Eintrag (Laufzeit). `pubkey` ist oeffentlich und gehoert ins Repo.
|
||||
|
||||
### Cargo.toml
|
||||
```toml
|
||||
# Source: $REG/tauri-plugin-updater-2.11.0/Cargo.toml (Features); Legitimitaet: OK (crates.io seit 2023, 361k/Woche, tauri-apps/plugins-workspace)
|
||||
tauri-plugin-updater = "2"
|
||||
```
|
||||
Per `cargo add tauri-plugin-updater@2` einpflegen (Cargo.lock konsistent, wie 18-04 mit opener). Cargo waehlt 2.11.0; `3.0.0-alpha.*` wird von `"2"` nicht erfasst.
|
||||
|
||||
### lib.rs (Kern)
|
||||
```rust
|
||||
// Source: updater.rs:211-233, 365-388, 432, 761; lib.rs:58-121 (Plugin); Doku-Beispiel v2.tauri.app/plugin/updater/
|
||||
use tauri_plugin_updater::UpdaterExt;
|
||||
|
||||
// in run(): .plugin(tauri_plugin_updater::Builder::new().build())
|
||||
|
||||
async fn check_for_update(app: &AppHandle, server: &str) -> tauri_plugin_updater::Result<Option<tauri_plugin_updater::Update>> {
|
||||
let endpoint = format!(
|
||||
"{}?target={{{{target}}}}&arch={{{{arch}}}}¤t={{{{current_version}}}}&base={}",
|
||||
api_url(server, "/desktop/update"),
|
||||
urlencoding_of(server) // percent-encodieren, z. B. mit url::form_urlencoded
|
||||
);
|
||||
let app_commit = env!("APP_COMMIT");
|
||||
app.updater_builder()
|
||||
.endpoints(vec![endpoint.parse()?])? // Err bei http:// im Release-Bau
|
||||
.timeout(Duration::from_secs(15))
|
||||
.version_comparator(move |current, release| is_newer(¤t, &release.version, app_commit))
|
||||
.build()?
|
||||
.check()
|
||||
.await
|
||||
}
|
||||
|
||||
// Tray-Klick "update":
|
||||
// let update = state.take(); update.download_and_install(|got, total| {..}, || {..}).await?; app.restart();
|
||||
```
|
||||
`{{target}}` usw. muessen wortwoertlich in der URL stehen — das Plugin ersetzt sie auch in Query-Parametern (`updater.rs:481-486`).
|
||||
|
||||
### NestJS `GET /desktop/update` (Skizze)
|
||||
```ts
|
||||
// Source: eigene Ableitung; Formatvorgabe updater.rs:1454-1497 (Felder version/url/signature/pub_date/notes)
|
||||
@Public() @Get('update') // VOR download/:platform registrieren (Route-Order)
|
||||
update(@Query() q, @Res({ passthrough: true }) res) {
|
||||
const platform = q.target === 'windows' ? 'windows' : q.target === 'linux' ? 'linux' : null;
|
||||
const manifest = this.desktopService.getManifest();
|
||||
const entry = platform && manifest?.files[platform];
|
||||
const origin = this.desktopService.safeOrigin(q.base); // URL-Parse, http/https, nur origin
|
||||
if (!entry?.signature || !origin || q.arch !== 'x86_64') { res.status(204); return; }
|
||||
const version = manifest.channel === 'beta' ? `${manifest.version}-beta.g${manifest.commit}` : manifest.version;
|
||||
return { version, pub_date: manifest.buildTime, notes: `channel=${manifest.channel};commit=${manifest.commit}`,
|
||||
url: `${origin}/api-proxy/desktop/download/${platform}`, signature: entry.signature };
|
||||
}
|
||||
```
|
||||
|
||||
### CI-Schluessel anlegen (einmalig, Dev-Host)
|
||||
```bash
|
||||
# Source: tauri-cli-2.11.3/src/signer/generate.rs:14-49; Gitea swagger.v1.json (updateRepoSecret)
|
||||
pnpm --filter @tessera/desktop exec tauri signer generate -w ~/.tauri/tessera-desktop.key -p '<passwort>'
|
||||
# -> ~/.tauri/tessera-desktop.key (GEHEIM, ausserhalb des Repos) und ~/.tauri/tessera-desktop.key.pub (in tauri.conf.json)
|
||||
curl -sS -X PUT -H "Authorization: token $GITEA_TOKEN" -H 'Content-Type: application/json' \
|
||||
--data "$(jq -n --arg d "$(cat ~/.tauri/tessera-desktop.key)" '{data:$d}')" \
|
||||
http://localhost:3002/api/v1/repos/schalli/tessera-ctl/actions/secrets/TAURI_SIGNING_PRIVATE_KEY # 201/204
|
||||
```
|
||||
Verlust des privaten Schluessels = keine Updates mehr fuer installierte Clients („if you lose this key you will NOT be able to publish new updates" [CITED: v2.tauri.app/plugin/updater/]) — Sicherungsort im Betriebshandbuch festhalten (Kap. 10). Keine Passwort-Hinweise an den User (Memory `feedback_no_password_leak_warnings.md`).
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
1. **`pubkey` fehlt / Bau ohne Secret:** App startet nicht (Plugin-Config) bzw. Bau bricht ab („no private key"). Lokale Baue: `tauri build --no-sign`; die Skripte muessen den Fall „keine .sig" sauber als 204 abbilden, aber auf `main`/Tags hart abbrechen.
|
||||
2. **Fuehrende Null im Commit-Stempel:** `1.2.0-beta.0123456` ist kein SemVer → `check()` liefert Err → nie ein Update. Immer `beta.g<sha7>`.
|
||||
3. **Relative `url`:** `url::Url` verlangt absolut; `/desktop/latest` bleibt fuer die Web-UI relativ, der Updater bekommt einen eigenen Endpunkt.
|
||||
4. **Route-Shadowing:** `update` statisch VOR `download/:platform` (Unit-Tests fangen es nicht — Memory).
|
||||
5. **`http://`-Server im Release:** `endpoints()` gibt Err — abfangen, nicht `dangerousInsecureTransportProtocol` setzen.
|
||||
6. **Alter Cache-Stand ohne Signatur:** `desktop-stamp.sh check` muss `signature` je Plattform verlangen, sonst wird ein unsigniertes Paket uebernommen und der Endpunkt liefert dauerhaft 204.
|
||||
7. **Dev-Modus (`tauri dev`, keine AppImage):** `download_and_install` wuerde die Binary im `target/` ueberschreiben — Update nur mit gebautem AppImage/Installer ausloesen.
|
||||
8. **Zwei reqwest-Majors:** `reqwest 0.12` (Projekt) und `0.13` (Plugin) koexistieren; Projekt-`reqwest` NICHT auf 0.13 heben (aws-lc-rs im Cross-Bau).
|
||||
9. **Endpunkt-Placeholder in der Query:** `{{target}}` darf nicht vorab URL-kodiert werden — als Rohtext in den String; `url::Url::parse` laesst `{`/`}` in der Query stehen und das Plugin ersetzt beide Schreibweisen (`updater.rs:481-486`).
|
||||
|
||||
## Environment Availability
|
||||
|
||||
| Abhaengigkeit | Benoetigt von | Verfuegbar | Version | Fallback |
|
||||
|---|---|---|---|---|
|
||||
| `cargo`/Rust (Dev-Host) | lokaler Bau/Tests | ✓ | cargo 1.96.0 | — |
|
||||
| `cargo-xwin` (Dev-Host) | lokaler Cross-Check | ✗ | — | Nachweis in der Pipeline (Runner installiert es, ci.yml Z. 156-160) |
|
||||
| `@tauri-apps/cli` | `tauri signer generate`, `--no-sign` | ✓ | 2.11.3 (pnpm) | — |
|
||||
| Gitea API | Secrets anlegen | ✓ | 1.26.2 (`localhost:3002`) | UI Settings → Actions → Secrets |
|
||||
| `tauri-plugin-updater` (crates.io) | Client | ✓ | 2.11.0, Legitimitaet OK | — |
|
||||
|
||||
## Validation Architecture
|
||||
|
||||
| Property | Value |
|
||||
|---|---|
|
||||
| Rust | `cargo fmt --check && cargo check && cargo clippy && cargo test --lib` in `apps/desktop/src-tauri` (`CARGO_BUILD_JOBS=4`) |
|
||||
| API | Vitest 3.2.6, `pnpm --filter @tessera/api test -- desktop` (Datei `apps/api/src/desktop/desktop.service.spec.ts` erweitern) |
|
||||
| Skripte | lokale Probe `sh .gitea/scripts/desktop-collect.sh --require linux` nach `tauri build --bundles appimage` (mit Schluessel in der Umgebung) + `jq -e '.files.linux.signature' desktop-dist/manifest.json` |
|
||||
|
||||
| Verhalten | Testart | Datei |
|
||||
|---|---|---|
|
||||
| `is_newer`: 6 Faelle (Basis >, <, gleich+anderer Stempel, gleicher Stempel, Live ohne Pre, leerer Commit) | unit (Rust) | `lib.rs mod tests` ❌ neu |
|
||||
| Endpunkt-URL-Bildung mit Rohplatzhaltern und kodiertem `base` | unit (Rust) | `lib.rs mod tests` ❌ neu |
|
||||
| `/desktop/update`: 204 ohne Manifest / ohne Signatur / falsche arch / ungueltiges base; 200 live vs beta-Version; `url` absolut aus `base` | unit (API) | `desktop.service.spec.ts` ❌ erweitern |
|
||||
| Manifest-Validierung akzeptiert optionales `signature` | unit (API) | `desktop.service.spec.ts` ❌ erweitern |
|
||||
| Ende-zu-Ende Linux: lokales AppImage (Version A) gegen lokale API mit Manifest (Version B) → Datei ersetzt, Neustart | manuell (Dev-Host, grafische Sitzung) | — |
|
||||
| Ende-zu-Ende Windows: CI-Paket auf der Test-VM, Tray-Klick → Fortschritt → Installer passiv → App neu gestartet, Tray zurueck, Version geaendert; SmartScreen-Verhalten notieren | manuell (Orchestrator/User, Windows-VM 8233) | — |
|
||||
|
||||
## Security Domain
|
||||
|
||||
| ASVS | Gilt | Kontrolle |
|
||||
|---|---|---|
|
||||
| V5 Eingabevalidierung | ja | `target`/`arch` Whitelist, `base` nur `http(s)`-Origin ohne Credentials/Pfad; Dateiname weiterhin nur aus dem Manifest (T-18-01/02 bleiben) |
|
||||
| V6 Kryptografie | ja | minisign (Ed25519) durch das Plugin — nie eigene Signaturpruefung; privater Schluessel nur als Gitea-Secret, `.key` nie ins Repo (`.gitignore`-Eintrag `*.key` unter `apps/desktop/`) |
|
||||
| V10 Schadcode/Integritaet | ja | Signaturpruefung vor Installation (`updater.rs:740`); Transport nur https (Plugin-Zwang im Release) |
|
||||
|
||||
| Muster | STRIDE | Mitigation |
|
||||
|---|---|---|
|
||||
| Manipuliertes Paket ueber MITM/kompromittierte API | Tampering | Signatur mit Offline-Schluessel; falsche Signatur → `Error::Minisign`, keine Installation |
|
||||
| Offene Weiterleitung ueber `base` | Spoofing | Origin-Validierung; selbst bei Missbrauch nur fehlgeschlagener Download (Signatur) |
|
||||
| Downgrade-Angriff (aelteres signiertes Paket) | Tampering | Comparator installiert nur hoehere Basisversion bzw. anderen Beta-Stempel; ein Angreifer braeuchte ohnehin den Schluessel |
|
||||
|
||||
## Assumptions Log
|
||||
|
||||
| # | Annahme | Abschnitt | Risiko |
|
||||
|---|---|---|---|
|
||||
| A1 | Der xwin-Cross-Bau von `ring`/`rustls` gelingt mit dem vorhandenen Clang-Setup | 8 | Pipeline rot → Fallback `native-tls`-Feature (dokumentiert) |
|
||||
| A2 | Vom Updater gestartete Installer-EXE loest keinen SmartScreen-Dialog aus (keine MOTW) | 8 | Nur Bedienkomfort; Handbuchtext |
|
||||
| A3 | act_runner setzt `CI=true` (leeres Passwort-Fallback) | 6 | Umgangen durch explizites Passwort-Secret |
|
||||
| A4 | Gitea erlaubt keinen leeren Secret-Wert | 6 | Umgangen durch Schluessel mit Passwort |
|
||||
| A5 | NPM setzt `X-Forwarded-Proto` | 6 | Irrelevant, weil `base` vom Client kommt |
|
||||
| A6 | `window-state` speichert beim `exit(0)`-Update nicht | 4 | Kosmetik |
|
||||
| A7 | Binary-Zuwachs „wenige MB" durch zweiten HTTP/TLS-Stack | 8 | Kosmetik; nach erstem CI-Bau messen |
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Produktfrage:** Soll der Browser-Download-Eintrag im Tray neben dem In-App-Update bleiben (Fallback), oder nur bei Fehler erscheinen? Empfehlung: ein Eintrag, der bei Fehlschlag des In-App-Updates die Einstellungsseite oeffnet.
|
||||
2. **Wo liegt die Sicherung des privaten Schluessels?** (Betriebshandbuch Kap. 10; nicht im Repo, nicht auf dem Runner.)
|
||||
3. **Erster Rollout:** Bereits installierte Clients (1.2.0 ohne Plugin) koennen sich nicht selbst aktualisieren — einmal noch der Browser-Weg; im CHANGELOG nennen.
|
||||
|
||||
## Sources
|
||||
|
||||
### Primary (HIGH)
|
||||
- `$REG/tauri-plugin-updater-2.11.0/src/{lib.rs,updater.rs,config.rs,error.rs}`, `Cargo.toml`, `permissions/default.toml`, `CHANGELOG.md`
|
||||
- `$REG/tauri-2.11.3/src/{app.rs,plugin.rs,process.rs}`, `$REG/tauri-utils-2.9.3/src/{lib.rs,config.rs,platform.rs}`
|
||||
- `$REG/tauri-bundler-2.9.4/src/bundle.rs`, `src/bundle/windows/nsis/{installer.nsi,utils.nsh,mod.rs}`
|
||||
- `tauri-cli-2.11.3` (crates.io-Tarball): `src/bundle.rs`, `src/build.rs`, `src/interface/rust.rs`, `src/helpers/updater_signature.rs`, `src/signer/generate.rs`, `Cargo.lock`
|
||||
- `$REG/reqwest-0.13.5/Cargo.toml`, `$REG/ring-0.17.14/build.rs` + `pregenerated/`
|
||||
- Gitea 1.26.2 `http://localhost:3002/swagger.v1.json`; `go-gitea/gitea` `release/v1.26` `routers/api/v1/api.go`
|
||||
- Repo: `apps/desktop/src-tauri/{src/lib.rs,tauri.conf.json,Cargo.toml,build.rs,capabilities/default.json}`, `.gitea/workflows/ci.yml`, `.gitea/scripts/{desktop-collect.sh,desktop-version.sh,desktop-stamp.sh,publish-images.sh}`, `apps/api/src/desktop/*`, `apps/web/next.config.ts`, `packages/shared/src/index.ts`, `.planning/quick/260917-jn2-*/260917-jn2-PLAN.md`, `18-RESEARCH.md`, `18-04-SUMMARY.md`, `docs/anleitung-betrieb.md` Kap. 10, `docs/ci-cd-setup.md`
|
||||
- Probe: `scratchpad/semver-probe` (semver 1.x, Ausgabe oben)
|
||||
|
||||
### Secondary (MEDIUM)
|
||||
- https://v2.tauri.app/plugin/updater/ (Signierung, Config, Antwortformat, installMode, Rust-Beispiel)
|
||||
- https://docs.gitea.com/api/1.26/operations/update-repo-secret/
|
||||
- https://github.com/tauri-apps/tauri/pull/12313, https://github.com/tauri-apps/tauri/issues/11392, https://github.com/tauri-apps/tauri/issues/7560
|
||||
|
||||
### Tertiary (LOW)
|
||||
- WebSearch-Treffer zu NSIS/Tray-Problemen (nur zur Auffindung der oben genannten Issues genutzt)
|
||||
|
||||
## Metadata
|
||||
|
||||
- Standard stack: HIGH — Plugin-Quelltext gelesen, Legitimitaet OK
|
||||
- Architecture: HIGH — Ablauf aus Code abgeleitet, Integrationspunkte im Repo gelesen
|
||||
- Pitfalls: HIGH fuer SemVer/Config/NSIS (Code + Probe), MEDIUM fuer Cross-Bau, LOW fuer SmartScreen
|
||||
- Research date: 2026-09-17 — gueltig ~30 Tage (Plugin 2.x stabil; 3.0.0-alpha laeuft parallel, wird von `"2"` nicht gewaehlt)
|
||||
+249
@@ -0,0 +1,249 @@
|
||||
---
|
||||
phase: quick-260917-kgc
|
||||
plan: 01
|
||||
subsystem: desktop-client
|
||||
tags: [tauri, rust, tauri-plugin-updater, minisign, nestjs, ci, gitea-actions, desktop]
|
||||
|
||||
# Dependency graph
|
||||
requires:
|
||||
- phase: quick-260917-jn2
|
||||
provides: TrayItems { connected, update } im State, apply_server, spawn_version_check(app, server_url), stored_server_url als einzige Store-Lesestelle — hier angedockt, unveraendert uebernommen
|
||||
- phase: quick-260917-jdh
|
||||
provides: desktop-stamp.sh (Stempel/Skip) — `check` verlangt jetzt zusaetzlich updateVersion + Signaturen
|
||||
- phase: 18-desktop-client-fertigstellen
|
||||
provides: /desktop/latest, /desktop/download/:platform, manifest.json aus desktop-collect.sh, Run-Handler-Muster (code: None → prevent_exit)
|
||||
provides:
|
||||
- "Desktop-Client aktualisiert sich selbst: Pruefung ueber tauri-plugin-updater gegen GET /api-proxy/desktop/update, Tray-Klick laedt das signierte Paket (Fortschritt im Menuetext), prueft die minisign-Signatur gegen plugins.updater.pubkey, installiert (Windows NSIS passiv, Linux AppImage in place) und startet neu"
|
||||
- "API GET /desktop/update?target=&arch=¤t=&base= (oeffentlich, vor download/:platform) im dynamischen Updater-Format; 400 bei unreinem base, 204 in allen 'kein Update'-Faellen"
|
||||
- "Manifest traegt updateVersion (X.Y.Z bzw. X.Y.Z-beta.g<sha7>) und je Plattform signature (Inhalt der .sig); CI signiert beide Bundles mit den Secrets TAURI_SIGNING_PRIVATE_KEY/_PASSWORD an genau den zwei tauri-build-Schritten"
|
||||
- "Reine, getestete Helfer is_update_newer, beta_commit, update_endpoint, release_labels, update_labels (neue Texte 'Auf Version {v} aktualisieren' / 'Auf Beta-Stand {sha7} aktualisieren')"
|
||||
affects: [desktop-client, updater, ci, api-desktop, docs]
|
||||
|
||||
# Actuals (#2632)
|
||||
actuals:
|
||||
tokens: 66460
|
||||
tasks: 4
|
||||
commits: 4
|
||||
plan_head_before: 5a444ec8f21f9a50d78aa7c0c6ab629fa6ebd85a
|
||||
|
||||
# Tech tracking
|
||||
tech-stack:
|
||||
added:
|
||||
- "tauri-plugin-updater 2.11.0 (Cargo.lock; zieht reqwest 0.13 + rustls/ring + rustls-platform-verifier neben dem Projekt-reqwest 0.12)"
|
||||
- "semver 1.0.28 als direkte Abhaengigkeit (lag bereits transitiv im Lock)"
|
||||
patterns:
|
||||
- "Updater-Kette komplett in Rust: app.updater_builder().endpoints(vec![url])?.timeout(15 s).version_comparator(is_update_newer …).build()?.check().await → PendingUpdate(Mutex<Option<(Update, String)>>) → Tray-Klick take() → download_and_install(on_chunk, on_finish) → app.restart(); capabilities/default.json unveraendert (Rust-Aufrufe laufen am ACL vorbei)"
|
||||
- "Endpunkt-URL zur Laufzeit aus stored_server_url mit Roh-Platzhaltern {{target}}/{{arch}}/{{current_version}} als Query-Werte (query_pairs_mut kodiert, das Plugin ersetzt beide Schreibweisen) plus base=<Origin> fuer die absolute Rueckgabe-URL"
|
||||
- "NestJS 204 ohne Body: @Res({ passthrough: true }) + res.status(204) + return undefined"
|
||||
- "Vertrauenskette: CI signiert (Secret nur am Bau-Schritt) → .sig → desktop-collect.sh → manifest.signature → API reicht durch → Plugin prueft VOR der Installation; nie eine Installation ohne gueltige Signatur"
|
||||
|
||||
key-files:
|
||||
created: []
|
||||
modified:
|
||||
- apps/desktop/src-tauri/Cargo.toml
|
||||
- apps/desktop/src-tauri/Cargo.lock
|
||||
- apps/desktop/src-tauri/tauri.conf.json
|
||||
- apps/desktop/src-tauri/src/lib.rs
|
||||
- apps/desktop/src-tauri/gen/schemas/acl-manifests.json
|
||||
- apps/desktop/src-tauri/gen/schemas/desktop-schema.json
|
||||
- apps/desktop/src-tauri/gen/schemas/linux-schema.json
|
||||
- packages/shared/src/index.ts
|
||||
- apps/api/src/desktop/desktop.service.ts
|
||||
- apps/api/src/desktop/desktop.controller.ts
|
||||
- apps/api/src/desktop/desktop.service.spec.ts
|
||||
- .gitea/scripts/desktop-collect.sh
|
||||
- .gitea/scripts/desktop-stamp.sh
|
||||
- .gitea/workflows/ci.yml
|
||||
- .gitignore
|
||||
- docs/anleitung-anwender.md
|
||||
- docs/anleitung-betrieb.md
|
||||
- docs/anleitung-entwicklung.md
|
||||
- docs/ci-cd-setup.md
|
||||
- CHANGELOG.md
|
||||
|
||||
key-decisions:
|
||||
- "Vergleichsregel (is_update_newer): hoehere Basis X.Y.Z → Update; niedrigere → keins; gleiche Basis mit Beta-Stempel beta.g<sha7> → Update nur bei bekanntem UND verschiedenem Client-Commit; gleiche Basis ohne Prerelease (Live) → KEIN Update. Das ist exakt die bisherige Regel (version_changed || channel==beta && commit != APP_COMMIT) — Live-Clients verhalten sich identisch, keine Update-Schleife; der Fall 'Beta-Client auf Live-Server gleicher Basis' ist client-seitig nicht entscheidbar und bekommt das Update mit der naechsten Freigabe."
|
||||
- "Timeout-Trennung: 15 s fuer die Pruefung (UpdaterBuilder::timeout), aber das Plugin uebernimmt denselben Wert als Gesamt-Timeout des Downloads — darum vor der Ablage update.timeout = Some(600 s), sonst braeche der ~100-MB-Download ab."
|
||||
- "http-Server im Release-Bau: kein Absturz, keine Benachrichtigung bei jedem Start, sondern dauerhaft der gesperrte Menuetext 'Update nur über https möglich' (Err(InsecureTransportProtocol)-Zweig); keine dangerous*-Schalter."
|
||||
- "Secrets auf Schritt-Ebene (env an den zwei tauri-build-Schritten), nicht Job-Ebene: pnpm install, apt-get, cargo install cargo-xwin und die Cache-Schritte sehen den Schluessel nie; js-yaml-Tiefenvergleich bestaetigt, dass sonst nichts in ci.yml geaendert ist."
|
||||
- "gen/schemas/*.json (vom Tauri-Bau generiert, im Repo getrackt) wurden mit committet — Praezedenz 18-04 (Opener-Plugin); ein liegengelassener Diff haette den Arbeitsbaum schmutzig hinterlassen (Rule 3, siehe Abweichungen)."
|
||||
|
||||
patterns-established:
|
||||
- "Beta-Stempel im SemVer-Prerelease immer mit Praefix g (beta.g<sha7>): ein rein numerischer SHA mit fuehrender Null waere kein gueltiger Identifier und liesse check() mit Err enden"
|
||||
- "Tray-Eintrag 'update' ist zustandsbehaftet: PendingUpdate.take() beim Klick, Reset + Leeren zu Beginn jeder Pruefung, Rueckgabe des Standes bei Fehler"
|
||||
|
||||
requirements-completed: [QUICK-260917-KGC]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "Client: Plugin, Config (pubkey/installMode/createUpdaterArtifacts), Comparator, Endpunkt-URL, Tray-Fluss Pruefen → Anzeige → Klick → Download → Signatur → Installation → Neustart, http-Rueckfall"
|
||||
requirement: "QUICK-260917-KGC"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "apps/desktop/src-tauri/src/lib.rs mod tests — 33 Tests (15 neu, 2 umgestellt, 16 unveraendert), cargo test --lib"
|
||||
status: pass
|
||||
- kind: other
|
||||
ref: "cargo fmt --check, cargo check, cargo clippy — 0 Warnungen; jq-Gate tauri.conf.json; Cargo.lock: tauri-plugin-updater 2.11.0, semver 1.0.28, reqwest-Projekt bleibt 0.12"
|
||||
status: pass
|
||||
human_judgment: true
|
||||
rationale: "Der Ende-zu-Ende-Nachweis (Windows-VM: Tray-Text, Klick, passiver Installer, Neustart auf neuem Stand) braucht ein vom CI signiertes Paket — Orchestrator, siehe unten."
|
||||
- id: D2
|
||||
description: "API GET /desktop/update: base-Origin-Validierung (400), 204-Faelle, dynamisches Updater-Format mit absoluter URL; Manifest-Typen in @tessera/shared"
|
||||
requirement: "QUICK-260917-KGC"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "apps/api/src/desktop/desktop.service.spec.ts — 23 Tests (10 neu, Test 11 erweitert), vitest run src/desktop"
|
||||
status: pass
|
||||
- kind: other
|
||||
ref: "pnpm --filter @tessera/api type-check, pnpm --filter @tessera/shared type-check — 0 Fehler; Route-Order-Gate (update vor download/:platform); kein fetch/axios im Service"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "desktop-collect.sh (signature/updateVersion/Pflichtregel), desktop-stamp.sh check, ci.yml Secrets an zwei Schritten, .gitignore *.key"
|
||||
requirement: "QUICK-260917-KGC"
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "Mini-Fixture-Proben im Scratchpad (beta/live/dev, fehlende .sig auf main/Tag/mit Schluessel → Abbruch, dev → Warnung, zweizeilige .sig → Abbruch, stamp check reuse=true nur mit updateVersion + beiden Signaturen) + js-yaml-Tiefenvergleich gegen HEAD (CI-OK, SCRIPTS-OK)"
|
||||
status: pass
|
||||
human_judgment: true
|
||||
rationale: "Ob die Tauri-CLI im xwin-Cross-Bau tatsaechlich .sig-Dateien erzeugt und ring/rustls dort bauen, ist nur in der Pipeline beweisbar."
|
||||
- id: D4
|
||||
description: "Anwender-, Betriebs-, Entwicklungshandbuch, ci-cd-setup.md (ASCII), CHANGELOG"
|
||||
requirement: "QUICK-260917-KGC"
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "Grep-Gate Task 4 + git diff CHANGELOG.md (genau eine neue Zeile)"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "apps/web/src/lib/changelog.test.ts — 10/10 gruen"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
|
||||
duration: ~20min (erster RED-Lauf 15:2x, letzter Commit 15:36:58 +02:00; nicht exakt gestoppt)
|
||||
completed: 2026-09-17
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Quick Task 260917-kgc: Desktop-Client — Update in der App herunterladen und installieren Summary
|
||||
|
||||
**Der Desktop-Client prueft beim Start ueber `tauri-plugin-updater` den neuen Endpunkt `GET /api-proxy/desktop/update`, und ein Klick auf „Auf Version X.Y.Z aktualisieren" im Infobereich laedt das im CI mit dem minisign-Schluessel signierte Paket, prueft die Signatur, installiert es (Windows: NSIS passiv, Linux: AppImage an Ort und Stelle) und startet die App neu — der Browser-Download bleibt nur noch als Rueckfall.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** ~20 min (vier Tasks, je RED → GREEN bei Task 1/2, vier Verifikations-Gates; der erste `cargo check` mit dem Plugin lief 23 s, weil reqwest 0.13/rustls bereits im Cargo-Zwischenspeicher lagen)
|
||||
- **Completed:** 2026-09-17T15:36:58+02:00
|
||||
- **Tasks:** 4/4
|
||||
- **Files modified:** 20 (davon 3 generierte Schema-Dateien, siehe Abweichungen)
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- **Client (`lib.rs`, Cargo.toml/Cargo.lock, tauri.conf.json):** `tauri-plugin-updater = "2"` (→ 2.11.0) und `semver = "1"` eingebunden, `.plugin(tauri_plugin_updater::Builder::new().build())` in `run()`; `bundle.createUpdaterArtifacts: true`, `plugins.updater.pubkey` (exakt der vorgegebene Schluessel), `windows.installMode: "passive"`, kein `endpoints`, keine `dangerous*`-Schalter. Reine Helfer `beta_commit`, `is_update_newer`, `update_endpoint`, `release_labels`, `update_labels` (neue Texte). `check_for_update` ruft `updater_builder().endpoints(…)?.timeout(15 s).version_comparator(…).build()?.check()`; `spawn_version_check` setzt Eintrag und `PendingUpdate` zurueck, legt bei Treffer `(Update, Menuetext)` mit `timeout = 600 s` ab, zeigt die Benachrichtigung und aktiviert den Eintrag; `InsecureTransportProtocol` → „Update nur über https möglich" gesperrt. Tray-Klick `update`: `take()` → `spawn_update_install` (Fortschritt `Lädt … {n} %`, `Wird installiert…`, `app.restart()`; Fehler → Text/Stand zurueck, Benachrichtigung „Update fehlgeschlagen: …", Einstellungsseite im Browser) bzw. `open_download_page` ohne abgelegten Stand. `DesktopLatest` entfernt, `/desktop/latest` wird im Client nicht mehr referenziert; Run-Handler, `on_window_event`, Commands, `capabilities/default.json`, `setup.html` unveraendert.
|
||||
- **API:** `GET /desktop/update` (`@Public()`, vor `download/:platform`): `safeOrigin(base)` (nur http/https-Origin, sonst 400), `getUpdate({ target, arch, origin })` mit Whitelist → `x86_64` → Manifest → `updateVersion` (`UPDATE_VERSION_RE`) → Eintrag mit `signature` → Antwort `{ version, pub_date (nur RFC 3339), url: <origin>/api-proxy/desktop/download/<p>, signature, notes }`; sonst 204 ohne Body. `isValidManifestFileEntry` akzeptiert nur String-Signaturen; `getManifest` verwirft ein Nicht-String-`updateVersion`. Typen `DesktopManifestFile.signature?`, `DesktopManifest.updateVersion?`, `DesktopUpdateResponse` in `@tessera/shared`.
|
||||
- **Skripte/CI:** `desktop-collect.sh` liest `<bundle>.sig` (genau eine Base64-Zeile, Zeilenzahl separat erzwungen) als `files.<p>.signature`, schreibt `updateVersion` (`X.Y.Z` bzw. `X.Y.Z-beta.g<sha7>`), bricht ohne `.sig` ab, wenn `TAURI_SIGNING_PRIVATE_KEY` gesetzt ist oder der Kanal nicht `dev` ist, warnt sonst; `.sig` wird nicht kopiert, Dateinamen unveraendert. `desktop-stamp.sh check` gibt `reuse=false` ohne `updateVersion` oder ohne eine der beiden Signaturen. `ci.yml`: `env` mit den zwei Secrets an genau den Schritten „Linux-AppImage bauen" und „Windows-Installer bauen (Cross-Bau)", sonst strukturgleich. `.gitignore`: `*.key`.
|
||||
- **Doku:** Anwenderhandbuch (Update per Klick, Windows/Linux-Ablauf, Fehlerfall, https-Bedingung, einmaliger Wechsel fuer Clients bis 1.2.0, zwei neue „Wenn etwas nicht klappt"-Zeilen), Betriebshandbuch Kap. 10 (neuer Unterabschnitt Signierschluessel, `updateVersion`/`signature` im Manifest, zweite `curl`-Kontrollzeile, zwei Fehlerbilder), Entwicklungshandbuch (`--no-sign` lokal, `tauri dev`-Hinweise), ci-cd-setup.md (drei Secrets, signierende Bau-Schritte, Fehlerbild „no private key", ASCII), CHANGELOG eine Zeile.
|
||||
|
||||
## Task Commits
|
||||
|
||||
Each task was committed atomically:
|
||||
|
||||
1. **Task 1: Client — Updater-Plugin, Config, Vergleichsregel, Endpunkt-URL, Tray-Fluss, Tests** - `678ba51` (feat(desktop))
|
||||
2. **Task 2: API — GET /desktop/update, Manifest-Felder, Spec-Tests** - `de81c74` (feat(api))
|
||||
3. **Task 3: Skripte + CI — signature/updateVersion, stamp check, Secrets, .gitignore** - `7004b5b` (ci)
|
||||
4. **Task 4: Doku — Handbuecher, ci-cd-setup.md, CHANGELOG** - `7479cb4` (docs)
|
||||
|
||||
_Hinweis: kein separater `test(...)`-Commit trotz `tdd="true"` an Task 1 und 2 — die Orchestrator-Vorgabe lautet ausdruecklich vier Commits (`feat(desktop)`/`feat(api)`/`ci`/`docs`). RED und GREEN liefen innerhalb der Tasks, siehe „TDD Gate Compliance"._
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `apps/desktop/src-tauri/src/lib.rs` — Imports (`semver::Version`, `Mutex`, `Update`, `UpdaterExt`), `UPDATE_ITEM_DEFAULT = "Update installieren"`, `UPDATE_ITEM_INSECURE`, `PendingUpdate`, Helfer `beta_commit`/`is_update_newer`/`update_endpoint`/`release_labels`, `update_labels` mit neuen Texten, `check_for_update`, umgebautes `spawn_version_check`, `open_download_page`, `spawn_update_install`, Plugin-Registrierung, `app.manage(PendingUpdate…)`, Menue-Zweig `update`; `mod tests` 33 Tests
|
||||
- `apps/desktop/src-tauri/Cargo.toml`, `Cargo.lock` — `tauri-plugin-updater = "2"` (2.11.0), `semver = "1"` (1.0.28); Projekt-`reqwest` bleibt 0.12
|
||||
- `apps/desktop/src-tauri/tauri.conf.json` — `createUpdaterArtifacts`, `plugins.updater`
|
||||
- `apps/desktop/src-tauri/gen/schemas/{acl-manifests,desktop-schema,linux-schema}.json` — vom Tauri-Bau regeneriert (Updater-Permissions), siehe Abweichungen
|
||||
- `packages/shared/src/index.ts` — `signature?`, `updateVersion?`, `DesktopUpdateResponse`
|
||||
- `apps/api/src/desktop/desktop.service.ts` — `safeOrigin` (exportiert), `UPDATE_VERSION_RE`, `RFC3339_RE`, `getUpdate`, erweiterte Manifest-Pruefung
|
||||
- `apps/api/src/desktop/desktop.controller.ts` — `update()` vor `download()`
|
||||
- `apps/api/src/desktop/desktop.service.spec.ts` — `writeManifest(files, head)`, `linuxEntry`, `updateUrl`, Tests 12-21, Test 11 erweitert
|
||||
- `.gitea/scripts/desktop-collect.sh` — `UPDATE_VERSION`, `SIGN_REQUIRED`, `read_signature`, `jq`-Manifest mit `updateVersion`/`signature`
|
||||
- `.gitea/scripts/desktop-stamp.sh` — `check` verlangt `updateVersion` + Signaturen
|
||||
- `.gitea/workflows/ci.yml` — Kopfkommentar + `env` an zwei Bau-Schritten
|
||||
- `.gitignore` — `*.key`
|
||||
- `docs/anleitung-anwender.md`, `docs/anleitung-betrieb.md`, `docs/anleitung-entwicklung.md`, `docs/ci-cd-setup.md`, `CHANGELOG.md`
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- **Vergleichsregel (gleiche Basis + Live → kein Update):** siehe `key-decisions`; steht als Doc-Kommentar an `is_update_newer`. Leerer `APP_COMMIT` (Quell-Tarball ohne git) → reiner Basisvergleich wie bisher. Der `version_comparator` ersetzt den Standardvergleich vollstaendig — ohne ihn gaelte `1.2.0-beta.gXXXX < 1.2.0` und ein Beta-Client saehe nie einen neueren Beta-Bau.
|
||||
- **Timeout-Trennung 15 s / 600 s:** `UpdaterBuilder::timeout` wandert ins pub-Feld `Update.timeout` und wird beim Download als reqwest-Gesamt-Timeout angewandt; darum wird es nach `check()` und vor der Ablage auf 600 s gesetzt.
|
||||
- **`http`-Anzeige:** gesperrter Menuetext statt Benachrichtigung — fuer Dauer-`http`-Server waere eine Meldung bei jedem Start eine Nervmeldung; der Text ist sichtbar, sobald das Menue geoeffnet wird. Kein `dangerousInsecureTransportProtocol`.
|
||||
- **Secrets auf Schritt-Ebene**, nicht Job-Ebene (geringste Sichtbarkeit; Gitea maskiert die Werte; die Skripte kennen den Schluessel nicht).
|
||||
- **`read_signature` prueft die Zeilenzahl getrennt:** `grep -qE '^…$'` arbeitet zeilenweise und haette eine zweizeilige `.sig` durchgelassen, sobald eine Zeile passt — darum zusaetzlich `wc -l` = 1 (die Plan-Probe „zweizeilige .sig → Abbruch" haette sonst nicht gehalten).
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 3 - Blocking] Generierte Tauri-Schema-Dateien mit committet**
|
||||
- **Found during:** Task 1, `cargo check`
|
||||
- **Issue:** `tauri-build` regeneriert bei jedem Bau `apps/desktop/src-tauri/gen/schemas/{acl-manifests,desktop-schema,linux-schema}.json` (die Updater-Permissions kommen hinzu). Die drei Dateien sind im Repo getrackt und stehen nicht in `files_modified`; sie unveraendert zu lassen haette den Arbeitsbaum dauerhaft schmutzig hinterlassen bzw. bei jedem CI-Bau erneut abweichen lassen.
|
||||
- **Fix:** Die drei Dateien in den Task-1-Commit aufgenommen — exakt wie beim Einbau des Opener-Plugins in 18-04 (`8b130fd`). Inhalt ausschliesslich generiert, kein manueller Eingriff.
|
||||
- **Files modified:** `apps/desktop/src-tauri/gen/schemas/acl-manifests.json`, `desktop-schema.json`, `linux-schema.json`
|
||||
- **Verification:** `git status` nach dem Commit leer (bis auf `.planning/`); `cargo check`/`clippy`/`test` gruen
|
||||
- **Committed in:** `678ba51`
|
||||
|
||||
**2. [Rule 1 - Bug, vor dem Commit behoben] Zeilenzahl-Pruefung in `read_signature`**
|
||||
- **Found during:** Task 3, beim Schreiben der Funktion
|
||||
- **Issue:** Die im Plan vorgesehene Pruefung `grep -qE '^[A-Za-z0-9+/=]+$'` allein ist zeilenweise — eine zweizeilige `.sig` (`zwei\nzeilen`) haette bestanden, obwohl der Plan den Abbruch verlangt.
|
||||
- **Fix:** Zusaetzlich `SIG_LINES="$(printf '%s\n' "$SIG_CONTENT" | wc -l)"` muss `1` sein.
|
||||
- **Files modified:** `.gitea/scripts/desktop-collect.sh`
|
||||
- **Verification:** Plan-Probe „zweizeilige .sig → Abbruch" gruen (Teil von SCRIPTS-OK)
|
||||
- **Committed in:** `7004b5b`
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 2 auto-fixed (1 blocking/generierte Dateien, 1 Bug in der geplanten Pruefung)
|
||||
**Impact on plan:** Kein Verhalten ausserhalb der `must_haves.truths` geaendert; das Threat-Register (T-KGC-01 … T-KGC-SC) ist wie geplant umgesetzt: Cargo.lock loest `tauri-plugin-updater` auf 2.11.0 (kein 3.0.0-alpha), `semver` 1.0.28, Projekt-`reqwest` 0.12; `~/.tessera/desktop-updater/` wurde nie gelesen; kein Secret-Wert in Code, Doku, Logs oder hier.
|
||||
|
||||
## TDD Gate Compliance
|
||||
|
||||
Task 1 und 2 liefen als RED → GREEN innerhalb eines Commits je Task (Orchestrator-Vorgabe: genau vier Commits):
|
||||
|
||||
- **Task 1 (Rust, tdd="true"):** RED — 15 neue Tests plus 2 umgestellte `update_labels_*`-Tests in `mod tests`; `cargo test --lib` (Exit 101) scheiterte mit 18 Compile-Fehlern, alle auf die fehlenden Funktionen `is_update_newer`/`beta_commit`/`update_endpoint`/`release_labels` und das noch fehlende `semver` zurueckfuehrbar — genau der im Plan als RED vorgesehene Zustand (streng nach `tdd.md` ein Compile-RED, kein Assertion-RED; vom Plan ausdruecklich so definiert). GREEN — Abhaengigkeiten, Config und Implementierung; `cargo test --lib` 33/33 gruen, `cargo fmt --check`/`check`/`clippy` ohne Warnung. Ein Commit (`678ba51`).
|
||||
- **Task 2 (API, tdd="true"):** RED — Tests 12-21 und die Erweiterung von Test 11; `vitest run src/desktop` zeigte 11 fehlgeschlagene Zieltests auf Assertions der geplanten Behauptungen (404/200 statt 204, `safeOrigin` nicht vorhanden, `update` nicht `@Public()`), 12 Bestandstests gruen. GREEN — Typen, Service, Controller; 23/23 gruen, api- und shared-type-check gruen. Ein Commit (`de81c74`).
|
||||
- **Task 3/4 (kein `tdd="true"`):** Standard-Tasks.
|
||||
|
||||
Kein separater `test(...)`-Commit — bewusst, wie bei 260917-jn2; die formale `gsd_run check tdd-red-evidence`-Pruefung gilt fuer Plaene mit `type: tdd`, dieser Plan hat `type: execute`.
|
||||
|
||||
## Testzahlen
|
||||
|
||||
| Pruefung | Ergebnis |
|
||||
|---|---|
|
||||
| `cargo test --lib` (apps/desktop/src-tauri) | 33 passed, 0 failed (vorher 18) |
|
||||
| `cargo fmt --check`, `cargo check`, `cargo clippy` | gruen, 0 Warnungen |
|
||||
| `vitest run src/desktop` (apps/api) | 23 passed (vorher 11) |
|
||||
| `pnpm --filter @tessera/api type-check`, `@tessera/shared type-check` | 0 Fehler |
|
||||
| Skript-Proben (Scratchpad-Fixtures) + js-yaml-Tiefenvergleich | CI-OK, SCRIPTS-OK |
|
||||
| `vitest run src/lib/changelog.test.ts` (apps/web) | 10 passed |
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
None ueber die dokumentierten Abweichungen hinaus. Der Cargo-Zwischenspeicher enthielt reqwest 0.13/rustls/ring bereits (aus der Recherche-`cargo fetch`), darum kein minutenlanger Erst-`cargo check`.
|
||||
|
||||
## Nachweis durch Orchestrator (offen)
|
||||
|
||||
Der echte Beweis der Kette braucht ein vom CI signiertes Paket und ist hier nicht erbracht:
|
||||
|
||||
- **(a) CI baut signierte Pakete:** Nach dem Push auf `main` muss der Job `desktop` beide `tauri build`-Schritte mit den Secrets durchlaufen (Voraussetzung: `TAURI_SIGNING_PRIVATE_KEY` und `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` sind in Gitea angelegt — sonst „A public key has been found, but no private key"). „Pakete einsammeln" muss `signiert` fuer linux und windows loggen; das Manifest im Abbild traegt `updateVersion: "X.Y.Z-beta.g<sha7>"` und je Plattform `signature`. `publish` gruen. Der alte Stempel-Cache (ohne die Felder) wird durch `desktop-stamp.sh check` verworfen — der erste Lauf baut also zwingend neu.
|
||||
- **(b) Endpunkt gegen alpha:** `curl -si "https://alpha.tessera.ctl.de/api-proxy/desktop/update?target=windows&arch=x86_64¤t=0.0.0&base=https://alpha.tessera.ctl.de"` → `200` mit `version`, `url` (absolut, `https://alpha.…/api-proxy/desktop/download/windows`), `signature`, `pub_date`, `notes`; `target=linux` analog; `base=https://alpha.tessera.ctl.de/x` → `400`; vor dem Pull des neuen Abbilds noch `404` (Route existiert nicht).
|
||||
- **(c) Windows-VM (8233): alter Client aktualisiert sich per Tray-Klick.** Der bereits installierte Client 1.2.0 hat das Plugin NICHT — er zeigt weiterhin „Version … herunterladen"/Browser-Weg (Einmaliger Wechsel: das erste Paket mit Plugin muss ein letztes Mal ueber den Browser installiert werden). Danach, mit einem weiteren Beta-Bau auf dem Server: Nach dem Start Benachrichtigung „Neuer Beta-Stand <sha7> verfügbar – Aktualisieren über das Symbol im Infobereich." und der Eintrag heisst **„Auf Beta-Stand <sha7> aktualisieren"** (bei neuer Basisversion: „Auf Version X.Y.Z aktualisieren"). Klick → Benachrichtigung „Update wird heruntergeladen…", Eintrag gesperrt, Text „Lädt … NN %", dann „Wird installiert…" → passives NSIS-Fenster mit Fortschrittsbalken → Tessera startet neu, Tray zeigt den neuen Stand, Server-Adresse erhalten. SmartScreen-Verhalten des vom Updater gestarteten Installers notieren (Recherche A2: vermutlich kein Dialog, keine Mark-of-the-Web). **Bei Fehler:** Menuetext springt zurueck, Eintrag wieder aktiv, Benachrichtigung „Update fehlgeschlagen: <Grund>. Die Download-Seite wird im Browser geöffnet." und die Seite Einstellungen → Desktop-App oeffnet im Browser. Bei `http://`-Adresse: Eintrag gesperrt mit „Update nur über https möglich".
|
||||
- **(d) Cross-Bau von ring/rustls in der Pipeline:** `ring 0.17.14` ist neu im Graph; fuer `x86_64-pc-windows-msvc` liefert es vorassemblierte Objekte mit (kein nasm), die C-Teile baut `cc` mit dem Clang aus `cargo-xwin` — nur im Runner beweisbar. Fallback bei rotem Bau: `tauri-plugin-updater = { version = "2", default-features = false, features = ["native-tls", "system-proxy"] }` (schannel/OpenSSL; `libssl-dev` steht bereits in der apt-Liste). Das Projekt-`reqwest` NICHT auf 0.13 heben (aws-lc-rs).
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- Dateien vorhanden: `apps/desktop/src-tauri/src/lib.rs`, `apps/api/src/desktop/desktop.service.ts`, `.gitea/scripts/desktop-collect.sh`, `docs/anleitung-betrieb.md` — FOUND
|
||||
- Commits vorhanden: `678ba51`, `de81c74`, `7004b5b`, `7479cb4` — FOUND (`git rev-list --count 5a444ec..HEAD` = 4)
|
||||
|
||||
## Nachweis durch Orchestrator — Ergebnis (2026-09-17/18)
|
||||
|
||||
- (a) CI-Lauf 383 (`7479cb4`): `Finished 1 updater signature` fuer AppImage und NSIS (Cross-Bau mit rustls/ring lief durch), Manifest `updateVersion 1.2.0-beta.g7479cb4`, `files.*.signature` gesetzt; Lauf 384 (`a6d1a64`) ebenso. (d) damit erledigt, kein native-tls-Fallback noetig.
|
||||
- (b) alpha nach Pull: `GET /api-proxy/desktop/update?target=windows&arch=x86_64¤t=1.2.0&base=https://alpha.tessera.ctl.de` → 200 mit absoluter `url` und `signature`; `base=https://evil.example/x?y` → 400.
|
||||
- (c) Windows-VM: Client `7479cb4` (einmalig per Browser installiert), gegen den http-Dev-Stack „Update nur über https möglich" (gesperrt); gegen alpha mit `a6d1a64`: Tray „Auf Beta-Stand a6d1a64 aktualisieren" → Klick → Download in Sekunden, App beendet sich, passiver NSIS-Installer laeuft durch, App startet selbst neu (kein SmartScreen), Server-Adresse erhalten, Update-Eintrag wieder gesperrt, Setup-Seite zeigt „Tessera-App 1.2.0 · Stand a6d1a64" (Quick-Fix `a6d1a64`).
|
||||
- Nebenbefund: NSIS-Seite „Bereits installiert" nennt „Eine -Version von Tessera" (leere Versionsangabe aus der Registry) — kosmetisch, Tauri-Vorlage, nicht angefasst.
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
---
|
||||
phase: quick-260917-kgc
|
||||
verified: 2026-09-17T15:45:00Z
|
||||
status: human_needed
|
||||
score: 8/10 must-haves verified
|
||||
covered_files:
|
||||
- ".gitea/scripts/desktop-collect.sh"
|
||||
- ".gitea/scripts/desktop-stamp.sh"
|
||||
- ".gitea/workflows/ci.yml"
|
||||
- ".gitignore"
|
||||
- ".planning/quick/260917-kgc-desktop-client-update-in-der-app-herunte/260917-kgc-PLAN.md"
|
||||
- ".planning/quick/260917-kgc-desktop-client-update-in-der-app-herunte/260917-kgc-SUMMARY.md"
|
||||
- "CHANGELOG.md"
|
||||
- "apps/api/src/desktop/desktop.controller.ts"
|
||||
- "apps/api/src/desktop/desktop.service.spec.ts"
|
||||
- "apps/api/src/desktop/desktop.service.ts"
|
||||
- "apps/desktop/src-tauri/Cargo.lock"
|
||||
- "apps/desktop/src-tauri/Cargo.toml"
|
||||
- "apps/desktop/src-tauri/src/lib.rs"
|
||||
- "apps/desktop/src-tauri/tauri.conf.json"
|
||||
- "docs/anleitung-anwender.md"
|
||||
- "docs/anleitung-betrieb.md"
|
||||
- "docs/anleitung-entwicklung.md"
|
||||
- "docs/ci-cd-setup.md"
|
||||
- "packages/shared/src/index.ts"
|
||||
covered_digest: "v1:sha256:dc01a09c0bf779e0c311f942b53240c8ad8a984254ebbc3a3a36b247441cf3cd"
|
||||
behavior_unverified: 2
|
||||
overrides_applied: 0
|
||||
behavior_unverified_items:
|
||||
- truth: "spawn_version_check setzt den Tray-Eintrag und den abgelegten PendingUpdate-Stand zu Beginn jeder Pruefung zurueck (Server-Wechsel darf keinen alten Hinweis stehen lassen)"
|
||||
test: "In der laufenden App (oder einem AppHandle-Mock) zweimal hintereinander die Server-Adresse wechseln, waehrend ein Update-Fund im State liegt, und pruefen, dass der Eintrag sofort auf den Standardtext springt und PendingUpdate leer ist, bevor die neue Pruefung antwortet"
|
||||
expected: "Menuetext == UPDATE_ITEM_DEFAULT, Eintrag gesperrt, PendingUpdate == None unmittelbar nach dem Wechsel"
|
||||
why_human: "Die 33 Unit-Tests decken ausschliesslich die reinen Helfer (is_update_newer, beta_commit, update_endpoint, release_labels, update_labels, parse_server_url, server_host, tray_labels, setup_page_url, with_desktop_marker) ab; spawn_version_check haengt an AppHandle/TrayItems/PendingUpdate-State und wird von keinem Test aufgerufen — Reset-Verhalten ist nur am Laufzeit-Client oder mit einem Tauri-Mock zu beobachten"
|
||||
- truth: "Tray-Klick 'update' nimmt den abgelegten Stand per take() (verhindert Doppelklick-Downloads), laedt mit Fortschritt im Menuetext, installiert (Windows NSIS passiv / Linux AppImage) und startet neu; bei Fehler springt Menuetext/Stand zurueck und der Browser-Rueckfall oeffnet"
|
||||
test: "Am echten (oder in der Windows-VM installierten) Client: Update-Fund abwarten, Tray-Eintrag zweimal schnell hintereinander anklicken, danach den vollen Ablauf bis zum Neustart beobachten; anschliessend denselben Ablauf mit einem absichtlich fehlerhaften Download (z. B. Netz trennen) wiederholen"
|
||||
expected: "Zweiter Klick loest keinen zweiten Download aus (Eintrag bleibt gesperrt); bei Erfolg Fortschritt 'Laedt ... N %' -> 'Wird installiert...' -> Neustart auf neuem Stand; bei Fehler Menuetext/Stand zurueck, Eintrag wieder aktiv, Benachrichtigung 'Update fehlgeschlagen: ...' und Einstellungsseite im Browser"
|
||||
why_human: "spawn_update_install und der Menue-Zweig 'update' (take()-Semantik, download_and_install, app.restart()) werden von keinem Unit-Test ausgeloest — das ist explizit als offener Orchestrator-Nachweis (Windows-VM) im PLAN/SUMMARY vermerkt und braucht ein vom CI signiertes Paket"
|
||||
coincidental_reliance_items: []
|
||||
human_verification:
|
||||
- test: "(a) CI-Bau auf main: beide tauri-build-Schritte mit den Secrets TAURI_SIGNING_PRIVATE_KEY/_PASSWORD durchlaufen lassen"
|
||||
expected: "'Pakete einsammeln' loggt 'signiert' fuer linux und windows; Manifest im Abbild traegt updateVersion (X.Y.Z-beta.g<sha7>) und je Plattform signature; Job publish gruen; alter Stempel-Cache (ohne die Felder) wird verworfen (Neubau)"
|
||||
why_human: "Nur im Gitea-Runner beweisbar (Secrets, echte tauri-CLI, Cross-Bau-Toolchain) — lokal simuliert per Mini-Fixture-Proben (siehe unten), aber nicht der echte Bau"
|
||||
- test: "(b) Endpunkt gegen alpha.tessera.ctl.de nach dem Pull des neuen Abbilds: curl -si \"https://alpha.tessera.ctl.de/api-proxy/desktop/update?target=windows&arch=x86_64¤t=0.0.0&base=https://alpha.tessera.ctl.de\""
|
||||
expected: "200 mit version, absoluter url (.../api-proxy/desktop/download/windows), signature, pub_date, notes; target=linux analog; base=https://alpha.tessera.ctl.de/x -> 400; vor dem Pull des neuen Abbilds noch 404 (Route existiert nicht)"
|
||||
why_human: "Braucht den laufenden Server mit dem neuen Abbild — nicht lokal simulierbar"
|
||||
- test: "(c) Windows-VM (8233): bereits installierter Client 1.2.0 aktualisiert sich per Tray-Klick, sobald ein neuer signierter Beta-Bau auf dem Server liegt"
|
||||
expected: "Benachrichtigung 'Neuer Beta-Stand <sha7> verfuegbar', Eintrag 'Auf Beta-Stand <sha7> aktualisieren'; Klick -> Fortschritt im Menuetext -> passives NSIS-Fenster -> Tessera startet neu, Tray zeigt neuen Stand, Server-Adresse bleibt erhalten; SmartScreen-Verhalten notieren"
|
||||
why_human: "Reale Installation/Neustart/Betriebssystem-Verhalten (SmartScreen) ist nur am Bildschirm zu pruefen; Client 1.2.0 hat das Plugin noch nicht, der erste Wechsel muss einmal ueber den Browser laufen (Einmaliger Wechsel, wie im Anwenderhandbuch dokumentiert)"
|
||||
- test: "(d) Cross-Bau von ring/rustls (x86_64-pc-windows-msvc, cargo-xwin) in der CI-Pipeline"
|
||||
expected: "Bau gruen; Fallback bei rotem Bau: tauri-plugin-updater mit default-features = false, features = [\"native-tls\", \"system-proxy\"]"
|
||||
why_human: "Nur im Runner mit der echten xwin/Clang-Toolchain beweisbar"
|
||||
---
|
||||
|
||||
# Quick Task 260917-kgc: Desktop-Client — Update in der App Verifizierung
|
||||
|
||||
**Ziel:** Der Desktop-Client aktualisiert sich selbst per Tray-Klick (tauri-plugin-updater 2, minisign-Signaturpruefung, Endpunkt zur Laufzeit aus der gespeicherten Server-Adresse); API `GET /desktop/update` liefert das Format im Updater-Vertrag; Manifest/CI signieren und stempeln entsprechend; Doku + CHANGELOG.
|
||||
|
||||
**Verifiziert:** 2026-09-17
|
||||
**Status:** human_needed
|
||||
**Hinweis:** Kein einziger `truth` ist FEHLGESCHLAGEN. Die zwei offenen Punkte sind Laufzeitverhalten (Zustandswechsel/Installation), die von keinem der bestehenden Unit-Tests ausgeloest werden koennen und laut PLAN/SUMMARY selbst als "Nachweis durch Orchestrator" gefuehrt werden (Windows-VM, echtes CI-Paket). Das ist keine Luecke in der Umsetzung, sondern der erwartete Zustand fuer diesen Task-Typ.
|
||||
|
||||
## Beobachtete Wahrheiten (must_haves.truths)
|
||||
|
||||
| # | Truth (gekuerzt) | Status | Evidenz |
|
||||
|---|---|---|---|
|
||||
| 1 | Cargo.toml/Cargo.lock: tauri-plugin-updater "2" (-> 2.11.x), semver "1" (1.0.28), reqwest bleibt 0.12 | ✓ VERIFIED | `grep`-Gates gruen; `cargo test --lib` 33/33 gruen |
|
||||
| 2 | tauri.conf.json: createUpdaterArtifacts, exakter pubkey, installMode passive, kein endpoints/dangerous* | ✓ VERIFIED | `jq`-Gate mit dem exakten Orchestrator-Schluessel bestanden |
|
||||
| 3 | Reine Helfer is_update_newer/beta_commit/update_endpoint/release_labels/update_labels + fmt/check/clippy/test gruen | ✓ VERIFIED | `cargo fmt --check`, `cargo clippy`, `cargo test --lib` (33/33) selbst ausgefuehrt; is_update_newer-Regel Zeile fuer Zeile gelesen (Z. 123-135) — hoehere Basis true, niedrigere false, gleiche Basis+Beta+anderer Commit true, gleicher Commit false, gleiche Basis ohne Prerelease false, leerer Commit false; alle 8 zugehoerigen Tests gruen |
|
||||
| 4 | spawn_version_check: check() mit eigenem Comparator, 15 s Pruefung, 600 s Download-Timeout vor Ablage, InsecureTransportProtocol -> gesperrter Text, Reset zu Beginn | ⚠️ PRESENT_BEHAVIOR_UNVERIFIED | Code exakt wie gefordert (Z. 263-338), Timeout-Trennung im Code bestaetigt; kein Test ruft die Funktion auf (State-Reset ist ein Laufzeit-Zustandswechsel) |
|
||||
| 5 | Tray-Klick update: take(), Fortschritt, Installation, Neustart, Fehler-Rueckfall Browser | ⚠️ PRESENT_BEHAVIOR_UNVERIFIED | Code exakt wie gefordert (Z. 352-411, 591-601); kein Test loest den Menue-Zweig/spawn_update_install aus — explizit als offener Windows-VM-Nachweis im PLAN gefuehrt |
|
||||
| 6 | API GET /desktop/update: base-Origin-Validierung 400, 204-Faelle, dynamisches Format, /latest und download unveraendert | ✓ VERIFIED | 23/23 vitest-Tests selbst ausgefuehrt, Route-Order-Gate bestanden, Code gelesen (safeOrigin, getUpdate, UPDATE_VERSION_RE, RFC3339_RE) — deckt sich exakt mit dem Vertrag |
|
||||
| 7 | desktop-collect.sh schreibt signature+updateVersion mit Pflichtregel; desktop-stamp.sh check verlangt beides | ✓ VERIFIED | Alle Mini-Fixture-Proben aus dem PLAN selbst nachgestellt (Beta/Live/Dev-Kanal, fehlende .sig auf main/Tag/mit Schluessel -> Abbruch, dev -> Warnung, zweizeilige .sig -> Abbruch, stamp reuse=true/false-Faelle) — alle bestanden |
|
||||
| 8 | CI: Secrets nur an den zwei tauri-build-Schritten, kein Job-env, kein --no-sign, sonst strukturgleich | ✓ VERIFIED | js-yaml-Tiefenvergleich gegen `git show 5a444ec:.gitea/workflows/ci.yml` selbst ausgefuehrt — CI-OK; Negativ-Grep `--no-sign` leer; `.gitignore` traegt `*.key`; `git ls-files '*.key'` leer |
|
||||
| 9 | Doku (Anwender/Betrieb/Entwicklung/ci-cd-setup/CHANGELOG) | ✓ VERIFIED | Alle Grep-Gates aus dem PLAN selbst ausgefuehrt (14 Pruefungen) — alle gruen; `changelog.test.ts` 10/10 gruen |
|
||||
| 10 | Vier Commits, kein Push/`.planning`/tauri build/Docker | ✓ VERIFIED | `git rev-list --count 5a444ec..HEAD` = 4; keine `.planning/`-Dateien in den vier Commits |
|
||||
|
||||
**Score:** 8/10 truths verified (2 present, behavior-unverified)
|
||||
|
||||
### Hinweis zu Wahrheit 3 (kein coincidental reliance)
|
||||
|
||||
Die is_update_newer-Regel wird von acht dedizierten Tests direkt und unmittelbar geprueft (kein Fixture-Only-Fall, keine unbekannte Vorbedingung) — als sauber VERIFIED eingestuft, keine Advisory-Markierung noetig.
|
||||
|
||||
## Artefakt-Pruefung
|
||||
|
||||
| Artefakt | Erwartet | Status | Details |
|
||||
|---|---|---|---|
|
||||
| `apps/desktop/src-tauri/src/lib.rs` | Updater-Kette, Helfer, Tests | ✓ VERIFIED | Alle geforderten Symbole vorhanden (is_update_newer, beta_commit, update_endpoint, release_labels, spawn_update_install, open_download_page, PendingUpdate, download_and_install, version_comparator); DesktopLatest entfernt, `/desktop/latest` nicht mehr referenziert |
|
||||
| `apps/desktop/src-tauri/Cargo.toml`/`Cargo.lock`/`tauri.conf.json` | Neue Abhaengigkeiten + Config | ✓ VERIFIED | Versionen exakt wie gefordert |
|
||||
| `packages/shared/src/index.ts` | `signature?`, `updateVersion?`, `DesktopUpdateResponse` | ✓ VERIFIED | Alle drei per grep bestaetigt, type-check gruen |
|
||||
| `apps/api/src/desktop/desktop.service.ts` + `.controller.ts` + `.spec.ts` | safeOrigin, getUpdate, update()-Route, Tests | ✓ VERIFIED | Code gelesen, Tests ausgefuehrt, Route-Order bestaetigt |
|
||||
| `.gitea/scripts/desktop-collect.sh`/`desktop-stamp.sh`/`ci.yml`/`.gitignore` | .sig -> signature, updateVersion, Secrets, `*.key` | ✓ VERIFIED | Proben + Deep-Compare selbst ausgefuehrt |
|
||||
| Vier Dokudateien + CHANGELOG | Update-in-der-App-Beschreibung | ✓ VERIFIED | Grep-Gates gruen |
|
||||
|
||||
## Schluesselverbindungen (key_links)
|
||||
|
||||
| Von | Nach | Via | Status |
|
||||
|---|---|---|---|
|
||||
| CI-Signatur (`.sig`) | `desktop-collect.sh` -> Manifest `signature` | `read_signature` liest `<bundle>.sig`, jq schreibt es ins Manifest | ✓ VERIFIED (Probe bestanden) |
|
||||
| Manifest `signature`/`updateVersion` | API-Antwort `GET /desktop/update` | `getUpdate()` liest beide Felder, 204 wenn eines fehlt | ✓ VERIFIED (Code + Tests) |
|
||||
| `version_comparator` | Plugin-Standardvergleich | `is_update_newer` ersetzt den Vergleich vollstaendig, Aufruf in `check_for_update` bestaetigt | ✓ VERIFIED (Code gelesen, Z. 273-276) |
|
||||
| `UpdaterBuilder::timeout(15s)` | `update.timeout = 600s` | Beide Stellen im Code gefunden und in der richtigen Reihenfolge (Pruefung vor Ablage) | ✓ VERIFIED (Z. 275, 316) |
|
||||
| Client-Klick "update" | `PendingUpdate.take()` -> `spawn_update_install` | Menue-Zweig ruft take(), dann spawn_update_install oder open_download_page | ✓ WIRED (Code), ⚠️ Laufzeitverhalten nicht getestet (siehe Wahrheit 5) |
|
||||
|
||||
## Verhaltens-Stichproben
|
||||
|
||||
| Verhalten | Befehl | Ergebnis | Status |
|
||||
|---|---|---|---|
|
||||
| Rust fmt/check/clippy/test | `cargo fmt --check && cargo clippy --lib && cargo test --lib` (CARGO_BUILD_JOBS=4) | fmt clean, clippy 0 Warnungen, 33/33 Tests gruen | ✓ PASS |
|
||||
| API-Tests | `pnpm --filter @tessera/api exec vitest run src/desktop` | 23/23 gruen | ✓ PASS |
|
||||
| API/Shared type-check | `pnpm --filter @tessera/api type-check` / `@tessera/shared type-check` | 0 Fehler je | ✓ PASS |
|
||||
| Skript-Syntax | `sh -n desktop-collect.sh` / `sh -n desktop-stamp.sh` | beide clean | ✓ PASS |
|
||||
| Route-Order-Gate | awk-Pruefung `@Get('update')` vor `@Get('download/:platform')` | Manuell im Quelltext bestaetigt (update() vor download()) | ✓ PASS |
|
||||
| pubkey-Gate | `jq -e .plugins.updater.pubkey == "<exakter Schluessel>"` | true | ✓ PASS |
|
||||
| CI js-yaml-Tiefenvergleich | Node-Skript gegen `git show 5a444ec:.gitea/workflows/ci.yml` | "CI-OK" | ✓ PASS |
|
||||
| Skript-Mini-Fixtures (Beta/Live/Dev, fehlende/zweizeilige .sig, stamp reuse) | Eigenstaendig im Scratchpad nachgestellt (siehe PLAN Task 3 `<verify>`) | Alle 15 Teilpruefungen gruen | ✓ PASS |
|
||||
| Doku-Grep-Gates (14 Pruefungen) | siehe PLAN Task 4 `<verify>` | Alle gruen | ✓ PASS |
|
||||
| changelog.test.ts | `pnpm --filter @tessera/web exec vitest run src/lib/changelog.test.ts` | 10/10 gruen | ✓ PASS |
|
||||
| Secret-Leck-Pruefung | grep auf private-key-Werte im Diff | Nichts gefunden | ✓ PASS |
|
||||
| `.key`-Dateien im Repo | `git ls-files '*.key'` | leer | ✓ PASS |
|
||||
|
||||
## Requirements Coverage
|
||||
|
||||
| Requirement | Quelle | Beschreibung | Status |
|
||||
|---|---|---|---|
|
||||
| QUICK-260917-KGC | 260917-kgc-PLAN.md | Desktop-Client Update in der App (siehe Task-Ziel) | ✓ SATISFIED (bis auf die zwei Laufzeit-Nachweise, siehe oben) |
|
||||
|
||||
## Anti-Pattern-Scan
|
||||
|
||||
Keine TBD/FIXME/XXX-Debt-Marker (der einzige Treffer auf "XXXX" ist ein Doc-Kommentar-Beispiel `1.2.0-beta.gXXXX`, kein Debt-Marker); keine TODO/HACK/PLACEHOLDER; keine "not implemented"-Stellen ausser zwei bereits bestehenden, legitimen `NotFoundException`-Meldungen ("Desktop packages are not available on this server"). Keine unerwarteten Dateien ausserhalb `files_modified` veraendert (3 generierte `gen/schemas/*.json`-Dateien sind dokumentierte Abweichung, Praezedenz aus Phase 18-04).
|
||||
|
||||
## Erforderliche menschliche Verifizierung
|
||||
|
||||
Siehe `human_verification` im Frontmatter — die vier vom Orchestrator selbst als offen gefuehrten Nachweise (a) CI-Bau mit echten Secrets, (b) Endpunkt-curl gegen alpha, (c) Windows-VM Update-Durchlauf, (d) Cross-Bau ring/rustls. Diese sind **keine Luecken**, sondern der erwartete naechste Schritt nach diesem Quick Task (siehe SUMMARY.md "Nachweis durch Orchestrator (offen)").
|
||||
|
||||
## Zusammenfassung
|
||||
|
||||
Alle zehn `must_haves.truths` sind im Code vorhanden, exakt wie im PLAN spezifiziert verdrahtet, und alle automatisierten Gates (Rust fmt/clippy/test, API-Tests/Type-Checks, Skript-Proben, CI-Deep-Compare, Doku-Grep-Gates, Changelog-Test) wurden von mir selbst ausgefuehrt und bestanden — keine SUMMARY-Behauptung wurde ungeprueft uebernommen. Zwei Wahrheiten (State-Reset bei Server-Wechsel, Tray-Klick-Installationsfluss inkl. take()-Semantik) haengen an Tauri-Laufzeitzustand, den keiner der 33 Unit-Tests ausloest; das deckt sich mit den vier vom Orchestrator selbst als offen gefuehrten Nachweisen und ist fuer einen Quick Task dieser Art normal, nicht ein Zeichen unvollstaendiger Umsetzung.
|
||||
|
||||
---
|
||||
|
||||
_Verifiziert: 2026-09-17_
|
||||
_Verifizierer: Claude (gsd-verifier)_
|
||||
+280
@@ -0,0 +1,280 @@
|
||||
---
|
||||
phase: quick-260918-gza
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
autonomous: true
|
||||
requirements: [QUICK-260918-GZA]
|
||||
|
||||
files_modified:
|
||||
- apps/api/src/bug-reports/origin.ts
|
||||
- apps/api/src/bug-reports/origin.spec.ts
|
||||
- apps/api/src/bug-reports/dto/bug-report.dto.ts
|
||||
- apps/api/src/bug-reports/bug-reports.service.ts
|
||||
- apps/api/src/bug-reports/bug-reports.service.spec.ts
|
||||
- apps/api/src/bug-reports/bug-reports.controller.spec.ts
|
||||
- apps/desktop/src-tauri/src/lib.rs
|
||||
- apps/web/src/middleware.ts
|
||||
- apps/web/src/middleware.test.ts
|
||||
- apps/web/src/lib/desktop-client.ts
|
||||
- apps/web/src/lib/desktop-client.test.ts
|
||||
- apps/web/src/lib/bug-report-api.ts
|
||||
- apps/web/src/lib/bug-report-api.test.ts
|
||||
- apps/web/src/components/bug-report/bug-report-dialog.tsx
|
||||
- apps/web/src/components/bug-report/bug-report-button.test.tsx
|
||||
- CHANGELOG.md
|
||||
- docs/anleitung-administration.md
|
||||
- docs/anleitung-betrieb.md
|
||||
|
||||
estimate:
|
||||
tokens: 95000
|
||||
raw_tokens: 95000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Der Betreff jeder Fehlermeldungs-E-Mail traegt direkt nach `[Tessera Fehlermeldung]` ein Herkunfts-Kuerzel: `[Browser]`, `[Desktop/Windows]`, `[Desktop/Linux]` (oder `[Desktop]`, wenn ein alter Client kein Betriebssystem meldet) — so lassen sich Meldungen im Postfach sortieren und filtern."
|
||||
- "Der Mailtext enthaelt eine Zeile `Herkunft: …`: im Browser `Herkunft: Browser — <Browser> <Hauptversion> auf <Betriebssystem>` (aus dem User-Agent abgeleitet, unbekannte Teile als `unbekannt`), in der Desktop-App `Herkunft: Desktop-App (<Windows|Linux>), Tessera-App <Version> · Stand <Commit>` (ohne `· Stand …`, wenn der Commit leer ist — dieselbe Regel wie `client_info_label`)."
|
||||
- "Die bestehenden Zeilen `Browser: <User-Agent>` und `Fenster: <BxH>` bleiben unveraendert erhalten; der rohe User-Agent bleibt in der Mail."
|
||||
- "Ein alter Desktop-Client (nur `desktop=1`, ohne Zusatzparameter) und ein alter Web-Bau (ohne die vier neuen Felder) erzeugen weiterhin eine gueltige Meldung: fehlende Felder fallen serverseitig auf `[Browser]` bzw. auf `Desktop-App (unbekannt)` zurueck, kein 400."
|
||||
- "Der Desktop-Client meldet Version, Commit und Betriebssystem bei jeder seiner drei Navigationen zur Server-Adresse mit (`dv`, `dc`, `dos` neben dem unveraenderten `desktop=1`); nach einem In-App-Update steht der neue Stand damit automatisch in der naechsten Meldung."
|
||||
- "Nichts davon wird in der Datenbank gespeichert; `main.ts` und die Body-Limits bleiben unangetastet (T-M97-03); die Werte dienen ausschliesslich der Anzeige in der Mail und dem Kuerzel in der einen bestehenden Protokollzeile (T-GZA-01)."
|
||||
artifacts:
|
||||
- "apps/api/src/bug-reports/origin.ts — NEU: reine Helfer `parseUserAgent(ua)` -> `{ browser, os }` und `describeOrigin(input)` -> `{ tag, line }` (nur Regex, keine Abhaengigkeit)"
|
||||
- "apps/api/src/bug-reports/origin.spec.ts — NEU: mindestens 8 Faelle (Edge/Windows, Chrome/Windows, Firefox/Linux, Safari/macOS, Android, iPad, WebKitGTK-UA mit clientKind desktop -> `[Desktop/Linux]`, Desktop ohne Commit, Desktop ohne Details, fehlende Felder -> Browser-Rueckfall)"
|
||||
- "apps/api/src/bug-reports/dto/bug-report.dto.ts — vier optionale Felder `clientKind`, `clientOs`, `clientVersion`, `clientCommit`"
|
||||
- "apps/api/src/bug-reports/bug-reports.service.ts — Betreff mit Kuerzel, Zeile `Herkunft:` vor `Browser:`, Kuerzel in der Protokollzeile"
|
||||
- "apps/desktop/src-tauri/src/lib.rs — `with_client_marker(url, version, commit, os)` (rein, getestet) + `with_desktop_marker(url)` als Huelle mit `env!`-Werten; drei Aufrufstellen unveraendert"
|
||||
- "apps/web/src/middleware.ts — `withDesktopCookie` setzt zusaetzlich Cookie `tessera_desktop_client` = `<dv>|<dc>|<dos>` (bereinigt, nur wenn alle drei Parameter vorhanden und gueltig)"
|
||||
- "apps/web/src/lib/desktop-client.ts — `DESKTOP_CLIENT_COOKIE_NAME`, `parseDesktopClientCookie(cookieString)` (rein) und `getDesktopClientInfo()` -> `{ version, commit, os } | null`"
|
||||
- "apps/web/src/lib/bug-report-api.ts — `BugReportPayload` um `clientKind`, `clientOs`, `clientVersion`, `clientCommit` erweitert, vier FormData-Felder"
|
||||
- "apps/web/src/lib/bug-report-api.test.ts — NEU: FormData-Felder fuer Desktop- und Browser-Nutzlast, Netzwerkfehler -> `{ ok: false, status: 0 }`"
|
||||
- "apps/web/src/components/bug-report/bug-report-dialog.tsx — `handleSend` fuellt die vier Felder aus `isDesktopClient()`/`getDesktopClientInfo()`"
|
||||
- "CHANGELOG.md, docs/anleitung-administration.md, docs/anleitung-betrieb.md — Herkunft und Betreff-Kuerzel beschrieben"
|
||||
key_links:
|
||||
- "Rust `with_desktop_marker` (drei Aufrufstellen: setup ~536, `save_server_url` ~476, `open_server` ~500) -> Query `desktop=1&dv=…&dc=…&dos=…` -> Next.js-Middleware `withDesktopCookie` -> Cookies `tessera_desktop=1` (wie bisher) und `tessera_desktop_client` (neu)"
|
||||
- "Cookie `tessera_desktop_client` -> `getDesktopClientInfo()` in desktop-client.ts -> `handleSend` in bug-report-dialog.tsx -> FormData-Felder in `sendBugReport` -> `BugReportDto` (whitelist verlangt die Deklaration!) -> `describeOrigin()` in origin.ts -> Betreff-Kuerzel + Zeile `Herkunft:` in bug-reports.service.ts"
|
||||
- "Rueckwaertskompatibilitaet: `@IsOptional()` an allen vier DTO-Feldern + Rueckfall `browser` im Dienst; globale Pipe hat KEIN `forbidNonWhitelisted` (gemessen in apps/api/src/main.ts Z. 17-20) -> neue Web-Felder gegen eine alte API werden still verworfen, alte Web-Baue gegen die neue API liefern `undefined`"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Fehlermeldungen des Fehler-melden-Knopfs (quick-260914-m97) weisen ihre Herkunft aus: Browser oder Desktop-App, Betriebssystem, bei der Desktop-App zusaetzlich Version und Commit-Stand. Der Betreff bekommt direkt nach `[Tessera Fehlermeldung]` ein kurzes Kuerzel (`[Browser]`, `[Desktop/Windows]`, `[Desktop/Linux]`), der Text eine Zeile `Herkunft: …`. Heute sieht eine Meldung aus WebView2 (Windows) wie Edge und aus WebKitGTK (Linux) wie Safari aus — im Postfach ist nicht erkennbar, ob ein Client oder ein Browser gemeldet hat.
|
||||
|
||||
Technischer Ansatz (nach Empfehlung des Orchestrators, keine Abweichung): Der bestehende Marker-/Cookie-Mechanismus aus quick-260917-h2s wird erweitert statt den WebView-User-Agent zu ueberschreiben. Der Rust-Client haengt neben `desktop=1` die Parameter `dv` (CARGO_PKG_VERSION), `dc` (APP_COMMIT, darf leer sein) und `dos` (`std::env::consts::OS`) an; die Middleware legt daraus ein zweites, bereinigtes Cookie `tessera_desktop_client` an; der Web-Client liest es und schickt vier neue Multipart-Felder; die API leitet Kuerzel und Herkunftszeile in einem reinen, eigens getesteten Helfer ab. Alles rein informativ, nichts wird gespeichert.
|
||||
|
||||
Purpose: Der Betreiber erkennt am Betreff sofort, ob eine Meldung aus einem Client (und welchem Betriebssystem, welcher App-Version) oder aus einem Browser kommt — und kann das Postfach danach sortieren.
|
||||
Output: Neue Datei `origin.ts` + Spec in der API; erweiterte DTO/Service/Specs; Rust-Marker mit Zusatzparametern; Middleware-Cookie; Web-Helfer + Nutzlastfelder; CHANGELOG und Handbuecher.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/gsd-core/workflows/execute-plan.md
|
||||
@~/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/STATE.md
|
||||
@.planning/quick/260914-m97-fehler-melden-knopf-bildschirmfoto-der-a/260914-m97-SUMMARY.md
|
||||
|
||||
Quelldateien (alle zur Planungszeit vollstaendig gelesen; Aenderungsumfang ist auf diese Pfade begrenzt):
|
||||
@apps/api/src/bug-reports/dto/bug-report.dto.ts
|
||||
@apps/api/src/bug-reports/bug-reports.service.ts
|
||||
@apps/api/src/bug-reports/bug-reports.service.spec.ts
|
||||
@apps/api/src/bug-reports/bug-reports.controller.spec.ts
|
||||
@apps/desktop/src-tauri/src/lib.rs
|
||||
@apps/web/src/middleware.ts
|
||||
@apps/web/src/middleware.test.ts
|
||||
@apps/web/src/lib/desktop-client.ts
|
||||
@apps/web/src/lib/desktop-client.test.ts
|
||||
@apps/web/src/lib/bug-report-api.ts
|
||||
@apps/web/src/components/bug-report/bug-report-dialog.tsx
|
||||
@apps/web/src/components/bug-report/bug-report-button.test.tsx
|
||||
|
||||
<planning_measurements>
|
||||
Zur Planungszeit gemessen — der Executor braucht das nicht neu herzuleiten:
|
||||
|
||||
- **Cookie-Kodierung:** Next.js 15.5 (`next/dist/compiled/@edge-runtime/cookies`) serialisiert Cookie-Werte mit `encodeURIComponent`. `res.cookies.set('tessera_desktop_client', '1.2.0|a6d1a64|windows', …)` erzeugt den Header `tessera_desktop_client=1.2.0%7Ca6d1a64%7Cwindows; Path=/; …`. Im Browser steht deshalb in `document.cookie` die KODIERTE Form. Der Parser in `desktop-client.ts` muss `decodeURIComponent` (in try/catch) anwenden, bevor er an `|` trennt. `res.cookies.get(name)?.value` in Middleware-Tests liefert bereits den dekodierten Wert.
|
||||
- **Globale ValidationPipe** (`apps/api/src/main.ts` Z. 17-20): `whitelist: true, transform: true`, KEIN `forbidNonWhitelisted`. Folge: Ein neuer Web-Bau gegen eine alte API verliert die vier Felder still (kein 400); ein alter Web-Bau gegen die neue API liefert `undefined` — beide Deploy-Reihenfolgen sind sicher, solange alle vier DTO-Felder `@IsOptional()` tragen.
|
||||
- **Baseline-Tests:** `pnpm --filter @tessera/api exec vitest run src/bug-reports` -> 11/11 gruen (8 Service + 3 Controller). `pnpm --filter @tessera/web exec vitest run src/lib/desktop-client.test.ts src/middleware.test.ts` -> 11/11 gruen (6 + 5). `bug-report-button.test.tsx` hat 11 Tests. Rust: 33 Tests laut STATE (kgc), `cargo test --lib` im Verzeichnis `apps/desktop/src-tauri` (target/ existiert, inkrementell).
|
||||
- **Biome:** installiert (2.5.0), aber laut Ledger #35 (STATE.md, 260914-ebg) im Bestand nicht lauffaehig — KEIN Biome-Gate in diesem Plan. Formatierung von Hand am Bestand orientieren (2 Leerzeichen, einfache Anfuehrungszeichen, Zeilen bis 100).
|
||||
- **Skripte:** `type-check` = `tsc --noEmit` in beiden Apps (`pnpm --filter @tessera/api type-check`, `pnpm --filter @tessera/web type-check`). Paketnamen `@tessera/api`, `@tessera/web`. Kein Paketmanager-Install noetig (keine neue Abhaengigkeit; `class-validator` 0.15 liefert `IsIn`).
|
||||
- **Docs-Stellen:** `docs/anleitung-administration.md` Z. 208 beschreibt den Mailinhalt und den Betreff `[Tessera Fehlermeldung]` (dort gehoert die Herkunft hin). `docs/anleitung-betrieb.md` hat KEINEN eigenen Fehlermeldungs-/SMTP-Abschnitt und erwaehnt `desktop=1` nirgends; die Anknuepfpunkte sind die Fehlersuche-Tabelle in Kapitel 7 (Zeile „Fehlermeldungen der Anwender kommen nicht an“, Z. 341) und die Tabelle „Fehlerbilder“ in Kapitel 10 (ab Z. 699). `docs/mandantentrennung-zugriffsklassifikation.md` listet keine Rumpffelder -> bleibt unveraendert. Kein UI-Text aendert sich -> `de.json`/`en.json` bleiben unveraendert.
|
||||
</planning_measurements>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer" tdd="true">
|
||||
<name>Task 1: API — Herkunft ableiten (origin.ts), DTO-Felder, Betreff-Kuerzel und Zeile `Herkunft:` (Browser-Pfad damit bereits Ende-zu-Ende fertig)</name>
|
||||
<files>apps/api/src/bug-reports/origin.ts, apps/api/src/bug-reports/origin.spec.ts, apps/api/src/bug-reports/dto/bug-report.dto.ts, apps/api/src/bug-reports/bug-reports.service.ts, apps/api/src/bug-reports/bug-reports.service.spec.ts, apps/api/src/bug-reports/bug-reports.controller.spec.ts</files>
|
||||
<read_first>apps/api/src/bug-reports/bug-reports.service.ts (Zeilen 122-149 Betreff/Text, 166-169 Protokollzeile), apps/api/src/bug-reports/dto/bug-report.dto.ts, beide bestehenden Specs (Muster fuer Kopfkommentar, `makeService`, `baseDto`)</read_first>
|
||||
<behavior>
|
||||
origin.spec.ts (NEU, Vitest, reine Funktionen, mindestens 8 Tests; `describe('origin (quick-260918-gza)')`):
|
||||
- Test 1 Edge auf Windows: UA `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36 Edg/129.0.2792.52` -> `parseUserAgent` = `{ browser: 'Edge 129', os: 'Windows' }`; `describeOrigin({ clientKind: 'browser', userAgent })` = `{ tag: '[Browser]', line: 'Browser — Edge 129 auf Windows' }` (Edge MUSS vor Chrome gewonnen werden).
|
||||
- Test 2 Chrome auf Windows (gleicher UA ohne `Edg/`): `{ browser: 'Chrome 129', os: 'Windows' }`.
|
||||
- Test 3 Firefox auf Linux: UA `Mozilla/5.0 (X11; Linux x86_64; rv:130.0) Gecko/20100101 Firefox/130.0` -> `{ browser: 'Firefox 130', os: 'Linux' }`; Zeile `Browser — Firefox 130 auf Linux`.
|
||||
- Test 4 Safari auf macOS: UA `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15` -> `{ browser: 'Safari 17', os: 'macOS' }` (Safari nur OHNE `Chrome/`, Version aus `Version/`).
|
||||
- Test 5 Mobil: Android-Chrome-UA `Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36` -> os `Android` (NICHT Linux); iPad-UA `Mozilla/5.0 (iPad; CPU OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1` -> os `iOS` (NICHT macOS), browser `Safari 17`; Opera-UA mit `OPR/114.0.0.0` -> `Opera 114`.
|
||||
- Test 6 WebKitGTK-Client als Desktop: UA `Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15` mit `clientKind: 'desktop', clientOs: 'linux', clientVersion: '1.2.0', clientCommit: 'a6d1a64'` -> `{ tag: '[Desktop/Linux]', line: 'Desktop-App (Linux), Tessera-App 1.2.0 · Stand a6d1a64' }` (der User-Agent spielt fuer Desktop KEINE Rolle).
|
||||
- Test 7 Desktop ohne Commit: `clientKind: 'desktop', clientOs: 'windows', clientVersion: '1.2.0', clientCommit: ''` -> `{ tag: '[Desktop/Windows]', line: 'Desktop-App (Windows), Tessera-App 1.2.0' }` (kein `· Stand`); auch `clientCommit: ' '` -> ohne Stand (trim, wie `client_info_label`).
|
||||
- Test 8 Desktop ohne Details (alter Client, Cookie fehlt): `clientKind: 'desktop', clientOs: '', clientVersion: '', clientCommit: ''` -> `{ tag: '[Desktop]', line: 'Desktop-App (unbekannt)' }`; `clientOs: 'freebsd'` -> ebenfalls `unbekannt`/`[Desktop]` (nur windows/linux/macos werden auf Windows/Linux/macOS abgebildet).
|
||||
- Test 9 Rueckfall: `describeOrigin({ userAgent: 'UA' })` (alle vier Felder `undefined`, alter Web-Bau) -> `{ tag: '[Browser]', line: 'Browser — unbekannt auf unbekannt' }`; `clientKind: 'browser'` mit leerem UA -> dasselbe.
|
||||
- Test 10 Bereinigung: `clientVersion: '1.2.0\nBenutzer: admin'`, `clientCommit: 'a6d1a64<b>'` -> Zeile enthaelt kein Zeilenumbruchzeichen und keine spitzen Klammern; nur `[A-Za-z0-9.+_-]` bleibt, hoechstens 40 Zeichen je Wert (T-GZA-01).
|
||||
bug-reports.service.spec.ts (bestehend, anpassen + 2 neue Tests):
|
||||
- Test 1 (bestehend): erwarteter Betreff wird `'[Tessera Fehlermeldung] [Browser] v1.2.3 beta - /admin/users?tab=x'`; Needle-Liste um `'Herkunft: Browser — unbekannt auf unbekannt'` ergaenzen (baseDto hat `userAgent: 'UA'` und keine Client-Felder -> Browser-Rueckfall). `'UA'` und `'1920x1080'` bleiben in der Liste (Zeilen `Browser:`/`Fenster:` bleiben).
|
||||
- Test 9 (NEU) Desktop/Windows: DTO `{ ...baseDto, clientKind: 'desktop', clientOs: 'windows', clientVersion: '1.2.0', clientCommit: 'a6d1a64', userAgent: '<Edge-UA aus origin Test 1>' }` -> `report.subject` beginnt mit `'[Tessera Fehlermeldung] [Desktop/Windows] v1.2.3 beta - '`; `report.text` enthaelt `'Herkunft: Desktop-App (Windows), Tessera-App 1.2.0 · Stand a6d1a64'`, enthaelt weiterhin `'Browser: Mozilla/5.0 (Windows NT 10.0'` und `'Fenster: 1920x1080'`; die Zeile `Herkunft:` steht im Text VOR der Zeile `Browser:` (Index-Vergleich); der `logger.log`-Spy wurde genau einmal mit einem String gerufen, der `'[Desktop/Windows]'` enthaelt.
|
||||
- Test 10 (NEU) Browser mit echtem UA: `{ ...baseDto, clientKind: 'browser', userAgent: '<Chrome-UA aus origin Test 2>' }` -> Betreff enthaelt `'[Browser]'`, Text enthaelt `'Herkunft: Browser — Chrome 129 auf Windows'`.
|
||||
bug-reports.controller.spec.ts (bestehend, 1 neuer Test):
|
||||
- Test 4 (NEU): Pipe mit `{ ...baseBody, clientKind: 'desktop', clientOs: 'windows', clientVersion: '1.2.0', clientCommit: '' }` -> alle vier Felder bleiben erhalten (Leerstring bleibt Leerstring); `{ ...baseBody }` -> `clientKind` ist `undefined` (kein Default im DTO); `clientKind: 'tablet'` -> `BadRequestException`; `clientOs` mit 21 Zeichen -> `BadRequestException`; `clientVersion`/`clientCommit` mit 41 Zeichen -> `BadRequestException`.
|
||||
</behavior>
|
||||
<action>
|
||||
Reihenfolge RED -> GREEN: zuerst `origin.spec.ts` und die Spec-Aenderungen schreiben, Lauf muss rot sein (origin.ts fehlt, Betreff ohne Kuerzel), dann implementieren.
|
||||
|
||||
1. `apps/api/src/bug-reports/origin.ts` (NEU, keine Abhaengigkeit ausser TypeScript): Kopfkommentar deutsch (ASCII-Umlaute wie im Bestand): Zweck (quick-260918-gza — Herkunft einer Fehlermeldung ausweisen, weil WebView2 wie Edge und WebKitGTK wie Safari aussehen), Trust-Modell (alle Eingaben stammen vom Client, rein informativ, laengenbegrenzt, nie fuer Routing/Berechtigung, T-GZA-01), warum Regex statt Bibliothek (kein neues Paket, fuenf Browser und fuenf Systeme reichen fuer ein Postfach). Exporte: Typ `ClientKind = 'desktop' | 'browser'`; Interface `OriginInput { clientKind?: string; clientOs?: string; clientVersion?: string; clientCommit?: string; userAgent?: string }`; Interface `Origin { tag: string; line: string }` — `tag` ist das Betreff-Kuerzel in eckigen Klammern, `line` der Text NACH dem Label `Herkunft: ` (der Dienst setzt das Label davor); Interface `ParsedUserAgent { browser: string; os: string }`; Konstante `UNKNOWN = 'unbekannt'`.
|
||||
`parseUserAgent(ua: string): ParsedUserAgent` — Browser in dieser Reihenfolge pruefen (die erste Uebereinstimmung gewinnt): `Edg/(\d+)` -> `Edge N`; `OPR/(\d+)` -> `Opera N`; `Firefox/(\d+)` -> `Firefox N`; `(?:Chrome|CriOS)/(\d+)` -> `Chrome N`; `Safari/` OHNE `Chrome/` -> `Safari N` mit N aus `Version/(\d+)`, ohne `Version/` nur `Safari`; sonst `UNKNOWN`. Betriebssystem in dieser Reihenfolge: `Windows NT` -> `Windows`; `Android` -> `Android`; `iPhone|iPad|iPod` -> `iOS`; `Mac OS X|Macintosh` -> `macOS`; `Linux|X11` -> `Linux`; sonst `UNKNOWN`. Kommentar an der Reihenfolge: Android-UAs enthalten `Linux`, iPad-UAs enthalten `like Mac OS X`, Edge/Opera-UAs enthalten `Chrome/` und `Safari/` — deshalb die Reihenfolge.
|
||||
`describeOrigin(input: OriginInput): Origin` — Hilfsfunktion `clean(value, max = 40)`: `String(value ?? '')`, alles ausser `[A-Za-z0-9.+_-]` entfernen, `slice(0, max)` (T-GZA-01: kein Zeilenumbruch, kein Markup in der Mail). Wenn `input.clientKind === 'desktop'`: `osLabel` aus `clean(clientOs).toLowerCase()` ueber die Abbildung `windows -> Windows`, `linux -> Linux`, `macos -> macOS`, sonst `UNKNOWN`; `version = clean(clientVersion)`, `commit = clean(clientCommit)`; `appLabel` = `Tessera-App ${version} · Stand ${commit}` wenn beide nicht leer, `Tessera-App ${version}` wenn nur Version, sonst leer (gleiche Regel wie `client_info_label` in lib.rs); `line = Desktop-App (${osLabel})` plus `, ${appLabel}` falls appLabel nicht leer; `tag` = `[Desktop/${osLabel}]` wenn osLabel nicht UNKNOWN, sonst `[Desktop]`. Sonst (alles andere, auch `undefined`): `{ browser, os } = parseUserAgent(input.userAgent ?? '')`, `line = Browser — ${browser} auf ${os}` (Gedankenstrich U+2014 wie im Auftrag), `tag = '[Browser]'`.
|
||||
|
||||
2. `apps/api/src/bug-reports/dto/bug-report.dto.ts`: `IsIn` aus `class-validator` importieren. Vier neue optionale Felder ans Ende der Klasse, jeweils mit Doc-Kommentar: `clientKind?: 'desktop' | 'browser'` mit `@IsOptional() @IsIn(['desktop', 'browser'])`; `clientOs?: string` mit `@IsOptional() @IsString() @MaxLength(20)`; `clientVersion?: string` mit `@IsOptional() @IsString() @MaxLength(40)`; `clientCommit?: string` mit `@IsOptional() @IsString() @MaxLength(40)`. Kopfkommentar der Klasse um einen Absatz ergaenzen: die vier Felder kommen seit quick-260918-gza vom Web-Client (Browser: `clientKind=browser`, uebrige leer; Desktop-App: aus dem Cookie `tessera_desktop_client`); sie sind optional, damit aeltere Web-Baue weiter gueltig senden (Rueckfall `browser` im Dienst); `whitelist: true` verlangt die Deklaration hier, sonst wuerde die Pipe sie entfernen; rein informativ, laengenbegrenzt (T-GZA-01).
|
||||
|
||||
3. `apps/api/src/bug-reports/bug-reports.service.ts`: `describeOrigin` aus `./origin` importieren. Vor Schritt (5) `const origin = describeOrigin(dto);`. Betreff wird `[Tessera Fehlermeldung] ${origin.tag} ${dto.webVersion} ${dto.webChannel} - ${pageShort}`. Im Text-Array direkt VOR der Zeile `Browser: ${dto.userAgent}` die neue Zeile `Herkunft: ${origin.line}` einfuegen; `Browser:` und `Fenster:` bleiben unveraendert. Protokollzeile (9) wird `Bug report ${origin.tag} from ${user.username} …` (Rest unveraendert; das Kuerzel ist ein aufgezaehlter Wert aus origin.ts, nie ein roher Client-String — deshalb protokollierbar). Kopfkommentar der Datei um einen Absatz „Herkunft (quick-260918-gza)“ ergaenzen: warum Kuerzel im Betreff (Sortieren im Postfach), warum der rohe User-Agent bleibt, Verweis auf origin.ts und T-GZA-01.
|
||||
|
||||
4. Specs gemaess `<behavior>` anpassen bzw. anlegen; Kopfkommentar von `origin.spec.ts` im Stil der bestehenden Specs (deutsch, Zweck, Liste der Faelle). In `bug-reports.service.spec.ts` und `bug-reports.controller.spec.ts` den Kopfkommentar um einen Satz zu den neuen Tests ergaenzen (Anzahl korrigieren).
|
||||
|
||||
Commit nach gruenem Lauf: `feat(bug-reports): Herkunft der Fehlermeldung im Betreff-Kuerzel und als Zeile Herkunft ausweisen`.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/api exec vitest run src/bug-reports && pnpm --filter @tessera/api type-check</automated>
|
||||
</verify>
|
||||
<done>`origin.spec.ts` mit mindestens 8 Tests, `bug-reports.service.spec.ts` mit 10 Tests, `bug-reports.controller.spec.ts` mit 4 Tests — alle gruen (mindestens 22 statt 11 in `src/bug-reports`); `tsc --noEmit` der API ohne Fehler. Betreff traegt das Kuerzel direkt nach `[Tessera Fehlermeldung]`, der Text die Zeile `Herkunft:` vor `Browser:`; ein DTO ohne die vier Felder ergibt `[Browser]` (Rueckfall), `clientKind: 'tablet'` ergibt 400. Der Browser-Pfad ist damit Ende-zu-Ende fertig: der bestehende Web-Client schickt bereits `userAgent`, die Mail zeigt ab jetzt `[Browser]` und `Herkunft: Browser — <Name> <Version> auf <System>`.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Desktop-Client meldet Version/Commit/OS im Marker, Middleware setzt Cookie `tessera_desktop_client`, Web-Client schickt die vier Felder</name>
|
||||
<files>apps/desktop/src-tauri/src/lib.rs, apps/web/src/middleware.ts, apps/web/src/middleware.test.ts, apps/web/src/lib/desktop-client.ts, apps/web/src/lib/desktop-client.test.ts, apps/web/src/lib/bug-report-api.ts, apps/web/src/lib/bug-report-api.test.ts, apps/web/src/components/bug-report/bug-report-dialog.tsx, apps/web/src/components/bug-report/bug-report-button.test.tsx</files>
|
||||
<read_first>apps/desktop/src-tauri/src/lib.rs Zeilen 49-62 (`with_desktop_marker` + Doc), 170-178 (`client_info_label`), 464-503 (`save_server_url`, `open_server`), 530-537 (setup-Navigation), 698-745 (bestehende Marker-Tests); apps/web/src/middleware.ts Zeilen 15-38; apps/web/src/lib/desktop-client.ts; apps/web/src/lib/bug-report-api.ts Zeilen 71-97; apps/web/src/components/bug-report/bug-report-dialog.tsx Zeilen 55-75; apps/web/src/components/bug-report/bug-report-button.test.tsx Zeilen 60-93 (Mocks, beforeEach/afterEach) und 118-156 (Test 2)</read_first>
|
||||
<behavior>
|
||||
Rust (`mod tests` in lib.rs; die drei bestehenden Marker-Tests werden auf die reine Funktion umgestellt, plus zwei neue):
|
||||
- `with_client_marker(&Url::parse("https://tessera.example.com").unwrap(), "1.2.0", "a6d1a64", "windows").as_str()` == `https://tessera.example.com/?desktop=1&dv=1.2.0&dc=a6d1a64&dos=windows`.
|
||||
- Mit vorhandenem Query `https://host/app?x=1` -> `https://host/app?x=1&desktop=1&dv=1.2.0&dc=a6d1a64&dos=windows`.
|
||||
- Original bleibt unveraendert (`url.query() == None` nach dem Aufruf).
|
||||
- Leerer bzw. nur aus Leerzeichen bestehender Commit -> `dc=` (leer, Paar bleibt vorhanden, damit die Middleware „alle drei Parameter vorhanden“ erkennt): `…?desktop=1&dv=1.2.0&dc=&dos=linux`.
|
||||
- Huelle `with_desktop_marker(&url)`: `query_pairs()` enthaelt die Paare `("desktop","1")`, `("dv", env!("CARGO_PKG_VERSION"))`, `("dos", std::env::consts::OS)` und ein Paar mit Schluessel `dc`.
|
||||
middleware.test.ts (bestehender describe-Block, 4 neue Tests):
|
||||
- Test 6: `/login?desktop=1&dv=1.2.0&dc=a6d1a64&dos=windows` -> `res.cookies.get('tessera_desktop')?.value === '1'` UND `res.cookies.get('tessera_desktop_client')?.value === '1.2.0|a6d1a64|windows'`; der rohe `set-cookie`-Header enthaelt `tessera_desktop_client=1.2.0%7Ca6d1a64%7Cwindows` (Next kodiert, gemessen), `Max-Age=31536000`, `Path=/` und fuer dieses Cookie kein `HttpOnly`.
|
||||
- Test 7 (alter Client): `/login?desktop=1` -> `tessera_desktop=1` gesetzt, `res.cookies.get('tessera_desktop_client')` ist `undefined` (kein Ueberschreiben eines evtl. vorhandenen Werts).
|
||||
- Test 8 (Bereinigung): `dv=1.2.0%3Cscript%3E` (spitze Klammern) -> kein `tessera_desktop_client`; `dos=win%20dows` -> keins; `dv` fehlt, `dc`/`dos` vorhanden -> keins; `dc=` leer mit gueltigem `dv`/`dos` -> Wert `1.2.0||linux`.
|
||||
- Test 9 (Redirect-Pfad): `/dashboard?desktop=1&dv=1.2.0&dc=a6d1a64&dos=linux` ohne Session -> Status 307, `location` enthaelt `/login`, beide Cookies gesetzt (Wert `1.2.0|a6d1a64|linux`).
|
||||
desktop-client.test.ts (neuer describe-Block `getDesktopClientInfo / parseDesktopClientCookie`; `clearCookie()` loescht zusaetzlich `tessera_desktop_client`):
|
||||
- `parseDesktopClientCookie('tessera_desktop=1; tessera_desktop_client=1.2.0%7Ca6d1a64%7Cwindows')` -> `{ version: '1.2.0', commit: 'a6d1a64', os: 'windows' }` (kodierte Form, wie der Browser sie haelt).
|
||||
- Rohe Form `tessera_desktop_client=1.2.0|a6d1a64|windows` -> gleiches Ergebnis.
|
||||
- Leerer Commit `1.2.0%7C%7Clinux` -> `{ version: '1.2.0', commit: '', os: 'linux' }`.
|
||||
- Ohne Cookie -> `null`; Wert `abc` (ein Teil) oder `1.2.0|x` (zwei Teile) oder `|a6d1a64|linux` (Version leer) oder `1.2.0|a6d1a64|` (OS leer) -> `null`.
|
||||
- `getDesktopClientInfo()` liest `document.cookie` (Cookie per `document.cookie = …` gesetzt -> Objekt; ohne Cookie -> `null`); mit `vi.stubGlobal('document', undefined)` -> `null`.
|
||||
bug-report-api.test.ts (NEU, 3 Tests, `vi.stubGlobal('fetch', mockFetch)` wie in bug-report-button.test.tsx, `mockFetch.mockResolvedValue(new Response('{}', { status: 200 }))`):
|
||||
- Test 1 Desktop-Nutzlast: `sendBugReport({ description: 'x', page: '/a', webVersion: 'v1', webChannel: 'beta', webCommit: 'c', userAgent: 'UA', viewport: '1x1', clientTime: 't', errors: ['e1', 'e2'], screenshot: null, clientKind: 'desktop', clientOs: 'windows', clientVersion: '1.2.0', clientCommit: 'a6d1a64' })` -> `{ ok: true }`; `init.body` ist `FormData` mit `get('clientKind') === 'desktop'`, `get('clientOs') === 'windows'`, `get('clientVersion') === '1.2.0'`, `get('clientCommit') === 'a6d1a64'`, `getAll('errors')` = `['e1','e2']`, `has('screenshot') === false`, `init.credentials === 'include'`, URL endet auf `/bug-reports`.
|
||||
- Test 2 Browser-Nutzlast: `clientKind: 'browser'`, uebrige drei `''` -> `get('clientKind') === 'browser'`, `get('clientOs') === ''`, `get('clientVersion') === ''`, `get('clientCommit') === ''` (Felder VORHANDEN, Leerstring — nicht weggelassen).
|
||||
- Test 3: `mockFetch.mockRejectedValue(new Error('offline'))` -> `{ ok: false, status: 0 }`; `mockFetch.mockResolvedValue(new Response('', { status: 429 }))` -> `{ ok: false, status: 429 }`.
|
||||
bug-report-button.test.tsx:
|
||||
- Test 2 (bestehend) ergaenzen: `body.get('clientKind') === 'browser'`, `body.get('clientOs') === ''`, `body.get('clientVersion') === ''`, `body.get('clientCommit') === ''` (jsdom ohne Cookies).
|
||||
- Test 12 (NEU): vor dem Rendern `document.cookie = 'tessera_desktop=1; path=/'` und `document.cookie = 'tessera_desktop_client=1.2.0%7Ca6d1a64%7Cwindows; path=/'`; Senden -> `body.get('clientKind') === 'desktop'`, `clientOs === 'windows'`, `clientVersion === '1.2.0'`, `clientCommit === 'a6d1a64'`. `afterEach` loescht beide Cookies (Ablaufdatum 1970, `path=/`), damit die uebrigen Tests Browser bleiben.
|
||||
- Test 13 (NEU, alter Client): nur `tessera_desktop=1` ohne `tessera_desktop_client` -> `clientKind === 'desktop'`, die drei anderen `''`.
|
||||
</behavior>
|
||||
<action>
|
||||
Reihenfolge: Rust zuerst (RED: Tests auf `with_client_marker` umstellen, `cargo test --lib` rot; GREEN: implementieren), dann Middleware, dann Web-Helfer, dann Nutzlast und Dialog — jeweils Test vor Implementierung.
|
||||
|
||||
1. `apps/desktop/src-tauri/src/lib.rs`: Neue reine Funktion `fn with_client_marker(url: &tauri::Url, version: &str, commit: &str, os: &str) -> tauri::Url` — klont die URL, haengt per `query_pairs_mut().append_pair` nacheinander `("desktop", "1")`, `("dv", version)`, `("dc", commit.trim())`, `("dos", os)` an (Reihenfolge fest, `dc` auch leer anhaengen). Die bestehende `fn with_desktop_marker(url: &tauri::Url) -> tauri::Url` wird zur Huelle: `with_client_marker(url, env!("CARGO_PKG_VERSION"), env!("APP_COMMIT"), std::env::consts::OS)` — so bleiben die drei Aufrufstellen (`save_server_url`, `open_server`, `setup`) UNVERAENDERT und die Tests bleiben rein (kein `env!` in der Erwartung). Doc-Kommentar von `with_desktop_marker` erweitern: seit quick-260918-gza wandern Version, Commit-Stempel und Betriebssystem (`dv`, `dc`, `dos`) mit, die Middleware legt daraus das Cookie `tessera_desktop_client` an, aus dem der Fehler-melden-Knopf die Herkunft der Meldung fuellt; `desktop=1` bleibt unveraendert, damit ein neuer Client gegen eine aeltere Middleware weiter erkannt wird; die Werte gehen NUR in die Navigation, nie in den Store (wie bisher); der Browser-Rueckfall `open_download_page` bekommt weiterhin keinen Marker. `mod tests`: die drei bestehenden `with_desktop_marker_*`-Tests auf `with_client_marker(&url, "1.2.0", "a6d1a64", "windows")` umstellen (Erwartungen laut `<behavior>`), Test fuer leeren Commit (`""` und `" "` -> `dc=`) und einen Test fuer die Huelle ueber `query_pairs()` ergaenzen. `cargo fmt` anwenden (2-Zeilen-Doc-Umbrueche wie im Bestand).
|
||||
|
||||
2. `apps/web/src/middleware.ts`: Konstante `DESKTOP_CLIENT_COOKIE = 'tessera_desktop_client'` und drei Muster als Modulkonstanten: `DESKTOP_VERSION_RE = /^[A-Za-z0-9][A-Za-z0-9.+_-]{0,39}$/`, `DESKTOP_COMMIT_RE = /^[A-Za-z0-9]{0,40}$/` (leer erlaubt), `DESKTOP_OS_RE = /^[a-z]{1,20}$/`. Neue reine Hilfsfunktion `buildDesktopClientCookieValue(params: URLSearchParams): string | null` — liest `dv`, `dc`, `dos`; wenn eines `null` (fehlt) ist oder sein Muster nicht passt -> `null`; sonst `${dv}|${dc}|${dos}` (hoechstens 82 Zeichen durch die Muster). In `withDesktopCookie` innerhalb des bestehenden `if (desktop === '1')`-Zweigs: `tessera_desktop=1` wie bisher setzen; zusaetzlich `const info = buildDesktopClientCookieValue(req.nextUrl.searchParams); if (info !== null) res.cookies.set(DESKTOP_CLIENT_COOKIE, info, { …dieselben Optionen wie fuer tessera_desktop… })`. Ohne gueltige Parameter wird das Info-Cookie NICHT gesetzt und NICHT geloescht (alter Client -> die Mail sagt `Desktop-App (unbekannt)`). Doc-Kommentar von `withDesktopCookie` ergaenzen: zweites Cookie, Herkunft (quick-260918-gza), Bereinigung per Muster und Laenge, warum `httpOnly: false` (wird von `getDesktopClientInfo()` gelesen; Version/OS sind kein Geheimnis, dieselbe Vertrauensstufe wie der User-Agent), Hinweis dass Next den Wert mit `encodeURIComponent` serialisiert (T-GZA-03). Tests laut `<behavior>` in `middleware.test.ts` ergaenzen; Kopfkommentar um einen Satz erweitern.
|
||||
|
||||
3. `apps/web/src/lib/desktop-client.ts`: `export const DESKTOP_CLIENT_COOKIE_NAME = 'tessera_desktop_client'`; `export interface DesktopClientInfo { version: string; commit: string; os: string }`; `export function parseDesktopClientCookie(cookieString: string): DesktopClientInfo | null` — trennt an `;`, trimmt, sucht den Eintrag mit Praefix `${DESKTOP_CLIENT_COOKIE_NAME}=`, nimmt den Rest, dekodiert per `decodeURIComponent` in try/catch (bei Fehler den Rohwert nehmen), trennt an `|`; genau drei Teile, Teil 1 (version) und Teil 3 (os) nicht leer, sonst `null`; Rueckgabe `{ version, commit, os }`. `export function getDesktopClientInfo(): DesktopClientInfo | null` — `typeof document === 'undefined'` -> `null`, sonst `parseDesktopClientCookie(document.cookie)`. Kopfkommentar ergaenzen (Gegenstueck zu `buildDesktopClientCookieValue`, warum dekodieren — Next kodiert `|` als `%7C`, gemessen). Tests laut `<behavior>`; `clearCookie()` im Test loescht beide Cookies.
|
||||
|
||||
4. `apps/web/src/lib/bug-report-api.ts`: `BugReportPayload` um `clientKind: 'desktop' | 'browser'`, `clientOs: string`, `clientVersion: string`, `clientCommit: string` erweitern; in `sendBugReport` nach `clientTime` vier `body.append(...)`-Zeilen fuer genau diese Feldnamen (Leerstrings mitschicken — das DTO ist optional, aber die Felder sollen fuer den Browser-Fall sichtbar leer sein, nicht fehlen). Kopfkommentar um einen Satz ergaenzen (Herkunft, quick-260918-gza; Desktop-Werte kommen aus `getDesktopClientInfo()`). NEU `apps/web/src/lib/bug-report-api.test.ts` laut `<behavior>` (Kopfkommentar: warum diese Datei erst jetzt entsteht — bisher pruefte nur der Komponententest die FormData; die reinen Nutzlastfelder gehoeren an die Funktion selbst).
|
||||
|
||||
5. `apps/web/src/components/bug-report/bug-report-dialog.tsx`: `getDesktopClientInfo` und `isDesktopClient` aus `@/lib/desktop-client` importieren. In `handleSend` vor dem `sendBugReport`-Aufruf: `const desktop = isDesktopClient(); const info = desktop ? getDesktopClientInfo() : null;` und im Aufruf `clientKind: desktop ? 'desktop' : 'browser', clientOs: info?.os ?? '', clientVersion: info?.version ?? '', clientCommit: info?.commit ?? ''`. Kein UI-Text, keine Uebersetzung aendert sich. Kurzer Kommentar an der Stelle: Herkunft (quick-260918-gza) — `tessera_desktop` entscheidet Desktop/Browser, `tessera_desktop_client` liefert die Details; fehlt es (alter Client), bleibt es bei Desktop ohne Details. `bug-report-button.test.tsx` laut `<behavior>` erweitern (Test 2 ergaenzen, Tests 12 und 13 neu, Cookie-Aufraeumen im `afterEach`, Kopfkommentar „Elf Tests“ -> „Dreizehn Tests“).
|
||||
|
||||
Zwei Commits nach gruenem Lauf: `feat(desktop): Version, Stand und Betriebssystem im Desktop-Marker mitgeben (dv, dc, dos)` fuer lib.rs; `feat(web): Herkunft der Fehlermeldung — Cookie tessera_desktop_client und Client-Felder in der Nutzlast` fuer die Web-Dateien.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl/apps/desktop/src-tauri && cargo fmt --check && cargo test --lib && cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web exec vitest run src/lib src/middleware.test.ts src/components/bug-report && pnpm --filter @tessera/web type-check</automated>
|
||||
</verify>
|
||||
<done>Rust: `cargo fmt --check` sauber, alle Tests gruen (mindestens 35, davon 5 Marker-Tests: drei umgestellte, leerer Commit, Huelle). Web: `middleware.test.ts` 9 Tests, `desktop-client.test.ts` mindestens 11, `bug-report-api.test.ts` 3, `bug-report-button.test.tsx` 13 — alle gruen, `tsc --noEmit` ohne Fehler. Kette nachgewiesen: Anfrage mit `desktop=1&dv&dc&dos` -> beide Cookies (auch auf dem 307 nach /login) -> `getDesktopClientInfo()` liefert das Tripel aus der kodierten Cookie-Form -> FormData traegt `clientKind=desktop`, `clientOs`, `clientVersion`, `clientCommit`; ohne Info-Cookie `desktop` mit leeren Details; ohne Desktop-Cookie `browser`.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: CHANGELOG und Handbuecher — Herkunft und Betreff-Kuerzel beschreiben</name>
|
||||
<files>CHANGELOG.md, docs/anleitung-administration.md, docs/anleitung-betrieb.md</files>
|
||||
<read_first>CHANGELOG.md Zeilen 1-25 (Abschnitt „Unveröffentlicht“ mit „Neu“/„Geändert“/„Behoben“); docs/anleitung-administration.md Zeile 208 (Absatz „Fehlermeldungen an“); docs/anleitung-betrieb.md Zeile 341 (Tabellenzeile „Fehlermeldungen der Anwender kommen nicht an“) und Zeilen 699-709 (Tabelle „Fehlerbilder“ in Kapitel 10)</read_first>
|
||||
<action>
|
||||
Alle Texte deutsch, Alltagssprache, Anwender/Betreiber werden gesiezt (App-Texte), echte Umlaute wie in den Handbuechern.
|
||||
|
||||
1. `CHANGELOG.md`, Abschnitt `## Unveröffentlicht` -> `### Geändert`, neue Zeile am Ende der Liste: `- Fehler melden: Fehlermeldungen nennen jetzt die Herkunft – Browser oder Desktop-App, Betriebssystem, bei der Desktop-App auch Version und Stand; der Betreff trägt dafür ein Kürzel wie „[Browser]“, „[Desktop/Windows]“ oder „[Desktop/Linux]“, nach dem sich das Postfach sortieren lässt`.
|
||||
|
||||
2. `docs/anleitung-administration.md`, Absatz **Fehlermeldungen an** (Z. 208): den Teilsatz „— der Betreff beginnt mit „[Tessera Fehlermeldung]“, das Bild hängt als PNG an.“ ersetzen durch einen Teilsatz, der sagt: der Betreff beginnt mit „[Tessera Fehlermeldung]“ und einem Kürzel für die Herkunft („[Browser]“, „[Desktop/Windows]“ oder „[Desktop/Linux]“), nach dem Sie das Postfach sortieren oder filtern können; im Text nennt die Zeile „Herkunft“ bei Browsern Browser und Betriebssystem (Beispiel „Browser — Chrome 129 auf Windows“), bei der Desktop-App Betriebssystem, Version und Stand (Beispiel „Desktop-App (Windows), Tessera-App 1.2.0 · Stand a6d1a64“); das Bild hängt als PNG an. Der uebrige Absatz bleibt.
|
||||
|
||||
3. `docs/anleitung-betrieb.md`:
|
||||
a) Kapitel 7, Tabellenzeile „Fehlermeldungen der Anwender kommen nicht an“ (Z. 341), Spalte „Prüfen / Beheben“: die Klammer „(eine Zeile je gesendeter Meldung, `Bug report mail failed` bei Versandfehler)“ erweitern zu „(eine Zeile je gesendeter Meldung mit dem Herkunfts-Kürzel `[Browser]`, `[Desktop/Windows]` oder `[Desktop/Linux]`, `Bug report mail failed` bei Versandfehler)“.
|
||||
b) Kapitel 10, Tabelle „Fehlerbilder“ (ab Z. 699), neue letzte Zeile: Symptom „Eine Fehlermeldung aus der Desktop-App nennt als Herkunft „Desktop-App (unbekannt)“ ohne Version, Betreff-Kürzel `[Desktop]`“ — Ursache „Der Client ist älter als diese Fassung: er meldet dem Server beim Start nur `desktop=1`, nicht Version, Stand und Betriebssystem (Parameter `dv`, `dc`, `dos`, aus denen `web` das Cookie `tessera_desktop_client` bildet)“ — Prüfen/Beheben „Kein Fehler, die Meldung ist trotzdem als Desktop-App erkennbar. Client über „Auf Version … aktualisieren“ im Infobereich oder den Browser-Installer aktualisieren; danach stehen Betriebssystem, Version und Stand in der Meldung.“
|
||||
|
||||
Nicht anfassen: `docs/mandantentrennung-zugriffsklassifikation.md` (listet keine Rumpffelder), `docs/anleitung-anwender.md` (Anwender sehen keine Aenderung), `de.json`/`en.json` (kein UI-Text).
|
||||
|
||||
Commit: `docs: Fehlermeldungen — Herkunft (Browser/Desktop-App, Betriebssystem, Version) und Betreff-Kürzel (Handbücher, CHANGELOG)`.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && grep -q "Desktop/Windows" CHANGELOG.md && grep -q "Desktop/Windows" docs/anleitung-administration.md && grep -q "Desktop/Windows" docs/anleitung-betrieb.md && grep -q "Desktop-App (unbekannt)" docs/anleitung-betrieb.md && grep -q "tessera_desktop_client" docs/anleitung-betrieb.md && echo DOCS-OK</automated>
|
||||
</verify>
|
||||
<done>Alle drei Dateien nennen das Kuerzel `[Desktop/Windows]`; das Betriebshandbuch erklaert in Kapitel 10 den Fall „Desktop-App (unbekannt)“ als alten Client mit Verweis auf `dv`/`dc`/`dos` und das Cookie `tessera_desktop_client`; der Changelog-Eintrag steht unter „Unveröffentlicht → Geändert“; der Verify-Befehl gibt `DOCS-OK` aus; genau ein Commit `docs: …` mit den drei Dateien (Nachweis: `git show --stat --format= <sha>` des Doku-Commits listet genau CHANGELOG.md, docs/anleitung-administration.md, docs/anleitung-betrieb.md).</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Desktop-Client -> Web (Query `desktop=1&dv&dc&dos`) | Ungepruefte Query-Parameter einer Navigation; jeder Browser kann sie ebenso setzen |
|
||||
| Web-Middleware -> Browser (Cookie `tessera_desktop_client`) | Nicht-httpOnly-Cookie, fuer Seiten-JavaScript lesbar und vom Anwender aenderbar |
|
||||
| Browser -> API (`POST /bug-reports`, vier neue Multipart-Felder) | Vom Client gelieferte Strings, unbeglaubigt wie der User-Agent |
|
||||
| API -> Postfach des Betreibers (Betreff, Textzeile, Protokollzeile) | Client-Text landet in einer E-Mail und teilweise im Log |
|
||||
|
||||
## STRIDE Threat Register (ASVS Level 1)
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-GZA-01 | Spoofing / Tampering | `clientKind`, `clientOs`, `clientVersion`, `clientCommit` im DTO; `describeOrigin` | low | mitigate | Rein informativ: nie fuer Routing, Berechtigung oder Speicherung genutzt; `@IsIn(['desktop','browser'])`, `@MaxLength` 20/40/40; `clean()` in origin.ts laesst nur `[A-Za-z0-9.+_-]` und 40 Zeichen zu (kein Zeilenumbruch, kein Markup in der Mail); OS wird auf drei feste Labels abgebildet; ins Log geht NUR das aufgezaehlte Kuerzel, nie ein Rohwert (Test 9 im Service-Spec, Test 10 in origin.spec) |
|
||||
| T-GZA-02 | Tampering | Betreff-Zeile (Header-Injection) | low | mitigate | In den Betreff geht ausschliesslich `origin.tag` — ein Wert aus einer festen Menge (`[Browser]`, `[Desktop]`, `[Desktop/Windows|Linux|macOS]`); Version/Commit stehen nur im Text, nie im Header |
|
||||
| T-GZA-03 | Tampering | `withDesktopCookie` — Query -> Cookie `tessera_desktop_client` | low | mitigate | Drei feste Muster (`DESKTOP_VERSION_RE`, `DESKTOP_COMMIT_RE`, `DESKTOP_OS_RE`), Gesamtlaenge ≤ 82; nur gesetzt, wenn `desktop=1` UND alle drei Parameter vorhanden und gueltig; Middleware trifft keine Entscheidung auf Grund des Werts; `sameSite: 'lax'`, `secure` bei https wie das bestehende Cookie (Tests 6-9 in middleware.test.ts) |
|
||||
| T-GZA-04 | Information Disclosure | Cookie `tessera_desktop_client` (App-Version und OS fuer Seiten-JS lesbar) | low | accept | Dieselbe Vertrauensstufe und Sichtbarkeit wie der User-Agent, den jede Seite ohnehin liest; kein Geheimnis, kein Token; nur die eigene Web-App laeuft im WebView |
|
||||
| T-M97-03 | Denial of Service | `main.ts`, Body-Limits | medium | mitigate (unveraendert) | Kein globales Limit angefasst; vier kurze Textfelder innerhalb des bestehenden Multipart-Rumpfs, DTO-Grenzen wie oben |
|
||||
| T-M97-09 | Spoofing | Irrefuehrende Herkunftsangaben durch einen Anwender | low | accept | Wie bisher fuer Beschreibung/Fehlerliste: reiner Text an den Administrator des eigenen Mandanten; Benutzer/Mandant/API-Version kommen weiterhin aus Sitzung und Umgebung, nicht aus dem Rumpf |
|
||||
| T-GZA-SC | Tampering | Paketinstallationen | — | n/a | Keine neue npm-/cargo-Abhaengigkeit (Regex und Standardbibliothek); kein Install-Schritt in diesem Plan |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
Automatisch (Executor, je Task im `<verify>`):
|
||||
- API: `pnpm --filter @tessera/api exec vitest run src/bug-reports` (≥ 22 Tests gruen, vorher 11) und `pnpm --filter @tessera/api type-check`.
|
||||
- Web: `pnpm --filter @tessera/web exec vitest run src/lib src/middleware.test.ts src/components/bug-report` und `pnpm --filter @tessera/web type-check`.
|
||||
- Rust: `cargo fmt --check && cargo test --lib` in `apps/desktop/src-tauri` (≥ 35 Tests).
|
||||
- Falsifizierung (RED zuerst): Betreff ohne Kuerzel laesst Service-Test 1 scheitern; fehlendes `origin.ts` laesst origin.spec scheitern; Marker ohne `dv` laesst die Rust-Tests scheitern; Middleware ohne Info-Cookie laesst middleware Test 6 scheitern.
|
||||
- Abschliessend einmal die vollen Suiten: `pnpm --filter @tessera/api exec vitest run` und `pnpm --filter @tessera/web exec vitest run` (keine Regression ausserhalb der geaenderten Dateien).
|
||||
|
||||
Kein Biome-Gate (Ledger #35: Konfiguration im Bestand nicht lauffaehig).
|
||||
|
||||
Nachweis durch den Orchestrator NACH der Ausfuehrung (nicht Aufgabe des Executors):
|
||||
- Browser-Fall lokal mit Playwright MCP und mailhog (`docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d mailhog`, `docker compose up -d --build api web`): Fehler melden -> Mail in `http://localhost:8025` mit Betreff `[Tessera Fehlermeldung] [Browser] dev dev - /…` und Zeile `Herkunft: Browser — Chrome <N> auf Linux`; Zeilen `Browser:`/`Fenster:` weiterhin vorhanden; `docker compose logs api | grep "Bug report"` zeigt das Kuerzel.
|
||||
- Desktop-Fall nach CI-Bau auf der Windows-Test-VM (Zugang laut Memory `reference_windows_test_vm.md`): Client installieren bzw. per In-App-Update aktualisieren, gegen alpha melden -> Betreff `[Desktop/Windows]`, Zeile `Herkunft: Desktop-App (Windows), Tessera-App <Version> · Stand <sha7>`; Kontrolle des Cookies `tessera_desktop_client` in der Seite ueber Einstellungen -> Desktop-App ist nicht noetig, die Mail genuegt.
|
||||
- Alter Client (optional): ein bestehender 1.2.0-Client ohne Update erzeugt `[Desktop]` und `Desktop-App (unbekannt)` — das ist das dokumentierte Verhalten (Betriebshandbuch Kap. 10).
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Jede Fehlermeldungs-Mail traegt im Betreff direkt nach `[Tessera Fehlermeldung]` genau eines der Kuerzel `[Browser]`, `[Desktop/Windows]`, `[Desktop/Linux]` (oder `[Desktop]` bei einem alten Client) und im Text die Zeile `Herkunft: …` in der im Auftrag festgelegten Form; `Browser:` und `Fenster:` bleiben.
|
||||
- Desktop-Client, Middleware, Web-Helfer, Nutzlast, DTO und Dienst sind durchgaengig verbunden und je Schicht durch Tests belegt (Rust 5 Marker-Tests, Middleware 9, desktop-client ≥ 11, bug-report-api 3, Komponententest 13, origin ≥ 8, Service 10, Controller 4).
|
||||
- Rueckwaertskompatibel in beide Richtungen (alter Client, alter Web-Bau, alte API) — kein 400, kein Verlust der bisherigen Meldung.
|
||||
- Keine neue Abhaengigkeit, keine DB-Aenderung, `main.ts` unveraendert, kein UI-Text geaendert.
|
||||
- CHANGELOG und beide Handbuecher beschreiben Kuerzel und Herkunftszeile; drei bis vier Code-/Doku-Commits mit den vorgegebenen Praefixen; `.planning/`-Artefakte werden vom Executor NICHT committet.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/quick/260918-gza-fehlermeldung-herkunft-ausweisen-browser/260918-gza-SUMMARY.md` when done
|
||||
</output>
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
---
|
||||
status: complete
|
||||
phase: quick-260918-gza
|
||||
plan: 01
|
||||
subsystem: bug-reports
|
||||
tags: [fehler-melden-knopf, herkunft, desktop-app, betreff-kuerzel]
|
||||
dependency-graph:
|
||||
requires: [quick-260914-m97, quick-260917-h2s]
|
||||
provides: [herkunfts-kuerzel-im-betreff, herkunftszeile-in-der-mail, cookie-tessera_desktop_client]
|
||||
affects: [apps/api/src/bug-reports, apps/desktop/src-tauri, apps/web/src/middleware.ts, apps/web/src/lib, apps/web/src/components/bug-report]
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns: [reine-helferfunktion-mit-eigener-spec, cookie-huelle-um-bestehenden-marker-mechanismus]
|
||||
key-files:
|
||||
created:
|
||||
- apps/api/src/bug-reports/origin.ts
|
||||
- apps/api/src/bug-reports/origin.spec.ts
|
||||
- apps/web/src/lib/bug-report-api.test.ts
|
||||
modified:
|
||||
- apps/api/src/bug-reports/dto/bug-report.dto.ts
|
||||
- apps/api/src/bug-reports/bug-reports.service.ts
|
||||
- apps/api/src/bug-reports/bug-reports.service.spec.ts
|
||||
- apps/api/src/bug-reports/bug-reports.controller.spec.ts
|
||||
- apps/desktop/src-tauri/src/lib.rs
|
||||
- apps/web/src/middleware.ts
|
||||
- apps/web/src/middleware.test.ts
|
||||
- apps/web/src/lib/desktop-client.ts
|
||||
- apps/web/src/lib/desktop-client.test.ts
|
||||
- apps/web/src/lib/bug-report-api.ts
|
||||
- apps/web/src/components/bug-report/bug-report-dialog.tsx
|
||||
- apps/web/src/components/bug-report/bug-report-button.test.tsx
|
||||
- CHANGELOG.md
|
||||
- docs/anleitung-administration.md
|
||||
- docs/anleitung-betrieb.md
|
||||
decisions:
|
||||
- "Marker-/Cookie-Mechanismus aus quick-260917-h2s erweitert statt WebView-User-Agent zu ueberschreiben (Vorgabe des Orchestrators im Plan, keine Abweichung)."
|
||||
- "Regex statt neuer ua-parser-Bibliothek in origin.ts — fuenf Browser/fuenf Betriebssysteme reichen fuer ein Postfach, kein neues Paket."
|
||||
metrics:
|
||||
duration: ca. 45 min
|
||||
completed: 2026-09-18
|
||||
actuals:
|
||||
tokens: 68000
|
||||
tasks: 3
|
||||
commits: 4
|
||||
plan_head_before: ab99a9a56e6d0e1e57e07c6feb2a2fa2ab866fc4
|
||||
---
|
||||
|
||||
# Phase quick-260918-gza Plan 01: Herkunft der Fehlermeldung ausweisen — Summary
|
||||
|
||||
Fehlermeldungen des Fehler-melden-Knopfs tragen jetzt ein Herkunfts-Kuerzel im Betreff (`[Browser]`, `[Desktop/Windows]`, `[Desktop/Linux]`, Rueckfall `[Desktop]`) und eine Zeile `Herkunft: …` im Text, abgeleitet vom reinen Helfer `origin.ts` aus vier neuen optionalen DTO-Feldern (Desktop-App, ueber ein zweites Cookie `tessera_desktop_client`) bzw. dem User-Agent (Browser).
|
||||
|
||||
## Ausgefuehrte Tasks
|
||||
|
||||
1. **API — `origin.ts`, DTO-Felder, Betreff-Kuerzel, Zeile `Herkunft:`** — Commit `7169472`
|
||||
2. **Desktop-Marker (`dv`/`dc`/`dos`), Middleware-Cookie, Web-Nutzlast** — zwei Commits:
|
||||
- `b03cb21` — Rust: `with_client_marker` (rein) + `with_desktop_marker` (Huelle)
|
||||
- `f245711` — Web: Middleware-Cookie, `desktop-client.ts`, `bug-report-api.ts`, Dialog
|
||||
3. **CHANGELOG und Handbuecher** — Commit `e2a7946`
|
||||
|
||||
## Commits
|
||||
|
||||
| Hash | Betreff |
|
||||
|------|---------|
|
||||
| `7169472` | feat(bug-reports): Herkunft der Fehlermeldung im Betreff-Kuerzel und als Zeile Herkunft ausweisen |
|
||||
| `b03cb21` | feat(desktop): Version, Stand und Betriebssystem im Desktop-Marker mitgeben (dv, dc, dos) |
|
||||
| `f245711` | feat(web): Herkunft der Fehlermeldung — Cookie tessera_desktop_client und Client-Felder in der Nutzlast |
|
||||
| `e2a7946` | docs: Fehlermeldungen — Herkunft (Browser/Desktop-App, Betriebssystem, Version) und Betreff-Kürzel (Handbücher, CHANGELOG) |
|
||||
|
||||
`commits: 4` (gemessen: `git rev-list --count ab99a9a..HEAD` = 4; `plan_head_before` ist der Stand vor Task 1).
|
||||
|
||||
## Testzahlen
|
||||
|
||||
| Suite | Vorher | Nachher | Befehl |
|
||||
|---|---|---|---|
|
||||
| API `src/bug-reports` | 11 | 24 (origin 10, service 10, controller 4) | `pnpm --filter @tessera/api exec vitest run src/bug-reports` |
|
||||
| API vollstaendig | — | 1124/1124 gruen (69 Testdateien) | `pnpm --filter @tessera/api exec vitest run` |
|
||||
| API `type-check` | — | ohne Fehler | `pnpm --filter @tessera/api type-check` |
|
||||
| Web `src/lib`, `middleware.test.ts`, `src/components/bug-report` | 11 (desktop-client) + 5 (middleware) + 11 (button) = 27 | 101/101 gruen (13 Testdateien; middleware 9, desktop-client 13, bug-report-api 3 NEU, bug-report-button 13) | `pnpm --filter @tessera/web exec vitest run src/lib src/middleware.test.ts src/components/bug-report` |
|
||||
| Web vollstaendig | — | 447/447 gruen (65 Testdateien) | `pnpm --filter @tessera/web exec vitest run` |
|
||||
| Web `type-check` | — | ohne Fehler | `pnpm --filter @tessera/web type-check` |
|
||||
| Rust `cargo test --lib` | 33 (STATE-Baseline) | 37/37 gruen, `cargo fmt --check` sauber | `apps/desktop/src-tauri && cargo fmt --check && cargo test --lib` |
|
||||
|
||||
Alle Zahlen erfuellen bzw. uebertreffen die Vorgaben aus `<success_criteria>` (Rust ≥ 5 Marker-Tests — 5 vorhanden: 3 umgestellt + leerer Commit + Huelle; Middleware 9; desktop-client ≥ 11 — 13; bug-report-api 3; Komponententest 13; origin ≥ 8 — 10; Service 10; Controller 4).
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
Keine — der Plan wurde wie geschrieben ausgefuehrt. Ergaenzend zwei kleine Implementierungsentscheidungen, die im Rahmen des Plans lagen (keine Abweichung von `<behavior>`/`<action>`):
|
||||
|
||||
- **Test 9 im Service-Spec** (Betreff/Zeile/Protokollzeile fuer Desktop/Windows) nutzt fuer die Pruefung "Logger genau einmal mit Kuerzel gerufen" eine zweite, frische `makeService()`-Instanz, damit der Aufruf-Zaehler nicht durch den vorherigen `submit()` in demselben Test verfaelscht wird. Ergebnis entspricht exakt der im Plan verlangten Erwartung.
|
||||
- In `origin.ts` wurde `clean()` mit Default-Parameter `max = 40` implementiert (im Plan als `clean(value, max = 40)` vorgegeben) — keine Abweichung, nur Bestaetigung der genauen Umsetzung.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
Keine.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
Keine neue, im Plan nicht erfasste Sicherheitsflaeche gefunden — alle vier neuen DTO-Felder, das zweite Cookie und die Bereinigungsregeln entsprechen exakt dem Threat Register des Plans (T-GZA-01 bis T-GZA-04, T-GZA-SC).
|
||||
|
||||
## Offene Punkte fuer den Orchestrator (Nachweis, nicht Aufgabe des Executors)
|
||||
|
||||
Laut `<verification>` des Plans, ausdruecklich NICHT Teil dieser Ausfuehrung:
|
||||
|
||||
1. **Browser-Fall (Playwright MCP + mailhog):** lokal `docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d mailhog` und `docker compose up -d --build api web`, dann ueber den Fehler-melden-Knopf eine Meldung senden und in `http://localhost:8025` pruefen: Betreff `[Tessera Fehlermeldung] [Browser] dev dev - /…`, Zeile `Herkunft: Browser — Chrome <N> auf Linux`, Zeilen `Browser:`/`Fenster:` weiterhin vorhanden; `docker compose logs api | grep "Bug report"` zeigt das Kuerzel.
|
||||
2. **Desktop-Fall (Windows-Test-VM nach CI-Bau):** Client auf der VM installieren bzw. per In-App-Update aktualisieren (Zugang laut Memory `reference_windows_test_vm.md`), gegen alpha melden -> Betreff `[Desktop/Windows]`, Zeile `Herkunft: Desktop-App (Windows), Tessera-App <Version> · Stand <sha7>`.
|
||||
3. **Optional — alter Desktop-Client:** ein bestehender 1.2.0-Client ohne Update erzeugt `[Desktop]` und `Desktop-App (unbekannt)` — dokumentiertes Verhalten (Betriebshandbuch Kap. 10), kein zwingender Nachweis.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- `apps/api/src/bug-reports/origin.ts` — FOUND
|
||||
- `apps/api/src/bug-reports/origin.spec.ts` — FOUND
|
||||
- `apps/web/src/lib/bug-report-api.test.ts` — FOUND
|
||||
- Commit `7169472` — FOUND (`git log --oneline --all | grep 7169472`)
|
||||
- Commit `b03cb21` — FOUND
|
||||
- Commit `f245711` — FOUND
|
||||
- Commit `e2a7946` — FOUND
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
---
|
||||
phase: quick-260918-gza
|
||||
verified: 2026-09-18T12:45:00Z
|
||||
status: passed
|
||||
score: 9/9 must-haves verified
|
||||
covered_files:
|
||||
- ".planning/quick/260918-gza-fehlermeldung-herkunft-ausweisen-browser/260918-gza-PLAN.md"
|
||||
- ".planning/quick/260918-gza-fehlermeldung-herkunft-ausweisen-browser/260918-gza-SUMMARY.md"
|
||||
- "CHANGELOG.md"
|
||||
- "apps/api/src/bug-reports/bug-reports.controller.spec.ts"
|
||||
- "apps/api/src/bug-reports/bug-reports.service.spec.ts"
|
||||
- "apps/api/src/bug-reports/bug-reports.service.ts"
|
||||
- "apps/api/src/bug-reports/dto/bug-report.dto.ts"
|
||||
- "apps/api/src/bug-reports/origin.spec.ts"
|
||||
- "apps/api/src/bug-reports/origin.ts"
|
||||
- "apps/desktop/src-tauri/src/lib.rs"
|
||||
- "apps/web/src/components/bug-report/bug-report-button.test.tsx"
|
||||
- "apps/web/src/components/bug-report/bug-report-dialog.tsx"
|
||||
- "apps/web/src/lib/bug-report-api.test.ts"
|
||||
- "apps/web/src/lib/bug-report-api.ts"
|
||||
- "apps/web/src/lib/desktop-client.test.ts"
|
||||
- "apps/web/src/lib/desktop-client.ts"
|
||||
- "apps/web/src/middleware.test.ts"
|
||||
- "apps/web/src/middleware.ts"
|
||||
- "docs/anleitung-administration.md"
|
||||
- "docs/anleitung-betrieb.md"
|
||||
covered_digest: "v1:sha256:8c8d70d3963d82115c5c4b6ec8f96a5a16f98ad13ffb0c8ce57a688af8114342"
|
||||
behavior_unverified: 0
|
||||
overrides_applied: 0
|
||||
human_verification:
|
||||
- test: "Browser-Fall lokal mit Playwright MCP und mailhog (docker compose up mailhog/api/web, Fehler melden -> Mail in http://localhost:8025 mit Betreff [Tessera Fehlermeldung] [Browser] dev dev - /... und Zeile Herkunft: Browser — Chrome <N> auf Linux)"
|
||||
expected: "Betreff traegt [Browser], Text enthaelt Herkunft-Zeile, Browser:/Fenster: bleiben, API-Log zeigt das Kuerzel"
|
||||
why_human: "Erfordert laufenden Mailhog/API/Web-Stack und echten Browser-Klick — Nachweis durch Orchestrator ausstehend, nicht statisch im Code pruefbar"
|
||||
- test: "Desktop-Fall auf der Windows-Test-VM nach CI-Bau (Client installieren/aktualisieren, gegen alpha melden)"
|
||||
expected: "Betreff [Desktop/Windows], Zeile Herkunft: Desktop-App (Windows), Tessera-App <Version> · Stand <sha7>"
|
||||
why_human: "Erfordert echten Desktop-Client-Build und eine Windows-VM — Nachweis durch Orchestrator ausstehend, nicht statisch im Code pruefbar"
|
||||
---
|
||||
|
||||
# Quick-Task 260918-gza: Fehlermeldung — Herkunft ausweisen (Browser/Desktop) — Verification Report
|
||||
|
||||
**Ziel:** `POST /bug-reports`-Mails weisen die Herkunft (Browser vs. Desktop-App, Betriebssystem, bei Desktop zusaetzlich Version/Commit) im Betreff-Kuerzel und in einer Textzeile aus; der Desktop-Client meldet die Werte ueber den bestehenden Marker-/Cookie-Mechanismus; Rueckwaertskompatibilitaet in beide Richtungen; keine DB-Aenderung, `main.ts` unangetastet; CHANGELOG und Handbuecher ergaenzt.
|
||||
|
||||
**Verified:** 2026-09-18
|
||||
**Status:** passed
|
||||
**Re-verification:** Nein — Erstverifikation
|
||||
|
||||
## Zusammenfassung
|
||||
|
||||
Alle vier Commits (`7169472`, `b03cb21`, `f245711`, `e2a7946`) sind auf `main` vorhanden und entsprechen inhaltlich exakt dem Plan. Ich habe jede der acht geforderten Pruefpunkte direkt am Code (nicht an der SUMMARY) nachvollzogen und zusaetzlich alle relevanten Testsuiten selbst ausgefuehrt statt die im SUMMARY behaupteten Zahlen zu uebernehmen.
|
||||
|
||||
## Observable Truths
|
||||
|
||||
| # | Truth | Status | Evidence |
|
||||
|---|-------|--------|----------|
|
||||
| 1 | `describeOrigin`/`parseUserAgent` liefern die im Auftrag festgelegten Kuerzel/Zeilen fuer Browser-UA (Edge/Windows), Desktop mit OS+Version+Commit, Desktop ohne Commit, Desktop ohne Details, fehlende Felder | ✓ VERIFIED | `apps/api/src/bug-reports/origin.ts:67-149`; `origin.spec.ts` Tests 1,6,7,8,9 — selbst ausgefuehrt: `pnpm --filter @tessera/api exec vitest run src/bug-reports` → 24/24 gruen (origin.spec.ts 10 Tests) |
|
||||
| 2 | Betreff `[Tessera Fehlermeldung] <tag> <webVersion> <webChannel> - <page>`; Zeile `Herkunft:` vor `Browser:`; `Browser:`/`Fenster:` unveraendert | ✓ VERIFIED | `bug-reports.service.ts:126-149` (`origin.tag` im Subject; `Herkunft:` direkt vor `Browser:` im Text-Array); `bug-reports.service.spec.ts` Test 9 prueft `text.indexOf('Herkunft:') < text.indexOf('Browser:')` explizit |
|
||||
| 3 | DTO: vier optionale Felder, `clientKind` mit `@IsIn`, Laengenbegrenzung | ✓ VERIFIED | `apps/api/src/bug-reports/dto/bug-report.dto.ts:96-115` — `@IsOptional() @IsIn(['desktop','browser'])` fuer `clientKind`, `@MaxLength(20)`/`@MaxLength(40)`/`@MaxLength(40)` fuer `clientOs`/`clientVersion`/`clientCommit`; `bug-reports.controller.spec.ts` Test 4 prueft alle Grenzen inkl. `BadRequestException` bei `clientKind: 'tablet'` |
|
||||
| 4 | Rust: `with_desktop_marker` bleibt `&Url`-only, drei Aufrufstellen unveraendert, neue reine `with_client_marker` getestet, `desktop=1` weiterhin vorhanden, `dos` aus `std::env::consts::OS` | ✓ VERIFIED | `lib.rs:56-89` (`with_client_marker(url,&str,&str,&str)`, `with_desktop_marker(url: &tauri::Url)` als Huelle mit `env!`/`std::env::consts::OS`); Aufrufstellen `save_server_url` (Z. 503), `open_server` (Z. 527), Setup (Z. 563) unveraendert `with_desktop_marker(&parsed)`; `cargo test --lib` selbst ausgefuehrt → 37/37 gruen, `cargo fmt --check` sauber |
|
||||
| 5 | Middleware: `tessera_desktop=1`-Verhalten unveraendert; neues Cookie nur bei allen drei validen Parametern; nicht httpOnly; secure nur bei https | ✓ VERIFIED | `middleware.ts:70-86` — `buildDesktopClientCookieValue` liefert `null` bei fehlendem/ungueltigem Parameter, dieselben `cookieOptions` (inkl. `httpOnly: false`, `secure: req.nextUrl.protocol === 'https:'`) wie das bestehende Cookie; `middleware.test.ts` Tests 6-9 selbst ausgefuehrt (Teil der 101/101 gruenen Web-Suite) |
|
||||
| 6 | Web: `getDesktopClientInfo()` dekodiert `%7C`; Dialog fuellt vier Felder; FormData haengt sie an | ✓ VERIFIED | `desktop-client.ts:34-54` (`decodeURIComponent` in try/catch, Split an `|`); `bug-report-dialog.tsx:61-77` (`isDesktopClient()`/`getDesktopClientInfo()` → vier Felder); `bug-report-api.ts:95-99` (vier `body.append`-Zeilen) |
|
||||
| 7 | Keine neue Abhaengigkeit, kein Schema, `main.ts` unangetastet | ✓ VERIFIED | `git diff ab99a9a..HEAD -- '**/package.json' '**/Cargo.toml' '**/Cargo.lock' '**/pnpm-lock.yaml' apps/api/prisma/schema.prisma apps/api/src/main.ts` → leerer Diff |
|
||||
| 8 | CHANGELOG-Eintrag unter „Unveröffentlicht → Geändert“; beide Handbuecher nennen Herkunft/Kuerzel | ✓ VERIFIED | `CHANGELOG.md:20` (Eintrag unter `### Geändert`); `docs/anleitung-administration.md:208` (Herkunfts-Absatz); `docs/anleitung-betrieb.md:341` (Kap. 7, Kuerzel in der Log-Zeile) und `docs/anleitung-betrieb.md:709` (Kap. 10, neue Fehlerbild-Zeile „Desktop-App (unbekannt)“) |
|
||||
|
||||
**Score:** 8/8 fachliche Wahrheiten aus dem Pruefauftrag verifiziert (plus die uebergeordnete Rueckwaertskompatibilitaets-Wahrheit aus dem Plan-Frontmatter unten separat gefuehrt) — insgesamt 9/9 must-haves.
|
||||
|
||||
| # | Zusaetzliche Plan-Wahrheit | Status | Evidence |
|
||||
|---|---|--------|----------|
|
||||
| 9 | Alter Desktop-Client (nur `desktop=1`) und alter Web-Bau (ohne vier Felder) bleiben gueltig, kein 400 | ✓ VERIFIED | `describeOrigin({ userAgent: 'UA' })` faellt auf `[Browser]` zurueck (origin.spec.ts Test 9); `middleware.test.ts` Test 7 zeigt: `desktop=1` ohne `dv/dc/dos` setzt `tessera_desktop` weiterhin, aber kein zweites Cookie (kein Fehler, keine Ausnahme); DTO-Felder sind `@IsOptional()` (`bug-report.dto.ts`), globale Pipe hat kein `forbidNonWhitelisted` (unveraendert in `main.ts`, siehe Wahrheit 7) |
|
||||
|
||||
## Required Artifacts
|
||||
|
||||
| Artifact | Expected | Status | Details |
|
||||
|----------|----------|--------|---------|
|
||||
| `apps/api/src/bug-reports/origin.ts` | reine Helfer `parseUserAgent`/`describeOrigin` | ✓ VERIFIED | Existiert, 149 Zeilen, keine Abhaengigkeit, exportiert beide Funktionen wie gefordert |
|
||||
| `apps/api/src/bug-reports/origin.spec.ts` | ≥8 Faelle | ✓ VERIFIED | 10 `it`-Bloecke, deckt alle im Plan geforderten Faelle |
|
||||
| `apps/api/src/bug-reports/dto/bug-report.dto.ts` | vier optionale Felder | ✓ VERIFIED | Vorhanden, `@IsOptional()` an allen vieren |
|
||||
| `apps/api/src/bug-reports/bug-reports.service.ts` | Betreff-Kuerzel, Zeile `Herkunft:`, Kuerzel im Log | ✓ VERIFIED | Zeilen 126-149 |
|
||||
| `apps/desktop/src-tauri/src/lib.rs` | `with_client_marker` rein + `with_desktop_marker` als Huelle, drei Aufrufstellen unveraendert | ✓ VERIFIED | Zeilen 56-89; Aufrufstellen 503/527/563 unveraendert |
|
||||
| `apps/web/src/middleware.ts` | `withDesktopCookie` setzt zweites Cookie bereinigt | ✓ VERIFIED | Zeilen 46-86 |
|
||||
| `apps/web/src/lib/desktop-client.ts` | `DESKTOP_CLIENT_COOKIE_NAME`, `parseDesktopClientCookie`, `getDesktopClientInfo` | ✓ VERIFIED | Zeilen 20-62 |
|
||||
| `apps/web/src/lib/bug-report-api.ts` | `BugReportPayload` + vier FormData-Felder | ✓ VERIFIED | Zeilen 76-99 |
|
||||
| `apps/web/src/lib/bug-report-api.test.ts` | NEU, FormData-Felder + Netzwerkfehler | ✓ VERIFIED | Neu erstellt, Teil der 101/101 gruenen Web-Suite |
|
||||
| `apps/web/src/components/bug-report/bug-report-dialog.tsx` | `handleSend` fuellt vier Felder | ✓ VERIFIED | Zeilen 61-77 |
|
||||
| `CHANGELOG.md`, `docs/anleitung-administration.md`, `docs/anleitung-betrieb.md` | Herkunft beschrieben | ✓ VERIFIED | Siehe Wahrheit 8 |
|
||||
|
||||
## Key Link Verification
|
||||
|
||||
| From | To | Via | Status | Details |
|
||||
|------|-----|-----|--------|---------|
|
||||
| Rust `with_desktop_marker` (3 Aufrufstellen) | Query `desktop=1&dv&dc&dos` | `query_pairs_mut().append_pair` | ✓ WIRED | `lib.rs:56-89`, Aufrufstellen unveraendert |
|
||||
| Query-Parameter | Middleware `withDesktopCookie` | `buildDesktopClientCookieValue(req.nextUrl.searchParams)` | ✓ WIRED | `middleware.ts:80-83` |
|
||||
| Cookie `tessera_desktop_client` | `getDesktopClientInfo()` | `document.cookie` + `decodeURIComponent` | ✓ WIRED | `desktop-client.ts:57-62` |
|
||||
| `getDesktopClientInfo()` | `handleSend` im Dialog | `isDesktopClient()`/`getDesktopClientInfo()` | ✓ WIRED | `bug-report-dialog.tsx:61-77` |
|
||||
| Dialog | `sendBugReport`/FormData | vier `body.append`-Zeilen | ✓ WIRED | `bug-report-api.ts:95-99` |
|
||||
| FormData | `BugReportDto` | Whitelist verlangt Deklaration | ✓ WIRED | `bug-report.dto.ts:96-115` |
|
||||
| `BugReportDto` | `describeOrigin()` | `bug-reports.service.ts:127` (`describeOrigin(dto)`) | ✓ WIRED | Betreff/Zeile/Log nutzen `origin.tag`/`origin.line` |
|
||||
|
||||
## Behavioral Spot-Checks / Tests (selbst ausgefuehrt, nicht aus SUMMARY uebernommen)
|
||||
|
||||
| Suite | Befehl | Ergebnis |
|
||||
|-------|--------|----------|
|
||||
| API `src/bug-reports` | `pnpm --filter @tessera/api exec vitest run src/bug-reports` | 24/24 gruen (origin 10, service 10, controller 4) |
|
||||
| API `type-check` | `pnpm --filter @tessera/api type-check` | ohne Fehler |
|
||||
| Web `src/lib`, `middleware.test.ts`, `src/components/bug-report` | `pnpm --filter @tessera/web exec vitest run src/lib src/middleware.test.ts src/components/bug-report` | 101/101 gruen (13 Testdateien) |
|
||||
| Web `type-check` | `pnpm --filter @tessera/web type-check` | ohne Fehler |
|
||||
| Rust `cargo fmt --check` | `cd apps/desktop/src-tauri && cargo fmt --check` | sauber |
|
||||
| Rust Tests | `cargo test --lib` | 37/37 gruen |
|
||||
| Abhaengigkeiten/Schema/main.ts | `git diff ab99a9a..HEAD -- '**/package.json' '**/Cargo.toml' '**/Cargo.lock' '**/pnpm-lock.yaml' apps/api/prisma/schema.prisma apps/api/src/main.ts` | leerer Diff — keine Aenderung |
|
||||
|
||||
## Anti-Patterns Found
|
||||
|
||||
Keine. `git diff ab99a9a..HEAD` ueber alle betroffenen Dateien enthaelt keine Treffer fuer `TODO|FIXME|XXX|TBD|HACK|PLACEHOLDER|not yet implemented|coming soon`.
|
||||
|
||||
## Requirements Coverage
|
||||
|
||||
Dieser Quick-Task ist nicht an `.planning/REQUIREMENTS.md` gebunden (kein Phasen-Requirement); der Plan traegt `requirements: [QUICK-260918-GZA]` als eigene Kennung, deren einziges Artefakt dieser Task selbst ist. Kein Abgleich noetig.
|
||||
|
||||
## Human Verification Required
|
||||
|
||||
Zwei Nachweise sind laut Plan ausdruecklich Aufgabe des Orchestrators nach der Ausfuehrung, nicht des Executors — beide sind reine End-to-End-Proben (laufender Stack bzw. Windows-VM) und nicht statisch im Code pruefbar. Sie blockieren den Status NICHT (siehe Verify-Auftrag): **Nachweis durch Orchestrator ausstehend.**
|
||||
|
||||
1. **Browser-Fall (Playwright MCP + mailhog):** lokal `docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d mailhog` + `docker compose up -d --build api web`, ueber den Fehler-melden-Knopf senden, in `http://localhost:8025` pruefen: Betreff `[Tessera Fehlermeldung] [Browser] dev dev - /…`, Zeile `Herkunft: Browser — Chrome <N> auf Linux`, `docker compose logs api | grep "Bug report"` zeigt das Kuerzel.
|
||||
2. **Desktop-Fall (Windows-Test-VM nach CI-Bau):** Client installieren/aktualisieren, gegen alpha melden -> Betreff `[Desktop/Windows]`, Zeile `Herkunft: Desktop-App (Windows), Tessera-App <Version> · Stand <sha7>`.
|
||||
|
||||
(Der dritte im Plan genannte Fall — alter Desktop-Client ohne Update — ist laut Plan optional und bereits durch `origin.spec.ts` Test 8 sowie `middleware.test.ts` Test 7 statisch abgedeckt.)
|
||||
|
||||
## Gaps Summary
|
||||
|
||||
Keine. Alle acht im Pruefauftrag genannten Punkte sowie die Rueckwaertskompatibilitaets-Wahrheit aus dem Plan sind direkt im Code nachgewiesen; alle vier Testsuiten (API, Web, Rust, Doku-Grep) wurden selbst ausgefuehrt und liefern die im SUMMARY behaupteten Zahlen exakt reproduziert (24 API-Tests, 101 Web-Tests, 37 Rust-Tests). Keine neue Abhaengigkeit, kein Schema-Wechsel, `main.ts` unveraendert. Die beiden offenen Punkte sind manuelle End-to-End-Proben, die laut Plan explizit dem Orchestrator obliegen.
|
||||
|
||||
---
|
||||
|
||||
_Verified: 2026-09-18_
|
||||
_Verifier: Claude (gsd-verifier)_
|
||||
+266
@@ -0,0 +1,266 @@
|
||||
---
|
||||
phase: quick-260921-9ie
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- biome.json
|
||||
- apps/api/package.json
|
||||
- apps/web/package.json
|
||||
- apps/desktop/package.json
|
||||
- packages/shared/package.json
|
||||
- packages/module-sdk/package.json
|
||||
- turbo.json
|
||||
- docs/anleitung-entwicklung.md
|
||||
- .planning/WINDOWS.md
|
||||
autonomous: true
|
||||
requirements: [WINDOWS-35]
|
||||
|
||||
estimate:
|
||||
tokens: 35000
|
||||
raw_tokens: 35000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Biome bricht nicht mehr mit einem Konfigurationsfehler ab: ein Aufruf von biome lint laeuft durch und liefert einen echten Befund-Bericht statt der Abbruchmeldung."
|
||||
- "NestJS-Parameter-Dekoratoren werden geparst: apps/api hat null parse-Fehler (vorher 238 in 19 Dateien)."
|
||||
- "pnpm lint fuehrt echte Pruefungen aus: turbo meldet 5 ausgefuehrte Aufgaben statt 'No tasks were executed'."
|
||||
- "pnpm lint endet auf dem unveraenderten Bestand mit Exit 0."
|
||||
- "pnpm lint endet mit Exit 1, sobald ein echter Regelverstoss im Quellcode steht — das Gate hat Biss."
|
||||
- "Die Regelgruppe security bleibt auf error; keine Sicherheitsregel wurde stummgeschaltet."
|
||||
- "Kein Quellcode wurde umformatiert: der Diff enthaelt ausschliesslich Konfiguration, Paket-Skripte und Dokumentation."
|
||||
artifacts:
|
||||
- biome.json
|
||||
- apps/api/package.json
|
||||
- apps/web/package.json
|
||||
- apps/desktop/package.json
|
||||
- packages/shared/package.json
|
||||
- packages/module-sdk/package.json
|
||||
- turbo.json
|
||||
- docs/anleitung-entwicklung.md
|
||||
key_links:
|
||||
- "package.json (root) Skript lint -> turbo.json Aufgabe lint -> lint-Skript je Workspace -> biome lint -> biome.json im Wurzelverzeichnis"
|
||||
- ".gitea/workflows/ci.yml Schritt 'Lint' ruft pnpm lint — ab jetzt mit echtem Pruefumfang"
|
||||
- "turbo.json globalDependencies enthaelt biome.json, damit eine Konfigurationsaenderung den Lint-Cache verwirft"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Biome ist im Bestand nicht lauffaehig und der CI-Schritt „Lint" ist ein Leerlauf, der gruen meldet. Dieser Plan repariert beides und weist das Ergebnis mit echten Messungen nach.
|
||||
|
||||
Purpose: Der Eintrag #35 im Maengelregister beschreibt ein Pruef-Tor, das nichts prueft. Solange `biome.json` einen in Biome 2.5.0 unbekannten Schluessel traegt und keine einzige App ein `lint`-Skript hat, laeuft jeder Lauf entweder in einen Konfigurationsfehler oder an allem vorbei. Ein Tor ohne Biss ist schaedlicher als gar keines, weil es Sicherheit vortaeuscht.
|
||||
|
||||
Output: Eine fuer Biome 2.5.0 gueltige `biome.json`, ein `lint`-Skript in allen fuenf Workspaces, ein Cache-Bezug in `turbo.json` und eine nachgezogene Entwickler-Anleitung. `pnpm lint` ist danach gruen auf dem Bestand und wird rot, sobald ein echter Fehler dazukommt.
|
||||
|
||||
## Gemessener Ausgangsstand (2026-09-21, vor dem Umbau)
|
||||
|
||||
Alles Folgende wurde in der Planung am laufenden Projekt gemessen, nicht aus dem Registereintrag uebernommen:
|
||||
|
||||
- `biome.json` traegt `organizeImports` auf oberster Ebene — Biome 2.5.0 kennt den Schluessel dort nicht und bricht jeden Aufruf ab.
|
||||
- `biome migrate --write` auf einer Kopie liefert die verbindliche Zielform: `assist.actions.source.organizeImports` mit Wert `"on"`, und `linter.rules.recommended: true` wird zu `linter.rules.preset: "recommended"`. Der zweite Teil steht **nicht** im Registereintrag, ist aber Teil der Migration.
|
||||
- Der Schalter `javascript.parser.unsafeParameterDecoratorsEnabled` senkt die parse-Fehler in `apps/api` von **238 in 19 Dateien** auf **30 in 1 Datei**. Der Registereintrag nennt „17 allein in user.controller.ts" — das ist in zwei Punkten falsch: der Pfad lautet `apps/api/src/user/user.controller.ts` (Einzahl `user`, nicht `users`), und die tatsaechliche Wirkung ist um ein Vielfaches groesser.
|
||||
- Die verbleibenden 30 parse-Fehler liegen in `apps/api/src/tenders/__fixtures__/cosinex-search.html`, dazu 2 in `apps/web/src/app/globals.css` (Tailwind-4-At-Regeln, die Biomes CSS-Parser nicht kennt).
|
||||
- Anfuehrungszeichen: 854 Importzeilen in `apps/api`, 642 in `apps/web` benutzen einfache Anfuehrungszeichen, **null** benutzen doppelte. `quoteStyle: "single"` ist damit belegt, nicht geraten.
|
||||
- `turbo lint` meldet heute „No tasks were executed" bei Exit 0 — der Leerlauf ist reproduziert.
|
||||
|
||||
## Entscheidung: Zweig (b), mit Begruendung aus der Messung
|
||||
|
||||
Die Vorgabe liess zwei Zweige zu. Gemessen wurde:
|
||||
|
||||
| Aufruf | Exit | Fehler | Warnungen |
|
||||
|---|---|---|---|
|
||||
| `biome check .` | 1 | 760 | 2594 |
|
||||
| `biome lint .` | 1 | 275 | 2594 |
|
||||
| `biome format .` | 1 | 319 | — |
|
||||
|
||||
Zweig (a) scheidet damit aus: die Befunde sind **nicht** blosse Warnungen, der Lauf faellt auch als reines `lint` durch. Also Zweig (b), in drei praezisen Schritten statt eines pauschalen Rundumschlags:
|
||||
|
||||
1. **Das Skript ruft `biome lint`, nicht `biome check`.** Das nimmt die 319 Formatierungsbefunde und die Importsortierung aus dem Tor heraus — genau die Befunde, die einen projektweiten Umbau erzwingen wuerden. Die Formatierungs-Einstellungen bleiben in der Datei gueltig und wirken weiter fuer `biome format --write` und den Editor.
|
||||
2. **Zwei Dateien werden von Biome ausgenommen.** `apps/api/src/tenders/__fixtures__/cosinex-search.html` ist eine abgespeicherte Fremdseite als Testvorlage, kein eigener Quellcode; das Verzeichnis `__fixtures__` enthaelt ausschliesslich Datendateien (html, zip, xml) und keine einzige TypeScript-Datei. `apps/web/src/app/globals.css` scheitert an Tailwind-4-Syntax, die Biome nicht kennt.
|
||||
3. **Gezielte Herabstufungen statt Quellcode-Umbau.** Von den 275 Fehlern sind 32 parse-Fehler (durch Schritt 2 erledigt) und 243 echte Regelverstoesse — 217 davon in `apps/web`, 25 in `apps/api`, 1 in `apps/desktop`. Verteilung: Barrierefreiheit 184, `suspicious` 33, `correctness/useExhaustiveDependencies` 20, `security/noScriptUrl` 6.
|
||||
|
||||
**Sicherheitsrelevanter Befund, der die Herabstufung begrenzt:** alle 6 Treffer der Regel `lint/security/noScriptUrl` liegen ausnahmslos in der ausgenommenen HTML-Testvorlage, in keiner einzigen echten Quelldatei. Die Gruppe `security` wird deshalb **nicht** herabgestuft und bleibt auf `error`. Es wird keine Sicherheitsregel stummgeschaltet — die 6 Treffer verschwinden, weil die Fremdseite nicht mehr geprueft wird, nicht weil die Regel entschaerft wurde.
|
||||
|
||||
Die uebrigen Regeln werden auf `warn` gesetzt, nicht abgeschaltet: sie bleiben im Bericht sichtbar und bilden den Rueckstand, den der Registereintrag ohnehin als eigenen Durchlauf vorsieht. Nach dem Umbau: **0 Fehler, 2826 Warnungen, Exit 0** — und Exit 1, sobald ein echter Fehler dazukommt (in der Planung mit einer Wegwerfdatei gegengeprueft).
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/gsd-core/workflows/execute-plan.md
|
||||
@~/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/STATE.md
|
||||
@CLAUDE.md
|
||||
|
||||
@biome.json
|
||||
@turbo.json
|
||||
@package.json
|
||||
@apps/api/package.json
|
||||
@apps/web/package.json
|
||||
@.gitea/workflows/ci.yml
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: biome.json reparieren und die Kette bis pnpm lint an apps/api nachweisen</name>
|
||||
<files>biome.json, apps/api/package.json</files>
|
||||
<read_first>biome.json, apps/api/package.json, turbo.json, package.json</read_first>
|
||||
<action>
|
||||
Schreibe `biome.json` im Wurzelverzeichnis neu. Der Schluessel `$schema` und der komplette `formatter`-Block (enabled true, indentStyle space, indentWidth 2, lineWidth 100) bleiben unveraendert stehen. Entferne den Schluessel `organizeImports` von der obersten Ebene und setze stattdessen `assist.actions.source.organizeImports` auf den Wert `"on"` — das ist wortgleich die Ausgabe von `biome migrate --write`, nicht geraten. Ersetze im selben Zug `linter.rules.recommended: true` durch `linter.rules.preset: "recommended"`; auch das gehoert zur Migration und wird sonst uebersehen. `linter.enabled` bleibt true.
|
||||
|
||||
Ergaenze einen `javascript`-Block mit zwei Unterschluesseln: `parser.unsafeParameterDecoratorsEnabled` auf true, damit NestJS-Parameter-Dekoratoren ueberhaupt geparst werden, und `formatter.quoteStyle` auf `"single"`, belegt durch 1496 einfach-gequotete Importzeilen gegen null doppelt-gequotete.
|
||||
|
||||
Ergaenze einen `vcs`-Block mit enabled true, clientKind `"git"` und useIgnoreFile true, damit `.gitignore` gilt und Biome nicht in node_modules, .next, dist oder target laeuft. Wichtig: dieser Block funktioniert nur, solange die Konfigurationsdatei im selben Verzeichnis wie `.gitignore` liegt, also im Wurzelverzeichnis — bei einem Aufruf mit abweichendem Konfigurationspfad bricht Biome mit einem Datei-nicht-gefunden-Fehler ab.
|
||||
|
||||
Ergaenze `files.includes` mit genau drei Eintraegen in dieser Reihenfolge: dem Alles-Muster, einem verneinenden Muster fuer beliebig tief liegende `__fixtures__`-Verzeichnisse, und einem verneinenden Muster fuer den Pfad `apps/web/src/app/globals.css`. Verneinende Muster tragen in Biome 2.x ein vorangestelltes Ausrufezeichen.
|
||||
|
||||
Ergaenze unter `linter.rules` die Herabstufungen: die Gruppe `a11y` bekommt direkt den Wert `"warn"` (Gruppen nehmen laut mitgeliefertem Schema eine Schweregrad-Zeichenkette entgegen), unter `correctness` bekommt `useExhaustiveDependencies` den Wert `"warn"`, und unter `suspicious` bekommen `noArrayIndexKey`, `noAssignInExpressions`, `noControlCharactersInRegex`, `noDoubleEquals` und `useIterableCallbackReturn` je den Wert `"warn"`. Die Gruppe `security` wird nicht aufgefuehrt und behaelt damit den voreingestellten Schweregrad error — das ist Absicht und darf nicht „der Vollstaendigkeit halber" mit herabgestuft werden.
|
||||
|
||||
Trage anschliessend in `apps/api/package.json` unter `scripts` einen Eintrag `lint` mit dem Wert `biome lint .` ein. Aendere sonst nichts an der Datei — insbesondere keine Abhaengigkeiten und keine Versionen, Biome 2.5.0 liegt bereits im Lockfile.
|
||||
|
||||
Fass in diesem Schritt keine einzige Quelldatei an. Wenn nach dem Umbau `git status` irgendetwas ausserhalb der beiden genannten Dateien zeigt, ist etwas schiefgelaufen.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>
|
||||
cd /home/vicolab/projects/tessera-ctl
|
||||
# 1. Konfiguration ist fuer Biome 2.5.0 gueltig und der alte Schluessel ist weg
|
||||
node -e "const c=require('./biome.json'); if(c.organizeImports!==undefined) throw new Error('Schluessel liegt noch auf oberster Ebene'); if(c.assist.actions.source.organizeImports!=='on') throw new Error('assist fehlt'); if(c.linter.rules.preset!=='recommended') throw new Error('preset fehlt'); if(c.javascript.parser.unsafeParameterDecoratorsEnabled!==true) throw new Error('Parser-Schalter fehlt'); if(c.javascript.formatter.quoteStyle!=='single') throw new Error('quoteStyle fehlt'); if(c.linter.rules.security!==undefined) throw new Error('security wurde angefasst'); console.log('config OK');"
|
||||
# erwartet: "config OK", Exit 0
|
||||
# 2. biome migrate meldet keinen Migrationsbedarf mehr
|
||||
pnpm exec biome migrate 2>&1 | grep -q "configuration needs migration" && { echo "FEHLER: noch migrationsbeduerftig"; exit 1; } || echo "migrate sauber"
|
||||
# erwartet: "migrate sauber"
|
||||
# 3. Null parse-Fehler repo-weit (vorher 238 allein in apps/api)
|
||||
pnpm exec biome lint . --reporter=json --max-diagnostics=20000 > /tmp/biome_probe.json 2>/dev/null
|
||||
node -e "const d=require('/tmp/biome_probe.json').diagnostics||[]; const p=d.filter(x=>x.category==='parse'); const e=d.filter(x=>x.severity==='error'); console.log('parse:',p.length,'errors:',e.length); if(p.length||e.length) process.exit(1);"
|
||||
# erwartet: "parse: 0 errors: 0", Exit 0
|
||||
# 4. Die Kette traegt: apps/api laeuft ueber turbo und ist gruen
|
||||
pnpm exec turbo lint --force --filter=@tessera/api 2>&1 | tail -5
|
||||
# erwartet: "1 successful, 1 total", Exit 0
|
||||
# 5. Kein Quellcode angefasst (git-Status zuerst festhalten, damit ein Fehler von git nicht verschluckt wird)
|
||||
git status --porcelain > /tmp/gsd_status.txt || { echo "FEHLER: git status fehlgeschlagen"; exit 1; }
|
||||
FREMD=$(grep -vE "^ M \.planning/STATE\.md$" /tmp/gsd_status.txt | grep -vE "biome\.json|apps/api/package\.json|^\?\? \.planning/quick/" || true)
|
||||
test -z "$FREMD" || { echo "FEHLER: fremde Aenderungen:"; echo "$FREMD"; exit 1; }
|
||||
echo "Diff sauber"
|
||||
</automated>
|
||||
</verify>
|
||||
<done>`biome.json` ist fuer Biome 2.5.0 gueltig, `biome migrate` meldet keinen Bedarf mehr, repo-weit null parse-Fehler und null Fehler, `turbo lint --filter=@tessera/api` fuehrt eine echte Aufgabe aus und endet mit Exit 0, und der Diff enthaelt ausser den beiden Zieldateien nichts.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: lint-Skript in den restlichen vier Workspaces und Cache-Bezug in turbo.json</name>
|
||||
<files>apps/web/package.json, apps/desktop/package.json, packages/shared/package.json, packages/module-sdk/package.json, turbo.json</files>
|
||||
<read_first>apps/web/package.json, apps/desktop/package.json, packages/shared/package.json, packages/module-sdk/package.json, turbo.json</read_first>
|
||||
<action>
|
||||
Trage in `apps/web/package.json`, `apps/desktop/package.json`, `packages/shared/package.json` und `packages/module-sdk/package.json` jeweils unter `scripts` einen Eintrag `lint` mit dem Wert `biome lint .` ein — dieselbe Zeile wie in Task 1 bei `apps/api`. Alle vier wurden in der Planung einzeln gemessen und laufen mit der reparierten Konfiguration gruen durch (Exit 0). Sonst nichts an den Dateien aendern.
|
||||
|
||||
Ergaenze in `turbo.json` auf oberster Ebene, neben dem vorhandenen `tasks`-Objekt, den Schluessel `globalDependencies` mit `biome.json` als einzigem Eintrag. Ohne diesen Bezug liegt die Konfigurationsdatei ausserhalb jedes Workspace-Verzeichnisses, und turbo wuerde nach einer Aenderung an den Regeln weiterhin zwischengespeicherte Lint-Ergebnisse ausliefern — also erneut ein Tor, das gruen meldet, ohne geprueft zu haben. Genau diese Klasse von Fehler ist der Anlass des Vorgangs. Die bestehende `lint`-Aufgabe unter `tasks` bleibt unveraendert.
|
||||
|
||||
Weise danach zwei Dinge nach, die zusammen den eigentlichen Mangel schliessen: dass `pnpm lint` fuenf echte Aufgaben ausfuehrt statt keiner, und dass der Lauf rot wird, sobald ein echter Regelverstoss im Baum liegt. Lege fuer die Gegenprobe eine Wegwerfdatei unter `apps/api/src` an, die eine `debugger`-Anweisung und einen losen Gleichheitsvergleich enthaelt, lass den Lauf darauf scheitern und **entferne die Datei danach wieder**. Der Lauf fuer die Gegenprobe braucht `--force`, sonst kann der turbo-Cache das Ergebnis verdecken.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>
|
||||
cd /home/vicolab/projects/tessera-ctl
|
||||
# 1. Alle fuenf Workspaces haben ein lint-Skript
|
||||
node -e "const fs=require('fs'); const ps=['apps/api','apps/web','apps/desktop','packages/shared','packages/module-sdk']; const miss=ps.filter(p=>!(JSON.parse(fs.readFileSync(p+'/package.json','utf8')).scripts||{}).lint); if(miss.length) throw new Error('ohne lint-Skript: '+miss); console.log('5/5 Workspaces haben lint');"
|
||||
# erwartet: "5/5 Workspaces haben lint"
|
||||
# 2. turbo kennt biome.json als globale Abhaengigkeit
|
||||
node -e "const t=require('./turbo.json'); if(!(t.globalDependencies||[]).includes('biome.json')) throw new Error('globalDependencies fehlt'); console.log('globalDependencies OK');"
|
||||
# erwartet: "globalDependencies OK"
|
||||
# 3. pnpm lint fuehrt echte Aufgaben aus und ist gruen (vorher: "No tasks were executed")
|
||||
pnpm exec turbo lint --force 2>&1 | tail -6
|
||||
# erwartet: "5 successful, 5 total", Exit 0, KEIN "No tasks were executed"
|
||||
# 4. GEGENPROBE — das Tor muss beissen
|
||||
printf 'export function probe(x: number) {\n debugger;\n return x == null;\n}\n' > apps/api/src/__gate_probe__.ts
|
||||
pnpm exec turbo lint --force --filter=@tessera/api > /tmp/bite.txt 2>&1; BITE=$?
|
||||
rm -f apps/api/src/__gate_probe__.ts
|
||||
echo "Gegenprobe Exit=$BITE (erwartet 1)"; grep -c "noDebugger" /tmp/bite.txt
|
||||
test "$BITE" -eq 1 || { echo "FEHLER: Tor beisst nicht"; exit 1; }
|
||||
# 5. Wegwerfdatei ist wieder weg und nach dem gruenen Lauf ist alles sauber
|
||||
test ! -f apps/api/src/__gate_probe__.ts && echo "Probe entfernt"
|
||||
pnpm exec turbo lint --force 2>&1 | tail -3
|
||||
# erwartet: erneut "5 successful, 5 total", Exit 0
|
||||
</automated>
|
||||
</verify>
|
||||
<done>Alle fuenf Workspaces tragen ein `lint`-Skript, `turbo.json` bezieht `biome.json` als globale Abhaengigkeit ein, `pnpm lint` meldet 5 von 5 ausgefuehrten Aufgaben mit Exit 0, und die Gegenprobe mit einem absichtlichen Verstoss liefert Exit 1 samt `noDebugger`-Befund. Die Wegwerfdatei ist entfernt, der abschliessende Lauf wieder gruen.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Entwickler-Anleitung nachziehen und Registereintrag #35 schliessen</name>
|
||||
<files>docs/anleitung-entwicklung.md, .planning/WINDOWS.md</files>
|
||||
<read_first>docs/anleitung-entwicklung.md</read_first>
|
||||
<action>
|
||||
In `docs/anleitung-entwicklung.md` beschreibt der Absatz ab Zeile 61 Biome als aktives Werkzeug und nennt dabei die Importsortierung. Die Aussage stimmt weiterhin, ist aber unvollstaendig, weil bis jetzt gar nichts geprueft wurde. Ergaenze den Absatz um drei Punkte in ganzen Saetzen und in derselben Tonlage wie der umgebende Text: dass `pnpm lint` seit diesem Vorgang je Workspace `biome lint .` ausfuehrt und der CI-Schritt damit echt prueft; dass das Tor auf Fehler blockiert, waehrend Stilhinweise als Warnungen erscheinen, ohne den Lauf zu stoppen; und dass derzeit rund 2800 solcher Warnungen offen sind — ueberwiegend aus der Regelfamilie um den Typ `any` sowie Barrierefreiheits-Hinweise in `apps/web` —, die bewusst als eigener Durchlauf stehen bleiben und nicht Teil dieses Vorgangs waren.
|
||||
|
||||
Erwaehne dabei ausdruecklich, dass `pnpm lint` nicht formatiert und nicht auf Formatierung besteht: fuer Formatierung gibt es `biome format --write`, das getrennt und absichtlich von Hand angestossen wird. Dieser Satz verhindert, dass jemand spaeter das Skript auf `biome check` umstellt und damit ungewollt einen projektweiten Umbau ausloest.
|
||||
|
||||
Schliesse danach den Registereintrag ueber das Werkzeug, nicht durch Handarbeit an der Tabelle — der Befehl zieht die Zaehler im Kopf der Datei mit. Nutze dafuer den `windows fixed`-Unterbefehl von gsd-tools mit der Kennung 35.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>
|
||||
cd /home/vicolab/projects/tessera-ctl
|
||||
# 1. Anleitung nennt den neuen Zustand
|
||||
grep -qE "pnpm lint" docs/anleitung-entwicklung.md && grep -qiE "warnung" docs/anleitung-entwicklung.md && echo "Anleitung ergaenzt"
|
||||
# erwartet: "Anleitung ergaenzt"
|
||||
# 2. Registereintrag 35 steht auf fixed und traegt ein Loesedatum
|
||||
node -e "const fs=require('fs'); const row=fs.readFileSync('.planning/WINDOWS.md','utf8').split('\n').find(l=>l.startsWith('| 35 |')); const c=row.split('|').map(s=>s.trim()); if(c[7]!=='fixed') throw new Error('Status ist: '+c[7]); if(!c[10]) throw new Error('resolved_at fehlt'); console.log('#35 fixed am',c[10]);"
|
||||
# erwartet: "#35 fixed am <Zeitstempel>", Exit 0
|
||||
# 3. Abschliessender Gesamtnachweis — das Tor laeuft, prueft und ist gruen
|
||||
pnpm exec turbo lint --force 2>&1 | tail -4
|
||||
# erwartet: "5 successful, 5 total", Exit 0
|
||||
</automated>
|
||||
</verify>
|
||||
<done>Die Entwickler-Anleitung beschreibt den tatsaechlichen Zustand samt offenem Warnungs-Rueckstand und der Trennung von Pruefen und Formatieren; Eintrag #35 im Maengelregister steht auf `fixed` mit Loesedatum; der abschliessende Gesamtlauf ist gruen.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Entwickler-Arbeitsplatz → CI-Runner | `biome.json` und die `lint`-Skripte bestimmen, was der CI-Schritt „Lint" in `.gitea/workflows/ci.yml` tatsaechlich ausfuehrt. Beide wandern per Commit in die Pipeline. |
|
||||
| Quellcode → Lint-Tor | Das Tor entscheidet, welche Befunde einen Lauf blockieren und welche nur berichtet werden. Eine Herabstufung hier wirkt auf jeden spaeteren Beitrag. |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-35-01 | Tampering | `biome.json` → `linter.rules` | high | mitigate | Eine pauschale Herabstufung koennte echte Sicherheitsregeln stummschalten. Die Gruppe `security` wird in Task 1 ausdruecklich **nicht** aufgefuehrt und behaelt `error`. Belegt: die 6 gemessenen `lint/security/noScriptUrl`-Treffer liegen ausnahmslos in der ausgenommenen HTML-Testvorlage, in keiner echten Quelldatei. Task 1 Verify prueft maschinell, dass `linter.rules.security` nicht gesetzt ist. |
|
||||
| T-35-02 | Tampering | `biome.json` → `files.includes` | medium | mitigate | Das verneinende Muster fuer `__fixtures__` koennte kuenftig echten Quellcode der Pruefung entziehen. Gemessen: das einzige solche Verzeichnis enthaelt ausschliesslich Datendateien (html, zip, xml) und keine einzige TypeScript-Datei. Der Ausschluss bleibt auf dieses Muster plus eine namentlich genannte CSS-Datei begrenzt; kein Verzeichnis unter `src` wird pauschal ausgenommen. |
|
||||
| T-35-03 | Tampering | falsch-gruenes Tor (`lint`-Skripte, `turbo.json`) | high | mitigate | Der eigentliche Mangel aus #35 ist ein Tor, das gruen meldet, ohne zu pruefen. Ein falsch geschriebenes Skript oder ein stale Cache wuerde ihn wiederholen. Zwei Gegenmassnahmen: `globalDependencies` verwirft den Cache bei Regelaenderungen, und Task 2 Verify baut einen absichtlichen Verstoss ein und verlangt Exit 1 samt `noDebugger`-Befund. |
|
||||
| T-35-04 | Denial of Service | `.gitea/workflows/ci.yml` Schritt „Lint" | medium | accept | Der Schritt kann ab jetzt rot werden und die Pipeline anhalten. Das ist der Zweck des Vorgangs, nicht ein Nebenschaden. Angenommen, weil der Ausgangszustand — ein Tor ohne Biss — das groessere Risiko traegt; das Tor ist auf dem aktuellen Bestand nachweislich gruen, blockiert also niemanden ohne Anlass. |
|
||||
| T-35-SC | Tampering | Paketinstallationen (npm/pnpm) | — | n/a | Dieser Plan installiert kein Paket und hebt keine Version an; Biome 2.5.0 liegt bereits im Lockfile und in `node_modules`. Es gibt keinen Paketmanager-Installationsschritt, das Package-Legitimacy-Gate greift hier nicht. Ausdruecklich festgehalten statt stillschweigend ausgelassen. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
Gesamtnachweis nach allen drei Tasks, vom Wurzelverzeichnis aus:
|
||||
|
||||
1. `pnpm exec biome migrate` meldet keinen Migrationsbedarf → Konfiguration ist fuer 2.5.0 gueltig.
|
||||
2. `pnpm exec biome lint . --reporter=json --max-diagnostics=20000` → null Eintraege mit `category` `parse` und null mit `severity` `error`.
|
||||
3. `pnpm exec turbo lint --force` → „5 successful, 5 total", Exit 0, und die Zeile „No tasks were executed" taucht nicht mehr auf.
|
||||
4. Gegenprobe: Wegwerfdatei mit `debugger` unter `apps/api/src` → `pnpm exec turbo lint --force --filter=@tessera/api` endet mit Exit 1; Datei danach entfernt, Lauf wieder gruen.
|
||||
5. `linter.rules.security` ist in `biome.json` nicht gesetzt → Sicherheitsregeln blieben auf `error`.
|
||||
6. `git diff --stat` zeigt ausschliesslich die neun in `files_modified` gelisteten Dateien — keine Quelldatei wurde umformatiert.
|
||||
7. Eintrag 35 in `.planning/WINDOWS.md` steht auf `fixed` mit gefuelltem `resolved_at`.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- `pnpm lint` fuehrt in allen fuenf Workspaces eine echte Biome-Pruefung aus und endet auf dem unveraenderten Bestand mit Exit 0.
|
||||
- Ein absichtlich eingebauter Regelverstoss laesst denselben Aufruf mit Exit 1 scheitern.
|
||||
- Biome bricht nirgends mehr mit einem Konfigurationsfehler ab; repo-weit null parse-Fehler (vorher 238 allein in `apps/api`).
|
||||
- Die Regelgruppe `security` ist unveraendert auf `error`; keine Sicherheitsregel wurde entschaerft.
|
||||
- Kein Quellcode wurde umformatiert und keine Paketversion angehoben.
|
||||
- Der offene Warnungs-Rueckstand (rund 2800, ueberwiegend `any`-Familie und Barrierefreiheit) ist in der Entwickler-Anleitung als bewusst offener Punkt festgehalten.
|
||||
- Eintrag #35 im Maengelregister ist geschlossen.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/quick/260921-9ie-windows-35-biome-json-fuer-biome-2-5-0-r/260921-9ie-SUMMARY.md` when done.
|
||||
|
||||
Halte in der Zusammenfassung ausdruecklich fest: (1) dass Zweig (b) gewaehlt wurde, mit den gemessenen Exit-Codes und Befundzahlen als Begruendung; (2) dass die Regelgruppe `security` unangetastet blieb und warum die 6 `noScriptUrl`-Treffer trotzdem verschwinden; (3) dass die beiden Zahlenangaben im Registereintrag #35 („17 Fehler in `users/user.controller.ts`") von der Messung abweichen — der Pfad lautet `apps/api/src/user/user.controller.ts` und die tatsaechliche Wirkung des Parser-Schalters betrug 238 parse-Fehler in 19 Dateien; (4) den verbleibenden Warnungs-Rueckstand als benannten, offenen Folgepunkt.
|
||||
</output>
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
---
|
||||
phase: quick-260921-9ie
|
||||
plan: 01
|
||||
subsystem: infra
|
||||
tags: [biome, turbo, lint, ci, tooling]
|
||||
|
||||
# Dependency graph
|
||||
requires: []
|
||||
provides:
|
||||
- "biome.json migriert auf Biome 2.5.0 (assist.actions.source.organizeImports, linter.rules.preset)"
|
||||
- "javascript.parser.unsafeParameterDecoratorsEnabled behebt 238 parse-Fehler in 19 Dateien (apps/api)"
|
||||
- "lint-Skript (biome lint .) in allen fuenf Workspaces"
|
||||
- "turbo.json globalDependencies bezieht biome.json ein, damit Regelaenderungen den Lint-Cache verwerfen"
|
||||
- "pnpm lint fuehrt echte Pruefung aus (5/5 Aufgaben), Exit 0 auf Bestand, Exit 1 auf echten Regelverstoss"
|
||||
- "docs/anleitung-entwicklung.md beschreibt echten Lint-Umfang und offenen Warnungs-Rueckstand"
|
||||
- "Registereintrag #35 in .planning/WINDOWS.md geschlossen (fixed)"
|
||||
affects: [ci, apps/api, apps/web, apps/desktop, packages/shared, packages/module-sdk]
|
||||
|
||||
actuals:
|
||||
tokens: 1301
|
||||
tasks: 3
|
||||
commits: 3
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "biome lint statt biome check als CI-Tor: Formatierung bleibt Handarbeit (biome format --write), nur echte Regelverstoesse blockieren"
|
||||
- "turbo.json globalDependencies auf biome.json, damit eine Config-Aenderung den Lint-Cache in jedem Workspace verwirft"
|
||||
|
||||
key-files:
|
||||
created: []
|
||||
modified:
|
||||
- biome.json
|
||||
- apps/api/package.json
|
||||
- apps/web/package.json
|
||||
- apps/desktop/package.json
|
||||
- packages/shared/package.json
|
||||
- packages/module-sdk/package.json
|
||||
- turbo.json
|
||||
- docs/anleitung-entwicklung.md
|
||||
- .planning/WINDOWS.md
|
||||
|
||||
key-decisions:
|
||||
- "Zweig (b) gewaehlt: lint statt check, zwei Dateien ausgenommen (__fixtures__, globals.css), gezielte Herabstufungen auf warn statt projektweitem Umbau — belegt durch gemessene Exit-Codes (check=1/760 Fehler, lint=1/275 Fehler, format=1/319 Befunde)"
|
||||
- "Regelgruppe security bleibt unangetastet auf error; die 6 gemessenen noScriptUrl-Treffer verschwinden nur, weil die ausgenommene Fremdseite (__fixtures__/cosinex-search.html) nicht mehr geprueft wird, nicht weil die Regel entschaerft wurde"
|
||||
- "Zahlen im Registereintrag #35 korrigiert: Pfad ist apps/api/src/user/user.controller.ts (nicht users/...), und der Parser-Schalter behebt 238 parse-Fehler in 19 Dateien (nicht 17 in einer Datei)"
|
||||
|
||||
patterns-established:
|
||||
- "Gegenprobe-Pflicht fuer Lint-Tore: eine Wegwerfdatei mit absichtlichem Verstoss muss das Tor auf Exit 1 zwingen, bevor ein Tor als funktionsfaehig gilt"
|
||||
|
||||
requirements-completed: [WINDOWS-35]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "biome.json ist fuer Biome 2.5.0 gueltig; biome migrate meldet keinen Bedarf mehr; repo-weit 0 parse-Fehler (vorher 238 in apps/api allein)"
|
||||
requirement: "WINDOWS-35"
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "pnpm exec biome migrate (kein 'configuration needs migration'); pnpm exec biome lint . --reporter=json -> parse:0 errors:0"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "pnpm lint (turbo lint --force) fuehrt in allen fuenf Workspaces eine echte Pruefung aus und ist auf dem unveraenderten Bestand gruen (Exit 0, 5 successful/5 total)"
|
||||
requirement: "WINDOWS-35"
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "pnpm exec turbo lint --force -> '5 successful, 5 total', Exit 0"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "Das Tor beisst: eine Wegwerfdatei mit debugger/loser Gleichheit unter apps/api/src laesst turbo lint --filter=@tessera/api mit Exit 1 und noDebugger-Befund scheitern; Datei danach entfernt, Lauf wieder gruen"
|
||||
requirement: "WINDOWS-35"
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "printf ... > apps/api/src/__gate_probe__.ts; pnpm exec turbo lint --force --filter=@tessera/api -> Exit 1, grep -c noDebugger = 1; rm datei; erneuter Lauf -> Exit 0"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "Regelgruppe security bleibt auf error, keine Sicherheitsregel wurde stummgeschaltet"
|
||||
requirement: "WINDOWS-35"
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "node -e Pruefung: c.linter.rules.security === undefined"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "Registereintrag #35 im Maengelregister ist geschlossen (fixed) und die Entwickler-Anleitung beschreibt den echten Lint-Umfang samt offenem Warnungs-Rueckstand"
|
||||
requirement: "WINDOWS-35"
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "gsd-tools windows fixed 35; .planning/WINDOWS.md Zeile 35 status=fixed, resolved_at gesetzt; docs/anleitung-entwicklung.md enthaelt pnpm lint + Warnung"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
|
||||
duration: 21min
|
||||
completed: 2026-09-21
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Quick Task 260921-9ie: Biome-Lint-Tor repariert
|
||||
|
||||
**biome.json auf Biome 2.5.0 migriert, echtes `biome lint .` in allen fuenf Workspaces verdrahtet, turbo-Cache an biome.json gebunden — Tor prueft jetzt tatsaechlich statt nur gruen zu melden.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 21 min
|
||||
- **Started:** 2026-09-21T05:07:15Z (Zeitpunkt der ersten Ausfuehrungsschritte; Sitzungsstart siehe STATE.md)
|
||||
- **Completed:** 2026-09-21T05:07:15Z + ~21 min
|
||||
- **Tasks:** 3/3
|
||||
- **Files modified:** 9 (davon 8 committet, `.planning/WINDOWS.md` bleibt fuer den Orchestrator ungestaged laut Auftrag)
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- `biome.json` ist fuer Biome 2.5.0 gueltig: `organizeImports` von oberster Ebene entfernt, `assist.actions.source.organizeImports: "on"` und `linter.rules.preset: "recommended"` ergaenzt — wortgleich mit der Ausgabe von `biome migrate --write`, gegengeprueft in dieser Sitzung an einer Kopie.
|
||||
- `javascript.parser.unsafeParameterDecoratorsEnabled: true` senkt die parse-Fehler repo-weit auf 0 (vorher 238 in 19 Dateien allein in `apps/api`, gemessen ueber `biome lint . --reporter=json`).
|
||||
- `vcs.useIgnoreFile` aktiv, `files.includes` schliesst `__fixtures__/**` und `apps/web/src/app/globals.css` aus.
|
||||
- Alle fuenf Workspaces (`apps/api`, `apps/web`, `apps/desktop`, `packages/shared`, `packages/module-sdk`) haben ein `lint`-Skript (`biome lint .`).
|
||||
- `turbo.json` bezieht `biome.json` als `globalDependencies` ein, damit eine Regelaenderung den Lint-Cache verwirft.
|
||||
- `pnpm lint` (`turbo lint --force`) meldet „5 successful, 5 total", Exit 0 — vorher „No tasks were executed".
|
||||
- Gegenprobe bestanden: eine Wegwerfdatei mit `debugger` und `== null` unter `apps/api/src` laesst denselben Aufruf mit Exit 1 und einem `noDebugger`-Befund scheitern; Datei danach entfernt, Lauf wieder gruen.
|
||||
- `docs/anleitung-entwicklung.md` beschreibt den echten Pruefumfang, die Trennung von Pruefen und Formatieren und den offenen Warnungs-Rueckstand.
|
||||
- Registereintrag #35 in `.planning/WINDOWS.md` per `gsd-tools windows fixed 35` auf `fixed` gesetzt (bleibt fuer den Orchestrator ungestaged).
|
||||
|
||||
## Task Commits
|
||||
|
||||
Each task was committed atomically:
|
||||
|
||||
1. **Task 1: biome.json reparieren und die Kette bis pnpm lint an apps/api nachweisen** - `6a727e9` (fix)
|
||||
2. **Task 2: lint-Skript in den restlichen vier Workspaces und Cache-Bezug in turbo.json** - `00d769b` (feat)
|
||||
3. **Task 3: Entwickler-Anleitung nachziehen und Registereintrag #35 schliessen** - `6f0f05a` (docs)
|
||||
|
||||
_Kein separater TDD-Zyklus: die Verify-Schritte sind Shell-Messungen, keine Test-Suite._
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `biome.json` - fuer Biome 2.5.0 migriert (assist, preset, javascript-Block, vcs, files.includes, gezielte warn-Herabstufungen)
|
||||
- `apps/api/package.json` - `lint`-Skript ergaenzt
|
||||
- `apps/web/package.json` - `lint`-Skript ergaenzt
|
||||
- `apps/desktop/package.json` - `lint`-Skript ergaenzt
|
||||
- `packages/shared/package.json` - `lint`-Skript ergaenzt
|
||||
- `packages/module-sdk/package.json` - `lint`-Skript ergaenzt
|
||||
- `turbo.json` - `globalDependencies: ["biome.json"]` ergaenzt
|
||||
- `docs/anleitung-entwicklung.md` - Absatz zu Biome um echten Pruefumfang und Warnungs-Rueckstand ergaenzt
|
||||
- `.planning/WINDOWS.md` - Eintrag #35 per Tool auf `fixed` gesetzt (bewusst ungestaged, siehe Auftrag)
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- **Zweig (b)** gewaehlt statt eines projektweiten Umbaus: `biome lint` statt `biome check` im Skript, zwei Dateien ausgenommen, gezielte Herabstufungen auf `warn`. Begruendung ueber gemessene Exit-Codes: `biome check .` -> Exit 1, 760 Fehler, 2594 Warnungen; `biome lint .` -> Exit 1, 275 Fehler, 2594 Warnungen; `biome format .` -> Exit 1, 319 Befunde. Zweig (a) — blosse Warnungen — schied damit aus, weil auch reines `lint` durchgefallen waere.
|
||||
- **Regelgruppe `security` unangetastet.** Sie ist in `linter.rules` nicht aufgefuehrt und behaelt damit den voreingestellten Schweregrad `error`. Alle 6 gemessenen `lint/security/noScriptUrl`-Treffer lagen ausnahmslos in der jetzt ausgenommenen Fremdseite `apps/api/src/tenders/__fixtures__/cosinex-search.html`, in keiner einzigen echten Quelldatei. Die Treffer verschwinden also, weil die Fremdseite nicht mehr geprueft wird — nicht weil die Regel entschaerft wurde. Task-1-Verify prueft das maschinell (`c.linter.rules.security === undefined`).
|
||||
- **Zwei Zahlenangaben aus dem Registereintrag #35 korrigiert.** Der Eintrag nannte „17 Fehler in `users/user.controller.ts`". Gemessen: der Pfad lautet `apps/api/src/user/user.controller.ts` (Einzahl `user`), und die tatsaechliche Wirkung des Parser-Schalters betraegt 238 parse-Fehler in 19 Dateien — nicht 17 in einer einzigen Datei. Beide Abweichungen sind in der Zusammenfassung und im geschlossenen Registereintrag dokumentiert.
|
||||
- **Verbleibender Warnungs-Rueckstand bewusst offen gelassen.** Rund 2800 Warnungen (ueberwiegend `any`-Familie und Barrierefreiheits-Hinweise in `apps/web`) bleiben als eigener, spaeter Durchlauf stehen und sind in `docs/anleitung-entwicklung.md` als offener Punkt benannt — nicht Teil dieses Vorgangs.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
None - plan executed exactly as written. Alle drei Tasks liefen wie geplant, keine Rule-1/2/3-Auto-Fixes noetig, keine architektonische Frage aufgetaucht.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
None.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None - keine externe Dienstkonfiguration erforderlich.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- `pnpm lint` ist ab sofort ein echtes CI-Tor; der Schritt „Lint" in `.gitea/workflows/ci.yml` kann jetzt tatsaechlich rot werden.
|
||||
- Der offene Warnungs-Rueckstand (~2800, ueberwiegend `any`-Familie und Barrierefreiheit in `apps/web`) ist als eigener, spaeter Durchlauf dokumentiert — kein Blocker fuer diesen Vorgang, aber ein benannter Folgepunkt.
|
||||
- `.planning/WINDOWS.md` bleibt laut Auftrag ungestaged; der Orchestrator uebernimmt den Docs-Commit.
|
||||
|
||||
---
|
||||
*Phase: quick-260921-9ie*
|
||||
*Completed: 2026-09-21*
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
Alle 8 geaenderten Zieldateien plus diese SUMMARY.md auf der Platte gefunden; alle drei Task-Commit-Hashes (`6a727e9`, `00d769b`, `6f0f05a`) in `git log --oneline --all` bestaetigt.
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
---
|
||||
phase: quick-260921-9ie
|
||||
verified: 2026-09-21T00:00:00Z
|
||||
status: passed
|
||||
score: 7/7 must-haves verified
|
||||
covered_files:
|
||||
- .planning/quick/260921-9ie-windows-35-biome-json-fuer-biome-2-5-0-r/260921-9ie-PLAN.md
|
||||
- .planning/quick/260921-9ie-windows-35-biome-json-fuer-biome-2-5-0-r/260921-9ie-SUMMARY.md
|
||||
- apps/api/package.json
|
||||
- apps/desktop/package.json
|
||||
- apps/web/package.json
|
||||
- biome.json
|
||||
- docs/anleitung-entwicklung.md
|
||||
- packages/module-sdk/package.json
|
||||
- packages/shared/package.json
|
||||
- turbo.json
|
||||
covered_digest: "v1:sha256:5c7f3295a6c3c92fdfb36d2034d4d76a8543c7d7b7145a43a5afa32596bcd476"
|
||||
behavior_unverified: 0
|
||||
overrides_applied: 0
|
||||
---
|
||||
|
||||
# Quick Task 260921-9ie: Biome-Lint-Tor repariert — Verifikation
|
||||
|
||||
**Task-Ziel:** WINDOWS #35: `biome.json` fuer Biome 2.5.0 reparieren und `pnpm lint` echt pruefen lassen
|
||||
**Verifiziert:** 2026-09-21
|
||||
**Status:** passed
|
||||
**Commits unter Pruefung:** `6a727e9`, `00d769b`, `6f0f05a` (auf `main`)
|
||||
|
||||
Alle Befunde unten stammen aus selbst ausgefuehrten Befehlen im Arbeitsverzeichnis, nicht aus der SUMMARY.
|
||||
|
||||
## Observable Truths
|
||||
|
||||
| # | Truth | Status | Evidenz |
|
||||
|---|-------|--------|---------|
|
||||
| 1 | `biome.json` ist fuer Biome 2.5.0 gueltig, kein Konfigurationsfehler mehr | ✓ VERIFIED | `pnpm exec biome --version` -> `2.5.0`; `pnpm exec biome migrate` -> "Your configuration file is up to date." / "no migration needed" |
|
||||
| 2 | NestJS-Parameter-Dekoratoren werden geparst, 0 parse-Fehler repo-weit | ✓ VERIFIED | `pnpm exec biome lint . --reporter=json --max-diagnostics=20000` -> `parse: 0 errors: 0 total: 2922` (selbst ausgefuehrt) |
|
||||
| 3 | `pnpm lint` fuehrt echte Pruefungen aus (5 von 5 Aufgaben, nicht "No tasks were executed") | ✓ VERIFIED | Erzwungener Lauf `pnpm exec turbo lint --force` -> "Tasks: 5 successful, 5 total", "Cached: 0 cached, 5 total", Exit 0; kein "No tasks were executed" |
|
||||
| 4 | `pnpm lint` endet auf dem unveraenderten Bestand mit Exit 0 | ✓ VERIFIED | Sowohl gecachter Lauf (`FULL TURBO`, Exit 0) als auch erzwungener Lauf (Exit 0) bestaetigt |
|
||||
| 5 | `pnpm lint` endet mit Exit 1, sobald ein echter Regelverstoss im Quellcode steht | ✓ VERIFIED | Wegwerfdatei `apps/api/src/__gate_probe__.ts` mit `debugger`/`== null` angelegt -> `turbo lint --force --filter=@tessera/api` Exit=1, `grep -c noDebugger` = 1; Datei danach entfernt, `git status --porcelain -- apps/api/src` leer, anschliessender Gesamtlauf wieder Exit 0 |
|
||||
| 6 | Regelgruppe `security` bleibt auf `error`, keine Sicherheitsregel stummgeschaltet | ✓ VERIFIED | `node -e "require('./biome.json').linter.rules.security !== undefined"` -> `false` (Schluessel nicht gesetzt, Voreinstellung `error` gilt); die 6 fruehren `noScriptUrl`-Treffer lagen ausschliesslich in der jetzt ausgeschlossenen Fremdseite `apps/api/src/tenders/__fixtures__/cosinex-search.html` |
|
||||
| 7 | Kein Quellcode umformatiert, Diff enthaelt nur Konfiguration/Skripte/Doku | ✓ VERIFIED | `git diff --stat HEAD~3 HEAD` zeigt exakt 8 Dateien: `apps/api/package.json`, `apps/desktop/package.json`, `apps/web/package.json`, `biome.json`, `docs/anleitung-entwicklung.md`, `packages/module-sdk/package.json`, `packages/shared/package.json`, `turbo.json` — keine `.ts`/`.tsx`/`.css`-Datei |
|
||||
|
||||
**Score:** 7/7 Truths verifiziert (0 present-behavior-unverified)
|
||||
|
||||
## Zusaetzliche harte Anforderungen aus dem Auftrag
|
||||
|
||||
| # | Anforderung | Ergebnis | Evidenz |
|
||||
|---|-------------|----------|---------|
|
||||
| 1 | `pnpm lint` auf unveraendertem Baum: Exit 0, 5/5 ausgefuehrt (nicht "No tasks were executed") | ✓ erfuellt | siehe Truth 3/4 oben; `--force`-Lauf zeigt reale Ausfuehrung, nicht nur Cache |
|
||||
| 2 | Gate beisst: Wegwerfdatei -> Exit 1 -> entfernt -> `git status` sauber | ✓ erfuellt | siehe Truth 5; `git status --porcelain` danach unveraendert (nur `.planning/STATE.md`, `.planning/WINDOWS.md` modifiziert, Quick-Verzeichnis untracked — Zustand vor Pruefung identisch) |
|
||||
| 3 | Null Parse-Fehler, null Fehler repo-weit | ✓ erfuellt | `parse: 0 errors: 0` aus eigenem Lauf |
|
||||
| 4 | `linter.rules.security` NICHT gesetzt | ✓ erfuellt | `security key present: false` |
|
||||
| 5 | `git show --stat` ueber die drei Commits enthaelt nur die neun erlaubten Dateien | ✓ erfuellt | Einzel-Commit-Stats: `6a727e9` -> `apps/api/package.json`, `biome.json`; `00d769b` -> `apps/desktop/package.json`, `apps/web/package.json`, `packages/module-sdk/package.json`, `packages/shared/package.json`, `turbo.json`; `6f0f05a` -> `docs/anleitung-entwicklung.md`. Summe = 8 Dateien, keine ausserhalb der Liste, keine Quelldatei |
|
||||
| 6 | Keine Paketversion angehoben, `pnpm-lock.yaml` unangetastet | ✓ erfuellt | `git diff HEAD~3 HEAD -- pnpm-lock.yaml` -> 0 Zeilen; alle fuenf `package.json`-Diffs enthalten ausschliesslich neue `"lint"`-Skriptzeilen, keine Versionsaenderung |
|
||||
| 7 | `.planning/WINDOWS.md` Eintrag 35 = `fixed` mit gefuelltem `resolved_at` | ✓ erfuellt | Zeile 35: Status `fixed`, `resolved_at` = `2026-09-21T05:06:47.012Z` |
|
||||
| 8 | `!**/__fixtures__/**` verdeckt keinen echten Quellcode | ✓ erfuellt | `find . -path "*__fixtures__*" -type f \( -name "*.ts" -o -name "*.tsx" \)` -> keine Treffer; einziges `__fixtures__`-Verzeichnis (`apps/api/src/tenders/__fixtures__`) enthaelt nur `.html`, `.zip`, `.xml` |
|
||||
|
||||
## CI-Einschaetzung (unabhaengiges Urteil)
|
||||
|
||||
`.gitea/workflows/ci.yml` Schritt „Lint" ruft `pnpm lint`, was auf `turbo lint` zeigt (Root-`package.json`). Das `lint`-Turbo-Task hat jetzt in allen fuenf Workspaces ein reales `biome lint .`-Skript hinter sich, `turbo.json` bindet `biome.json` in `globalDependencies` ein, sodass eine kuenftige Regelaenderung den Cache verwirft. Auf einem frischen CI-Checkout (kein Turbo-Cache vorhanden) fuehrt der Schritt zwangslaeufig alle fuenf Aufgaben real aus — der zuvor bestehende Leerlauf ("No tasks were executed") ist damit strukturell behoben, nicht nur lokal beobachtet. Urteil: Der CI-Schritt „Lint" wird ab diesem Stand tatsaechlich pruefen und bei echten Fehlern (nicht bei den verbleibenden ~2800 Warnungen) rot werden. Das erfuellt den Zweck des Tickets.
|
||||
|
||||
## Anti-Pattern-Scan
|
||||
|
||||
Alle neun in `files_modified` gelisteten Dateien auf `TBD`, `FIXME`, `XXX`, `TODO`, `HACK`, `PLACEHOLDER` durchsucht — keine Treffer.
|
||||
|
||||
## Requirements Coverage
|
||||
|
||||
| Requirement | Status | Evidenz |
|
||||
|---|---|---|
|
||||
| WINDOWS-35 | ✓ SATISFIED | Alle sieben Must-Have-Truths sowie alle acht Zusatzanforderungen des Auftrags oben verifiziert |
|
||||
|
||||
## Human Verification Required
|
||||
|
||||
Keine — alle Pruefpunkte sind maschinell/deterministisch nachvollziehbar (Exit-Codes, Diagnostik-Zaehlungen, Diff-Stat, Datei-Existenz).
|
||||
|
||||
## Gaps Summary
|
||||
|
||||
Keine Luecken gefunden. Die im Plan als bewusst offen benannten ~2800 Warnungen (ueberwiegend `any`-Familie und Barrierefreiheit in `apps/web`) sind explizit als eigener, spaeterer Durchlauf dokumentiert (`docs/anleitung-entwicklung.md`) und waren nicht Gegenstand dieses Auftrags — kein Gap, sondern dokumentierte Abgrenzung.
|
||||
|
||||
---
|
||||
|
||||
_Verifiziert: 2026-09-21_
|
||||
_Verifier: Claude (gsd-verifier)_
|
||||
+388
@@ -0,0 +1,388 @@
|
||||
---
|
||||
phase: quick-260921-a1d
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- apps/web/src/app/(portal)/admin/users/page.tsx
|
||||
- apps/web/src/app/(portal)/admin/users/users-page.test.tsx
|
||||
- apps/web/src/messages/de.json
|
||||
- apps/web/src/messages/en.json
|
||||
# nur falls der Umlaut-Waechter ein neues Wort meldet, siehe Aufgabe 1
|
||||
- apps/web/src/messages/umlaut-dictionary.ts
|
||||
autonomous: true
|
||||
requirements: [WINDOWS-36]
|
||||
|
||||
estimate:
|
||||
tokens: 55000
|
||||
raw_tokens: 55000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Wird eine Benutzeraenderung vom Server mit 403 abgewiesen, erscheint im Formular eine sichtbare Meldung; das Formular bleibt offen und der Text des Servers steht darin."
|
||||
- "Wird ein Loeschvorgang vom Server mit 403 abgewiesen, erscheint im Loeschdialog eine sichtbare Meldung; der Dialog bleibt offen."
|
||||
- "Traegt die Antwort keinen verwertbaren Text (kein JSON, leerer Rumpf) oder schlaegt die Verbindung ganz fehl, erscheint stattdessen eine uebersetzte Ersatzmeldung — nie eine leere Reaktion."
|
||||
- "Scheitert das Laden der Benutzerliste, sagt die Seite das; sie zeigt nicht mehr faelschlich 'Keine Benutzer gefunden'."
|
||||
- "Ein Benutzer mit der Rolle ADMIN bekommt in der Zeile des SUPER_ADMIN weder einen Bearbeiten- noch einen Loeschen-Knopf angeboten; ein SUPER_ADMIN bekommt beide."
|
||||
- "Alle neuen Texte liegen in de.json und en.json mit identischem Schluesselsatz vor; kein Text steht fest verdrahtet im Quelltext."
|
||||
- "Die Serverpruefungen in apps/api sind unveraendert — die ausgeblendeten Knoepfe sind Ergonomie, kein Berechtigungsersatz."
|
||||
artifacts:
|
||||
- "apps/web/src/app/(portal)/admin/users/page.tsx — drei Fehlerzustaende, drei Meldungsflaechen, Rollenfilter fuer die Aktionsknoepfe"
|
||||
- "apps/web/src/app/(portal)/admin/users/users-page.test.tsx — neue Vitest-Datei mit den Verhaltensnachweisen"
|
||||
- "apps/web/src/messages/de.json und en.json — Zweig admin.users.errors mit vier Schluesseln je Sprache"
|
||||
key_links:
|
||||
- "readApiMessage(res) -> t('errors.serverRejected', { detail }) -> sichtbares Banner: die Kette, an der heute der 403 verschwindet"
|
||||
- "currentUser.role aus dem auth-store -> Sichtbarkeit der Aktionsknoepfe je Zeile"
|
||||
- "de.json/en.json Schluesselgleichheit -> umlaut-guard.spec.ts (prueft den GESAMTEN Katalog, nicht nur einen Zweig)"
|
||||
---
|
||||
|
||||
<objective>
|
||||
WINDOWS #36 schliessen: die Benutzerverwaltung schluckt abgewiesene Serverantworten heute vollstaendig.
|
||||
`handleSubmit` und `handleDelete` in `apps/web/src/app/(portal)/admin/users/page.tsx` pruefen nur `res.ok`,
|
||||
haben keinen Sonst-Zweig und fangen Ausnahmen mit einem Rumpf, der nur einen Kommentar enthaelt. Ein 403
|
||||
fuehrt damit zu gar keiner sichtbaren Reaktion — das Formular bleibt offen, der Loeschdialog bleibt stehen,
|
||||
es erscheint keine Meldung. Fuer die bedienende Person sieht das aus, als haenge die Anwendung.
|
||||
|
||||
Seit Quick 260914-ebg (WINDOWS #29) ist dieser Fall im Alltag erreichbar: die Zeile des SUPER_ADMIN steht in
|
||||
der Benutzerliste eines ADMIN, und Aendern oder Loeschen darauf liefert jetzt 403
|
||||
(`apps/api/src/user/user.controller.ts`, Zielrollen-Riegel in `update` und `remove`).
|
||||
|
||||
Zwei Haelften, beide verbindlich:
|
||||
1. Das Scheitern sichtbar machen — mit dem Text aus dem Antwortrumpf, wo der Server einen liefert, sonst mit
|
||||
einer uebersetzten Ersatzmeldung.
|
||||
2. Das Unmoegliche gar nicht erst anbieten — fuer einen ADMIN entfallen Bearbeiten und Loeschen in der Zeile
|
||||
des SUPER_ADMIN. Der 403 ist danach das Sicherungsnetz, nicht der Regelweg.
|
||||
|
||||
Purpose: Die Benutzerverwaltung gibt bei jeder abgewiesenen Aktion eine Antwort, die man lesen kann.
|
||||
Output: Sichtbare Fehlermeldungen in Formular, Loeschdialog und Listenkopf; rollenrichtige Aktionsknoepfe;
|
||||
eine neue Vitest-Datei, die beides nachweist.
|
||||
|
||||
## Entscheidungen, die in diesen Plan eingeflossen sind
|
||||
|
||||
- **D-01 (gesetzt):** Alle neuen Texte laufen ueber next-intl in `de.json` UND `en.json`. Keine fest
|
||||
verdrahteten Zeichenketten — Quick 260701-abc hat genau diese Fehlerklasse schon einmal beseitigt.
|
||||
- **D-02 (gesetzt):** Deutsche Oberflaechentexte in der Sie-Form, wie der restliche Katalog.
|
||||
- **D-03 (gesetzt):** Der Text des Servers hat Vorrang, wenn die Antwort einen traegt; sonst greift eine
|
||||
uebersetzte Ersatzmeldung. Kein Fall endet ohne Rueckmeldung.
|
||||
- **D-04 (gesetzt):** Die Serverpruefung wird nicht angefasst. `apps/api` steht nicht in der Dateiliste.
|
||||
Das Ausblenden eines Knopfes ist eine Schicht OBERHALB der Serverpruefung, niemals ihr Ersatz
|
||||
(siehe `<threat_model>`, T-A1D-02).
|
||||
- **D-05 (gesetzt):** Aenderung bleibt in der Benutzerseite und den beiden Katalogen. Vorhandene Muster
|
||||
werden wiederverwendet statt neu erfunden — gemessen: `apps/web/src/app/(portal)/admin/groups/page.tsx`
|
||||
hat bereits ein Fehlerbanner, `apps/web/src/lib/tender-radar-api.ts` bereits eine Rumpf-Auswertung.
|
||||
- **D-06 (gesetzt):** Keine Umformatierung der Datei, keine Versionsspruenge.
|
||||
|
||||
## Gemessene Ausgangslage (2026-09-21, vor der Planung geprueft)
|
||||
|
||||
- `apps/web/src/app/(portal)/admin/users/page.tsx`: 428 Zeilen. Drei Stellen verschlucken still:
|
||||
`fetchUsers` (Zeile 60-73), `handleSubmit` (107-142), `handleDelete` (144-157).
|
||||
- **Dritte Fundstelle ist IN SCOPE.** `fetchUsers` wird mitbehandelt: scheitert das Laden, zeigt die Seite
|
||||
heute "Keine Benutzer gefunden" — eine falsche Aussage, dieselbe Fehlerfamilie, dieselbe Datei, sehr
|
||||
geringe Zusatzkosten. Nichts wird ausgelassen.
|
||||
- Serverantworten fuer die 403-Wege, woertlich gemessen in `apps/api/src/user/user.controller.ts`:
|
||||
`Cannot modify a SUPER_ADMIN user` (Z. 199), `Cannot delete a SUPER_ADMIN user` (Z. 262),
|
||||
`Cannot modify users from other tenants` (Z. 188), `Cannot delete users from other tenants` (Z. 255),
|
||||
`Cannot delete your own account` (Z. 247), `Cannot assign SUPER_ADMIN role` (Z. 151/204).
|
||||
Es gibt keinen eigenen ExceptionFilter in `apps/api/src` (geprueft), also gilt die Standardform von
|
||||
NestJS: `{ statusCode, message, error }`, bei 500 lautet `message` schlicht `Internal server error`.
|
||||
- **Bekannte Eigenheit, ausdruecklich NICHT Teil dieses Plans:** diese Servertexte sind englisch. Nach D-03
|
||||
werden sie angezeigt wie sie sind, eingefasst in einen deutschen Rahmensatz. Eine Uebersetzung der
|
||||
Servertexte waere eine Aenderung an `apps/api` und damit eine eigene Aufgabe — hier bewusst nicht getan,
|
||||
damit der Plan die Serverantwort nicht anfasst (D-04).
|
||||
- Testbestand `apps/web`, gemessen mit `pnpm --filter @tessera/web test`:
|
||||
**65 Dateien, 447 Tests, alle gruen.** Erwartung nach diesem Plan: 66 Dateien, 447 + neue Tests.
|
||||
- `pnpm --filter @tessera/web type-check`: Exit 0.
|
||||
- `pnpm lint` (Wurzel, Biome 2.5.0 ueber alle fuenf Workspaces): 5 von 5 erfolgreich. Bestehende Warnungen
|
||||
blockieren nicht, jede NEUE Meldung im Fehlerrang faerbt den Lauf rot.
|
||||
- Der Waechter `apps/web/src/messages/umlaut-guard.spec.ts` prueft dreierlei: keine Ersatzschreibung im
|
||||
Deutschen, kein neues Wort mit ae/oe/ue/ss ausserhalb der Erlaubnisliste, und **Schluesselgleichheit von
|
||||
de.json und en.json ueber den gesamten Katalog**. Ein Schluessel nur in einer Sprache faellt sofort durch.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/gsd-core/workflows/execute-plan.md
|
||||
@~/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/STATE.md
|
||||
@apps/web/src/app/(portal)/admin/users/page.tsx
|
||||
@apps/web/src/app/(portal)/admin/groups/page.tsx
|
||||
@apps/web/src/app/(portal)/admin/groups/groups-page.test.tsx
|
||||
@apps/web/src/app/(portal)/admin/users/user-access-modal.test.tsx
|
||||
@apps/web/src/messages/umlaut-guard.spec.ts
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer" tdd="true">
|
||||
<name>Aufgabe 1: Loeschweg von der Serverantwort bis zur sichtbaren Meldung durchziehen</name>
|
||||
<files>apps/web/src/messages/de.json, apps/web/src/messages/en.json, apps/web/src/app/(portal)/admin/users/page.tsx, apps/web/src/app/(portal)/admin/users/users-page.test.tsx</files>
|
||||
<read_first>
|
||||
apps/web/src/app/(portal)/admin/users/page.tsx (Zeilen 36-73 Zustand und Laden, 144-157 handleDelete, 393-416 Loeschdialog)
|
||||
apps/web/src/app/(portal)/admin/groups/page.tsx (Zeilen 100-110 Rumpf-Auswertung, 136-140 Bannerklassen)
|
||||
apps/web/src/lib/tender-radar-api.ts (Zeilen 428-446, Funktion extractErrorMessage als Formvorlage)
|
||||
apps/web/src/app/(portal)/admin/users/user-access-modal.test.tsx (Zeilen 1-70, Konvention fuer den next-intl-Ersatz)
|
||||
apps/web/src/app/(portal)/admin/groups/groups-page.test.tsx (Zeilen 92-190, Konvention fuer auth-store- und fetch-Ersatz)
|
||||
</read_first>
|
||||
<behavior>
|
||||
Neue Datei `users-page.test.tsx`, Aufbau exakt wie `groups-page.test.tsx`: namensraum-bewusster
|
||||
next-intl-Ersatz ueber `vi.mock('next-intl', ...)`, `vi.mock('@/lib/stores/auth-store', ...)` mit
|
||||
Selektorweitergabe, `vi.stubGlobal('fetch', ...)` je Test, `cleanup()` und `vi.restoreAllMocks()` in
|
||||
`afterEach`. Angemeldete Person in dieser Aufgabe: Rolle ADMIN, id `u1`, tenantId `t1`.
|
||||
- Test 1 (403 mit Text): Die Liste laedt zwei Benutzer. Nach Klick auf Loeschen und Bestaetigen
|
||||
antwortet fetch mit `{ ok: false, status: 403, json: () => Promise.resolve({ statusCode: 403,
|
||||
message: 'Cannot delete a SUPER_ADMIN user' }) }`. Erwartet: der Text
|
||||
"Der Server hat die Aktion abgelehnt: Cannot delete a SUPER_ADMIN user" steht im Dokument, und der
|
||||
Bestaetigungstext des Dialogs steht weiterhin im Dokument (der Dialog bleibt offen).
|
||||
- Test 2 (Antwort ohne verwertbaren Rumpf): `{ ok: false, status: 500, json: () => Promise.reject(new
|
||||
Error('not json')) }`. Erwartet: die Ersatzmeldung "Die Aktion konnte nicht durchgefuehrt werden."
|
||||
als Teiltext im Dokument; nicht der Rahmensatz aus Test 1.
|
||||
- Test 3 (Verbindung scheitert): fetch wirft beim Loeschaufruf. Erwartet: die Netzmeldung
|
||||
"Der Server ist nicht erreichbar." als Teiltext im Dokument.
|
||||
- Test 4 (Erfolgsfall unveraendert): `{ ok: true, json: ... }`. Erwartet: der Bestaetigungstext des
|
||||
Dialogs ist verschwunden, keine Meldungsflaeche im Dokument, und fetch wurde fuer das Neuladen der
|
||||
Liste erneut aufgerufen.
|
||||
</behavior>
|
||||
<action>
|
||||
Erstens die Texte. In `apps/web/src/messages/de.json` und `apps/web/src/messages/en.json` jeweils unter
|
||||
`admin.users` ein neues Objekt `errors` mit genau vier Schluesseln anlegen — gleicher Schluesselsatz in
|
||||
beiden Sprachen, sonst faellt der Waechter durch. Deutsch, Sie-Form, mit echten Umlauten:
|
||||
`serverRejected` = `Der Server hat die Aktion abgelehnt: {detail}`,
|
||||
`generic` = `Die Aktion konnte nicht durchgeführt werden. Bitte erneut versuchen.`,
|
||||
`network` = `Der Server ist nicht erreichbar. Bitte erneut versuchen.`,
|
||||
`loadFailed` = `Die Benutzerliste konnte nicht geladen werden. Bitte laden Sie die Seite neu.`
|
||||
Englisch: `serverRejected` = `The server rejected the action: {detail}`,
|
||||
`generic` = `The action could not be completed. Please try again.`,
|
||||
`network` = `The server is not reachable. Please try again.`,
|
||||
`loadFailed` = `The user list could not be loaded. Please reload the page.`
|
||||
Diese Formulierungen sind bewusst so gewaehlt, dass kein Wort eine ae/oe/ue/ss-Folge enthaelt — der
|
||||
Waechter muss ohne Aenderung an `umlaut-dictionary.ts` gruen bleiben. Sollte er wider Erwarten doch ein
|
||||
Wort melden, ist der einzige erlaubte Eingriff dort das Eintragen genau dieses Wortes in
|
||||
`UMLAUT_ALLOWLIST`, so wie es die Meldung des Waechters selbst anweist; die bestehenden Eintraege
|
||||
bleiben unberuehrt.
|
||||
|
||||
Zweitens die Auswertung des Antwortrumpfs. In `page.tsx` auf Modulebene (ausserhalb der Komponente,
|
||||
unterhalb der Schnittstellen-Deklarationen) eine Funktion `readApiMessage` mit der Signatur
|
||||
`(res: Response) => Promise<string | null>` anlegen. Sie liest `await res.json()`, gibt `body.message`
|
||||
zurueck, wenn es eine nicht-leere Zeichenkette ist, verbindet ein Feld von Zeichenketten mit
|
||||
`, ` (so liefert NestJS Pruefmeldungen aus class-validator), und gibt in jedem anderen Fall sowie bei
|
||||
einer Ausnahme aus `res.json()` `null` zurueck. Formvorlage ist `extractErrorMessage` in
|
||||
`apps/web/src/lib/tender-radar-api.ts`; bewusst lokal kopiert statt importiert, weil jene Datei zum
|
||||
Modul Ausschreibungs-Radar gehoert und die Verwaltungsseite nicht davon abhaengen soll. Ausdruecklich
|
||||
NUR das Feld `message` lesen — niemals `res.text()` des ganzen Rumpfes, damit eine fremde HTML-
|
||||
Fehlerseite eines vorgelagerten Dienstes nicht in die Oberflaeche geraet (T-A1D-01).
|
||||
|
||||
Drittens der Loeschweg. Einen Zustand `deleteError` vom Typ `string | null` ergaenzen. In `handleDelete`
|
||||
zu Beginn auf `null` setzen; im Sonst-Zweig zu `res.ok` den Text ueber `readApiMessage` holen und
|
||||
`t('errors.serverRejected', { detail })` setzen, wenn ein Text kam, sonst `t('errors.generic')`. Im
|
||||
Fang-Zweig — dessen Rumpf bisher nur einen Kommentar enthaelt — `t('errors.network')` setzen; der
|
||||
Kommentar entfaellt ersatzlos. Beim Oeffnen des Dialogs (`setDeleteConfirm(user.id)`) ebenfalls auf
|
||||
`null` zuruecksetzen, damit eine alte Meldung nicht an einer neuen Zeile klebt.
|
||||
|
||||
Viertens die Meldungsflaeche. Im Loeschdialog oberhalb der Knopfreihe ein Banner rendern, wenn
|
||||
`deleteError` gesetzt ist, mit `role="alert"` und exakt den Klassen aus
|
||||
`apps/web/src/app/(portal)/admin/groups/page.tsx`:
|
||||
`rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive`. Der Text wird
|
||||
als React-Kind gerendert, niemals ueber `dangerouslySetInnerHTML` (T-A1D-03). Anders als die
|
||||
Gruppenseite wird weder `res.status` noch der Rohrumpf angezeigt — nur der gerahmte Servertext.
|
||||
|
||||
Die uebrige Datei bleibt unangetastet: keine Umformatierung, keine Umsortierung der Einfuhren, keine
|
||||
Aenderung an `apps/api`.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web test 'admin/users' 2>&1 | tail -8 # erwartet: 2 Dateien, alle Tests gruen (Ausgangslage war 1 Datei / 8 Tests)</automated>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web test 'messages/umlaut-guard' 2>&1 | tail -8 # erwartet: 3 Tests gruen — belegt Schluesselgleichheit de/en und saubere Umlaute</automated>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && node -e "const d=require('./apps/web/src/messages/de.json'),e=require('./apps/web/src/messages/en.json');const k=o=>Object.keys(o.admin.users.errors).sort().join(',');if(k(d)!=='generic,loadFailed,network,serverRejected')throw new Error('de errors keys: '+k(d));if(k(e)!==k(d))throw new Error('en weicht ab: '+k(e));console.log('OK',k(d))"</automated>
|
||||
</verify>
|
||||
<done>
|
||||
Ein mit 403 abgewiesener Loeschvorgang zeigt den Servertext im Loeschdialog; der Dialog bleibt offen.
|
||||
Eine Antwort ohne verwertbaren Rumpf und ein Verbindungsfehler zeigen jeweils ihre uebersetzte
|
||||
Ersatzmeldung. Der Erfolgsfall schliesst den Dialog und laedt die Liste neu wie bisher. Vier Schluessel
|
||||
unter `admin.users.errors` in beiden Katalogen, Umlaut-Waechter gruen.
|
||||
</done>
|
||||
<reversibility rating="reversible">Zusaetzlicher Zustand und ein Banner in einer Datei — ruecknehmbar durch Zuruecksetzen der Datei.</reversibility>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Aufgabe 2: Formularweg und Listenladen auf dieselbe Rueckmeldung heben</name>
|
||||
<files>apps/web/src/app/(portal)/admin/users/page.tsx, apps/web/src/app/(portal)/admin/users/users-page.test.tsx</files>
|
||||
<read_first>
|
||||
apps/web/src/app/(portal)/admin/users/page.tsx (Zeilen 60-73 fetchUsers, 83-105 openCreate/openEdit, 107-142 handleSubmit, 178-196 Kopfbereich und Ladezustand, 281-391 Formulardialog)
|
||||
</read_first>
|
||||
<behavior>
|
||||
Weitere Tests in `users-page.test.tsx`, gleiche Bauart wie in Aufgabe 1:
|
||||
- Test 5 (Aendern wird abgewiesen): Liste laedt, Klick auf Bearbeiten, Absenden des Formulars; fetch
|
||||
antwortet `{ ok: false, status: 403, json: () => Promise.resolve({ message: 'Cannot modify a
|
||||
SUPER_ADMIN user' }) }`. Erwartet: "Der Server hat die Aktion abgelehnt: Cannot modify a SUPER_ADMIN
|
||||
user" steht im Dokument UND das Formular ist weiterhin offen (das Feld Benutzername ist noch da).
|
||||
- Test 6 (Pruefmeldungen als Feld): Rumpf `{ message: ['username must be longer', 'email must be an
|
||||
email'] }`. Erwartet: beide Teiltexte erscheinen, mit `, ` verbunden, im Rahmensatz.
|
||||
- Test 7 (Verbindung scheitert beim Speichern): fetch wirft. Erwartet: die Netzmeldung steht im
|
||||
Dokument, das Formular bleibt offen.
|
||||
- Test 8 (Liste laedt nicht): der erste fetch antwortet `{ ok: false, status: 500, json: () =>
|
||||
Promise.reject(new Error('not json')) }`. Erwartet: "Die Benutzerliste konnte nicht geladen werden."
|
||||
steht im Dokument und der Text "Keine Benutzer gefunden" steht NICHT im Dokument.
|
||||
- Test 9 (Meldung ueberdauert nicht): nach einem abgewiesenen Speichern das Formular schliessen und
|
||||
erneut Bearbeiten oeffnen — die alte Meldung ist verschwunden.
|
||||
</behavior>
|
||||
<action>
|
||||
Zwei weitere Zustaende ergaenzen: `formError` und `loadError`, beide `string | null`.
|
||||
|
||||
`handleSubmit`: zu Beginn `formError` auf `null` setzen. Sonst-Zweig zu `res.ok` und Fang-Zweig genau
|
||||
wie in Aufgabe 1 fuer den Loeschweg aufgebaut — `readApiMessage` befragen, bei Text
|
||||
`t('errors.serverRejected', { detail })`, sonst `t('errors.generic')`, im Fang-Zweig
|
||||
`t('errors.network')`. Der Rumpf des Fang-Zweigs enthaelt danach echte Zuweisung statt eines
|
||||
Kommentars. Zusaetzlich in `openCreate` und `openEdit` `formError` auf `null` setzen, damit eine
|
||||
Meldung nicht in den naechsten Dialogaufruf hinueberwandert.
|
||||
|
||||
Meldungsflaeche im Formulardialog: unterhalb des Rollenfeldes und oberhalb der Knopfreihe
|
||||
(Abbrechen/Speichern) ein Banner mit `role="alert"` und denselben Klassen wie in Aufgabe 1. Die
|
||||
Platzierung innerhalb des `<form>` ist bewusst: die Meldung muss dort stehen, wo der Blick nach dem
|
||||
Klick auf Speichern ohnehin ist.
|
||||
|
||||
`fetchUsers`: dieser dritte Fall ist ausdruecklich Teil dieses Plans und wird NICHT ausgelassen. Zu
|
||||
Beginn `loadError` auf `null` setzen, im Sonst-Zweig zu `res.ok` und im Fang-Zweig
|
||||
`t('errors.loadFailed')` setzen. Weil `fetchUsers` in `useCallback` mit leerer Abhaengigkeitsliste
|
||||
steckt und `t` aus `useTranslations` stammt: `t` der Abhaengigkeitsliste hinzufuegen, damit die
|
||||
Biome-Regel `useExhaustiveDependencies` keine neue Meldung erzeugt (sie steht auf Warnung, aber der
|
||||
Rueckstand soll nicht wachsen). `setLoading(false)` im `finally` bleibt unveraendert.
|
||||
|
||||
Meldungsflaeche fuer `loadError`: direkt unter dem Seitenkopf, vor der Tabelle — dieselbe Stelle und
|
||||
dieselben Klassen wie das Fehlerbanner in `apps/web/src/app/(portal)/admin/groups/page.tsx`. Solange
|
||||
`loadError` gesetzt ist, darf der Hinweis "Keine Benutzer gefunden" nicht erscheinen: die leere Liste
|
||||
ist dann keine Aussage ueber den Datenbestand, sondern Folge des gescheiterten Ladens.
|
||||
|
||||
Weiterhin nichts an `apps/api`, keine Umformatierung, keine Aenderung an der Erfolgslogik.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web test 'admin/users' 2>&1 | tail -8 # erwartet: 2 Dateien gruen, Testzahl gegenueber Aufgabe 1 gestiegen</automated>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && grep -v '^\s*//' "apps/web/src/app/(portal)/admin/users/page.tsx" | grep -c 'role="alert"' # erwartet: 3 (Loeschdialog, Formular, Listenkopf)</automated>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && test "$(grep -c 'silently fail' "apps/web/src/app/(portal)/admin/users/page.tsx")" = "0" && echo OK # kein still verschluckender Fang-Zweig mehr</automated>
|
||||
</verify>
|
||||
<done>
|
||||
Alle drei bisher stillen Wege melden sich: abgewiesenes Speichern im Formular, abgewiesenes Loeschen im
|
||||
Dialog, gescheitertes Laden im Listenkopf. Bei gescheitertem Laden erscheint nicht mehr faelschlich
|
||||
"Keine Benutzer gefunden". Meldungen ueberdauern das Schliessen eines Dialogs nicht.
|
||||
</done>
|
||||
<reversibility rating="reversible">Reine Ergaenzung in einer Datei.</reversibility>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Aufgabe 3: Aktionsknoepfe der SUPER_ADMIN-Zeile einem ADMIN nicht anbieten, Gesamtlauf</name>
|
||||
<files>apps/web/src/app/(portal)/admin/users/page.tsx, apps/web/src/app/(portal)/admin/users/users-page.test.tsx</files>
|
||||
<read_first>
|
||||
apps/web/src/app/(portal)/admin/users/page.tsx (Zeilen 221-275, Tabellenzeile mit der Knopfreihe)
|
||||
apps/api/src/user/user.controller.ts (Zeilen 196-205 und 258-264, Zielrollen-Riegel — nur lesen, nicht aendern)
|
||||
</read_first>
|
||||
<behavior>
|
||||
Weitere Tests in `users-page.test.tsx`, Liste enthaelt drei Zeilen: ein SUPER_ADMIN (`u0`), die
|
||||
angemeldete Person selbst (`u1`) und ein gewoehnlicher Benutzer (`u2`).
|
||||
- Test 10 (ADMIN sieht keine Knoepfe an der obersten Rolle): angemeldet als ADMIN. Erwartet: in der
|
||||
Zeile des SUPER_ADMIN gibt es weder einen Knopf "Bearbeiten" noch "Löschen"; der Knopf "Details"
|
||||
ist weiterhin da. In der Zeile von `u2` gibt es beide Knoepfe. Die Zuordnung Zeile-zu-Knopf ueber
|
||||
`within(...)` auf der Tabellenzeile pruefen, nicht ueber Gesamtzaehlungen.
|
||||
- Test 11 (SUPER_ADMIN sieht beide Knoepfe): angemeldet als SUPER_ADMIN. Erwartet: in der Zeile des
|
||||
SUPER_ADMIN sind Bearbeiten und Löschen vorhanden.
|
||||
- Test 12 (Selbstloeschung bleibt wie bisher): angemeldet als ADMIN `u1`. Erwartet: der Loeschknopf in
|
||||
der eigenen Zeile ist vorhanden und gesperrt (`toBeDisabled`) — dieses Verhalten wird nicht
|
||||
veraendert.
|
||||
</behavior>
|
||||
<action>
|
||||
Eine Hilfsfunktion innerhalb der Komponente anlegen, zum Beispiel `canManageRow`, die genau die
|
||||
Serverbedingung spiegelt: eine Zeile ist gesperrt, wenn `user.role === 'SUPER_ADMIN'` und
|
||||
`currentUser?.role !== 'SUPER_ADMIN'`. Dieselbe Bedingung steht serverseitig in
|
||||
`apps/api/src/user/user.controller.ts` in `update` und in `remove`; sie wird hier gespiegelt, nicht
|
||||
ersetzt (D-04, T-A1D-02).
|
||||
|
||||
In der Knopfreihe der Tabellenzeile: Bearbeiten und Löschen nur rendern, wenn die Zeile nicht gesperrt
|
||||
ist. Gesperrte Zeilen zeigen gar keinen dieser Knoepfe — nicht einen gesperrten, sondern keinen. Das
|
||||
ist die Anforderung aus der Fehlerliste, und ein sichtbarer, aber gesperrter Knopf wuerde denselben
|
||||
Ratespielraum lassen wie heute. "Details" bleibt in jeder Zeile stehen, weil der lesende Zugriff auf
|
||||
die Zugriffsuebersicht davon nicht betroffen ist.
|
||||
|
||||
Die vorhandene Sperre des Loeschknopfes fuer die eigene Zeile (`disabled={user.id === currentUser?.id}`
|
||||
samt ihren Klassen) bleibt unveraendert bestehen — sie ist ein anderer Fall und nicht Teil von
|
||||
WINDOWS #36.
|
||||
|
||||
Keine Aenderung an `apps/api`. Keine Aenderung an der Rollenauswahl im Formular (die Einschraenkung der
|
||||
Option SUPER_ADMIN dort besteht bereits und bleibt, wie sie ist).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web test 'admin/users' 2>&1 | tail -8 # erwartet: 2 Dateien, alle Tests gruen</automated>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web test 2>&1 | tail -6 # erwartet: 66 Dateien (Ausgangslage 65), mindestens 447 Tests, 0 Fehler</automated>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web type-check 2>&1 | tail -3; echo "EXIT=${PIPESTATUS[0]}" # erwartet: EXIT=0 wie in der Ausgangslage</automated>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm lint 2>&1 | tail -4 # erwartet: 5 successful, 5 total — keine NEUE Meldung im Fehlerrang</automated>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && ST=$(git status --porcelain) || { echo "git fehlgeschlagen"; exit 1; }; case "$ST" in *apps/api/*) echo "FEHLER: apps/api wurde angefasst"; exit 1;; *) echo "OK: apps/api unberuehrt";; esac # T-A1D-02, kein Pipe: der Status von git wird zuerst gesichert</automated>
|
||||
</verify>
|
||||
<done>
|
||||
Ein ADMIN bekommt in der Zeile des SUPER_ADMIN keine Aktionsknoepfe mehr angeboten, ein SUPER_ADMIN
|
||||
schon; die Sperre gegen Selbstloeschung ist unveraendert. Gesamter Testbestand von apps/web gruen
|
||||
(66 Dateien), `type-check` mit Exit 0, `pnpm lint` in allen fuenf Workspaces erfolgreich, und
|
||||
`apps/api` ist unberuehrt.
|
||||
</done>
|
||||
<reversibility rating="reversible">Sichtbarkeitsbedingung in einer Zeile der Tabelle — ruecknehmbar.</reversibility>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| API → Browser (Antwortrumpf) | Text aus der Serverantwort wird neu in die Oberflaeche uebernommen und angezeigt. Bisher wurde er verworfen. |
|
||||
| Browser → API (Aktionsaufruf) | Unveraendert: jeder Aendern-/Loeschaufruf laeuft weiterhin durch Wache, Rollenpruefung und Zielrollen-Riegel der API. |
|
||||
| Rolle des Anmeldenachweises → Darstellung | `currentUser.role` steuert ab jetzt zusaetzlich, welche Knoepfe eine Zeile anbietet. Ein Wert, den der Browser haelt — daher niemals eine Berechtigungsgrenze. |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-A1D-01 | Information Disclosure | `readApiMessage` in `apps/web/src/app/(portal)/admin/users/page.tsx` | low | mitigate | Vor der Planung gemessen: saemtliche Ausnahmen der Benutzer-Endpunkte in `apps/api/src/user/user.controller.ts` werfen feste englische Zeichenketten ohne Benutzernamen, Kennungen oder Aufrufspuren; in `apps/api/src` existiert kein eigener ExceptionFilter, es gilt also die NestJS-Standardform `{ statusCode, message, error }` und ein 500 traegt nur `Internal server error`. Zusaetzlich liest die Auswertung ausschliesslich das Feld `message` — niemals `res.text()` des ganzen Rumpfes. Eine fremde HTML-Fehlerseite (Nginx Proxy Manager davor) liefert damit `null` und fuehrt zur uebersetzten Ersatzmeldung statt zu fremdem Inhalt in der Oberflaeche. |
|
||||
| T-A1D-02 | Elevation of Privilege | Sichtbarkeit der Aktionsknoepfe in der Benutzertabelle | high | mitigate | Das Ausblenden ist ausschliesslich Ergonomie und liegt OBERHALB der Serverpruefung. Der Zielrollen-Riegel in `update` und `remove` (`apps/api/src/user/user.controller.ts`) bleibt unveraendert und bleibt die einzige wirksame Grenze; wer den Aufruf direkt absetzt, bekommt weiterhin 403. `apps/api` steht nicht in `files_modified`, und Aufgabe 3 prueft das mit einem eigenen Gatter (`git status --porcelain` darf keinen Pfad unter `apps/api/` zeigen). |
|
||||
| T-A1D-03 | Tampering | Darstellung des Servertextes im Banner | medium | mitigate | Der Text wird als React-Kind gerendert und damit maskiert; `dangerouslySetInnerHTML` ist fuer diese Flaechen ausgeschlossen. Damit kann ein manipulierter Antwortrumpf kein Markup in die Seite bringen. |
|
||||
| T-A1D-04 | Information Disclosure | Meldung `Cannot modify users from other tenants` | low | accept | Diese Meldung bestaetigt einem ADMIN die Existenz einer Kennung in einem fremden Mandanten. Das ist bestehendes Serververhalten: die Antwort erreicht den Browser schon heute vollstaendig, nur wird sie verworfen. Das Anzeigen aendert nichts daran, wer sie lesen kann. Abstellen hiesse die Serverantwort aendern, was D-04 ausschliesst — als Beobachtung fuer die Fehlerliste vermerkt, nicht in diesem Lauf behandelt. |
|
||||
| T-A1D-05 | Denial of Service | Ersatzmeldungen bei fehlender Antwort | low | mitigate | Jeder Zweig endet in einer Meldung: Servertext, `generic`, `network` oder `loadFailed`. Kein Pfad laesst die Oberflaeche ohne Rueckmeldung stehen — genau das war der Befund von WINDOWS #36. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
Nach allen drei Aufgaben, aus dem Projektverzeichnis:
|
||||
|
||||
1. `pnpm --filter @tessera/web test` — erwartet 66 Testdateien (Ausgangslage 65) und mindestens 447 Tests,
|
||||
0 Fehler. Eine niedrigere Zahl oder ein roter Lauf ist ein Rueckschritt gegenueber der Ausgangsmessung.
|
||||
2. `pnpm --filter @tessera/web type-check` — Exit 0 (Ausgangslage: Exit 0).
|
||||
3. `pnpm lint` — 5 von 5 Workspaces erfolgreich. Bestehende Warnungen bleiben zulaessig, jede neue Meldung
|
||||
im Fehlerrang faerbt den Lauf rot und ist zu beheben.
|
||||
4. `git status --porcelain` zeigt ausschliesslich Pfade unter `apps/web/src/` — kein Pfad unter `apps/api/`.
|
||||
|
||||
**Von Hand, ausdruecklich nicht automatisiert** (optional, die Tests decken das Verhalten bereits ab; hier
|
||||
geht es allein um Platzierung und Lesbarkeit): in der laufenden Anwendung als ADMIN die Benutzerverwaltung
|
||||
oeffnen. Sichtprobe a — in der Zeile des SUPER_ADMIN stehen nur noch "Details". Sichtprobe b — eine
|
||||
abgewiesene Aktion (etwa ueber die Entwicklerwerkzeuge erzwungen) zeigt das rote Banner an der erwarteten
|
||||
Stelle, im Formular oberhalb der Knopfreihe und im Loeschdialog oberhalb der Knopfreihe.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Keiner der drei bisher stillen Wege in `page.tsx` endet noch ohne sichtbare Rueckmeldung.
|
||||
- Bei vorhandenem Servertext steht dieser im Rahmensatz; sonst greift die passende uebersetzte
|
||||
Ersatzmeldung.
|
||||
- Ein ADMIN bekommt an der SUPER_ADMIN-Zeile keine Aktionsknoepfe angeboten; ein SUPER_ADMIN schon.
|
||||
- Alle Texte liegen in beiden Katalogen mit gleichem Schluesselsatz; der Umlaut-Waechter ist gruen.
|
||||
- 66 Testdateien gruen, `type-check` Exit 0, `pnpm lint` 5 von 5 erfolgreich.
|
||||
- `apps/api` ist unveraendert; die 403-Antworten sind exakt dieselben wie vorher.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/quick/260921-a1d-windows-36-benutzerverwaltung-zeigt-bei-/260921-a1d-SUMMARY.md` when done.
|
||||
|
||||
In der Zusammenfassung festhalten:
|
||||
- gemessene Testzahlen vorher (65 Dateien / 447 Tests) und nachher,
|
||||
- dass `fetchUsers` bewusst mitbehandelt wurde (dritte Fundstelle, in scope),
|
||||
- dass die Servertexte englisch bleiben und eine Uebersetzung serverseitig waere (Beobachtung fuer die
|
||||
Fehlerliste, nicht Teil dieses Laufs — siehe T-A1D-04 und den Hinweis in der Ausgangslage),
|
||||
- dass WINDOWS #36 als geschlossen zu markieren ist, waehrend #28 und #32 derselben Familie offen bleiben.
|
||||
</output>
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
---
|
||||
phase: quick-260921-a1d
|
||||
plan: 01
|
||||
subsystem: ui
|
||||
tags: [next-intl, react, vitest, error-handling, rbac]
|
||||
|
||||
requires:
|
||||
- phase: quick-260914-ebg
|
||||
provides: "Zielrollen-Riegel in apps/api/src/user/user.controller.ts (update/remove), der SUPER_ADMIN-Zeilen fuer Nicht-SUPER_ADMIN mit 403 abweist (WINDOWS #29)"
|
||||
provides:
|
||||
- "Sichtbare Fehlermeldungen (Servertext oder Ersatzmeldung) im Loeschdialog, im Formulardialog und im Listenkopf der Benutzerverwaltung"
|
||||
- "canManageRow spiegelt den Zielrollen-Riegel client-seitig — ADMIN sieht in der SUPER_ADMIN-Zeile keine Aktionsknoepfe mehr"
|
||||
- "readApiMessage-Muster fuer den Antwortrumpf, lokal zu apps/web/src/app/(portal)/admin/users/page.tsx"
|
||||
affects: [admin-users-page, benutzerverwaltung, error-handling-conventions]
|
||||
|
||||
actuals:
|
||||
tokens: 7650
|
||||
tasks: 3
|
||||
commits: 3
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "readApiMessage(res) liest ausschliesslich res.json().message (String oder String[]), nie res.text() — verhindert, dass eine fremde HTML-Fehlerseite (vorgelagerter Proxy) in die Oberflaeche geraet"
|
||||
- "Drei-Zustaende-Fehlermuster pro Formular/Dialog: serverRejected (mit Detail) / generic (kein Rumpf) / network (Verbindung gescheitert), alle als role=\"alert\"-Banner mit denselben Klassen wie admin/groups/page.tsx"
|
||||
- "canManageRow spiegelt eine Server-Autorisierungsbedingung rein als UI-Ergonomie — Kommentar verweist explizit auf die Serverstelle, die die eigentliche Grenze zieht"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- "apps/web/src/app/(portal)/admin/users/users-page.test.tsx"
|
||||
modified:
|
||||
- "apps/web/src/app/(portal)/admin/users/page.tsx"
|
||||
- "apps/web/src/messages/de.json"
|
||||
- "apps/web/src/messages/en.json"
|
||||
|
||||
key-decisions:
|
||||
- "D-01 bis D-06 aus dem Plan unveraendert umgesetzt: next-intl in beiden Katalogen, Sie-Form, Servertext hat Vorrang vor Ersatzmeldung, apps/api unangetastet, vorhandene Muster (groups/page.tsx Banner, tender-radar-api.ts Rumpf-Auswertung) wiederverwendet, keine Umformatierung"
|
||||
- "Servertexte bleiben englisch (D-03/Ausgangslage) — Uebersetzung waere eine apps/api-Aenderung und damit ausserhalb dieses Plans; siehe T-A1D-04 im Plan"
|
||||
|
||||
requirements-completed: [WINDOWS-36]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "Ein mit 403 abgewiesener Loeschvorgang zeigt den Servertext im offenen Loeschdialog; Ersatzmeldungen fuer verwertbaren-losen Rumpf und Verbindungsfehler; Erfolgsfall unveraendert"
|
||||
requirement: "WINDOWS-36"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "apps/web/src/app/(portal)/admin/users/users-page.test.tsx#AdminUsersPage — Loeschweg (WINDOWS #36, Aufgabe 1) (4 Tests)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "Abgewiesenes Speichern zeigt den Servertext (inkl. verketteter Pruefmeldungen) im offenen Formular; Netzmeldung bei Verbindungsfehler; gescheitertes Laden meldet sich im Listenkopf statt faelschlich 'Keine Benutzer gefunden' zu zeigen; Meldungen ueberdauern kein Dialog-Schliessen"
|
||||
requirement: "WINDOWS-36"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "apps/web/src/app/(portal)/admin/users/users-page.test.tsx#AdminUsersPage — Formularweg und Listenladen (WINDOWS #36, Aufgabe 2) (5 Tests)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "Ein ADMIN bekommt in der SUPER_ADMIN-Zeile weder Bearbeiten noch Loeschen angeboten (Details bleibt); ein SUPER_ADMIN bekommt beide; Selbstloeschungssperre unveraendert"
|
||||
requirement: "WINDOWS-36"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "apps/web/src/app/(portal)/admin/users/users-page.test.tsx#AdminUsersPage — Aktionsknoepfe der SUPER_ADMIN-Zeile (WINDOWS #36, Aufgabe 3) (3 Tests)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
|
||||
duration: 14min
|
||||
completed: 2026-09-21
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Quick 260921-a1d: Stille 403-Antworten in der Benutzerverwaltung Summary
|
||||
|
||||
**Loesch-, Formular- und Ladeweg der Benutzerverwaltung melden abgewiesene Serverantworten jetzt sichtbar (Servertext oder uebersetzte Ersatzmeldung); ein ADMIN bekommt an der SUPER_ADMIN-Zeile keine Aktionsknoepfe mehr angeboten.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 14 min
|
||||
- **Started:** 2026-09-21T07:19:00+02:00 (gemessen: erster Lesevorgang)
|
||||
- **Completed:** 2026-09-21T07:33:48+02:00 (letzter Task-Commit)
|
||||
- **Tasks:** 3/3
|
||||
- **Files modified:** 4 (1 neu, 3 geaendert)
|
||||
|
||||
## Gemessene Testzahlen
|
||||
|
||||
- **Vorher** (Ausgangsmessung im Plan, 2026-09-21): 65 Dateien, 447 Tests, alle gruen.
|
||||
- **Nachher** (dieser Lauf, `pnpm --filter @tessera/web test`): **66 Dateien, 459 Tests, alle gruen.**
|
||||
Die neue Datei `users-page.test.tsx` traegt 20 Tests (4 aus Aufgabe 1, 5 aus Aufgabe 2, 3 aus Aufgabe 3 —
|
||||
plus die schon vorher zu `admin/users` zaehlende `user-access-modal.test.tsx` mit 8 Tests, macht 12 in der
|
||||
Datei `admin/users`-Glob-Messung von Aufgabe 1/2/3).
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- `readApiMessage(res)` liest gezielt `body.message` (String oder verkettetes String-Feld) aus einer
|
||||
Nicht-2xx-Antwort und gibt sonst `null` zurueck — niemals den ganzen Rumpf.
|
||||
- Drei bisher stumme Fehlerwege melden sich jetzt sichtbar: `handleDelete` (Loeschdialog),
|
||||
`handleSubmit` (Formulardialog), `fetchUsers` (Listenkopf). Jeder Zweig endet in einer Meldung:
|
||||
Servertext (`errors.serverRejected`), Ersatzmeldung ohne Rumpf (`errors.generic`), Verbindungsfehler
|
||||
(`errors.network`) oder Ladefehler (`errors.loadFailed`).
|
||||
- Bei gescheitertem Laden erscheint nicht mehr faelschlich "Keine Benutzer gefunden" — die Tabelle bleibt
|
||||
einfach weg, das Banner sagt, was wirklich passiert ist.
|
||||
- `canManageRow(user)` spiegelt exakt die serverseitige Bedingung
|
||||
(`user.role === 'SUPER_ADMIN' && currentUser?.role !== 'SUPER_ADMIN'`) aus
|
||||
`apps/api/src/user/user.controller.ts` (`update`/`remove`) und blendet Bearbeiten/Loeschen in der
|
||||
SUPER_ADMIN-Zeile fuer jeden Nicht-SUPER_ADMIN komplett aus — nicht nur gesperrt, sondern nicht vorhanden.
|
||||
- Vier neue Schluessel unter `admin.users.errors` in `de.json` und `en.json`, identischer Schluesselsatz,
|
||||
Umlaut-Waechter gruen.
|
||||
|
||||
## Task Commits
|
||||
|
||||
Jede Aufgabe wurde atomar committet:
|
||||
|
||||
1. **Aufgabe 1: Loeschweg von der Serverantwort bis zur sichtbaren Meldung** - `38d2586` (fix)
|
||||
2. **Aufgabe 2: Formularweg und Listenladen auf dieselbe Rueckmeldung heben** - `51bff75` (fix)
|
||||
3. **Aufgabe 3: Aktionsknoepfe der SUPER_ADMIN-Zeile einem ADMIN nicht anbieten, Gesamtlauf** - `13b70df` (fix)
|
||||
|
||||
**Plan-Basis:** `24f51e9` (Plan bereits committet vor Ausfuehrung)
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `apps/web/src/app/(portal)/admin/users/page.tsx` - `readApiMessage`, drei neue Fehlerzustaende
|
||||
(`deleteError`, `formError`, `loadError`), drei `role="alert"`-Banner, `canManageRow` fuer die
|
||||
Sichtbarkeit der Aktionsknoepfe.
|
||||
- `apps/web/src/app/(portal)/admin/users/users-page.test.tsx` - neu, 20 Tests fuer alle drei Aufgaben.
|
||||
- `apps/web/src/messages/de.json` / `en.json` - Zweig `admin.users.errors` mit vier Schluesseln je Sprache.
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- Servertexte bleiben englisch und werden ungeaendert im deutschen Rahmensatz angezeigt (D-03 aus dem
|
||||
Plan) — eine Uebersetzung waere eine `apps/api`-Aenderung und damit ausserhalb dieses Plans. Als
|
||||
Beobachtung im Plan unter T-A1D-04 vermerkt, hier nicht behandelt.
|
||||
- `readApiMessage` ist bewusst lokal in `page.tsx` kopiert statt aus `apps/web/src/lib/tender-radar-api.ts`
|
||||
importiert — jene Datei gehoert zum Modul Ausschreibungs-Radar, die Verwaltungsseite soll nicht davon
|
||||
abhaengen (Formvorlage `extractErrorMessage`, D-05).
|
||||
- `fetchUsers` (dritte Fundstelle, urspruenglich nicht Teil der Fehlermeldung) wurde bewusst mitbehandelt,
|
||||
wie der Plan es verlangt — nichts wurde aus Bequemlichkeit ausgelassen.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
None - plan executed exactly as written.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
None.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None - keine externe Dienstkonfiguration erforderlich.
|
||||
|
||||
## Verifikation (aus der `<verification>`-Sektion des Plans)
|
||||
|
||||
1. `pnpm --filter @tessera/web test` → **66 Testdateien, 459 Tests, 0 Fehler** (Ausgangslage: 65/447).
|
||||
2. `pnpm --filter @tessera/web type-check` → **Exit 0**.
|
||||
3. `pnpm lint` → **5 von 5 Workspaces erfolgreich** (287 bestehende Warnungen, 0 neue Fehlerrang-Meldungen).
|
||||
4. `git status --porcelain` → ausschliesslich Pfade unter `apps/web/src/`, kein Pfad unter `apps/api/`.
|
||||
|
||||
Von Hand vorgesehene Sichtproben (Sichtprobe a: SUPER_ADMIN-Zeile zeigt nur "Details" fuer einen ADMIN;
|
||||
Sichtprobe b: Platzierung des roten Banners) wurden **nicht** durchgefuehrt — der Plan bezeichnet sie
|
||||
ausdruecklich als optional, da die automatisierten Tests dasselbe Verhalten bereits abdecken.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- WINDOWS #36 ist inhaltlich geschlossen — dieser Lauf schliesst den beschriebenen Fehler vollstaendig
|
||||
(alle drei stillen Wege sowie die Sichtbarkeit der Aktionsknoepfe). Die Ledger-Eintragung selbst
|
||||
(`gsd-tools windows fixed 36`) erfolgt durch den Orchestrator, nicht durch diesen Ausfuehrungslauf.
|
||||
- WINDOWS #28 und #32 derselben Fehlerfamilie bleiben ausdruecklich offen — nicht Teil dieses Plans.
|
||||
- Keine Blocker fuer nachfolgende Arbeit an der Benutzerverwaltung.
|
||||
|
||||
---
|
||||
*Phase: quick-260921-a1d*
|
||||
*Completed: 2026-09-21*
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
Alle vier veraenderten Dateien und die neue Testdatei existieren auf der Platte; alle drei
|
||||
Task-Commits (`38d2586`, `51bff75`, `13b70df`) sind in der Git-Historie auffindbar.
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
---
|
||||
phase: quick-260921-a1d
|
||||
verified: 2026-09-21T05:39:19Z
|
||||
status: passed
|
||||
score: 7/7 must-haves verified
|
||||
covered_files:
|
||||
- .planning/quick/260921-a1d-windows-36-benutzerverwaltung-zeigt-bei-/260921-a1d-PLAN.md
|
||||
- .planning/quick/260921-a1d-windows-36-benutzerverwaltung-zeigt-bei-/260921-a1d-SUMMARY.md
|
||||
- "apps/web/src/app/(portal)/admin/users/page.tsx"
|
||||
- "apps/web/src/app/(portal)/admin/users/users-page.test.tsx"
|
||||
- apps/web/src/messages/de.json
|
||||
- apps/web/src/messages/en.json
|
||||
behavior_unverified: 0
|
||||
overrides_applied: 0
|
||||
---
|
||||
|
||||
# Quick 260921-a1d: WINDOWS #36 (stille 403-Antworten) Verification Report
|
||||
|
||||
**Task-Ziel:** Beide Haelften von WINDOWS #36 schliessen — (a) jede nicht-ok Serverantwort erzeugt eine
|
||||
sichtbare Meldung aus dem API-Rumpf, (b) ein ADMIN bekommt in der SUPER_ADMIN-Zeile keine Bearbeiten-/
|
||||
Loeschen-Knoepfe angeboten.
|
||||
|
||||
**Verified:** 2026-09-21T05:39:19Z
|
||||
**Status:** passed
|
||||
|
||||
## Goal Achievement
|
||||
|
||||
### Observable Truths
|
||||
|
||||
| # | Truth | Status | Evidence |
|
||||
|---|-------|--------|----------|
|
||||
| 1 | 403 beim Speichern zeigt sichtbare Meldung im offenen Formular mit Servertext | ✓ VERIFIED | `page.tsx:164-176` (`handleSubmit` Sonst-/Fang-Zweig, `formError`-Banner Z. 437-444); Test "zeigt den Servertext im offenen Formular..." (Z. 323-361) prueft `screen.getByText(...)` auf den tatsaechlichen Rahmensatz UND dass das Feld Benutzername weiterhin im Dokument steht |
|
||||
| 2 | 403 beim Loeschen zeigt sichtbare Meldung im offenen Loeschdialog | ✓ VERIFIED | `page.tsx:178-197` (`handleDelete`, `deleteError`-Banner Z. 472-479); Test Z. 167-208 prueft Servertext UND dass der Bestaetigungstext weiterhin im Dokument steht |
|
||||
| 3 | Unverwertbarer Rumpf oder Verbindungsfehler zeigen uebersetzte Ersatzmeldung, nie leere Reaktion | ✓ VERIFIED | `readApiMessage` (Z. 39-50) gibt bei Ausnahme/leerem Feld `null` zurueck, Aufrufer faellt auf `t('errors.generic')`; Fang-Zweige setzen `t('errors.network')`. Tests Z. 210-244 (Loeschen), 402-433 (Formular) pruefen exakt diese Texte im DOM |
|
||||
| 4 | Gescheitertes Laden der Liste meldet sich, zeigt nicht mehr faelschlich "Keine Benutzer gefunden" | ✓ VERIFIED | `fetchUsers` (Z. 83-99) setzt `loadError`; Rendern Z. 251 unterdrueckt "noUsers", wenn `loadError` gesetzt ist. Test Z. 435-457 prueft beides per `getByText`/`queryByText` |
|
||||
| 5 | ADMIN sieht in SUPER_ADMIN-Zeile weder Bearbeiten noch Loeschen; SUPER_ADMIN sieht beide | ✓ VERIFIED | `canManageRow` (Z. 212-213) spiegelt exakt die Serverbedingung; Knopfreihe Z. 316-335 rendert Bearbeiten/Loeschen nur bei `canManageRow(user)`. Tests Z. 516-549 pruefen beide Rollen per `within(row)` |
|
||||
| 6 | Alle neuen Texte in de.json UND en.json, identischer Schluesselsatz, kein fest verdrahteter Text | ✓ VERIFIED | Vier Schluessel unter `admin.users.errors` in beiden Dateien identisch; volle Katalogpruefung: 890 Schluessel je Sprache, 0 Abweichung (siehe Data-Flow-Trace); `grep` auf die deutschen Fehlertexte im TSX findet nichts — alles laeuft ueber `t(...)` |
|
||||
| 7 | apps/api unveraendert — ausgeblendete Knoepfe sind Ergonomie, kein Ersatz fuer die Serverpruefung | ✓ VERIFIED | `git diff --name-only 24f51e9..13b70df` zeigt ausschliesslich `apps/web`-Pfade; `apps/api/src/user/user.controller.ts` Z. 196-205/258-264 traegt den unveraenderten Zielrollen-Riegel, dessen Bedingung `canManageRow` client-seitig spiegelt |
|
||||
|
||||
**Score:** 7/7 truths verified (0 present, behavior-unverified)
|
||||
|
||||
### Required Artifacts
|
||||
|
||||
| Artifact | Expected | Status | Details |
|
||||
|----------|----------|--------|---------|
|
||||
| `apps/web/src/app/(portal)/admin/users/page.tsx` | drei Fehlerzustaende, drei Meldungsflaechen, Rollenfilter | ✓ VERIFIED | `deleteError`/`formError`/`loadError`, drei `role="alert"`-Stellen (Z. 241, 439, 474), `canManageRow` (Z. 212-213) und dessen Anwendung (Z. 316, 324) |
|
||||
| `apps/web/src/app/(portal)/admin/users/users-page.test.tsx` | neue Vitest-Datei mit Verhaltensnachweisen | ✓ VERIFIED | Neue Datei, 12 Tests (4+5+3), alle pruefen gerenderten Text via `screen.getByText`/`within`, nicht nur State |
|
||||
| `apps/web/src/messages/de.json` / `en.json` | Zweig `admin.users.errors` mit vier Schluesseln je Sprache | ✓ VERIFIED | `serverRejected`, `generic`, `network`, `loadFailed` identisch in beiden Dateien |
|
||||
|
||||
### Key Link Verification
|
||||
|
||||
| From | To | Via | Status | Details |
|
||||
|------|-----|-----|--------|---------|
|
||||
| `readApiMessage(res)` | `t('errors.serverRejected', { detail })` -> sichtbares Banner | direkter Aufruf im Sonst-Zweig von `handleSubmit`/`handleDelete` | WIRED | Code Z. 164-176, 185-197; durch Tests Z. 167-208, 323-361 mit tatsaechlichem DOM-Text bestaetigt |
|
||||
| `currentUser.role` (auth-store) | Sichtbarkeit der Aktionsknoepfe je Zeile | `canManageRow(user)` nutzt `currentUser?.role` | WIRED | Code Z. 60, 212-213, 316, 324; Tests Z. 516-549 mit zwei Rollen |
|
||||
| `de.json`/`en.json` Schluesselgleichheit | `umlaut-guard.spec.ts` | Test laeuft ueber gesamten Katalog | WIRED | `pnpm --filter @tessera/web test -- --run messages/umlaut-guard` -> 3 Tests gruen; zusaetzlich eigene Node-Pruefung: 890/890 Schluessel identisch |
|
||||
|
||||
### Behavioral Spot-Checks
|
||||
|
||||
| Behavior | Command | Result | Status |
|
||||
|----------|---------|--------|--------|
|
||||
| Testbestand admin/users (neu+bestehend) | `pnpm --filter @tessera/web test -- --run 'admin/users'` | 2 Dateien, 20 Tests, alle gruen | ✓ PASS |
|
||||
| Gesamter Web-Testbestand (einmalig, voller Lauf) | `pnpm --filter @tessera/web test -- --run` | 66 Dateien, 459 Tests, alle gruen (Ausgangslage 65/447) | ✓ PASS |
|
||||
| Umlaut-Waechter | `pnpm --filter @tessera/web test -- --run 'messages/umlaut-guard'` | 3 Tests gruen | ✓ PASS |
|
||||
| Typpruefung `apps/web` | `pnpm --filter @tessera/web type-check` | Exit 0 | ✓ PASS |
|
||||
| Lint, ganzes Monorepo (frisch, ohne Cache) | `pnpm lint --force` | 5 von 5 Workspaces erfolgreich, keine neue Fehlerrang-Meldung | ✓ PASS |
|
||||
| Lint gezielt auf die zwei geaenderten Dateien | `biome lint page.tsx users-page.test.tsx` | 12 Warnungen (a11y/useButtonType, noExplicitAny — bestehende Muster), 0 Fehler | ✓ PASS |
|
||||
| `apps/api` unveraendert | `git diff --name-only 24f51e9 13b70df` | Nur `apps/web`-Pfade | ✓ PASS |
|
||||
| Lockfile/Version unveraendert | `git diff --stat 24f51e9 13b70df -- pnpm-lock.yaml package.json apps/web/package.json` | keine Ausgabe (kein Diff) | ✓ PASS |
|
||||
| Debt-Marker / dangerouslySetInnerHTML | `grep -n -E "TBD\|FIXME\|XXX\|TODO\|HACK\|PLACEHOLDER"` und `dangerouslySetInnerHTML` auf beiden Dateien | keine Treffer | ✓ PASS |
|
||||
| Kein fest verdrahteter Fehlertext im TSX | `grep` auf die deutschen Fehlerformulierungen in `page.tsx` | keine Treffer (alles ueber `t(...)`) | ✓ PASS |
|
||||
|
||||
### Anti-Patterns Found
|
||||
|
||||
Keine. Kein Debt-Marker, kein `dangerouslySetInnerHTML`, keine fest verdrahtete Zeichenkette, keine
|
||||
Umformatierung (Diffstats 42/38/44 Zeilen je Commit in `page.tsx`, keine Ganzdatei-Rewrites), kein
|
||||
Abhaengigkeits-/Lockfile-Wechsel.
|
||||
|
||||
### Requirements Coverage
|
||||
|
||||
| Requirement | Beschreibung | Status | Evidence |
|
||||
|-------------|-------------|--------|----------|
|
||||
| WINDOWS-36 | Benutzerverwaltung zeigt bei 403 keine Rueckmeldung; SUPER_ADMIN-Zeile bietet ADMIN keine Aktionsknoepfe | ✓ SATISFIED | Alle 7 Truths oben verifiziert; Ledger-Eintragung (`gsd-tools windows fixed 36`) laut SUMMARY bewusst dem Orchestrator ueberlassen, kein Teil dieses Ausfuehrungslaufs |
|
||||
|
||||
### Human Verification Required
|
||||
|
||||
Keine. Die im Plan vorgesehenen manuellen Sichtproben sind ausdruecklich als optional markiert ("die Tests
|
||||
decken das Verhalten bereits ab") und durch die automatisierten DOM-Assertions (nicht nur State-Pruefungen)
|
||||
tatsaechlich abgedeckt.
|
||||
|
||||
### Gaps Summary
|
||||
|
||||
Keine Luecken gefunden. Alle sieben Must-Have-Truths, alle drei Artefakte und alle drei Key-Links sind
|
||||
verifiziert; die Testzahlen (66 Dateien / 459 Tests), die Typpruefung (Exit 0) und der Lint-Lauf (5/5, keine
|
||||
neue Fehlermeldung) decken sich mit den SUMMARY-Angaben und wurden unabhaengig nachvollzogen. `apps/api` ist
|
||||
nachweislich unveraendert, der Zielrollen-Riegel im Controller bleibt die alleinige wirksame Grenze.
|
||||
|
||||
---
|
||||
|
||||
_Verified: 2026-09-21T05:39:19Z_
|
||||
_Verifier: Claude (gsd-verifier)_
|
||||
+680
@@ -0,0 +1,680 @@
|
||||
---
|
||||
phase: quick-260921-bi2
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- biome.json
|
||||
- docs/anleitung-entwicklung.md
|
||||
# Aufgabe 2 — 53 Dateien: 45 maschinell + 11 toter Code, 3 in beiden (gemessen, siehe <verify>)
|
||||
- apps/api/src/**
|
||||
- apps/api/scripts/rls-scratch-check.mjs
|
||||
- apps/web/src/**
|
||||
- apps/desktop/src/setup.html
|
||||
# Aufgabe 3 — Barrierefreiheit von Hand, 53 Dateien
|
||||
- apps/web/src/app/**
|
||||
- apps/web/src/components/**
|
||||
- apps/web/src/app/icon.svg
|
||||
autonomous: true
|
||||
requirements: [LINT-BACKLOG]
|
||||
|
||||
estimate:
|
||||
tokens: 205000
|
||||
raw_tokens: 205000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Der Lint-Rueckstand faellt von 2923 auf rund 466 Befunde; kein einziger davon hat die Stufe Fehler, der CI-Schritt Lint bleibt gruen (D-07)."
|
||||
- "Die Ausnahme fuer Testdateien greift nachweislich NICHT in echten Quelltext: nach der Konfigurationsaenderung stehen weiterhin exakt 289 noExplicitAny-Befunde in echtem Quelltext, und 0 in Testdateien (D-01)."
|
||||
- "Die NestJS-Abhaengigkeitsspritze ueberlebt den maschinellen Durchlauf unversehrt: die aus apps/api erzeugten __metadata-Zeilen sind vor und nach der Aenderung Zeichen fuer Zeichen identisch (593 Zeilen, sha256 6e1583f1...)."
|
||||
- "Beide Testlaeufe bleiben punktgleich gruen: apps/api 69 Dateien / 1124 Tests, apps/web 66 Dateien / 459 Tests; pnpm type-check bleibt bei 4/4 (D-07)."
|
||||
- "Die Regelgruppe security steht unveraendert auf error — weder im Regelblock noch in einer Ausnahme wird sie erwaehnt (D-06)."
|
||||
- "Alle 155 Barrierefreiheits-Befunde der sechs bearbeiteten Regeln sind auf 0; keine a11y-Regel wurde dafuer herabgestuft oder abgeschaltet (D-04, D-06)."
|
||||
- "In den drei Formularseiten der Verwaltung ist die Zahl der absendenden Schaltflaechen unveraendert (Mandanten 1, Benutzer 1, LDAP 2) — kein Formular hat seinen Absendeknopf verloren, keine Nebenschaltflaeche loest mehr versehentlich ein Absenden aus."
|
||||
- "Es wurde keine repo-weite Formatierung angestossen; der Quelltext-Diff ist zeilenbilanziert (D-08)."
|
||||
- "Die Entwickleranleitung nennt den neuen Stand, beide Ausnahmen mit Begruendung und die namentlich aufgefuehrten Folgeaufgaben."
|
||||
artifacts:
|
||||
- "biome.json — zwei zielgenaue overrides-Eintraege (Testdateien / apps-api) plus die korrigierte Fixture-Ausnahme; security unberuehrt"
|
||||
- "docs/anleitung-entwicklung.md — Abschnitt zum Lint-Tor auf den neuen Stand gebracht, beide Ausnahmen begruendet, Folgeaufgaben benannt"
|
||||
- "45 Quelldateien mit maschinell erzeugten, danach von Hand gelesenen mechanischen Korrekturen (Aufgabe 2)"
|
||||
- "15 Fundstellen toten Codes entfernt bzw. begruendet gemeldet (Aufgabe 2, D-03)"
|
||||
- "53 Dateien mit handgeschriebenen Barrierefreiheits-Korrekturen (Aufgabe 3, D-04)"
|
||||
- "Ein Abschlussbericht mit Vorher/Nachher-Zahlen, getrennt nach Testdateien und echtem Quelltext, und der Liste dessen, was bewusst stehen bleibt"
|
||||
key_links:
|
||||
- "biome.json overrides[0].includes -> nur *.spec.ts/*.spec.tsx/*.test.ts/*.test.tsx: die Kette, an der D-01 still zu viel abschalten koennte; Nachweis ist die unveraenderte Zahl 289"
|
||||
- "emitDecoratorMetadata (apps/api/tsconfig.json) -> __metadata(design:paramtypes) im erzeugten JS -> Nest loest Abhaengigkeiten auf: die Kette, die ein maschinelles import type in apps/api zerreisst; Nachweis ist der sha256-Vergleich"
|
||||
- "biome lint --only=<regel> -> schaltet eine in der Konfiguration abgeschaltete Regel wieder AN: der Grund, warum useImportType pfadgebunden (apps/web packages) und nie repo-weit laufen darf"
|
||||
- "<button> ohne Typangabe innerhalb eines <form> -> loest beim Klick ein Absenden aus: die Kette, die useButtonType repariert und die die Zahl der absendenden Schaltflaechen pro Datei belegt"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Den Lint-Rueckstand abbauen, den der Vorgang 260921-9ie sichtbar gemacht hat, ohne das Verhalten der
|
||||
Anwendung zu veraendern und ohne eine kosmetische Massenumschreibung.
|
||||
|
||||
Ausgangslage, heute frisch gemessen mit `biome lint . --reporter=json --max-diagnostics=20000`:
|
||||
**2923 Befunde, davon 0 auf der Stufe Fehler** — 2067 in Testdateien, 856 in echtem Quelltext.
|
||||
|
||||
Der Plan geht in drei Durchgaengen vor, geordnet nach Risikoklasse, nicht nach Dateien:
|
||||
|
||||
| Durchgang | Art der Arbeit | Befunde danach (gesamt / echt / Test) |
|
||||
|---|---|---|
|
||||
| Start | — | 2923 / 856 / 2067 |
|
||||
| 1 — Konfiguration | keine Zeile Quelltext angefasst | 754 / 633 / 121 |
|
||||
| 2 — maschinell + toter Code | Werkzeug erzeugt, von Hand gelesen | 621 / 542 / 79 |
|
||||
| 3 — Barrierefreiheit | vollstaendig Handarbeit | **466 / 387 / 79** |
|
||||
|
||||
Alle vier Zeilen sind gemessen, nicht geschaetzt: Durchgang 1 und 2 wurden beim Planen vollstaendig
|
||||
probeweise ausgefuehrt, gemessen und danach restlos zurueckgenommen (`git restore .`, Arbeitsbaum
|
||||
wieder sauber). Die Zahl fuer Durchgang 3 ist die Differenz der sechs bearbeiteten Regeln.
|
||||
|
||||
**Zwei Befunde aus der Probe, die den Zuschnitt bestimmen — beide belegt, nicht vermutet:**
|
||||
|
||||
1. **`style/useImportType` darf in `apps/api` NICHT maschinell angewendet werden.** Es ist mit 224
|
||||
Befunden die groesste Einzelregel, 222 davon in `apps/api`. Biome stuft die Korrektur als
|
||||
„sicher" ein, sie ist es hier aber nicht: `apps/api/tsconfig.json` hat `emitDecoratorMetadata`
|
||||
eingeschaltet, und NestJS loest seine Abhaengigkeiten ueber genau diese erzeugten Daten auf.
|
||||
Probelauf an `auth.service.ts`: vorher
|
||||
`__metadata("design:paramtypes", [prisma_service_1.PrismaService, ...])`, nachher
|
||||
`__metadata("design:paramtypes", [Function, Function, Function, Function, Function, Function])`,
|
||||
und die `require`-Zeile des Dienstes verschwindet ersatzlos. Ueber den ganzen Workspace gemessen:
|
||||
**61 von 65 Dateien mit Abhaengigkeitsdaten werden beschaedigt.** Die API startet danach nicht
|
||||
mehr. Dabei bleibt `pnpm type-check` gruen, und **kein einziger der 1124 API-Tests faellt**, denn
|
||||
`grep -rl createTestingModule apps/api/src` liefert 0 Treffer — der Testbestand startet den
|
||||
NestJS-Container nirgends. Der Fehler waere also durch jedes bestehende Tor dieses Projekts
|
||||
unbemerkt hindurchgegangen. Biome beschreibt das Problem in der eigenen Regelbeschreibung
|
||||
(`biome explain useImportType`, Abschnitt „Caveat with TypeScript experimental decorators") und
|
||||
empfiehlt dort woertlich, die Regel bei solchen Dekoratoren abzuschalten. Aufgabe 1 folgt dieser
|
||||
Empfehlung mit einem auf `apps/api/**` begrenzten Eintrag. Das ist eine Abweichung von D-02 und
|
||||
eine zweite Ausnahme neben D-01 — sie geschieht ausschliesslich, weil D-07 (kein
|
||||
Verhaltenswechsel) Vorrang hat, nicht um eine Zahl zu druecken.
|
||||
|
||||
2. **Nur 4 der 10 in D-02 genannten Regeln haben ueberhaupt eine Korrektur der Klasse „sicher".**
|
||||
Probelauf je Regel mit `--write` ohne `--unsafe`: `useImportType`, `noUselessEscapeInRegex`,
|
||||
`useConst` und `useExponentiationOperator` aendern etwas; `useNodejsImportProtocol`,
|
||||
`useLiteralKeys`, `useOptionalChain`, `useTemplate`, `noUselessSwitchCase` und `useParseIntRadix`
|
||||
melden „No fixes applied" und brauchen `--unsafe`. Deren Gesamtdiff ist mit 45 Dateien und rund
|
||||
100 Zeilen klein genug, um ihn vollstaendig von Hand zu lesen — was Aufgabe 2 verlangt.
|
||||
|
||||
Purpose: Das Lint-Tor ist seit dem Vorgang 260921-9ie scharf. Ein Rueckstand von 2923 Warnungen macht
|
||||
es blind: eine neue, echte Warnung geht darin unter. Nach diesem Vorgang ist der Rueckstand klein
|
||||
genug, um ihn zu ueberblicken, und der Rest ist namentlich benannt statt anonym.
|
||||
|
||||
Output: Bereinigte `biome.json`, rund 100 bereinigte Quelldateien, aktualisierte Entwickleranleitung,
|
||||
ein ehrlicher Abschlussbericht.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/gsd-core/workflows/execute-plan.md
|
||||
@~/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/STATE.md
|
||||
@CLAUDE.md
|
||||
@biome.json
|
||||
@docs/anleitung-entwicklung.md
|
||||
|
||||
Messbefehl, der in allen drei Aufgaben gebraucht wird — einmal ausfuehren, Ergebnisdatei
|
||||
wiederverwenden:
|
||||
|
||||
```bash
|
||||
LJ=$(mktemp /tmp/tessera-lint-XXXXXX.json)
|
||||
pnpm exec biome lint . --reporter=json --max-diagnostics=20000 2>/dev/null > "$LJ"
|
||||
node -e '
|
||||
const d=require(process.argv[1]).diagnostics;
|
||||
const T=p=>/\.(spec|test)\.(ts|tsx)$/.test(p);
|
||||
const r=d.filter(x=>!T(x.location.path));
|
||||
console.log("total",d.length,"| real",r.length,"| test",d.length-r.length,
|
||||
"| errors",d.filter(x=>x.severity==="error").length);
|
||||
const m={};for(const x of r)m[x.category]=(m[x.category]||0)+1;
|
||||
console.log(Object.entries(m).sort((a,b)=>b[1]-a[1]).map(([k,v])=>String(v).padStart(4)+" "+k).join("\n"));
|
||||
' "$LJ"
|
||||
```
|
||||
|
||||
Wichtig zur Messung: Der Lauf muss aus dem Wurzelverzeichnis kommen. `pnpm lint` fuehrt
|
||||
`biome lint .` je Workspace aus und erreicht `biome.json` selbst nicht; die Grundmessung oben tut es.
|
||||
Ausserdem zwischenspeichert Turborepo den Lint-Schritt — fuer die Toraussage `pnpm lint --force`
|
||||
verwenden, sonst meldet der Lauf ein altes Ergebnis.
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Aufgabe 1: Konfiguration bereinigen und die Messgrundlage herstellen</name>
|
||||
<files>biome.json, docs/anleitung-entwicklung.md</files>
|
||||
<precondition>Der Arbeitsbaum ist sauber (`git status --porcelain` liefert nichts). Die drei Durchgaenge bauen aufeinander auf und werden je einzeln festgeschrieben; ein vorbelasteter Baum macht die Diff-Nachweise unlesbar.</precondition>
|
||||
<action>
|
||||
`biome.json` an drei Stellen aendern, sonst nirgends.
|
||||
|
||||
(a) **Ausnahme fuer Testdateien (D-01).** Einen `overrides`-Block anlegen, erster Eintrag:
|
||||
`includes` auf die vier Muster `**/*.spec.ts`, `**/*.spec.tsx`, `**/*.test.ts`, `**/*.test.tsx`,
|
||||
darin `linter.rules.suspicious.noExplicitAny` auf `off`. Diese Muster sind gegen den Bestand
|
||||
geprueft: `git ls-files` findet 71 Dateien auf `.spec.ts`, 50 auf `.test.tsx`, 14 auf `.test.ts` und
|
||||
keine auf `.spec.tsx`; letzteres Muster bleibt der Symmetrie halber stehen. Weitere Testablagen gibt
|
||||
es nicht — keine `__tests__`- und keine `__mocks__`-Verzeichnisse im Bestand.
|
||||
|
||||
(b) **Ausnahme fuer apps/api (Abweichung von D-02, begruendet in `<objective>` Punkt 1).** Zweiter
|
||||
`overrides`-Eintrag: `includes` auf `apps/api/**`, darin `linter.rules.style.useImportType` auf
|
||||
`off`. In denselben Eintrag gehoert keine weitere Regel. Der Grund muss als Satz in der
|
||||
Entwickleranleitung stehen, nicht nur in der Festschreibung.
|
||||
|
||||
(c) **Fehlerhafte Fixture-Ausnahme reparieren.** `files.includes` enthaelt heute den Eintrag fuer das
|
||||
Fixture-Verzeichnis mit angehaengtem Doppelstern. Biome meldet das selbst als
|
||||
`lint/suspicious/useBiomeIgnoreFolder` in `biome.json:11` und nennt die richtige Form in seinem
|
||||
Hinweis: seit Version 2.2.0 wird ein Verzeichnis ohne den angehaengten Doppelstern ausgenommen. Den
|
||||
Eintrag entsprechend kuerzen. Das betrifft die sechs Dateien unter
|
||||
`apps/api/src/tenders/__fixtures__/`.
|
||||
|
||||
Was NICHT geschieht (D-06): Der Block `linter.rules` wird an keiner anderen Stelle angefasst. Die
|
||||
Gruppe `security` kommt in der Datei weder vorher noch nachher vor und erbt damit weiter `error` aus
|
||||
`preset: recommended`. Die in 260921-9ie bewusst auf `warn` gesetzten Gruppen bleiben, wie sie sind.
|
||||
|
||||
Danach `docs/anleitung-entwicklung.md` im Abschnitt zum Lint-Tor (heute rund Zeile 61-69)
|
||||
fortschreiben. Der Satz „Aktuell stehen rund 2800 solcher Warnungen offen" ist ab jetzt falsch. Der
|
||||
neue Text nennt, in ganzen Saetzen und auf Deutsch: den Stand nach diesem Vorgang; dass `any` in
|
||||
Testdateien absichtlich nicht mehr gemeldet wird, weil es dort ausnahmslos an Attrappen haengt und
|
||||
eine Umschreibung viel Bewegung bei null Gewinn waere; dass `useImportType` in `apps/api`
|
||||
abgeschaltet ist, weil die Korrektur dort die Abhaengigkeitsaufloesung von NestJS zerstoert, Biome
|
||||
das in der eigenen Regelbeschreibung einraeumt und der Schaden von keinem Tor dieses Projekts
|
||||
bemerkt wuerde; und die Liste dessen, was als eigener Durchgang folgt (siehe Aufgabe 3, Abschnitt
|
||||
„Was bewusst stehen bleibt").
|
||||
</action>
|
||||
<verify>
|
||||
<automated>
|
||||
# 1. Konfiguration ist gueltiges JSON, hat genau zwei Ausnahmen, und security kommt nicht vor
|
||||
node -e '
|
||||
const c=require("/home/vicolab/projects/tessera-ctl/biome.json");
|
||||
const ok=[];
|
||||
ok.push(["overrides-Anzahl", (c.overrides||[]).length===2]);
|
||||
ok.push(["Testmuster", JSON.stringify(c.overrides[0].includes)===JSON.stringify(["**/*.spec.ts","**/*.spec.tsx","**/*.test.ts","**/*.test.tsx"])]);
|
||||
ok.push(["Testregel", c.overrides[0].linter.rules.suspicious.noExplicitAny==="off"]);
|
||||
ok.push(["api-Pfad", JSON.stringify(c.overrides[1].includes)===JSON.stringify(["apps/api/**"])]);
|
||||
ok.push(["api-Regel", c.overrides[1].linter.rules.style.useImportType==="off"]);
|
||||
ok.push(["security nirgends genannt", !JSON.stringify(c).includes("security")]);
|
||||
ok.push(["Fixture-Muster ohne Doppelstern", c.files.includes.includes("!**/__fixtures__")]);
|
||||
let bad=0; for(const [n,v] of ok){ if(!v) bad++; console.log((v?"OK ":"FEHL")+" "+n); }
|
||||
process.exit(bad);'
|
||||
|
||||
# 2. Zaehlung: Gesamt 754, echt 633, Test 121, Fehler 0
|
||||
LJ=$(mktemp /tmp/tessera-lint-XXXXXX.json)
|
||||
pnpm exec biome lint . --reporter=json --max-diagnostics=20000 2>/dev/null > "$LJ"
|
||||
node -e '
|
||||
const d=require(process.argv[1]).diagnostics;
|
||||
const T=p=>/\.(spec|test)\.(ts|tsx)$/.test(p);
|
||||
const r=d.filter(x=>!T(x.location.path)).length, t=d.length-r;
|
||||
const e=d.filter(x=>x.severity==="error").length;
|
||||
const any=d.filter(x=>x.category==="lint/suspicious/noExplicitAny");
|
||||
const anyReal=any.filter(x=>!T(x.location.path)).length, anyTest=any.length-anyReal;
|
||||
console.log("gesamt",d.length,"echt",r,"test",t,"fehler",e,"| any echt",anyReal,"any test",anyTest);
|
||||
const exp=[[d.length,754],[r,633],[t,121],[e,0],[anyReal,289],[anyTest,0]];
|
||||
process.exit(exp.filter(([a,b])=>a!==b).length);' "$LJ"
|
||||
|
||||
# 3. Das Tor bleibt gruen (Turbo-Zwischenspeicher umgehen)
|
||||
pnpm lint --force
|
||||
pnpm type-check
|
||||
</automated>
|
||||
</verify>
|
||||
<done>
|
||||
`biome.json` traegt genau zwei `overrides`-Eintraege und die korrigierte Fixture-Ausnahme; das Wort
|
||||
security kommt in der Datei nicht vor. Der Gesamtstand steht bei 754 Befunden (633 echt, 121 Test),
|
||||
Fehlerstufe 0. Entscheidend: `noExplicitAny` steht weiterhin bei **exakt 289 in echtem Quelltext**
|
||||
und 0 in Testdateien — die Ausnahme reicht nachweislich nicht in den Produktivcode hinein (D-01).
|
||||
`pnpm lint --force` meldet 5/5, `pnpm type-check` 4/4. Die Entwickleranleitung nennt den neuen Stand
|
||||
und begruendet beide Ausnahmen.
|
||||
</done>
|
||||
<reversibility rating="reversible">Eine Konfigurationsdatei und ein Dokumentabschnitt; beides ist mit einer Festschreibung zurueckgenommen.</reversibility>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Aufgabe 2: Maschinelle Korrekturen und toter Code</name>
|
||||
<files>
|
||||
53 Dateien insgesamt. Davon 45 aus dem maschinellen Durchgang in apps/api/src, apps/api/scripts,
|
||||
apps/web/src, packages und apps/desktop/src/setup.html — deren genauen Satz bestimmt das Werkzeug,
|
||||
<verify> nagelt Anzahl und Zeilenbilanz fest. Namentlich dazu die 11 Fundstellen toten Codes
|
||||
(D-03, drei davon stehen schon im maschinellen Satz):
|
||||
apps/api/scripts/rls-scratch-check.mjs,
|
||||
apps/api/src/auth/decorators/current-user.decorator.ts,
|
||||
apps/api/src/auth/interceptors/force-password-change.interceptor.ts,
|
||||
apps/api/src/calendar/calendar.service.ts,
|
||||
apps/api/src/calendar/dto/create-calendar-source.dto.ts,
|
||||
apps/api/src/cert-manager/cert-manager.service.ts,
|
||||
apps/api/src/dkv/dkv-parser.service.ts,
|
||||
apps/web/src/app/(auth)/login/page.tsx,
|
||||
apps/web/src/app/(portal)/change-password/page.tsx,
|
||||
apps/web/src/app/(portal)/modules/cert-manager/components/SplitTab.tsx,
|
||||
apps/web/src/app/(portal)/modules/dkv-fleet/settings/components/VehicleTable.tsx
|
||||
</files>
|
||||
<precondition>Aufgabe 1 ist festgeschrieben und der Arbeitsbaum ist wieder sauber. Ohne die Ausnahme fuer `apps/api` aus Aufgabe 1 wuerde der erste Befehl dieses Durchgangs 61 von 65 Dateien mit Abhaengigkeitsdaten beschaedigen.</precondition>
|
||||
<action>
|
||||
**Erst die beiden Nachweis-Anker setzen, dann anfassen.**
|
||||
|
||||
Anker 1 — der Ausgangspunkt im Verlauf. `<verify>` misst den Umfang gegen diesen Punkt und nicht
|
||||
gegen `HEAD`; sonst faellt die Messung leer aus, sobald die Arbeit festgeschrieben ist, und der
|
||||
Umfangsnachweis geht ins Leere:
|
||||
|
||||
```bash
|
||||
BASE=$(git rev-parse HEAD) # Stand nach Aufgabe 1; Wert notieren, in <verify> wiederverwenden
|
||||
```
|
||||
|
||||
Anker 2 — der Abdruck der erzeugten Dekoratordaten. Er ist der eigentliche Beleg dafuer, dass sich
|
||||
nichts am Verhalten geaendert hat, und ein gruener Testlauf ersetzt ihn nicht:
|
||||
|
||||
```bash
|
||||
SNAP=$(mktemp -d); pnpm --filter @tessera/api exec tsc --outDir "$SNAP" >/dev/null 2>&1
|
||||
grep -rh '__metadata(' "$SNAP" | sort | sha256sum
|
||||
# erwartet: 6e1583f1eb72a089eb0ed98f81158b54a9fbd40dbf41371292725f36ef764300 (593 Zeilen)
|
||||
```
|
||||
|
||||
**(A) Durchgang mit gesicherten Korrekturen.** Vier Regeln haben eine Korrektur der Klasse „sicher";
|
||||
sie duerfen ohne `--unsafe` laufen:
|
||||
|
||||
```bash
|
||||
pnpm exec biome lint apps/web packages --only=lint/style/useImportType --write
|
||||
for R in complexity/noUselessEscapeInRegex style/useConst style/useExponentiationOperator; do
|
||||
pnpm exec biome lint . --only="lint/$R" --write --max-diagnostics=20000
|
||||
done
|
||||
```
|
||||
|
||||
Die erste Zeile ist **pfadgebunden, und das ist kein Schoenheitsfehler**: `--only=<regel>` schaltet
|
||||
eine Regel, die in der Konfiguration auf `off` steht, fuer diesen Lauf wieder AN. Ein repo-weites
|
||||
`biome lint . --only=lint/style/useImportType --write` haengt die Ausnahme aus Aufgabe 1 also
|
||||
wirkungslos aus und schreibt 201 `import type`-Zeilen nach `apps/api` — beim Planen genau so
|
||||
passiert und gemessen. Der Pfad `apps/web packages` ist die einzige zulaessige Form. Betroffen sind
|
||||
dort 2 Fundstellen.
|
||||
|
||||
**(B) Durchgang mit ungesicherten Korrekturen, danach vollstaendig von Hand gelesen.** Sechs Regeln
|
||||
haben keine gesicherte Korrektur. Fuenf davon laufen mit `--unsafe`:
|
||||
|
||||
```bash
|
||||
for R in style/useNodejsImportProtocol complexity/useLiteralKeys complexity/useOptionalChain \
|
||||
style/useTemplate correctness/useParseIntRadix; do
|
||||
pnpm exec biome lint . --only="lint/$R" --write --unsafe --max-diagnostics=20000
|
||||
done
|
||||
```
|
||||
|
||||
Danach `git diff` **vollstaendig lesen** — es sind rund 100 Zeilen, das ist zumutbar und verlangt
|
||||
(D-02: nie ungeprueft anwenden). Zwei Stellen brauchen dabei ausdruecklich Aufmerksamkeit, weil sie
|
||||
in Wegen liegen, die dieses Projekt schon einmal Zeit gekostet haben:
|
||||
|
||||
- `apps/api/src/ldap/ldap.service.ts` traegt 25 der 31 `useLiteralKeys`-Aenderungen. Es geht um
|
||||
Verzeichnis-Merkmale, die heute in eckigen Klammern gelesen werden und danach mit Punkt. Das ist in
|
||||
JavaScript dieselbe Operation; die Schreibweise der Merkmalsnamen (`objectGUID`, `sAMAccountName`,
|
||||
`cn`, `ou`, `mail`) darf sich dabei an **keiner** Stelle aendern — ein verschluckter Grossbuchstabe
|
||||
macht den AD-Abgleich still leer. Zeichenweise gegenlesen.
|
||||
- `apps/api/src/auth/strategies/jwt.strategy.ts` und `apps/api/src/auth/auth.service.ts` liegen im
|
||||
Anmeldeweg. Dort entstehen Verkuerzungen wie „kein Benutzer oder Benutzer nicht aktiv" zu einer
|
||||
einzigen abgesicherten Kette. Pruefen, dass jede dieser Verkuerzungen dieselbe Entscheidung faellt
|
||||
wie vorher — insbesondere, dass eine fehlende Sitzung weiterhin zur Abweisung fuehrt und nicht zum
|
||||
Durchwinken.
|
||||
|
||||
**Die sechste Regel wird bewusst NICHT angewendet.** `complexity/noUselessSwitchCase` meldet eine
|
||||
Fundstelle in `apps/api/src/tenders/tender-normalizer.service.ts:60`. Die Regel moechte dort eine
|
||||
Fallmarke streichen, die unmittelbar ueber einem Kommentar steht, der erklaert, warum der
|
||||
Standardzweig genau auf diesem Weg bleiben muss. Die Marke dokumentiert also Absicht, die der Regel
|
||||
entgeht. Sie bleibt stehen, wird nicht unterdrueckt und im Abschlussbericht als bewusster Nicht-Fix
|
||||
genannt — das ist ehrlicher als eine Unterdrueckung, die wie eine Erledigung aussieht.
|
||||
|
||||
**(C) Toter Code (D-03), 15 Fundstellen.** Jede einzeln lesen, bevor etwas verschwindet. Drei
|
||||
Gruppen:
|
||||
|
||||
1. *Echt tot, folgenlos entfernbar* — die nicht benutzten Fehlervariablen in
|
||||
`calendar.service.ts` (Zeilen 321, 358), `cert-manager.service.ts` (305, 679) und
|
||||
`dkv-parser.service.ts` (42): den Namen aus der Auffangklausel streichen, den Rumpf und damit den
|
||||
Kontrollfluss unveraendert lassen. Dazu die nicht benutzten Einfuhren in
|
||||
`create-calendar-source.dto.ts:2` und die nicht benutzte Funktion `forSystemQuery` in
|
||||
`rls-scratch-check.mjs:226` (ein Pruefskript, nicht im Auslieferungsweg).
|
||||
2. *Nicht entfernbar, nur umbenennen* — `current-user.decorator.ts:4`: der Wert `data` ist der
|
||||
erste von zwei Parametern, die NestJS positionsgebunden uebergibt. Streichen wuerde den zweiten
|
||||
verschieben. Stattdessen mit fuehrendem Unterstrich kennzeichnen.
|
||||
3. *Symptome, die gemeldet und NICHT stillschweigend repariert werden* — hier ist die unbenutzte
|
||||
Variable der Hinweis auf eine echte Luecke, und sie zu schliessen waere ein Verhaltenswechsel, den
|
||||
D-07 diesem Vorgang verbietet:
|
||||
- `force-password-change.interceptor.ts:53` liest das HTTP-Verfahren in eine Variable und
|
||||
befragt sie nie; die Freigabeliste unterscheidet also nicht zwischen Lese- und Schreibzugriff
|
||||
auf die freigegebenen Wege. Variable entfernen, Luecke im Bericht als Folgeaufgabe nennen.
|
||||
- `change-password/page.tsx:11-12` haelt Wegweiser und Benutzerablage vor und benutzt beide
|
||||
nicht. Beim Lesen der Datei bestaetigt: nach erfolgreichem Wechsel wird weder weitergeleitet
|
||||
noch die Benutzerablage aufgefrischt — bei erzwungenem Wechsel bleibt die Person auf der Seite
|
||||
stehen. Die drei Bindungen entfernen, den Befund im Bericht als Folgeaufgabe nennen.
|
||||
- `VehicleTable.tsx:164` setzt einen Laufzustand fuers Loeschen, liest ihn aber nie; die
|
||||
Loeschschaltflaeche hat also keinen Besetztzustand und laesst sich doppelt ausloesen. Nur die
|
||||
lesende Bindung entfernen, die setzende bleibt; Befund im Bericht als Folgeaufgabe nennen.
|
||||
- `SplitTab.tsx:20` bekommt die Uebersetzungsfunktion und benutzt sie nicht — ein Hinweis auf
|
||||
fest verdrahtete Texte in diesem Reiter. Parameter entfernen, Befund melden.
|
||||
- `login/page.tsx:21` haelt einen unbenutzten Wegweiser; hier ist die Weiterleitung
|
||||
nachweislich anderswo geloest. Entfernen, keine Meldung noetig.
|
||||
|
||||
Kein `biome format --write`, auch nicht auf einzelne Dateien (D-08). Der Diff muss zeilenbilanziert
|
||||
bleiben.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>
|
||||
# 1. STAERKSTER NACHWEIS: die Abhaengigkeitsdaten von NestJS sind Zeichen fuer Zeichen unveraendert.
|
||||
# Ein gruener Testlauf kann das NICHT belegen — kein Test dieses Projekts startet den Container
|
||||
# (grep -rl createTestingModule apps/api/src liefert 0 Treffer).
|
||||
SNAP=$(mktemp -d); pnpm --filter @tessera/api exec tsc --outDir "$SNAP" >/dev/null 2>&1
|
||||
test "$(grep -rh '__metadata(' "$SNAP" | wc -l)" = "593" || { echo "FEHL: Zeilenzahl der Dekoratordaten"; exit 1; }
|
||||
grep -rh '__metadata(' "$SNAP" | sort | sha256sum \
|
||||
| grep -q '^6e1583f1eb72a089eb0ed98f81158b54a9fbd40dbf41371292725f36ef764300' \
|
||||
|| { echo "FEHL: Dekoratordaten veraendert — Abhaengigkeitsaufloesung gefaehrdet"; exit 1; }
|
||||
echo "OK Dekoratordaten unveraendert (593 Zeilen)"
|
||||
|
||||
# 2. Umfang festgenagelt: 53 Quelldateien (45 aus dem maschinellen Durchgang + 11 toter Code,
|
||||
# davon 3 in beiden), und der Diff hat keinen Zeilenueberschuss (keine Formatierung, D-08).
|
||||
# $BASE ist der in <action> notierte Stand nach Aufgabe 1 — gegen HEAD gemessen waere die
|
||||
# Pruefung leer und damit wertlos, sobald die Arbeit festgeschrieben ist.
|
||||
# git-Ausgabe zuerst einfangen, damit ein Fehlschlag von git nicht in einer Pipe verschwindet.
|
||||
test -n "$BASE" || { echo "FEHL: BASE nicht gesetzt (siehe <action>, Anker 1)"; exit 1; }
|
||||
CHANGED=$(git diff --name-only "$BASE") || { echo "FEHL: git diff --name-only"; exit 1; }
|
||||
N=$(printf '%s\n' "$CHANGED" | grep -v '^\(biome\.json\|docs/\)' | grep -c .)
|
||||
test "$N" = "53" || { echo "FEHL: $N statt 53 Quelldateien"; printf '%s\n' "$CHANGED"; exit 1; }
|
||||
STAT=$(git diff --numstat "$BASE" -- ':!biome.json' ':!docs') || { echo "FEHL: git diff --numstat"; exit 1; }
|
||||
printf '%s\n' "$STAT" | awk '
|
||||
{a+=$1;b+=$2}
|
||||
END {print "Zugaenge",a,"Abgaenge",b;
|
||||
if (a>b) {print "FEHL: Zeilenueberschuss — Formatierung mitgelaufen?"; exit 1}}'
|
||||
|
||||
# Zwischenprobe, direkt nach Schritt (B) und VOR dem toten Code auszufuehren:
|
||||
# MID=$(git diff --name-only "$BASE") ; printf '%s\n' "$MID" | grep -vc '^biome\.json'
|
||||
# -> muss 45 ergeben
|
||||
|
||||
# 3. Die neun bearbeiteten Regeln stehen auf 0, die bewusst stehen gelassene auf 1
|
||||
LJ=$(mktemp /tmp/tessera-lint-XXXXXX.json)
|
||||
pnpm exec biome lint . --reporter=json --max-diagnostics=20000 2>/dev/null > "$LJ"
|
||||
node -e '
|
||||
const d=require(process.argv[1]).diagnostics;
|
||||
const c=r=>d.filter(x=>x.category==="lint/"+r).length;
|
||||
const soll={"style/useImportType":0,"style/useNodejsImportProtocol":0,"complexity/useLiteralKeys":0,
|
||||
"complexity/useOptionalChain":0,"style/useTemplate":0,"complexity/noUselessEscapeInRegex":0,
|
||||
"style/useConst":0,"style/useExponentiationOperator":0,"correctness/useParseIntRadix":0,
|
||||
"correctness/noUnusedVariables":0,"correctness/noUnusedImports":0,
|
||||
"correctness/noUnusedFunctionParameters":0,"complexity/noUselessSwitchCase":1};
|
||||
let bad=0; for(const [r,s] of Object.entries(soll)){const v=c(r); if(v!==s){bad++;console.log("FEHL",r,v,"statt",s);} }
|
||||
const T=p=>/\.(spec|test)\.(ts|tsx)$/.test(p);
|
||||
const re=d.filter(x=>!T(x.location.path)).length;
|
||||
console.log("gesamt",d.length,"echt",re,"test",d.length-re,"fehler",d.filter(x=>x.severity==="error").length);
|
||||
if(d.length!==621||re!==542) {bad++;console.log("FEHL Gesamtstand, erwartet 621/542");}
|
||||
if(d.filter(x=>x.severity==="error").length!==0){bad++;console.log("FEHL Fehlerstufe nicht 0");}
|
||||
process.exit(bad);' "$LJ"
|
||||
|
||||
# 4. Unveraenderte gruene Balken
|
||||
pnpm --filter @tessera/api run test 2>&1 | grep -q "Test Files 69 passed (69)" || { echo "FEHL api Dateien"; exit 1; }
|
||||
pnpm --filter @tessera/api run test 2>&1 | grep -q "Tests 1124 passed (1124)" || { echo "FEHL api Tests"; exit 1; }
|
||||
pnpm --filter @tessera/web run test 2>&1 | grep -q "Test Files 66 passed (66)" || { echo "FEHL web Dateien"; exit 1; }
|
||||
pnpm --filter @tessera/web run test 2>&1 | grep -q "Tests 459 passed (459)" || { echo "FEHL web Tests"; exit 1; }
|
||||
pnpm type-check
|
||||
pnpm lint --force
|
||||
</automated>
|
||||
</verify>
|
||||
<done>
|
||||
Die erzeugten Dekoratordaten aus `apps/api` sind unveraendert (593 Zeilen, sha256 `6e1583f1...`) — der
|
||||
maschinelle Durchgang hat die Abhaengigkeitsaufloesung von NestJS nachweislich nicht angetastet.
|
||||
Genau 45 Quelldateien sind geaendert, der Quelltext-Diff ist zeilenbilanziert (keine Formatierung
|
||||
mitgelaufen). Die neun bearbeiteten Regeln stehen auf 0, `noUselessSwitchCase` bewusst auf 1. Der
|
||||
Gesamtstand liegt bei 621 Befunden (542 echt, 79 Test), Fehlerstufe 0. Beide Testlaeufe sind
|
||||
punktgleich gruen (69/1124 und 66/459), `pnpm type-check` 4/4, `pnpm lint --force` 5/5. Die fuenf
|
||||
Symptomfunde aus D-03 sind im Bericht namentlich als Folgeaufgaben festgehalten, nicht stillschweigend
|
||||
repariert.
|
||||
</done>
|
||||
<reversibility rating="costly">45 Dateien maschinell veraendert. Zuruecknehmen heisst eine Festschreibung verwerfen — technisch einfach, aber der von Hand gelesene Diff waere noch einmal zu lesen. Deshalb steht der Abdruck der Dekoratordaten VOR der ersten Aenderung.</reversibility>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Aufgabe 3: Barrierefreiheit von Hand</name>
|
||||
<files>
|
||||
53 Dateien, ueberwiegend apps/web/src/components und apps/web/src/app, dazu
|
||||
apps/web/src/app/icon.svg und apps/desktop/src/setup.html.
|
||||
Schwerpunkte (Befunde je Datei):
|
||||
apps/web/src/app/(portal)/admin/ldap/page.tsx (15),
|
||||
apps/web/src/app/(portal)/admin/users/page.tsx (11),
|
||||
apps/web/src/components/settings/calendar-settings-panel.tsx (11),
|
||||
apps/web/src/components/layout/sidebar.tsx (9),
|
||||
apps/web/src/app/(portal)/admin/tenants/page.tsx (8),
|
||||
apps/web/src/components/dashboard/widget-catalog-modal.tsx (8),
|
||||
apps/web/src/components/layout/header.tsx (8),
|
||||
apps/web/src/components/admin/admin-sidebar.tsx (7),
|
||||
apps/web/src/components/dashboard/widget-registry.tsx (7)
|
||||
</files>
|
||||
<precondition>Aufgabe 2 ist festgeschrieben und der Arbeitsbaum ist sauber. Die Zaehlungen in diesem Durchgang gehen vom Stand 621/542/79 aus.</precondition>
|
||||
<action>
|
||||
Zuerst den Ausgangspunkt im Verlauf festhalten, damit `<verify>` den Umfang messen kann, auch wenn
|
||||
die Arbeit zwischendurch festgeschrieben wird:
|
||||
|
||||
```bash
|
||||
BASE3=$(git rev-parse HEAD) # Stand nach Aufgabe 2
|
||||
```
|
||||
|
||||
Hier hilft kein Werkzeug: fuer alle sechs Regeln dieses Durchgangs liefert Biome weder eine
|
||||
gesicherte noch eine ungesicherte Korrektur — beim Planen je Regel mit `--write` und mit
|
||||
`--write --unsafe` geprueft, Ergebnis jeweils null geaenderte Dateien (einzige Ausnahme
|
||||
`noRedundantRoles` mit 3 Dateien unter `--unsafe`). **155 Fundstellen in 53 Dateien, von Hand.**
|
||||
|
||||
Bearbeitet werden diese sechs Regeln:
|
||||
|
||||
| Regel | Befunde | Vorgehen |
|
||||
|---|---|---|
|
||||
| `a11y/noSvgWithoutTitle` | 71 | je Symbol entscheiden |
|
||||
| `a11y/useButtonType` | 52 | je Schaltflaeche entscheiden |
|
||||
| `a11y/noLabelWithoutControl` | 22 | Beschriftung an Feld binden |
|
||||
| `a11y/useAriaPropsSupportedByRole` | 5 | Merkmal oder Rolle richtigstellen |
|
||||
| `a11y/noRedundantRoles` | 4 | doppelte Rollenangabe entfernen |
|
||||
| `a11y/useAriaPropsForRole` | 1 | fehlendes Pflichtmerkmal ergaenzen |
|
||||
|
||||
**Symbole (71).** Alle Symbole in diesem Projekt sind eingebettete Grafiken; eine Symbolbibliothek
|
||||
gibt es nicht. Je Fundstelle eine von zwei Entscheidungen, und die Entscheidung haengt daran, ob
|
||||
daneben schon Text steht:
|
||||
- Das Symbol begleitet sichtbaren Text (Menueeintrag mit Beschriftung, Schaltflaeche mit Wort) →
|
||||
es ist Schmuck und wird fuer die Vorlesehilfe ausgeblendet. Sonst liest sie die Sache doppelt.
|
||||
- Das Symbol steht allein und traegt die ganze Bedeutung (Schliessen-Kreuz, Zahnrad, Lupe ohne
|
||||
Wort) → es bekommt einen Titel, der sagt, was die Bedienung tut, nicht wie sie aussieht. Der Text
|
||||
gehoert in den Uebersetzungskatalog, wenn die umgebende Datei bereits uebersetzt; sonst als
|
||||
deutscher Klartext, passend zum Umfeld.
|
||||
Zwei Fundstellen liegen ausserhalb der React-Oberflaeche: `apps/web/src/app/icon.svg` (die
|
||||
Bildmarke — Titel mit dem Produktnamen) und `apps/desktop/src/setup.html:173` (Einrichtungsseite des
|
||||
Desktop-Programms).
|
||||
|
||||
**Schaltflaechen (52).** Hier ist die Regel kein Formfehler, sondern deckt echte Fehlbedienung auf:
|
||||
eine Schaltflaeche ohne Typangabe innerhalb eines Formulars sendet das Formular ab. Deshalb je
|
||||
Fundstelle lesen, was die Schaltflaeche tatsaechlich tun soll, und entsprechend auszeichnen —
|
||||
nicht pauschal dasselbe eintragen. Nur drei der 25 betroffenen Dateien enthalten ueberhaupt ein
|
||||
Formular:
|
||||
- `admin/tenants/page.tsx` — 1 Formular, 6 Fundstellen
|
||||
- `admin/users/page.tsx` — 1 Formular, 6 Fundstellen
|
||||
- `admin/ldap/page.tsx` — 2 Formulare, 4 Fundstellen
|
||||
In diesen drei Dateien ist der Absendeknopf jeweils **schon richtig ausgezeichnet** (gezaehlt:
|
||||
1 / 1 / 2). Die 16 Fundstellen dort sind also durchweg Neben-Schaltflaechen — Abbrechen, Schliessen,
|
||||
Zeilenaktionen —, die heute beim Klick ungewollt absenden. Sie bekommen die Auszeichnung fuer „tut
|
||||
etwas anderes". Damit aendert sich Verhalten, und zwar genau das kaputte: das ist der Zweck der
|
||||
Regel und ausdruecklich von D-04 gedeckt. Die Zahl der absendenden Schaltflaechen je Datei muss
|
||||
danach unveraendert bei 1 / 1 / 2 stehen — das prueft `<verify>`. In den uebrigen 22 Dateien gibt es
|
||||
kein Formular; dort ist die Auszeichnung reine Absicherung.
|
||||
|
||||
**Beschriftungen (22).** Beschriftung und Eingabefeld ueber eine Kennung verbinden, oder die
|
||||
Beschriftung um das Feld legen. Kennungen muessen je Seite eindeutig bleiben — in Listen mit
|
||||
wiederholten Zeilen die Zeilenkennung in die Feldkennung aufnehmen. Danach gehoert zu jeder
|
||||
Beschriftung genau ein Feld.
|
||||
|
||||
**Rollen und Merkmale (10).** Die fuenf Merkmale, die zur gesetzten Rolle nicht passen, das eine
|
||||
fehlende Pflichtmerkmal und die vier doppelt gesetzten Rollen einzeln richtigstellen. Bei
|
||||
`noRedundantRoles` darf der maschinelle Vorschlag als Ausgangspunkt dienen
|
||||
(`pnpm exec biome lint apps/web --only=lint/a11y/noRedundantRoles --write --unsafe`, 3 Dateien),
|
||||
das Ergebnis ist trotzdem zu lesen.
|
||||
|
||||
**Was bewusst stehen bleibt (30 Befunde, D-05).** Diese sechs Regeln werden NICHT bearbeitet, weil
|
||||
jede eine Gestaltungsentscheidung oder einen Verhaltenswechsel verlangt, den dieser Vorgang nicht
|
||||
treffen darf:
|
||||
- `noNoninteractiveElementInteractions` (11) und `noStaticElementInteractions` (5) — verlangen die
|
||||
Entscheidung, ob aus einem geklickten Bereich eine echte Bedienung wird oder der Klick weg soll.
|
||||
- `useKeyWithClickEvents` (5) — verlangt einen Tastaturweg, den es heute nicht gibt; das ist neue
|
||||
Bedienung, kein Aufraeumen.
|
||||
- `noAutofocus` (4) — das Entfernen verschiebt den Eingabefokus beim Seitenaufruf und ist damit ein
|
||||
Verhaltenswechsel, den D-07 hier verbietet.
|
||||
- `useSemanticElements` (4) — verlangt einen Austausch von Bauelementen mitsamt Gestaltung.
|
||||
- `noNoninteractiveTabindex` (1) — aendert die Tabulatorreihenfolge.
|
||||
Diese 30 gehoeren in den Bericht und in die Entwickleranleitung, nicht in eine stille Ablage. Keine
|
||||
dieser Regeln wird herabgestuft oder abgeschaltet (D-06).
|
||||
|
||||
**Abschlussbericht.** Zum Schluss den Vorher/Nachher-Stand festhalten: 2923 → 466, getrennt nach
|
||||
echtem Quelltext (856 → 387) und Testdateien (2067 → 79), mit der Aufschluesselung dessen, was
|
||||
bleibt, und den in Aufgabe 2 gefundenen Symptomen als benannte Folgeaufgaben. Denselben Stand in
|
||||
`docs/anleitung-entwicklung.md` nachziehen, falls die in Aufgabe 1 geschriebene Zahl abweicht.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>
|
||||
# 1. Die sechs bearbeiteten Regeln stehen auf 0, die sechs zurueckgestellten unveraendert
|
||||
LJ=$(mktemp /tmp/tessera-lint-XXXXXX.json)
|
||||
pnpm exec biome lint . --reporter=json --max-diagnostics=20000 2>/dev/null > "$LJ"
|
||||
node -e '
|
||||
const d=require(process.argv[1]).diagnostics;
|
||||
const c=r=>d.filter(x=>x.category==="lint/a11y/"+r).length;
|
||||
const erledigt={noSvgWithoutTitle:0,useButtonType:0,noLabelWithoutControl:0,
|
||||
useAriaPropsSupportedByRole:0,noRedundantRoles:0,useAriaPropsForRole:0};
|
||||
const zurueck={noNoninteractiveElementInteractions:11,noStaticElementInteractions:5,
|
||||
useKeyWithClickEvents:5,noAutofocus:4,useSemanticElements:4,noNoninteractiveTabindex:1};
|
||||
let bad=0;
|
||||
for(const [r,s] of Object.entries(erledigt)) if(c(r)!==s){bad++;console.log("FEHL erledigt",r,c(r),"statt",s);}
|
||||
for(const [r,s] of Object.entries(zurueck)) if(c(r)!==s){bad++;console.log("FEHL zurueckgestellt",r,c(r),"statt",s);}
|
||||
const T=p=>/\.(spec|test)\.(ts|tsx)$/.test(p);
|
||||
const re=d.filter(x=>!T(x.location.path)).length;
|
||||
console.log("ENDSTAND gesamt",d.length,"echt",re,"test",d.length-re,
|
||||
"fehler",d.filter(x=>x.severity==="error").length);
|
||||
if(d.length!==466||re!==387){bad++;console.log("FEHL Endstand, erwartet 466/387");}
|
||||
if(d.filter(x=>x.severity==="error").length!==0){bad++;console.log("FEHL Fehlerstufe nicht 0");}
|
||||
process.exit(bad);' "$LJ"
|
||||
|
||||
# 2. Keine a11y-Regel wurde herabgestuft, um diese Zahlen zu erreichen (D-06)
|
||||
node -e '
|
||||
const c=require("/home/vicolab/projects/tessera-ctl/biome.json");
|
||||
const s=JSON.stringify(c.linter.rules.a11y)+JSON.stringify(c.overrides);
|
||||
const ok = c.linter.rules.a11y==="warn" && !s.includes("a11y/") && !/"a11y"\s*:\s*\{/.test(JSON.stringify(c.overrides));
|
||||
console.log(ok?"OK a11y unveraendert auf warn, keine Einzelausnahme":"FEHL a11y angefasst");
|
||||
process.exit(ok?0:1);'
|
||||
|
||||
# 3. Kein Formular hat seinen Absendeknopf verloren, keine Nebenschaltflaeche sendet mehr ab
|
||||
cd /home/vicolab/projects/tessera-ctl
|
||||
for f in "apps/web/src/app/(portal)/admin/tenants/page.tsx:1" \
|
||||
"apps/web/src/app/(portal)/admin/users/page.tsx:1" \
|
||||
"apps/web/src/app/(portal)/admin/ldap/page.tsx:2"; do
|
||||
p="${f%:*}"; soll="${f##*:}"
|
||||
ist=$(grep -c 'type="submit"' "$p")
|
||||
formulare=$(grep -c '<form' "$p")
|
||||
test "$ist" = "$soll" && test "$formulare" = "$soll" \
|
||||
|| { echo "FEHL $p: $ist absendende Schaltflaechen bei $formulare Formularen, erwartet $soll"; exit 1; }
|
||||
echo "OK $p $ist/$formulare"
|
||||
done
|
||||
|
||||
# 4. Der API-Teil ist in diesem Durchgang gar nicht angefasst worden.
|
||||
# $BASE3 ist der in <action> notierte Stand nach Aufgabe 2; gegen HEAD gemessen liefe die
|
||||
# Pruefung nach dem Festschreiben leer. git-Ausgabe zuerst einfangen — ein Fehlschlag von git
|
||||
# saehe sonst aus wie "nichts geaendert".
|
||||
test -n "$BASE3" || { echo "FEHL: BASE3 nicht gesetzt (siehe <action>)"; exit 1; }
|
||||
API_DIFF=$(git diff --name-only "$BASE3" -- apps/api packages) || { echo "FEHL: git diff"; exit 1; }
|
||||
test -z "$API_DIFF" || { echo "FEHL: apps/api oder packages veraendert:"; printf '%s\n' "$API_DIFF"; exit 1; }
|
||||
|
||||
# 5. Unveraenderte gruene Balken
|
||||
pnpm --filter @tessera/web run test 2>&1 | grep -q "Test Files 66 passed (66)" || { echo "FEHL web Dateien"; exit 1; }
|
||||
pnpm --filter @tessera/web run test 2>&1 | grep -q "Tests 459 passed (459)" || { echo "FEHL web Tests"; exit 1; }
|
||||
pnpm --filter @tessera/api run test 2>&1 | grep -q "Tests 1124 passed (1124)" || { echo "FEHL api Tests"; exit 1; }
|
||||
pnpm type-check
|
||||
pnpm lint --force
|
||||
</automated>
|
||||
<human-check>
|
||||
Die 52 Schaltflaechen-Aenderungen sind die einzige Stelle dieses Vorgangs, an der sich absichtlich
|
||||
Verhalten aendert, und ein gruener Testlauf deckt davon nur einen Teil ab. Darum im Entwicklungsbetrieb
|
||||
(`pnpm dev`, nicht gegen den Testserver) die drei Formularseiten der Verwaltung oeffnen — Mandanten,
|
||||
Benutzer, LDAP — und je Seite zwei Dinge pruefen: das Formular laesst sich weiterhin ueber seinen
|
||||
Absendeknopf abschicken, und ein Klick auf Abbrechen beziehungsweise Schliessen schickt es NICHT ab.
|
||||
Nicht ueber Netzwerkaufrufe aus der Seite heraus messen, sondern die Oberflaeche bedienen.
|
||||
</human-check>
|
||||
</verify>
|
||||
<done>
|
||||
Die sechs bearbeiteten Barrierefreiheits-Regeln stehen auf 0 (155 Fundstellen in 53 Dateien von Hand
|
||||
erledigt), die sechs zurueckgestellten stehen unveraendert bei zusammen 30 und sind im Bericht
|
||||
namentlich mit Begruendung aufgefuehrt. Keine a11y-Regel wurde dafuer herabgestuft oder in eine
|
||||
Ausnahme gelegt. In den drei Formularseiten steht die Zahl der absendenden Schaltflaechen unveraendert
|
||||
bei 1 / 1 / 2, und die Bedienprobe bestaetigt, dass Absenden weiter geht und Abbrechen nicht mehr
|
||||
absendet. `apps/api` und `packages` sind in diesem Durchgang unberuehrt. Endstand: **466 Befunde
|
||||
(387 echt, 79 Test), Fehlerstufe 0** — von 2923 zu Beginn. Beide Testlaeufe punktgleich gruen,
|
||||
`pnpm type-check` 4/4, `pnpm lint --force` 5/5.
|
||||
</done>
|
||||
<reversibility rating="reversible">Handarbeit an der Oberflaeche, ausschliesslich apps/web und apps/desktop; jede Datei einzeln zuruecknehmbar.</reversibility>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Lint-Konfiguration → CI-Tor | `biome.json` entscheidet, was der CI-Schritt „Lint" ueberhaupt sehen kann. Eine zu weit gefasste Ausnahme blendet echte Befunde dauerhaft aus, ohne dass jemand es bemerkt. |
|
||||
| Werkzeug → Quelltext | `biome lint --write` aendert Dateien ohne menschlichen Zwischenschritt, auch in Anmelde-, Sitzungs- und Verzeichnisabgleich-Wegen. |
|
||||
| Erzeugtes JS → NestJS-Laufzeit | Die Abhaengigkeitsaufloesung liest Daten, die der Compiler aus Typangaben erzeugt. Kein Tor dieses Projekts prueft diese Daten heute. |
|
||||
| Browser → Formular | Eine Schaltflaeche ohne Typangabe loest im Formular ein Absenden aus; ihre Auszeichnung entscheidet, ob ein Klick Daten schreibt. |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-bi2-01 | Tampering | `biome.json` overrides[1] (`apps/api/**`, `useImportType` off) | high | mitigate | Nur moeglich, weil die Alternative beweisbar schlimmer ist: 61 von 65 Dateien mit Abhaengigkeitsdaten werden vom maschinellen Fix zerstoert, bei gruenem `tsc` und gruenen 1124 Tests. Begrenzt auf genau eine Regel und einen Pfad; `<verify>` in Aufgabe 1 prueft die Form des Eintrags und dass er keine zweite Regel enthaelt. Begruendung steht schriftlich in der Entwickleranleitung, nicht nur in der Festschreibung. |
|
||||
| T-bi2-02 | Tampering | NestJS-Abhaengigkeitsdaten, gesamter maschineller Durchgang | high | mitigate | sha256 ueber alle 593 erzeugten `__metadata`-Zeilen vor und nach dem Durchgang; muss identisch bleiben (`6e1583f1...`). Beim Planen mit dem vollstaendigen Durchgang erprobt: Abdruck blieb identisch. Das ist der einzige Nachweis, der hier traegt — `grep -rl createTestingModule apps/api/src` liefert 0 Treffer, der Testbestand startet den Container nirgends. |
|
||||
| T-bi2-03 | Information Disclosure | `biome.json` overrides[0] (Testdateien) | medium | mitigate | Die Ausnahme koennte echten Produktivcode mit abdecken. Geprueft und ausgeschlossen: keine Datei auf `.spec`/`.test` exportiert ein Symbol (`grep -rln "^export" --include=*.spec.ts --include=*.test.ts --include=*.test.tsx` liefert nichts), und keine Datei ausserhalb dieser Muster fuehrt eine solche Datei ein. Laufender Nachweis: `noExplicitAny` bleibt bei exakt 289 in echtem Quelltext — waere die Ausnahme zu weit, faellt diese Zahl. Sie deckt genau eine Regel ab, nicht die Gruppe. |
|
||||
| T-bi2-04 | Tampering | `apps/api/src/ldap/ldap.service.ts` (25 `useLiteralKeys`-Aenderungen) | high | mitigate | Der AD-Abgleich liest Verzeichnis-Merkmale ueber ihren Namen; eine verschluckte Grossschreibung macht ihn still leer, und AD ist in diesem Projekt bewusst nur lesend angebunden, also faellt es erst beim Anmelden auf. Diff zeichenweise gegenlesen (in `<action>` verlangt), zusaetzlich `ldap.service.spec.ts` im 1124er-Lauf und `tsc` ueber die getypten Zugriffe. |
|
||||
| T-bi2-05 | Elevation of Privilege | `jwt.strategy.ts`, `auth.service.ts` (`useOptionalChain`, `useLiteralKeys`) | high | mitigate | Die Verkuerzungen liegen in Sitzungs-Cookie-Lesung und Aktiv-Pruefung des Kontos. Falsch verkuerzt liesse eine fehlende Sitzung durch. Der Gesamtdiff ist rund 100 Zeilen und wird vollstaendig gelesen; diese beiden Dateien sind in `<action>` namentlich als Lesepflicht markiert. `auth.service.spec.ts` und `jwt.strategy.spec.ts` laufen im 1124er-Bestand mit. |
|
||||
| T-bi2-06 | Tampering | Entfernen „unbenutzter" Werte (D-03, 15 Stellen) | medium | mitigate | Ein Wert kann eine Nebenwirkung tragen. Darum drei Gruppen mit unterschiedlicher Behandlung statt eines pauschalen Loeschens: folgenlose Auffangvariablen werden entfernt, der positionsgebundene Dekoratorparameter wird nur umbenannt statt gestrichen, und fuenf Fundstellen sind Symptome echter Luecken und werden gemeldet statt repariert. `<verify>` nagelt den Diff auf 45 Dateien und Zeilenbilanz fest. |
|
||||
| T-bi2-07 | Tampering | 52 Schaltflaechen in `apps/web` (`useButtonType`) | medium | mitigate | Eine falsch ausgezeichnete Schaltflaeche kann ein Formular entweder unabsendbar machen oder weiter ungewollt absenden lassen. Nur 3 der 25 Dateien enthalten ueberhaupt ein Formular; dort wird die Zahl der absendenden Schaltflaechen auf 1 / 1 / 2 festgenagelt, und eine Bedienprobe prueft beide Richtungen (Absenden geht, Abbrechen sendet nicht). |
|
||||
| T-bi2-08 | Repudiation | Regelgruppe `security`, gesamte Konfiguration | high | mitigate | Der naheliegende Missbrauch dieses Vorgangs waere, Zahlen ueber Herabstufungen zu druecken. Dagegen drei Pruefungen: die Zeichenkette `security` darf in `biome.json` ueberhaupt nicht vorkommen (erbt damit `error` aus dem Vorgabesatz), `linter.rules.a11y` muss unveraendert auf `warn` stehen, und in `overrides` darf keine a11y-Regel auftauchen. Genau zwei Ausnahmen sind zugelassen, beide namentlich in `<verify>` von Aufgabe 1 beschrieben. |
|
||||
| T-bi2-09 | Denial of Service | CI-Schritt „Lint" | medium | mitigate | Ein neuer Befund der Stufe Fehler faerbt CI rot. Jede der drei Aufgaben prueft `errors === 0` in der JSON-Zaehlung und laesst zusaetzlich `pnpm lint --force` laufen — mit `--force`, weil Turborepo den Schritt sonst aus dem Zwischenspeicher als gruen meldet. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
Nach der letzten Aufgabe, aus dem Wurzelverzeichnis:
|
||||
|
||||
```bash
|
||||
LJ=$(mktemp /tmp/tessera-lint-XXXXXX.json)
|
||||
pnpm exec biome lint . --reporter=json --max-diagnostics=20000 2>/dev/null > "$LJ"
|
||||
node -e '
|
||||
const d=require(process.argv[1]).diagnostics;
|
||||
const T=p=>/\.(spec|test)\.(ts|tsx)$/.test(p);
|
||||
const r=d.filter(x=>!T(x.location.path)).length;
|
||||
console.log("gesamt",d.length,"(Start 2923) | echt",r,"(Start 856) | test",d.length-r,"(Start 2067)");
|
||||
console.log("fehler",d.filter(x=>x.severity==="error").length,"(muss 0 sein)");
|
||||
' "$LJ"
|
||||
|
||||
SNAP=$(mktemp -d); pnpm --filter @tessera/api exec tsc --outDir "$SNAP" >/dev/null 2>&1
|
||||
grep -rh '__metadata(' "$SNAP" | sort | sha256sum # 6e1583f1eb72a089eb0ed98f81158b54a9fbd40dbf41371292725f36ef764300
|
||||
|
||||
pnpm type-check # 4/4
|
||||
pnpm lint --force # 5/5, 0 Fehler
|
||||
pnpm --filter @tessera/api run test # 69 Dateien / 1124 Tests
|
||||
pnpm --filter @tessera/web run test # 66 Dateien / 459 Tests
|
||||
```
|
||||
|
||||
Zur Ehrlichkeit des Nachweises: Ein gruener Testbestand allein belegt fuer einen maschinellen
|
||||
Durchgang ueber 45 Dateien **nicht**, dass sich nichts geaendert hat — dieser Plan hat beim Planen
|
||||
einen Fall gemessen, der genau das zeigt (`import type` in `apps/api` laesst `tsc` und alle 1124
|
||||
Tests gruen und zerstoert trotzdem den Start der Anwendung). Die tragenden Nachweise sind deshalb,
|
||||
in dieser Reihenfolge: der unveraenderte Abdruck der Dekoratordaten, der zeilenbilanzierte und
|
||||
vollstaendig von Hand gelesene Diff, die regelweisen Zaehlungen, und erst danach die gruenen
|
||||
Testlaeufe.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Lint-Rueckstand von 2923 auf 466 gesenkt (echt 856 → 387, Test 2067 → 79), Fehlerstufe durchgehend 0.
|
||||
- `biome.json` traegt genau zwei zielgenaue Ausnahmen; `security` kommt in der Datei nicht vor; keine a11y-Regel herabgestuft.
|
||||
- `noExplicitAny` steht nach der Testdatei-Ausnahme unveraendert bei 289 in echtem Quelltext.
|
||||
- Die erzeugten NestJS-Dekoratordaten sind Zeichen fuer Zeichen unveraendert (593 Zeilen, sha256 `6e1583f1...`).
|
||||
- `pnpm type-check` 4/4, `pnpm lint --force` 5/5, apps/api 69/1124, apps/web 66/459 — alle punktgleich zum Ausgangsstand.
|
||||
- Quelltext-Diff zeilenbilanziert; keine repo-weite Formatierung angestossen.
|
||||
- Bericht nennt Vorher/Nachher getrennt nach Testdateien und echtem Quelltext, die 30 zurueckgestellten a11y-Befunde mit Begruendung, die fuenf Symptomfunde aus D-03 und die Regeln, die als eigener Durchgang folgen.
|
||||
- Entwickleranleitung gibt den neuen Stand und beide Ausnahmen wieder.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/quick/260921-bi2-lint-rueckstand-abbauen-mechanische-fixe/260921-bi2-SUMMARY.md` when done
|
||||
</output>
|
||||
</content>
|
||||
</invoke>
|
||||
+439
@@ -0,0 +1,439 @@
|
||||
---
|
||||
phase: quick-260921-bi2
|
||||
plan: 01
|
||||
subsystem: tooling
|
||||
tags: [biome, lint, a11y, nestjs, decorator-metadata, i18n]
|
||||
|
||||
requires: []
|
||||
provides:
|
||||
- "biome.json mit genau zwei zielgenauen overrides (Testdateien / apps/api) statt eines
|
||||
unueberschaubaren 2923er-Rueckstands"
|
||||
- "45 maschinell erzeugte, von Hand gelesene Korrekturen ueber vier sichere und fuenf
|
||||
unsichere Biome-Regeln"
|
||||
- "15 untersuchte Fundstellen toten Codes: 10 folgenlos entfernt, 1 umbenannt (NestJS-
|
||||
Parameterposition), 4 als benannte Folgeaufgaben gemeldet statt repariert"
|
||||
- "155 von Hand erledigte Barrierefreiheits-Fundstellen ueber sechs Regeln (Symbole,
|
||||
Schaltflaechentyp, Beschriftungsbindung, Rollen/ARIA-Merkmale, Tab-Reihenfolge)"
|
||||
- "Entwickleranleitung auf Endstand 465/386/79 gebracht, beide Ausnahmen begruendet,
|
||||
Folgeaufgaben benannt"
|
||||
affects: [ci-lint-gate, apps/web-a11y, apps/api-di]
|
||||
|
||||
actuals:
|
||||
tokens: 37573
|
||||
tasks: 3
|
||||
commits: 13
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "biome.json overrides sind pfadgebunden (apps/api/**), nie repo-weit mit --only=<regel>
|
||||
angewendet -- --only schaltet eine per Konfiguration abgeschaltete Regel wieder an."
|
||||
- "Bei einer Regel ohne gesicherten Fix zuerst den Diff lesen (--write ohne --unsafe testen),
|
||||
danach --unsafe pruefen, danach den ganzen Diff lesen -- nie ungeprueft anwenden."
|
||||
- "a11y-Fixes koennen sich gegenseitig verschieben: eine Regel loesen kann eine ANDERE
|
||||
(zurueckgestellte) Regel neu anschlagen, wenn ein Element seine interaktive
|
||||
Klassifikation verliert (role=button entfernt -> noStaticElementInteractions greift
|
||||
auf die verbliebenen Drag-Handler). Nach jedem Einzel-Fix die komplette Regelmenge
|
||||
(in Scope UND zurueckgestellt) neu messen, nicht nur die Zielregel."
|
||||
|
||||
key-files:
|
||||
created: []
|
||||
modified:
|
||||
- biome.json
|
||||
- docs/anleitung-entwicklung.md
|
||||
- "apps/api/src/** (45 maschinelle Korrekturen + toter Code, Aufgabe 2)"
|
||||
- "apps/web/src/** (a11y, Aufgabe 3, beide Teillieferungen)"
|
||||
- apps/desktop/src/setup.html
|
||||
- apps/web/src/messages/de.json
|
||||
- apps/web/src/messages/en.json
|
||||
|
||||
key-decisions:
|
||||
- "useImportType in apps/api/** abgeschaltet statt maschinell angewendet: emitDecoratorMetadata
|
||||
erzeugt __metadata(design:paramtypes,...), das NestJS zur Abhaengigkeitsaufloesung liest;
|
||||
import type entfernt den Wert-Import und ersetzt die erzeugten Typen durch Function-
|
||||
Platzhalter. 61 von 65 Dateien mit Abhaengigkeitsdaten waeren beschaedigt worden, bei
|
||||
gruenem tsc und gruenen 1124 Tests (kein Test ruft createTestingModule auf) -- der Fehler
|
||||
waere durch jedes Tor dieses Projekts unbemerkt hindurchgegangen."
|
||||
- "noUselessSwitchCase (1 Fundstelle, tender-normalizer.service.ts:60) bewusst NICHT
|
||||
angewendet: die Fallmarke dokumentiert, warum der Standardzweig auf diesem Weg bleiben
|
||||
muss; die Regel sieht nur den Code, nicht die Absicht dahinter."
|
||||
- "useSemanticElements (4 Fundstellen) wurde entgegen der urspruenglichen Einordnung in
|
||||
PLAN.md (dort unter D-05 zurueckgestellt) in Teillieferung B doch bearbeitet -- Abweichung
|
||||
von der Vorgabe, siehe Deviations."
|
||||
- "DropZone.tsx: Datei-Entfernen-Schaltflaeche als Geschwister statt verschachtelt in der
|
||||
Drop-Flaeche (kein <button> darf ein zweites <button> enthalten); Drag-Handler wanderten
|
||||
von der Flaechen-<div> auf die Flaechen-<button>, sonst waere ein 'statisches Element mit
|
||||
Ereignis-Handler' entstanden und haette zwei zurueckgestellte Regeln neu ausgeloest."
|
||||
- "admin-sidebar.tsx/settings-sidebar.tsx: role=navigation vom <aside> aufs bereits
|
||||
vorhandene <nav> verschoben statt das <aside> in ein zweites <nav> zu verwandeln --
|
||||
sonst waere ein doppeltes Navigations-Landmark entstanden."
|
||||
|
||||
requirements-completed: [LINT-BACKLOG]
|
||||
|
||||
duration: ~150min (Teillieferung B; Gesamtvorgang laenger, ueber mehrere Sitzungen)
|
||||
completed: 2026-09-21
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Quick-Vorgang 260921-bi2: Lint-Rueckstand abbauen (mechanische Fixe) Summary
|
||||
|
||||
**Lint-Rueckstand von 2923 auf 465 Befunde gesenkt (0 Fehlerstufe durchgehend) — durch eine
|
||||
begruendete, pfadgebundene Konfigurationsbereinigung, einen von Hand gelesenen maschinellen
|
||||
Durchgang und 155 handgeschriebene Barrierefreiheits-Korrekturen, ohne die NestJS-
|
||||
Abhaengigkeitsaufloesung anzutasten.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Aufgabe 1 (Konfiguration):** Commit `8d1c8f3`
|
||||
- **Aufgabe 2 (maschinell + toter Code):** Commits `3811578`, `636fe0d`
|
||||
- **Aufgabe 3, Teillieferung A (a11y — Symbole, Schaltflaechentyp, Layout/Formulare):**
|
||||
Commits `e76f3b8`, `4cff316`, `ae82125`, `278aedb`, `969fd01`, `27a6e29`, `73ac08a`
|
||||
- **Aufgabe 3, Teillieferung B (a11y — Rollen, Semantik, Tab-Reihenfolge, Beschriftungsbindung):**
|
||||
Commits `21c85a8`, `c79bafa`
|
||||
- **Abschlussarbeiten (Entwickleranleitung, dieser Vorgang):** Commit `97a6836`
|
||||
- **Tasks:** 3 (laut PLAN.md), ueber mehrere Sitzungen in zwei Teillieferungen ausgefuehrt
|
||||
- **Dateien geaendert (gesamter Vorgang):** 110 (biome.json, docs, 2 i18n-Kataloge, ~106 Quell-
|
||||
und Testdateien)
|
||||
- **Commits gesamt:** 13
|
||||
|
||||
## Vorher/Nachher
|
||||
|
||||
| Durchgang | gesamt | echter Quelltext | Testdateien | Fehlerstufe |
|
||||
|---|---|---|---|---|
|
||||
| Start | 2923 | 856 | 2067 | 0 |
|
||||
| Aufgabe 1 — Konfiguration | 754 | 633 | 121 | 0 |
|
||||
| Aufgabe 2 — maschinell + toter Code | 621 | 542 | 79 | 0 |
|
||||
| Aufgabe 3A — a11y (Symbole, Schaltflaechen, Layout) | 497 | 418 | 79 | 0 |
|
||||
| **Aufgabe 3B — a11y (Rollen, Semantik, Beschriftungen) — Endstand** | **465** | **386** | **79** | **0** |
|
||||
|
||||
Der Zielwert im PLAN.md-Text lautete 466; der korrekte Wert ist 465, weil Aufgabe 2 beim
|
||||
Entfernen einer unbenutzten `catch (e: any)`-Bindung in `calendar.service.ts` inzidentell einen
|
||||
zusaetzlichen `noExplicitAny`-Befund mitgenommen hat (288 statt 289 in echtem Quelltext, 0 in
|
||||
Testdateien — die Testdatei-Ausnahme aus Aufgabe 1 reicht weiterhin nachweislich nicht in
|
||||
Produktivcode hinein). Das ist keine nachtraeglich "passend gemachte" Zahl, sondern die
|
||||
gemessene Tatsache; im Endstand wurde nichts unternommen, um wieder auf 466 zu kommen.
|
||||
|
||||
## Aufgabe 1: Konfiguration bereinigen
|
||||
|
||||
`biome.json` traegt seither genau zwei `overrides`-Eintraege, sonst nichts an der Regelmenge
|
||||
geaendert; `security` kommt in der Datei nicht vor (erbt weiterhin `error`).
|
||||
|
||||
1. **Testdateien** (`**/*.spec.ts`, `**/*.spec.tsx`, `**/*.test.ts`, `**/*.test.tsx`):
|
||||
`suspicious/noExplicitAny` auf `off`. Begruendung: `any` haengt dort ausnahmslos an
|
||||
Attrappen; geprueft mit `grep -rln "^export" --include=*.spec.ts ...` — keine Testdatei
|
||||
exportiert ein Symbol, keine Nicht-Testdatei fuehrt eine Testdatei ein.
|
||||
2. **`apps/api/**`**: `style/useImportType` auf `off`. Das ist die wichtigste Einzelentscheidung
|
||||
des gesamten Vorgangs — siehe naechster Abschnitt.
|
||||
3. Die fehlerhafte Fixture-Ausnahme (`__fixtures__**` statt `__fixtures__`) korrigiert; Biome
|
||||
meldte das selbst als `lint/suspicious/useBiomeIgnoreFolder`.
|
||||
|
||||
### Der useImportType/NestJS-Befund — der wichtigste Einzelfund dieses Vorgangs
|
||||
|
||||
`style/useImportType` ist mit 224 Befunden die groesste Einzelregel, 222 davon in `apps/api`.
|
||||
Biome stuft die Korrektur als „sicher" ein und wendet sie standardmaessig ohne `--unsafe` an.
|
||||
Sie ist es in diesem Projekt aber nicht: `apps/api/tsconfig.json` hat `emitDecoratorMetadata`
|
||||
eingeschaltet, und NestJS loest seine Konstruktor-Abhaengigkeiten zur Laufzeit ueber genau die
|
||||
Daten auf, die der TypeScript-Compiler aus den Parametertypen erzeugt
|
||||
(`__metadata("design:paramtypes", [...])`). `import type` markiert einen Import als reinen
|
||||
Typ-Import; der Compiler entfernt ihn vollstaendig aus dem erzeugten JavaScript — inklusive
|
||||
der Werte, die `__metadata` braucht. Probelauf an `auth.service.ts` beim Planen: vorher
|
||||
`__metadata("design:paramtypes", [prisma_service_1.PrismaService, ...])`, nachher
|
||||
`__metadata("design:paramtypes", [Function, Function, Function, Function, Function, Function])`,
|
||||
und die zugehoerige `require`-Zeile verschwindet ersatzlos. Ueber den ganzen Workspace gemessen:
|
||||
**61 von 65 Dateien mit Abhaengigkeitsdaten waeren beschaedigt worden.** Die API startet danach
|
||||
nicht mehr — jeder DI-Aufloesungsversuch traefe auf `Function` statt auf die echte Klasse.
|
||||
|
||||
Der entscheidende Punkt: **kein Tor dieses Projekts haette das bemerkt.** `pnpm type-check`
|
||||
bleibt gruen (TypeScript prueft Typen, nicht erzeugte Laufzeit-Metadaten). Und kein einziger
|
||||
der 1124 API-Tests waere rot geworden, denn `grep -rl createTestingModule apps/api/src` liefert
|
||||
0 Treffer — der gesamte Testbestand startet den NestJS-Dependency-Injection-Container nirgends,
|
||||
er testet Services/Controller mit von Hand konstruierten Abhaengigkeiten. Ein automatischer,
|
||||
"sicherer" Fix haette die Anwendung im Betrieb lautlos zerstoert, waehrend jede automatisierte
|
||||
Pruefung dieses Projekts weiterhin gruen gemeldet haette.
|
||||
|
||||
Biome raeumt das Problem in der eigenen Regelbeschreibung ein
|
||||
(`biome explain useImportType` → Abschnitt „Caveat with TypeScript experimental decorators")
|
||||
und empfiehlt dort woertlich, die Regel bei solchen Dekoratoren abzuschalten. Aufgabe 1 folgt
|
||||
dieser Empfehlung mit einem auf `apps/api/**` begrenzten Eintrag — eine bewusste Abweichung von
|
||||
der generellen mechanischen-Regeln-Vorgabe (D-02), die ausschliesslich deshalb geschieht, weil
|
||||
"kein Verhaltenswechsel" (D-07) hier Vorrang vor einer kleineren Zahl hat.
|
||||
|
||||
**Nachweis, nicht Vermutung:** Vor dem maschinellen Durchgang (Aufgabe 2) wurde ein Fingerabdruck
|
||||
ueber alle erzeugten `__metadata`-Zeilen genommen (593 Zeilen, sha256
|
||||
`6e1583f1eb72a089eb0ed98f81158b54a9fbd40dbf41371292725f36ef764300`). Nach Abschluss aller drei
|
||||
Aufgaben — Konfiguration, maschineller Durchgang, Barrierefreiheit — ist dieser Fingerabdruck
|
||||
**Zeichen fuer Zeichen identisch**, gerade jetzt erneut gemessen:
|
||||
|
||||
```
|
||||
$ SNAP=$(mktemp -d); pnpm --filter @tessera/api exec tsc --outDir "$SNAP"
|
||||
$ grep -rh '__metadata(' "$SNAP" | wc -l
|
||||
593
|
||||
$ grep -rh '__metadata(' "$SNAP" | sort | sha256sum
|
||||
6e1583f1eb72a089eb0ed98f81158b54a9fbd40dbf41371292725f36ef764300 -
|
||||
```
|
||||
|
||||
Das ist der tragende Nachweis dieses gesamten Vorgangs — nicht der gruene Testlauf, der diesen
|
||||
spezifischen Schaden nachweislich nicht aufdecken kann.
|
||||
|
||||
## Aufgabe 2: Maschinelle Korrekturen und toter Code
|
||||
|
||||
**(A) Vier Regeln mit gesichertem Fix** (`--write` ohne `--unsafe`): `style/useImportType`
|
||||
(pfadgebunden auf `apps/web packages`, NIE repo-weit — ein repo-weiter Lauf haette die
|
||||
Ausnahme aus Aufgabe 1 wirkungslos ausgehaengt und 201 `import type`-Zeilen nach `apps/api`
|
||||
geschrieben, beim Planen genau so gemessen), `complexity/noUselessEscapeInRegex`,
|
||||
`style/useConst`, `style/useExponentiationOperator`.
|
||||
|
||||
**(B) Fuenf Regeln mit `--unsafe`-Fix, Diff vollstaendig von Hand gelesen** (~100 Zeilen):
|
||||
`style/useNodejsImportProtocol`, `complexity/useLiteralKeys`, `complexity/useOptionalChain`,
|
||||
`style/useTemplate`, `correctness/useParseIntRadix`. Zwei Dateien brauchten besondere
|
||||
Aufmerksamkeit:
|
||||
|
||||
- `ldap.service.ts` (25 der 31 `useLiteralKeys`-Aenderungen): Verzeichnis-Merkmale
|
||||
(`objectGUID`, `sAMAccountName`, `cn`, `ou`, `mail`) zeichenweise gegengelesen — ein
|
||||
verschluckter Grossbuchstabe macht den AD-Abgleich still leer, und AD ist in diesem Projekt
|
||||
bewusst nur lesend angebunden, faellt also erst beim Anmelden auf.
|
||||
- `jwt.strategy.ts` / `auth.service.ts`: Verkuerzungen im Anmeldeweg (`useOptionalChain`)
|
||||
geprueft, dass eine fehlende Sitzung weiterhin zur Abweisung fuehrt, nicht zum Durchwinken.
|
||||
|
||||
**Die sechste ungesicherte Regel, `complexity/noUselessSwitchCase` (1 Fundstelle,
|
||||
`tender-normalizer.service.ts:60`), wurde bewusst NICHT angewendet.** Der Vorschlag wuerde
|
||||
eine Fallmarke streichen, die unmittelbar ueber einem Kommentar steht, der erklaert, warum der
|
||||
Standardzweig genau auf diesem Weg bleiben muss. Die Marke dokumentiert Absicht, die der Regel
|
||||
entgeht. Sie steht weiterhin sichtbar in der Zaehlung (1), nicht unterdrueckt — eine
|
||||
Unterdrueckung waere hier unehrlicher als das sichtbare Stehenlassen, weil sie wie eine
|
||||
Erledigung aussehen wuerde, ohne eine zu sein.
|
||||
|
||||
**(C) Toter Code, 15 Fundstellen, drei Gruppen:**
|
||||
|
||||
1. *Echt tot, folgenlos entfernt (10):* nicht benutzte Fehlervariablen in `calendar.service.ts`
|
||||
(Zeilen 321, 358), `cert-manager.service.ts` (305, 679), `dkv-parser.service.ts` (42); nicht
|
||||
benutzte Einfuhren in `create-calendar-source.dto.ts:2`; nicht benutzte Funktion
|
||||
`forSystemQuery` in `rls-scratch-check.mjs:226`; und `login/page.tsx:21` (unbenutzter
|
||||
Wegweiser — Weiterleitung ist nachweislich anderswo geloest, daher entfernt ohne Meldung).
|
||||
2. *Nicht entfernbar, umbenannt (1):* `current-user.decorator.ts:4` — `data` ist der erste von
|
||||
zwei positionsgebundenen NestJS-Parametern; Streichen wuerde den zweiten verschieben.
|
||||
Stattdessen mit fuehrendem Unterstrich gekennzeichnet.
|
||||
3. *Symptome, gemeldet statt repariert (4 — siehe naechster Abschnitt).*
|
||||
|
||||
### Die vier gemeldeten Symptomfunde (D-03) — offene Folgeaufgaben
|
||||
|
||||
Diese vier unbenutzten Werte waren beim Lesen KEIN totes Code-Rauschen, sondern der Hinweis auf
|
||||
eine echte Luecke. Sie zu schliessen waere ein Verhaltenswechsel gewesen, den dieser
|
||||
lint-abbauende Vorgang nicht treffen durfte (D-07):
|
||||
|
||||
1. **`force-password-change.interceptor.ts:53`** liest das HTTP-Verfahren in eine Variable und
|
||||
befragt sie nie — die Freigabeliste unterscheidet also nicht zwischen Lese- und
|
||||
Schreibzugriff auf die freigegebenen Wege. Variable entfernt, Luecke hier als eigene
|
||||
Folgeaufgabe benannt.
|
||||
2. **`change-password/page.tsx:11-12`** hielt Wegweiser und Benutzerablage vor, benutzte beide
|
||||
nicht. Bestaetigt beim Lesen: nach erfolgreichem Wechsel wird weder weitergeleitet noch die
|
||||
Benutzerablage aufgefrischt — bei erzwungenem Wechsel bleibt die Person auf der Seite
|
||||
stehen. Die drei Bindungen entfernt, Befund hier als Folgeaufgabe benannt.
|
||||
3. **`VehicleTable.tsx:164`** setzte einen Laufzustand fuers Loeschen, las ihn aber nie — die
|
||||
Loeschschaltflaeche hat also keinen Besetztzustand und laesst sich doppelt ausloesen. Nur
|
||||
die lesende Bindung entfernt, die setzende blieb; Befund hier als Folgeaufgabe benannt.
|
||||
4. **`SplitTab.tsx:20`** bekam die Uebersetzungsfunktion und benutzte sie nicht — ein Hinweis
|
||||
auf fest verdrahtete Texte in diesem Reiter. Parameter entfernt, Befund hier als
|
||||
Folgeaufgabe benannt.
|
||||
|
||||
(Ein fuenfter untersuchter Kandidat, `login/page.tsx:21`, gehoerte urspruenglich zur selben
|
||||
"Symptom"-Kategorie in der Planung, stellte sich beim Lesen aber als echt folgenlos heraus —
|
||||
siehe Gruppe 1 oben. Er wird hier zur Vollstaendigkeit genannt, braucht aber keine eigene
|
||||
Folgeaufgabe.)
|
||||
|
||||
**Nachweis fuer den gesamten maschinellen Durchgang:** 45 Quelldateien geaendert, Diff
|
||||
zeilenbilanziert (keine Formatierung mitgelaufen, D-08), NestJS-Metadaten-Fingerabdruck
|
||||
unveraendert (siehe oben), beide Testlaeufe punktgleich gruen.
|
||||
|
||||
## Aufgabe 3: Barrierefreiheit von Hand (beide Teillieferungen)
|
||||
|
||||
155 Fundstellen in 53 Dateien, durchgehend Handarbeit — fuer alle sechs bearbeiteten Regeln bot
|
||||
Biome weder einen gesicherten noch ungesicherten Fix (einzige Ausnahme: `noRedundantRoles` mit 3
|
||||
Dateien unter `--unsafe`, Ergebnis trotzdem gelesen).
|
||||
|
||||
### Teillieferung A (vorherige Sitzung, Commits `e76f3b8` … `73ac08a`)
|
||||
|
||||
- `a11y/noSvgWithoutTitle` (71): je Symbol entschieden — begleitet es sichtbaren Text, wird es
|
||||
als Schmuck vor der Vorlesehilfe verborgen; steht es allein, bekommt es einen Titel, der die
|
||||
Bedienung nennt (Uebersetzungskatalog, wo die Datei schon uebersetzt ist). Zwei Fundstellen
|
||||
ausserhalb der React-Oberflaeche: `apps/web/src/app/icon.svg` (Bildmarke, Titel mit
|
||||
Produktnamen) und `apps/desktop/src/setup.html:173` (Desktop-Einrichtungsseite).
|
||||
- `a11y/useButtonType` (52): nur 3 von 25 betroffenen Dateien enthalten ein Formular
|
||||
(`admin/tenants/page.tsx`, `admin/users/page.tsx`, `admin/ldap/page.tsx`); dort war der
|
||||
Absendeknopf je bereits richtig ausgezeichnet (1/1/2), die restlichen 16 Fundstellen waren
|
||||
Neben-Schaltflaechen (Abbrechen, Schliessen, Zeilenaktionen), die beim Klick ungewollt
|
||||
absendeten — bekamen `type="button"`. In den uebrigen 22 Dateien ohne Formular ist die
|
||||
Auszeichnung reine Absicherung.
|
||||
- Zwei neue Uebersetzungsschluessel (LDAP-Standardzuordnung, Suchbutton), in `de.json` UND
|
||||
`en.json` ergaenzt (Commit `3811578`).
|
||||
|
||||
### Teillieferung B (diese Sitzung, Commits `21c85a8`, `c79bafa`)
|
||||
|
||||
Die verbleibenden fuenf zugewiesenen Regeln (32 Fundstellen) auf 0 gebracht:
|
||||
|
||||
- **`a11y/noRedundantRoles` (4):** maschineller `--unsafe`-Fix, gelesen. Entfernte
|
||||
`role="button"`/`role="time"`/`role="combobox"` von `button`/`time`/`select`-Elementen, wo
|
||||
die Rolle bereits implizit ist.
|
||||
- **`a11y/useAriaPropsForRole` (1):** entfiel automatisch mit obigem Fix — das
|
||||
`<select role="combobox">` in `search-widget.tsx` verlangte die fehlenden ARIA-Attribute nur
|
||||
wegen der ueberfluessigen Rolle.
|
||||
- **`a11y/useSemanticElements` (4):** `admin-sidebar.tsx`/`settings-sidebar.tsx` tragen
|
||||
`role="navigation"` jetzt am bereits vorhandenen `<nav>` statt am `<aside>` (kein doppeltes
|
||||
Landmark); `widget-wrapper.tsx` ist jetzt ein echtes `<article>` statt `div role="article"`;
|
||||
`DropZone.tsx` trennt die "Entfernen"-Schaltflaeche als Geschwister ab, damit die Drop-Flaeche
|
||||
selbst ein echtes `<button>` werden kann (ein `<button>` darf kein zweites `<button>`
|
||||
verschachteln) — Klick- UND Drag-Handler wanderten dabei auf den `<button>`, sonst waere die
|
||||
umgebende `<div>` ein "statisches Element mit Ereignis-Handler" geworden und haette die
|
||||
zurueckgestellten Regeln `noStaticElementInteractions`/`noNoninteractiveElementInteractions`
|
||||
neu ausgeloest (geprueft — geschah zunaechst versehentlich, wurde vor dem Commit korrigiert).
|
||||
- **`a11y/noNoninteractiveTabindex` (1):** `calculator-widget.tsx` traegt jetzt `tabIndex={-1}`
|
||||
statt `{0}`. Die Zifferntasten sind bereits echte `<button>`-Elemente und damit selbst Teil
|
||||
der Tab-Reihenfolge; Tastendruecke erreichen `handleKeyboard` weiterhin per Bubbling, sobald
|
||||
eine Taste fokussiert ist — Verhalten unveraendert, nur ein wirkungsloser Tab-Stopp auf dem
|
||||
Container selbst entfaellt.
|
||||
- **`a11y/noLabelWithoutControl` (22):** jede Beschriftung ueber `htmlFor`/`id` an ihr Feld
|
||||
gebunden — in Formularen mit wiederholten Feldnamen (LDAP, Benutzer, Mandanten) ueber
|
||||
seitenweit eindeutige, praefixierte Kennungen (`ldap-*`, `user-*`, `tenant-*`). Sonderfall
|
||||
`calendar-source-form.tsx`: die Farbauswahl beschriftet eine ganze Gruppe von
|
||||
Farb-Schaltflaechen, kein einzelnes Feld — dafuer `fieldset`/`legend` statt `htmlFor`/`id`
|
||||
(Rand/Abstand zurueckgesetzt, damit sich am Erscheinungsbild nichts aendert); eine Umwandlung
|
||||
in `<span>` haette die Assoziation entfernt statt sie herzustellen und wurde darum nicht
|
||||
gewaehlt.
|
||||
|
||||
### Was bewusst stehen bleibt (30 Befunde, D-05)
|
||||
|
||||
Diese fuenf Regeln wurden NICHT bearbeitet, weil jede eine Gestaltungsentscheidung oder einen
|
||||
Verhaltenswechsel verlangt, den dieser Vorgang nicht treffen darf. Gepruefte, unveraenderte
|
||||
Zaehlung nach Teillieferung B:
|
||||
|
||||
| Regel | Befunde | Warum zurueckgestellt |
|
||||
|---|---|---|
|
||||
| `a11y/noNoninteractiveElementInteractions` | 11 | verlangt die Entscheidung, ob ein geklickter Bereich eine echte Bedienung wird oder der Klick verschwindet |
|
||||
| `a11y/noStaticElementInteractions` | 5 | dieselbe Entscheidung, andere Fundstellenmenge |
|
||||
| `a11y/useKeyWithClickEvents` | 5 | verlangt einen Tastaturweg, den es heute nicht gibt — neue Bedienung, kein Aufraeumen |
|
||||
| `a11y/useAriaPropsSupportedByRole` | 5 | verlangt einen Blick auf jede gesetzte Rolle einzeln, teils mit Gestaltungsfolgen |
|
||||
| `a11y/noAutofocus` | 4 | Entfernen verschiebt den Eingabefokus beim Seitenaufruf — Verhaltenswechsel (D-07 verbietet ihn hier) |
|
||||
|
||||
Keine dieser Regeln wurde herabgestuft oder abgeschaltet — sie stehen weiterhin auf `warn` und
|
||||
tauchen in keiner `overrides`-Ausnahme auf.
|
||||
|
||||
## Verifikation (soeben erneut ausgefuehrt)
|
||||
|
||||
```
|
||||
--- in-scope (Ziel 0) ---
|
||||
noLabelWithoutControl 0
|
||||
noRedundantRoles 0
|
||||
useSemanticElements 0
|
||||
useAriaPropsForRole 0
|
||||
noNoninteractiveTabindex 0
|
||||
|
||||
--- zurueckgestellt (muss unveraendert bleiben) ---
|
||||
noNoninteractiveElementInteractions 11
|
||||
useKeyWithClickEvents 5
|
||||
noStaticElementInteractions 5
|
||||
useAriaPropsSupportedByRole 5
|
||||
noAutofocus 4
|
||||
|
||||
Endstand: total 465 real 386 test 79 errors 0
|
||||
```
|
||||
|
||||
- **`apps/api` und `packages` unberuehrt seit `636fe0d`:** `git diff --name-only 636fe0d..HEAD -- apps/api packages` → leer.
|
||||
- **NestJS-`__metadata`-Fingerabdruck:** 593 Zeilen, sha256
|
||||
`6e1583f1eb72a089eb0ed98f81158b54a9fbd40dbf41371292725f36ef764300` — identisch zum Ausgangswert.
|
||||
- **`apps/web` Vitest:** `Test Files 66 passed (66)`, `Tests 459 passed (459)`.
|
||||
- **`apps/api` Vitest:** `Test Files 69 passed (69)`, `Tests 1124 passed (1124)`.
|
||||
- **`pnpm type-check`:** 4/4 erfolgreich.
|
||||
- **`pnpm lint --force`:** 5/5 erfolgreich, 0 Befunde der Stufe Fehler (nur Warnungen/Info).
|
||||
- **de/en-Uebersetzungskataloge:** 892 Schluessel je Katalog, Mengen identisch (0 nur-de, 0 nur-en).
|
||||
- **`noExplicitAny` nach der Testdatei-Ausnahme:** 288 in echtem Quelltext (siehe Abschnitt
|
||||
"Vorher/Nachher" fuer die 289→288-Abweichung), 0 in Testdateien.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 3 - Blocking] Handler-Typannotation in DropZone.tsx nach Restrukturierung**
|
||||
- **Found during:** Teillieferung B, `useSemanticElements`-Fix an `DropZone.tsx`
|
||||
- **Issue:** Nach dem Verschieben der Drag-Handler von der `<div>` auf die `<button>`
|
||||
blieb die Typannotation `React.DragEvent<HTMLDivElement>` stehen; `pnpm type-check` schlug
|
||||
mit zwei `TS2322`-Fehlern fehl.
|
||||
- **Fix:** Annotation auf `React.DragEvent<HTMLButtonElement>` korrigiert.
|
||||
- **Files modified:** `apps/web/src/app/(portal)/modules/cert-manager/components/DropZone.tsx`
|
||||
- **Verification:** `pnpm type-check` danach 4/4.
|
||||
- **Committed in:** `21c85a8`
|
||||
|
||||
**2. [Rule 1 - Bug, waehrend der Arbeit selbst erkannt und korrigiert] Neu ausgeloeste
|
||||
zurueckgestellte Regeln in DropZone.tsx**
|
||||
- **Found during:** Teillieferung B, unmittelbar nach dem ersten Entwurf des
|
||||
`useSemanticElements`-Fixes an `DropZone.tsx`
|
||||
- **Issue:** Das blosse Entfernen von `role="button"`/`tabIndex`/`onClick` von der Flaechen-
|
||||
`<div>` (um die Verschachtelung von zwei `<button>`-Elementen aufzuloesen) liess die
|
||||
verbliebenen `onDragOver`/`onDragLeave`/`onDrop`-Handler auf einer jetzt rollenlosen `<div>`
|
||||
stehen — das loeste `noStaticElementInteractions` und `noNoninteractiveElementInteractions`
|
||||
NEU aus, zwei Regeln, die laut Auftrag bei 5 bzw. 11 unveraendert bleiben mussten.
|
||||
- **Fix:** Struktur korrigiert: die Drop-Flaeche selbst wurde zum `<button>` (traegt jetzt
|
||||
Klick- UND Drag-Handler), die "Entfernen"-Schaltflaeche liegt als absolut positioniertes
|
||||
Geschwister in der Ecke statt verschachtelt.
|
||||
- **Files modified:** dieselbe Datei wie oben.
|
||||
- **Verification:** Volle Regelmessung nach dem Fix zeigt `noStaticElementInteractions=5`,
|
||||
`noNoninteractiveElementInteractions=11` — unveraendert zum Ausgangswert.
|
||||
- **Committed in:** `21c85a8` (im selben Commit korrigiert, nie mit dem Fehler committet)
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 2 auto-fixed (1 blocking type error, 1 selbst erkannte und vor dem
|
||||
Commit korrigierte Regelkollision). Keine der beiden Abweichungen hat den Endstand beeinflusst
|
||||
— beide wurden vor dem jeweiligen Commit vollstaendig geloest.
|
||||
|
||||
### Vom Plantext abweichende Zuordnung
|
||||
|
||||
**[Scope-Abweichung] `a11y/useSemanticElements` (4 Fundstellen) in Teillieferung B bearbeitet,
|
||||
obwohl PLAN.md diese Regel unter D-05 als "erst mit Gestaltungsentscheidung" zurueckgestellt
|
||||
hatte.** Der Auftrag fuer diese Sitzung hat die Regel explizit in den 32er-Umfang von
|
||||
Teillieferung B aufgenommen (Zielwert 465 rechnet sie mit ein: 497 − 32 = 465). Bearbeitet wie
|
||||
oben beschrieben — in allen vier Faellen ohne Verhaltenswechsel, nur Tag-/Attributverschiebung.
|
||||
Dokumentiert hier, weil PLAN.md selbst etwas anderes vorsah.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
Keine ungeloesten Probleme. Die einzige echte Schwierigkeit — die gegenseitige Verschiebung von
|
||||
a11y-Klassifikationen zwischen bearbeiteten und zurueckgestellten Regeln bei `DropZone.tsx` — ist
|
||||
unter Deviations dokumentiert und vor dem Commit geloest worden.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
Keine. Alle Aenderungen sind vollstaendige, funktionierende Korrekturen; keine Platzhalter,
|
||||
keine leeren Datenquellen.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None — keine externe Konfiguration erforderlich.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
Der Lint-Rueckstand ist von 2923 auf 465 gesunken und vollstaendig benannt: fuenf
|
||||
zurueckgestellte a11y-Regeln (30 Befunde, D-05), eine bewusst nicht angewendete Regel
|
||||
(`noUselessSwitchCase`, 1 Befund) und vier gemeldete D-03-Symptomfunde. Empfohlene Folgeaufgaben
|
||||
fuer einen spaeteren Vorgang, in absteigender Dringlichkeit:
|
||||
|
||||
1. `force-password-change.interceptor.ts` — HTTP-Verfahren tatsaechlich pruefen, sonst
|
||||
unterscheidet die Freigabeliste nicht zwischen Lese- und Schreibzugriff.
|
||||
2. `change-password/page.tsx` — nach erzwungenem Wechsel weiterleiten und Benutzerablage
|
||||
auffrischen.
|
||||
3. `VehicleTable.tsx` — Besetztzustand der Loeschschaltflaeche tatsaechlich anzeigen.
|
||||
4. `SplitTab.tsx` — fest verdrahtete Texte durch den Uebersetzungskatalog ersetzen.
|
||||
5. Die fuenf zurueckgestellten a11y-Regeln (30 Befunde) als eigener, mit UI/Design
|
||||
abgestimmter Durchgang.
|
||||
|
||||
Kein Blocker fuer laufenden Betrieb: `pnpm lint --force` bleibt gruen, CI-Tor unveraendert scharf.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
Alle referenzierten Dateien (`biome.json`, `docs/anleitung-entwicklung.md`, diese Summary,
|
||||
`DropZone.tsx`, `calendar-source-form.tsx`) auf Datenträger gefunden. Alle referenzierten
|
||||
Commit-Hashes (`8d1c8f3`, `3811578`, `636fe0d`, `e76f3b8`, `4cff316`, `ae82125`, `278aedb`,
|
||||
`969fd01`, `27a6e29`, `73ac08a`, `21c85a8`, `c79bafa`, `97a6836`) im Verlauf gefunden.
|
||||
|
||||
---
|
||||
*Vorgang: quick-260921-bi2*
|
||||
*Abgeschlossen: 2026-09-21*
|
||||
+111
File diff suppressed because one or more lines are too long
@@ -4,6 +4,24 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
|
||||
|
||||
## Unveröffentlicht
|
||||
|
||||
### Neu
|
||||
|
||||
- Favoriten-Widget: Reihenfolge der Links im Bearbeitungsmodus mit den Pfeilen „Nach oben“/„Nach unten“ festlegen
|
||||
- Desktop-App: das Symbol im Infobereich zeigt den verbundenen Tessera-Server – im Hinweistext und als erste Zeile des Menüs; in der App auch unter Einstellungen → Desktop-App als „Verbunden mit: …“
|
||||
- Desktop-App: Server-Adresse nachträglich änderbar über „Server-Adresse ändern…“ im Menü des Infobereich-Symbols – ohne Neustart
|
||||
- Desktop-App: Update mit einem Klick – „Auf Version X.Y.Z aktualisieren“ im Menü des Infobereich-Symbols lädt das signierte Paket, installiert es und startet die App neu (Windows und Linux); Voraussetzung ist eine https-Adresse, bereits installierte Versionen bis 1.2.0 wechseln einmal noch über den Browser
|
||||
- Desktop-App: die Seite „Server-Adresse ändern“ (und der Erststart) zeigt Version und Stand der installierten App, z. B. „Tessera-App 1.2.0 · Stand 7479cb4“
|
||||
|
||||
### Geändert
|
||||
|
||||
- Tessera-Bildmarke: das ganze T übernimmt die persönliche Akzentfarbe (die vier Kacheln in einem dunkleren Ton derselben Farbe)
|
||||
- Desktop-App: Beta-Pakete werden nur noch neu gebaut, wenn sich an der Desktop-App etwas geändert hat; sonst bleiben die zuletzt gebauten Pakete gültig, und die App meldet keinen neuen Beta-Stand
|
||||
- Fehler melden: Fehlermeldungen nennen jetzt die Herkunft – Browser oder Desktop-App, Betriebssystem, bei der Desktop-App auch Version und Stand; der Betreff trägt dafür ein Kürzel wie „[Browser]“, „[Desktop/Windows]“ oder „[Desktop/Linux]“, nach dem sich das Postfach sortieren lässt
|
||||
|
||||
### Behoben
|
||||
|
||||
- Favoriten-Widget: kein Symbol bei Seiten mit Zertifikatsfehler oder internen Adressen – das Symbol wird jetzt trotz Zertifikatsfehler geholt, bei internen Adressen versucht es der Browser direkt
|
||||
|
||||
## 1.2.0 – 2026-09-17
|
||||
|
||||
### Neu
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"start": "node dist/main.js",
|
||||
"start:dev": "nest start --watch",
|
||||
"type-check": "tsc --noEmit",
|
||||
"lint": "biome lint .",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"postinstall": "test -f prisma/schema.prisma && prisma generate || true"
|
||||
@@ -48,6 +49,7 @@
|
||||
"reflect-metadata": "^0.2.0",
|
||||
"rxjs": "^7.0.0",
|
||||
"tsdav": "2.2.2",
|
||||
"undici": "7.28.0",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -216,19 +216,6 @@ async function forTenantQuery(prisma, tenantId, queryFn, userId) {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spiegelbildlich zu `forSystem()` in apps/api/src/prisma/prisma-tenant.extension.ts
|
||||
* (Etappe 3c, 260914-eym) — bei jeder Aenderung dort HIER nachziehen: EINE
|
||||
* getaggte Anweisung setzt `app.system_context = 'true'` und AUSDRUECKLICH
|
||||
* `app.current_tenant = ''` und `app.current_user = ''`, alle drei als
|
||||
* Literale; danach die Abfrage in derselben Array-Transaktion.
|
||||
*/
|
||||
async function forSystemQuery(prisma, queryFn) {
|
||||
const setContext = prisma.$executeRaw`SELECT set_config('app.system_context', 'true', true), set_config('app.current_tenant', '', true), set_config('app.current_user', '', true)`;
|
||||
const [, result] = await prisma.$transaction([setContext, queryFn(prisma)]);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aufgabe 1 — misst die fuenf im Plan genannten Verhaltensweisen von
|
||||
* forTenant() unter der Rolle ohne BYPASSRLS.
|
||||
@@ -3242,7 +3229,7 @@ async function runCalendarAreaChecks(adminUrl, scratchRoleUrl, results) {
|
||||
// gebunden MIT Benutzer jetzt NICHT mehr lesbar ist (siehe Umkehrung
|
||||
// unten).
|
||||
const a2Row = rowsForA.find((r) => r.userId === 'user-a2');
|
||||
const a2CredentialsVisible = Boolean(a2Row && a2Row.encryptedPassword);
|
||||
const a2CredentialsVisible = Boolean(a2Row?.encryptedPassword);
|
||||
report(
|
||||
results,
|
||||
'calendarsource-ohne-benutzer-sieht-beide-nutzer-desselben-mandanten',
|
||||
|
||||
@@ -9,7 +9,7 @@ import { ConfigService } from '@nestjs/config';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { Role } from '@prisma/client';
|
||||
import * as argon2 from 'argon2';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Response } from 'express';
|
||||
import { LdapConfigService } from '../ldap/ldap-config.service';
|
||||
import { LdapService } from '../ldap/ldap.service';
|
||||
@@ -111,7 +111,7 @@ export class AuthService {
|
||||
`;
|
||||
const user = rows[0];
|
||||
|
||||
if (!user || !user.isActive) {
|
||||
if (!user?.isActive) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -125,7 +125,7 @@ export class AuthService {
|
||||
}
|
||||
|
||||
const config = await this.ldapConfigService.getConfig(user.tenantId);
|
||||
if (!config || !config.isActive) {
|
||||
if (!config?.isActive) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -220,7 +220,7 @@ export class AuthService {
|
||||
const user = rows[0];
|
||||
|
||||
// Always return success to prevent email enumeration (T-02-12)
|
||||
if (!user || !user.isActive) {
|
||||
if (!user?.isActive) {
|
||||
this.logger.log(
|
||||
`Password reset requested for unknown/inactive email: ${email}`,
|
||||
);
|
||||
@@ -358,7 +358,7 @@ export class AuthService {
|
||||
where: { id: userId },
|
||||
});
|
||||
|
||||
if (!user || !user.passwordHash) {
|
||||
if (!user?.passwordHash) {
|
||||
throw new UnauthorizedException('User not found or has no local password');
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(data: unknown, ctx: ExecutionContext) => {
|
||||
(_data: unknown, ctx: ExecutionContext) => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
return request.user;
|
||||
},
|
||||
|
||||
@@ -50,7 +50,6 @@ export class ForcePasswordChangeInterceptor implements NestInterceptor {
|
||||
|
||||
// Allow specific routes even when password change is required
|
||||
const path = request.route?.path || request.url;
|
||||
const method = request.method;
|
||||
|
||||
const allowedPaths = [
|
||||
'/auth/change-password',
|
||||
|
||||
@@ -8,8 +8,8 @@ import { Request } from 'express';
|
||||
* Custom extractor that reads JWT from the httpOnly "session" cookie.
|
||||
*/
|
||||
function cookieExtractor(req: Request): string | null {
|
||||
if (req && req.cookies) {
|
||||
return req.cookies['session'] || null;
|
||||
if (req?.cookies) {
|
||||
return req.cookies.session || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { BugReportDto } from './dto/bug-report.dto';
|
||||
/**
|
||||
* BugReportsController.spec — NEU (quick-260914-m97, Fehler-melden-Knopf).
|
||||
*
|
||||
* Drei Tests an der Grenze Browser -> API:
|
||||
* Vier Tests an der Grenze Browser -> API:
|
||||
* 1. die globale Pipe (`whitelist: true, transform: true`, wie in
|
||||
* `main.ts`) entfernt Fremdfelder wie `tenantId` (T-M97-06) und
|
||||
* normalisiert `errors` (multer/append-field liefert EIN Feld als
|
||||
@@ -16,6 +16,8 @@ import { BugReportDto } from './dto/bug-report.dto';
|
||||
* 2. die DTO-Grenzen greifen (31 Eintraege, 4001 Zeichen -> 400);
|
||||
* 3. die Route steht JEDEM angemeldeten Benutzer offen — kein
|
||||
* `@Roles`-Metadatum, Pfad `bug-reports`.
|
||||
* 4. (quick-260918-gza) die vier neuen Client-Felder bleiben optional
|
||||
* und laengenbegrenzt erhalten.
|
||||
*/
|
||||
const pipe = new ValidationPipe({ whitelist: true, transform: true });
|
||||
const meta = { type: 'body' as const, metatype: BugReportDto };
|
||||
@@ -63,4 +65,29 @@ describe('BugReportsController (quick-260914-m97)', () => {
|
||||
expect(Reflect.getMetadata(ROLES_KEY, BugReportsController.prototype.submit)).toBeUndefined();
|
||||
expect(Reflect.getMetadata('path', BugReportsController)).toBe('bug-reports');
|
||||
});
|
||||
|
||||
it('Test 4 (quick-260918-gza): die vier Client-Felder bleiben optional und laengenbegrenzt erhalten', async () => {
|
||||
const withDesktop = (await pipe.transform(
|
||||
{ ...baseBody, clientKind: 'desktop', clientOs: 'windows', clientVersion: '1.2.0', clientCommit: '' },
|
||||
meta,
|
||||
)) as any;
|
||||
expect(withDesktop.clientKind).toBe('desktop');
|
||||
expect(withDesktop.clientOs).toBe('windows');
|
||||
expect(withDesktop.clientVersion).toBe('1.2.0');
|
||||
expect(withDesktop.clientCommit).toBe('');
|
||||
|
||||
const withoutClientFields = (await pipe.transform({ ...baseBody }, meta)) as any;
|
||||
expect(withoutClientFields.clientKind).toBeUndefined();
|
||||
|
||||
await expect(pipe.transform({ ...baseBody, clientKind: 'tablet' }, meta)).rejects.toThrow(BadRequestException);
|
||||
await expect(pipe.transform({ ...baseBody, clientOs: 'x'.repeat(21) }, meta)).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
await expect(pipe.transform({ ...baseBody, clientVersion: 'x'.repeat(41) }, meta)).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
await expect(pipe.transform({ ...baseBody, clientCommit: 'x'.repeat(41) }, meta)).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ import { BugReportsService } from './bug-reports.service';
|
||||
/**
|
||||
* BugReportsService.spec — NEU (quick-260914-m97, Fehler-melden-Knopf).
|
||||
*
|
||||
* Acht Tests, darunter die vier Falsifizierungen des Plans:
|
||||
* Zehn Tests, darunter die vier Falsifizierungen des Plans:
|
||||
* (a) Drossel: der sechste Bericht in zehn Minuten -> 429, nach dem
|
||||
* Fenster (Fake-Timer) wieder durch;
|
||||
* (b) manipulierte Bilddatei ohne PNG-Kopf -> 400, nie versendet;
|
||||
@@ -20,6 +20,8 @@ import { BugReportsService } from './bug-reports.service';
|
||||
* (d) Fremdfelder im Rumpf (tenantId/userId) aendern NICHTS an der
|
||||
* Mandantenkennung — Empfaenger, Benutzerzeile und Versand laufen
|
||||
* ausschliesslich mit der Kennung aus dem Sitzungsnachweis.
|
||||
* Test 9 und 10 (quick-260918-gza): Betreff-Kuerzel und Zeile `Herkunft:`
|
||||
* fuer den Desktop- und den Browser-Pfad, Protokollzeile mit Kuerzel.
|
||||
*
|
||||
* `forTenant` wird wie in `user.controller.spec.ts` durch einen gebundenen
|
||||
* Fake-Klienten ersetzt, der nur Zeilen des eigenen Mandanten liefert;
|
||||
@@ -135,7 +137,7 @@ describe('BugReportsService (quick-260914-m97)', () => {
|
||||
const [tenantId, to, report] = mailService.sendBugReport.mock.calls[0] as any[];
|
||||
expect(tenantId).toBe('t1');
|
||||
expect(to).toBe('fehler@a.example.invalid');
|
||||
expect(report.subject).toBe('[Tessera Fehlermeldung] v1.2.3 beta - /admin/users?tab=x');
|
||||
expect(report.subject).toBe('[Tessera Fehlermeldung] [Browser] v1.2.3 beta - /admin/users?tab=x');
|
||||
for (const needle of [
|
||||
'Knopf tut nichts',
|
||||
'/admin/users?tab=x',
|
||||
@@ -145,6 +147,7 @@ describe('BugReportsService (quick-260914-m97)', () => {
|
||||
't1',
|
||||
'v1.2.3 (beta) abc1234',
|
||||
'Tessera API v9.9.9 (live)',
|
||||
'Herkunft: Browser — unbekannt auf unbekannt',
|
||||
'UA',
|
||||
'1920x1080',
|
||||
'[2026-09-14T09:59:00.000Z] fetch: GET /modules -> 500 {"statusCode":500}',
|
||||
@@ -291,4 +294,53 @@ describe('BugReportsService (quick-260914-m97)', () => {
|
||||
expect(report.text).not.toContain('Eindringling');
|
||||
expect(report.text).not.toContain('fremd@x.invalid');
|
||||
});
|
||||
|
||||
it('Test 9 (quick-260918-gza): Desktop/Windows — Betreff-Kuerzel, Zeile Herkunft VOR Browser:, Protokollzeile mit Kuerzel', async () => {
|
||||
const { service, mailService } = makeService({});
|
||||
const edgeUa =
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36 Edg/129.0.2792.52';
|
||||
|
||||
await service.submit(
|
||||
sessionUser,
|
||||
{
|
||||
...baseDto,
|
||||
clientKind: 'desktop',
|
||||
clientOs: 'windows',
|
||||
clientVersion: '1.2.0',
|
||||
clientCommit: 'a6d1a64',
|
||||
userAgent: edgeUa,
|
||||
} as any,
|
||||
undefined,
|
||||
);
|
||||
|
||||
const report = (mailService.sendBugReport.mock.calls[0] as any[])[2];
|
||||
expect(report.subject.startsWith('[Tessera Fehlermeldung] [Desktop/Windows] v1.2.3 beta - ')).toBe(true);
|
||||
expect(report.text).toContain('Herkunft: Desktop-App (Windows), Tessera-App 1.2.0 · Stand a6d1a64');
|
||||
expect(report.text).toContain('Browser: Mozilla/5.0 (Windows NT 10.0');
|
||||
expect(report.text).toContain('Fenster: 1920x1080');
|
||||
expect(report.text.indexOf('Herkunft:')).toBeLessThan(report.text.indexOf('Browser:'));
|
||||
|
||||
const { service: freshService, mailService: freshMail } = makeService({});
|
||||
freshMail.sendBugReport.mockClear();
|
||||
(freshService as any).logger.log.mockClear();
|
||||
await freshService.submit(
|
||||
sessionUser,
|
||||
{ ...baseDto, clientKind: 'desktop', clientOs: 'windows', clientVersion: '1.2.0', clientCommit: 'a6d1a64', userAgent: edgeUa } as any,
|
||||
undefined,
|
||||
);
|
||||
expect((freshService as any).logger.log).toHaveBeenCalledTimes(1);
|
||||
expect((freshService as any).logger.log.mock.calls[0][0]).toContain('[Desktop/Windows]');
|
||||
});
|
||||
|
||||
it('Test 10 (quick-260918-gza): Browser mit echtem User-Agent — Betreff und Zeile Herkunft nennen Browser und Version', async () => {
|
||||
const { service, mailService } = makeService({});
|
||||
const chromeUa =
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36';
|
||||
|
||||
await service.submit(sessionUser, { ...baseDto, clientKind: 'browser', userAgent: chromeUa } as any, undefined);
|
||||
|
||||
const report = (mailService.sendBugReport.mock.calls[0] as any[])[2];
|
||||
expect(report.subject).toContain('[Browser]');
|
||||
expect(report.text).toContain('Herkunft: Browser — Chrome 129 auf Windows');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { SettingsService } from '../settings/settings.service';
|
||||
import { BugReportDto } from './dto/bug-report.dto';
|
||||
import { describeOrigin } from './origin';
|
||||
|
||||
/**
|
||||
* BugReportsService — Fehler-melden-Knopf (quick-260914-m97).
|
||||
@@ -43,6 +44,14 @@ import { BugReportDto } from './dto/bug-report.dto';
|
||||
* Benutzer ausschliesslich aus dem Sitzungsnachweis, Benutzerzeile ueber
|
||||
* einen gebundenen Klienten — Zeile in
|
||||
* docs/mandantentrennung-zugriffsklassifikation.md).
|
||||
*
|
||||
* Herkunft (quick-260918-gza): der Betreff traegt seit dieser Aenderung
|
||||
* ein Kuerzel (`[Browser]`, `[Desktop/Windows]`, `[Desktop/Linux]`, oder
|
||||
* `[Desktop]` bei einem alten Client ohne Details), damit sich das
|
||||
* Postfach danach sortieren laesst — WebView2 sieht sonst aus wie Edge,
|
||||
* WebKitGTK wie Safari. Der rohe User-Agent bleibt in der Zeile
|
||||
* `Browser:` unveraendert erhalten, die Ableitung selbst steht in
|
||||
* `origin.ts` (dort auch T-GZA-01: rein informativ, laengenbegrenzt).
|
||||
*/
|
||||
|
||||
const WINDOW_MS = 10 * 60 * 1000;
|
||||
@@ -119,9 +128,10 @@ export class BugReportsService {
|
||||
const email: string = row?.email ?? '-';
|
||||
const role: string = row?.role ?? user.role;
|
||||
|
||||
// (5) Betreff
|
||||
// (5) Betreff — Herkunfts-Kuerzel direkt nach dem festen Praefix (quick-260918-gza).
|
||||
const origin = describeOrigin(dto);
|
||||
const pageShort = dto.page.slice(0, 120);
|
||||
const subject = `[Tessera Fehlermeldung] ${dto.webVersion} ${dto.webChannel} - ${pageShort}`;
|
||||
const subject = `[Tessera Fehlermeldung] ${origin.tag} ${dto.webVersion} ${dto.webChannel} - ${pageShort}`;
|
||||
|
||||
// (6) Text
|
||||
const bytes = file ? file.buffer.length : 0;
|
||||
@@ -139,6 +149,7 @@ export class BugReportsService {
|
||||
`Mandant: ${user.tenantId}`,
|
||||
`Web: ${dto.webVersion} (${dto.webChannel}) ${dto.webCommit}`.trimEnd(),
|
||||
`API: ${formatAppVersionLine()}`,
|
||||
`Herkunft: ${origin.line}`,
|
||||
`Browser: ${dto.userAgent}`,
|
||||
`Fenster: ${dto.viewport}`,
|
||||
'',
|
||||
@@ -164,8 +175,10 @@ export class BugReportsService {
|
||||
}
|
||||
|
||||
// (9) Genau eine Protokollzeile — nie Beschreibung, nie Bild (T-M97-07).
|
||||
// Das Kuerzel ist ein aufgezaehlter Wert aus origin.ts, nie ein roher
|
||||
// Client-String — deshalb protokollierbar (T-GZA-01).
|
||||
this.logger.log(
|
||||
`Bug report from ${user.username} (tenant ${user.tenantId}) sent to ${to} — page ${pageShort}, screenshot ${bytes} bytes`,
|
||||
`Bug report ${origin.tag} from ${user.username} (tenant ${user.tenantId}) sent to ${to} — page ${pageShort}, screenshot ${bytes} bytes`,
|
||||
);
|
||||
return { sent: true };
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Expose, Transform } from 'class-transformer';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
@@ -21,6 +22,16 @@ import {
|
||||
* Dienst nimmt beides ausschliesslich aus dem Sitzungsnachweis
|
||||
* (`@CurrentUser()`), und `whitelist: true` der globalen ValidationPipe
|
||||
* entfernt jedes Fremdfeld, das ein Client hier trotzdem mitschickt.
|
||||
*
|
||||
* Vier weitere Felder (`clientKind`, `clientOs`, `clientVersion`,
|
||||
* `clientCommit`) kommen seit quick-260918-gza vom Web-Client dazu — im
|
||||
* Browser als `clientKind=browser` mit leeren uebrigen Feldern, in der
|
||||
* Desktop-App aus dem Cookie `tessera_desktop_client`. Sie sind optional,
|
||||
* damit aeltere Web-Baue weiterhin gueltig senden (der Dienst faellt ohne
|
||||
* sie auf `browser` zurueck); `whitelist: true` verlangt ihre Deklaration
|
||||
* hier, sonst wuerde die Pipe sie stillschweigend entfernen. Sie dienen
|
||||
* ausschliesslich der Anzeige in der Mail und sind laengenbegrenzt
|
||||
* (T-GZA-01) — siehe `origin.ts`.
|
||||
*/
|
||||
export class BugReportDto {
|
||||
/** Freitext „Was ist passiert?“ — optional, hoechstens 4000 Zeichen. */
|
||||
@@ -77,4 +88,27 @@ export class BugReportDto {
|
||||
@IsString({ each: true })
|
||||
@MaxLength(1000, { each: true })
|
||||
errors!: string[];
|
||||
|
||||
/** `desktop` oder `browser` — vom Web-Client gesetzt, siehe Kopfkommentar. */
|
||||
@IsOptional()
|
||||
@IsIn(['desktop', 'browser'])
|
||||
clientKind?: 'desktop' | 'browser';
|
||||
|
||||
/** Betriebssystem der Desktop-App (`windows`, `linux`, `macos`, …), unbeglaubigt. */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
clientOs?: string;
|
||||
|
||||
/** Version der Desktop-App (`CARGO_PKG_VERSION`), unbeglaubigt. */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(40)
|
||||
clientVersion?: string;
|
||||
|
||||
/** Commit-Stempel der Desktop-App (`APP_COMMIT`), darf leer sein. */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(40)
|
||||
clientCommit?: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describeOrigin, parseUserAgent } from './origin';
|
||||
|
||||
/**
|
||||
* origin.spec.ts — NEU (quick-260918-gza, Herkunft einer Fehlermeldung).
|
||||
*
|
||||
* Zehn Tests fuer die beiden reinen Funktionen aus origin.ts:
|
||||
* 1-5 `parseUserAgent` fuer Desktop- und Mobil-Browser (Reihenfolge der
|
||||
* Muster: Edge/Opera vor Chrome, Safari nur ohne Chrome-Token,
|
||||
* Android/iOS vor Linux/macOS);
|
||||
* 6-8 `describeOrigin` im Desktop-Fall (mit/ohne Commit, ganz ohne
|
||||
* Details — alter Client);
|
||||
* 9 Rueckfall auf den Browser-Pfad, wenn alle vier Client-Felder
|
||||
* fehlen (alter Web-Bau);
|
||||
* 10 Bereinigung: kein Zeilenumbruch, kein Markup, hoechstens 40
|
||||
* Zeichen je Wert (T-GZA-01).
|
||||
*/
|
||||
|
||||
const EDGE_WINDOWS_UA =
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36 Edg/129.0.2792.52';
|
||||
const CHROME_WINDOWS_UA =
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36';
|
||||
const FIREFOX_LINUX_UA = 'Mozilla/5.0 (X11; Linux x86_64; rv:130.0) Gecko/20100101 Firefox/130.0';
|
||||
const SAFARI_MACOS_UA =
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15';
|
||||
const ANDROID_CHROME_UA =
|
||||
'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36';
|
||||
const IPAD_SAFARI_UA =
|
||||
'Mozilla/5.0 (iPad; CPU OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1';
|
||||
const OPERA_UA =
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36 OPR/114.0.0.0';
|
||||
const WEBKITGTK_LINUX_UA =
|
||||
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15';
|
||||
|
||||
describe('origin (quick-260918-gza)', () => {
|
||||
it('Test 1: Edge auf Windows — Edge gewinnt vor Chrome', () => {
|
||||
expect(parseUserAgent(EDGE_WINDOWS_UA)).toEqual({ browser: 'Edge 129', os: 'Windows' });
|
||||
expect(describeOrigin({ clientKind: 'browser', userAgent: EDGE_WINDOWS_UA })).toEqual({
|
||||
tag: '[Browser]',
|
||||
line: 'Browser — Edge 129 auf Windows',
|
||||
});
|
||||
});
|
||||
|
||||
it('Test 2: derselbe UA ohne Edg/ — Chrome auf Windows', () => {
|
||||
expect(parseUserAgent(CHROME_WINDOWS_UA)).toEqual({ browser: 'Chrome 129', os: 'Windows' });
|
||||
});
|
||||
|
||||
it('Test 3: Firefox auf Linux', () => {
|
||||
expect(parseUserAgent(FIREFOX_LINUX_UA)).toEqual({ browser: 'Firefox 130', os: 'Linux' });
|
||||
expect(describeOrigin({ userAgent: FIREFOX_LINUX_UA }).line).toBe('Browser — Firefox 130 auf Linux');
|
||||
});
|
||||
|
||||
it('Test 4: Safari auf macOS — Version aus Version/, nur ohne Chrome-Token', () => {
|
||||
expect(parseUserAgent(SAFARI_MACOS_UA)).toEqual({ browser: 'Safari 17', os: 'macOS' });
|
||||
});
|
||||
|
||||
it('Test 5: Mobil — Android statt Linux, iOS statt macOS, Opera erkannt', () => {
|
||||
expect(parseUserAgent(ANDROID_CHROME_UA).os).toBe('Android');
|
||||
const ipad = parseUserAgent(IPAD_SAFARI_UA);
|
||||
expect(ipad.os).toBe('iOS');
|
||||
expect(ipad.browser).toBe('Safari 17');
|
||||
expect(parseUserAgent(OPERA_UA).browser).toBe('Opera 114');
|
||||
});
|
||||
|
||||
it('Test 6: WebKitGTK-Client als Desktop — der User-Agent spielt keine Rolle', () => {
|
||||
expect(
|
||||
describeOrigin({
|
||||
userAgent: WEBKITGTK_LINUX_UA,
|
||||
clientKind: 'desktop',
|
||||
clientOs: 'linux',
|
||||
clientVersion: '1.2.0',
|
||||
clientCommit: 'a6d1a64',
|
||||
}),
|
||||
).toEqual({
|
||||
tag: '[Desktop/Linux]',
|
||||
line: 'Desktop-App (Linux), Tessera-App 1.2.0 · Stand a6d1a64',
|
||||
});
|
||||
});
|
||||
|
||||
it('Test 7: Desktop ohne Commit — kein "· Stand"; Leerraum wird wie ein leerer Commit behandelt', () => {
|
||||
expect(
|
||||
describeOrigin({ clientKind: 'desktop', clientOs: 'windows', clientVersion: '1.2.0', clientCommit: '' }),
|
||||
).toEqual({ tag: '[Desktop/Windows]', line: 'Desktop-App (Windows), Tessera-App 1.2.0' });
|
||||
|
||||
expect(
|
||||
describeOrigin({ clientKind: 'desktop', clientOs: 'windows', clientVersion: '1.2.0', clientCommit: ' ' }),
|
||||
).toEqual({ tag: '[Desktop/Windows]', line: 'Desktop-App (Windows), Tessera-App 1.2.0' });
|
||||
});
|
||||
|
||||
it('Test 8: Desktop ohne Details (alter Client, Cookie fehlt) — unbekanntes Betriebssystem ebenfalls', () => {
|
||||
expect(
|
||||
describeOrigin({ clientKind: 'desktop', clientOs: '', clientVersion: '', clientCommit: '' }),
|
||||
).toEqual({ tag: '[Desktop]', line: 'Desktop-App (unbekannt)' });
|
||||
|
||||
expect(
|
||||
describeOrigin({ clientKind: 'desktop', clientOs: 'freebsd', clientVersion: '', clientCommit: '' }),
|
||||
).toEqual({ tag: '[Desktop]', line: 'Desktop-App (unbekannt)' });
|
||||
});
|
||||
|
||||
it('Test 9: Rueckfall auf den Browser-Pfad, wenn alle vier Client-Felder fehlen (alter Web-Bau)', () => {
|
||||
expect(describeOrigin({ userAgent: 'UA' })).toEqual({
|
||||
tag: '[Browser]',
|
||||
line: 'Browser — unbekannt auf unbekannt',
|
||||
});
|
||||
expect(describeOrigin({ clientKind: 'browser', userAgent: '' })).toEqual({
|
||||
tag: '[Browser]',
|
||||
line: 'Browser — unbekannt auf unbekannt',
|
||||
});
|
||||
});
|
||||
|
||||
it('Test 10: Bereinigung — kein Zeilenumbruch, kein Markup, hoechstens 40 Zeichen je Wert (T-GZA-01)', () => {
|
||||
const origin = describeOrigin({
|
||||
clientKind: 'desktop',
|
||||
clientOs: 'windows',
|
||||
clientVersion: '1.2.0\nBenutzer: admin',
|
||||
clientCommit: 'a6d1a64<b>',
|
||||
});
|
||||
expect(origin.line).not.toMatch(/[\n\r<>]/);
|
||||
expect(origin.line).toBe('Desktop-App (Windows), Tessera-App 1.2.0Benutzeradmin · Stand a6d1a64b');
|
||||
|
||||
const longValue = 'x'.repeat(60);
|
||||
const truncated = describeOrigin({
|
||||
clientKind: 'desktop',
|
||||
clientOs: 'linux',
|
||||
clientVersion: longValue,
|
||||
clientCommit: longValue,
|
||||
});
|
||||
expect(truncated.line).toBe(`Desktop-App (Linux), Tessera-App ${'x'.repeat(40)} · Stand ${'x'.repeat(40)}`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* origin.ts — Herkunft einer Fehlermeldung ausweisen (quick-260918-gza).
|
||||
*
|
||||
* Zweck: WebView2 (Windows-Desktop-App) sieht im User-Agent aus wie Edge,
|
||||
* WebKitGTK (Linux-Desktop-App) wie Safari — im Postfach des Betreibers ist
|
||||
* damit nicht erkennbar, ob eine Meldung aus einem Client oder einem
|
||||
* echten Browser stammt. `describeOrigin()` leitet aus den vier optionalen
|
||||
* Client-Feldern (gesetzt durch die Desktop-App ueber das Cookie
|
||||
* `tessera_desktop_client`) und, im Browser-Fall, aus dem User-Agent ein
|
||||
* kurzes Betreff-Kuerzel und eine Textzeile ab.
|
||||
*
|
||||
* Trust-Modell (T-GZA-01): alle Eingaben stammen unbeglaubigt vom Client,
|
||||
* wie der bestehende User-Agent auch. Die Ableitung ist rein informativ —
|
||||
* sie entscheidet nie ueber Routing, Berechtigung oder Speicherung, und
|
||||
* nichts davon wird in der Datenbank abgelegt. `clean()` laesst nur ein
|
||||
* begrenztes Alphabet und hoechstens 40 Zeichen zu, damit weder ein
|
||||
* Zeilenumbruch noch Markup in die E-Mail gelangt; das Betriebssystem wird
|
||||
* ausserdem auf eine feste Menge von Labels abgebildet.
|
||||
*
|
||||
* Warum Regex statt Bibliothek: fuenf Browser und fuenf Betriebssysteme
|
||||
* reichen aus, um ein Postfach sinnvoll zu sortieren — ein neues Paket
|
||||
* (etwa ua-parser-js) waere fuer diesen Umfang unverhaeltnismaessig.
|
||||
*/
|
||||
|
||||
export type ClientKind = 'desktop' | 'browser';
|
||||
|
||||
export interface OriginInput {
|
||||
clientKind?: string;
|
||||
clientOs?: string;
|
||||
clientVersion?: string;
|
||||
clientCommit?: string;
|
||||
userAgent?: string;
|
||||
}
|
||||
|
||||
export interface Origin {
|
||||
/** Betreff-Kuerzel in eckigen Klammern, z. B. `[Browser]`, `[Desktop/Windows]`. */
|
||||
tag: string;
|
||||
/** Text NACH dem Label `Herkunft: ` — der Dienst setzt das Label davor. */
|
||||
line: string;
|
||||
}
|
||||
|
||||
export interface ParsedUserAgent {
|
||||
browser: string;
|
||||
os: string;
|
||||
}
|
||||
|
||||
const UNKNOWN = 'unbekannt';
|
||||
|
||||
/**
|
||||
* Entfernt alles ausser `[A-Za-z0-9.+_-]` und kuerzt auf `max` Zeichen
|
||||
* (Vorgabe 40) — kein Zeilenumbruch, kein Markup in Betreff oder Text
|
||||
* (T-GZA-01).
|
||||
*/
|
||||
function clean(value: unknown, max = 40): string {
|
||||
return String(value ?? '')
|
||||
.replace(/[^A-Za-z0-9.+_-]/g, '')
|
||||
.slice(0, max);
|
||||
}
|
||||
|
||||
/**
|
||||
* Leitet Browsername samt Hauptversion und Betriebssystem aus einem
|
||||
* User-Agent ab. Reihenfolge ist entscheidend: Android-UAs enthalten
|
||||
* `Linux`, iPad-UAs enthalten `like Mac OS X`, Edge- und Opera-UAs
|
||||
* enthalten sowohl `Chrome/` als auch `Safari/` — die spezifischeren
|
||||
* Muster muessen deshalb zuerst gewinnen.
|
||||
*/
|
||||
export function parseUserAgent(ua: string): ParsedUserAgent {
|
||||
const value = ua ?? '';
|
||||
|
||||
let browser = UNKNOWN;
|
||||
const edg = value.match(/Edg\/(\d+)/);
|
||||
const opr = value.match(/OPR\/(\d+)/);
|
||||
const firefox = value.match(/Firefox\/(\d+)/);
|
||||
const chrome = value.match(/(?:Chrome|CriOS)\/(\d+)/);
|
||||
const hasSafariToken = /Safari\//.test(value);
|
||||
const hasChromeToken = /Chrome\//.test(value);
|
||||
|
||||
if (edg) {
|
||||
browser = `Edge ${edg[1]}`;
|
||||
} else if (opr) {
|
||||
browser = `Opera ${opr[1]}`;
|
||||
} else if (firefox) {
|
||||
browser = `Firefox ${firefox[1]}`;
|
||||
} else if (chrome) {
|
||||
browser = `Chrome ${chrome[1]}`;
|
||||
} else if (hasSafariToken && !hasChromeToken) {
|
||||
const version = value.match(/Version\/(\d+)/);
|
||||
browser = version ? `Safari ${version[1]}` : 'Safari';
|
||||
}
|
||||
|
||||
let os = UNKNOWN;
|
||||
if (/Windows NT/.test(value)) {
|
||||
os = 'Windows';
|
||||
} else if (/Android/.test(value)) {
|
||||
os = 'Android';
|
||||
} else if (/iPhone|iPad|iPod/.test(value)) {
|
||||
os = 'iOS';
|
||||
} else if (/Mac OS X|Macintosh/.test(value)) {
|
||||
os = 'macOS';
|
||||
} else if (/Linux|X11/.test(value)) {
|
||||
os = 'Linux';
|
||||
}
|
||||
|
||||
return { browser, os };
|
||||
}
|
||||
|
||||
/** Bildet `clean(clientOs)` auf eines von drei festen Labels ab. */
|
||||
function desktopOsLabel(clientOs: string | undefined): string {
|
||||
switch (clean(clientOs).toLowerCase()) {
|
||||
case 'windows':
|
||||
return 'Windows';
|
||||
case 'linux':
|
||||
return 'Linux';
|
||||
case 'macos':
|
||||
return 'macOS';
|
||||
default:
|
||||
return UNKNOWN;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Leitet Betreff-Kuerzel und Herkunftszeile ab. Desktop-Fall: der
|
||||
* User-Agent spielt keine Rolle, nur die vier Client-Felder zaehlen
|
||||
* (dieselbe Regel wie `client_info_label` im Rust-Client — Version und
|
||||
* Commit werden nur zusammen mit " · Stand " verbunden, ein leerer
|
||||
* Commit faellt weg). Alles andere (auch fehlende Felder, alter
|
||||
* Web-Bau) faellt auf den Browser-Pfad mit `parseUserAgent` zurueck.
|
||||
*/
|
||||
export function describeOrigin(input: OriginInput): Origin {
|
||||
if (input.clientKind === 'desktop') {
|
||||
const osLabel = desktopOsLabel(input.clientOs);
|
||||
const version = clean(input.clientVersion);
|
||||
const commit = clean(input.clientCommit);
|
||||
|
||||
let appLabel = '';
|
||||
if (version && commit) {
|
||||
appLabel = `Tessera-App ${version} · Stand ${commit}`;
|
||||
} else if (version) {
|
||||
appLabel = `Tessera-App ${version}`;
|
||||
}
|
||||
|
||||
const line = appLabel ? `Desktop-App (${osLabel}), ${appLabel}` : `Desktop-App (${osLabel})`;
|
||||
const tag = osLabel === UNKNOWN ? '[Desktop]' : `[Desktop/${osLabel}]`;
|
||||
return { tag, line };
|
||||
}
|
||||
|
||||
const { browser, os } = parseUserAgent(input.userAgent ?? '');
|
||||
return { tag: '[Browser]', line: `Browser — ${browser} auf ${os}` };
|
||||
}
|
||||
@@ -318,7 +318,7 @@ export class CalendarService {
|
||||
});
|
||||
|
||||
return { success };
|
||||
} catch (error) {
|
||||
} catch {
|
||||
const errorMsg = 'Connection failed'; // T-05-13: generic error, no credentials
|
||||
await tenantPrisma.calendarSource.update({
|
||||
where: { id },
|
||||
@@ -355,7 +355,7 @@ export class CalendarService {
|
||||
try {
|
||||
const success = await provider.testConnection(tempSource);
|
||||
return { success };
|
||||
} catch (e: any) {
|
||||
} catch {
|
||||
return { success: false, error: 'Connection failed' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
IsBoolean,
|
||||
IsHexColor,
|
||||
IsIn,
|
||||
IsNotEmpty,
|
||||
|
||||
@@ -47,7 +47,7 @@ export class CalDAVProvider implements CalendarProvider {
|
||||
|
||||
for (const key of Object.keys(parsed)) {
|
||||
const component = parsed[key];
|
||||
if (!component || component.type !== 'VEVENT') continue;
|
||||
if (component?.type !== 'VEVENT') continue;
|
||||
|
||||
const vevent = component as ical.VEvent;
|
||||
const start = new Date(vevent.start);
|
||||
|
||||
@@ -180,8 +180,8 @@ export class ExchangeProvider implements CalendarProvider {
|
||||
id: `${source.id}-${item.id}`,
|
||||
sourceId: source.id,
|
||||
title: item.subject || 'Untitled',
|
||||
start: new Date(item.start?.dateTime + 'Z'),
|
||||
end: new Date(item.end?.dateTime + 'Z'),
|
||||
start: new Date(`${item.start?.dateTime}Z`),
|
||||
end: new Date(`${item.end?.dateTime}Z`),
|
||||
allDay: item.isAllDay || false,
|
||||
location: item.location?.displayName || undefined,
|
||||
description: item.bodyPreview || undefined,
|
||||
|
||||
@@ -30,7 +30,7 @@ export class ICSProvider implements CalendarProvider {
|
||||
|
||||
for (const key of Object.keys(data)) {
|
||||
const component = data[key];
|
||||
if (!component || component.type !== 'VEVENT') continue;
|
||||
if (component?.type !== 'VEVENT') continue;
|
||||
|
||||
const vevent = component as ical.VEvent;
|
||||
|
||||
|
||||
@@ -116,7 +116,7 @@ describe('CertManagerService helpers', () => {
|
||||
const cert2 = generateSelfSignedCert();
|
||||
const pem1 = forge.pki.certificateToPem(cert1);
|
||||
const pem2 = forge.pki.certificateToPem(cert2);
|
||||
const chain = pem1 + '\n' + pem2;
|
||||
const chain = `${pem1}\n${pem2}`;
|
||||
|
||||
const parsed = service.parsePemChain(chain);
|
||||
expect(parsed).toHaveLength(2);
|
||||
@@ -266,7 +266,7 @@ describe('splitCerts', () => {
|
||||
cert2Pem = forge.pki.certificateToPem(c2);
|
||||
|
||||
// Fullchain fixture: two PEMs concatenated
|
||||
const fullchainPem = cert1Pem + '\n' + cert2Pem;
|
||||
const fullchainPem = `${cert1Pem}\n${cert2Pem}`;
|
||||
fullchainBuffer = Buffer.from(fullchainPem, 'utf-8');
|
||||
|
||||
// P7B fixture: PEM-wrapped PKCS7 SignedData bundle with both certs
|
||||
|
||||
@@ -302,7 +302,7 @@ export class CertManagerService {
|
||||
fingerprint: { sha1, sha256 },
|
||||
pemPreview: forge.pki.certificateToPem(cert),
|
||||
};
|
||||
} catch (err) {
|
||||
} catch {
|
||||
this.logger.warn('parseCert: failed to extract CertDetails fields');
|
||||
throw new BadRequestException('Failed to extract certificate details');
|
||||
}
|
||||
@@ -505,7 +505,7 @@ export class CertManagerService {
|
||||
return {
|
||||
filename: 'chain.pem',
|
||||
content,
|
||||
mimeType: FORMAT_MIME['pem'],
|
||||
mimeType: FORMAT_MIME.pem,
|
||||
};
|
||||
} else if (outputFormat === 'pfx') {
|
||||
// Open Question 1 resolution: toPkcs12Asn1(null, certs, password) works in node-forge 1.4.0
|
||||
@@ -523,7 +523,7 @@ export class CertManagerService {
|
||||
return {
|
||||
filename: 'bundle.pfx',
|
||||
content,
|
||||
mimeType: FORMAT_MIME['pfx'],
|
||||
mimeType: FORMAT_MIME.pfx,
|
||||
};
|
||||
} else {
|
||||
throw new BadRequestException(
|
||||
@@ -667,7 +667,7 @@ export class CertManagerService {
|
||||
return {
|
||||
filename: 'converted.pfx',
|
||||
content,
|
||||
mimeType: FORMAT_MIME['pfx'],
|
||||
mimeType: FORMAT_MIME.pfx,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -676,7 +676,7 @@ export class CertManagerService {
|
||||
content,
|
||||
mimeType: FORMAT_MIME[targetFormat],
|
||||
};
|
||||
} catch (err) {
|
||||
} catch {
|
||||
this.logger.warn('convertCert: failed to serialize to target format');
|
||||
throw new BadRequestException(
|
||||
`Failed to convert certificate to ${targetFormat}: serialization error`,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';
|
||||
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
|
||||
|
||||
/** Current name of the platform-wide encryption key. */
|
||||
export const ENCRYPTION_KEY_ENV = 'TESSERA_ENCRYPTION_KEY';
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import { Controller, Get, Inject, Param, StreamableFile } from '@nestjs/common';
|
||||
import type { DesktopLatestResponse } from '@tessera/shared';
|
||||
import {
|
||||
BadRequestException,
|
||||
Controller,
|
||||
Get,
|
||||
Inject,
|
||||
Param,
|
||||
Query,
|
||||
Res,
|
||||
StreamableFile,
|
||||
} from '@nestjs/common';
|
||||
import type { DesktopLatestResponse, DesktopUpdateResponse } from '@tessera/shared';
|
||||
import type { Response } from 'express';
|
||||
import { Public } from '../auth/decorators/public.decorator';
|
||||
import { DesktopService } from './desktop.service';
|
||||
import { DesktopService, safeOrigin } from './desktop.service';
|
||||
|
||||
@Controller('desktop')
|
||||
export class DesktopController {
|
||||
@@ -22,6 +32,36 @@ export class DesktopController {
|
||||
return this.desktopService.getLatest();
|
||||
}
|
||||
|
||||
// Bewusst oeffentlich (D-10, wie latest/download): der Desktop-Client prueft
|
||||
// beim Start vor jeder Anmeldung. Statische Route VOR `download/:platform`
|
||||
// (Konvention Route-Order, auch wenn sich die beiden hier nicht
|
||||
// ueberschatten). 204 ist der vom Updater-Plugin definierte Status fuer
|
||||
// "kein Update"; `passthrough` + `res.status(204)` funktioniert, weil Nest
|
||||
// den Standardstatus VOR dem Handler setzt und die Antwort danach ohne
|
||||
// eigenen Statuscode schreibt -- der Handler-Status gewinnt. `current`
|
||||
// wird nicht deklariert, weil der Service es nicht auswertet.
|
||||
@Public()
|
||||
@Get('update')
|
||||
update(
|
||||
@Query('target') target: unknown,
|
||||
@Query('arch') arch: unknown,
|
||||
@Query('base') base: unknown,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): DesktopUpdateResponse | undefined {
|
||||
const origin = safeOrigin(base);
|
||||
if (!origin) {
|
||||
throw new BadRequestException(
|
||||
'base must be an http(s) origin without path, query or credentials',
|
||||
);
|
||||
}
|
||||
const result = this.desktopService.getUpdate({ target, arch, origin });
|
||||
if (!result) {
|
||||
res.status(204);
|
||||
return undefined;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Bewusst oeffentlich (D-10): der Download selbst braucht keine Anmeldung,
|
||||
// gleicher Grund wie getLatest oben.
|
||||
@Public()
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import 'reflect-metadata';
|
||||
import * as crypto from 'crypto';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import * as crypto from 'node:crypto';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { IS_PUBLIC_KEY } from '../auth/decorators/public.decorator';
|
||||
import { DesktopController } from './desktop.controller';
|
||||
import { DesktopModule } from './desktop.module';
|
||||
import { DesktopService } from './desktop.service';
|
||||
import { DesktopService, safeOrigin } from './desktop.service';
|
||||
|
||||
/**
|
||||
* DesktopService/DesktopController.spec — HTTP-Durchstich ueber
|
||||
@@ -34,7 +34,23 @@ const PACKAGE_NAME = 'test-package.bin';
|
||||
let packageSize: number;
|
||||
let packageSha256: string;
|
||||
|
||||
function writeManifest(files: Record<string, { name: string; size: number; sha256: string }>) {
|
||||
/** Origin des "eigenen" Servers, wie ihn der Desktop-Client als `base` mitschickt. */
|
||||
const ORIGIN = 'https://tessera.example.com';
|
||||
/** Beliebige Base64-Zeile -- die API reicht die Signatur nur durch, prueft sie nicht. */
|
||||
const SIG = 'dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZQo=';
|
||||
|
||||
type ManifestHead = {
|
||||
version?: string;
|
||||
channel?: string;
|
||||
commit?: string;
|
||||
buildTime?: string;
|
||||
updateVersion?: string;
|
||||
};
|
||||
|
||||
function writeManifest(
|
||||
files: Record<string, { name: string; size: number; sha256: string; signature?: string }>,
|
||||
head: ManifestHead = {},
|
||||
) {
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, 'manifest.json'),
|
||||
JSON.stringify({
|
||||
@@ -42,11 +58,29 @@ function writeManifest(files: Record<string, { name: string; size: number; sha25
|
||||
channel: 'dev',
|
||||
commit: 'abc1234',
|
||||
buildTime: '2026-09-16T00:00:00Z',
|
||||
...head,
|
||||
files,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** Standard-Eintrag fuer linux, optional signiert. */
|
||||
function linuxEntry(signature?: string) {
|
||||
return {
|
||||
linux: {
|
||||
name: PACKAGE_NAME,
|
||||
size: packageSize,
|
||||
sha256: packageSha256,
|
||||
...(signature === undefined ? {} : { signature }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function updateUrl(query: Record<string, string>) {
|
||||
const params = new URLSearchParams(query);
|
||||
return `${baseUrl}/desktop/update?${params.toString()}`;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tessera-desktop-'));
|
||||
const packageBytes = crypto.randomBytes(64 * 1024);
|
||||
@@ -134,7 +168,7 @@ describe('DesktopService/DesktopController — HTTP-Durchstich (Phase 18)', () =
|
||||
});
|
||||
|
||||
it('Test 5 (Whitelist vor Dateisystem): nicht existierendes Verzeichnis + mac wirft BadRequestException, nicht NotFoundException', () => {
|
||||
const missingDir = path.join(os.tmpdir(), 'tessera-desktop-does-not-exist-' + Date.now());
|
||||
const missingDir = path.join(os.tmpdir(), `tessera-desktop-does-not-exist-${Date.now()}`);
|
||||
const previous = process.env.DESKTOP_DIST_DIR;
|
||||
process.env.DESKTOP_DIST_DIR = missingDir;
|
||||
try {
|
||||
@@ -211,8 +245,139 @@ describe('DesktopService/DesktopController — HTTP-Durchstich (Phase 18)', () =
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('Test 11 (bewusst oeffentlich): getLatest und download tragen @Public()', () => {
|
||||
it('Test 11 (bewusst oeffentlich): getLatest, download und update tragen @Public()', () => {
|
||||
expect(Reflect.getMetadata(IS_PUBLIC_KEY, DesktopController.prototype.getLatest)).toBe(true);
|
||||
expect(Reflect.getMetadata(IS_PUBLIC_KEY, DesktopController.prototype.download)).toBe(true);
|
||||
expect(Reflect.getMetadata(IS_PUBLIC_KEY, DesktopController.prototype.update)).toBe(true);
|
||||
});
|
||||
|
||||
it('Test 12 (update, beta, signiert): 200 im dynamischen Updater-Format mit absoluter URL aus base', async () => {
|
||||
writeManifest(linuxEntry(SIG), { channel: 'beta', updateVersion: '1.1.0-beta.gabc1234' });
|
||||
const res = await fetch(
|
||||
updateUrl({ target: 'linux', arch: 'x86_64', current: '1.1.0', base: ORIGIN }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get('content-type')).toContain('application/json');
|
||||
expect(await res.json()).toEqual({
|
||||
version: '1.1.0-beta.gabc1234',
|
||||
pub_date: '2026-09-16T00:00:00Z',
|
||||
url: `${ORIGIN}/api-proxy/desktop/download/linux`,
|
||||
signature: SIG,
|
||||
notes: 'Tessera 1.1.0-beta.gabc1234',
|
||||
});
|
||||
});
|
||||
|
||||
it('Test 13 (update, live): version und notes tragen die reine X.Y.Z', async () => {
|
||||
writeManifest(linuxEntry(SIG), { channel: 'live', updateVersion: '1.1.0' });
|
||||
const res = await fetch(
|
||||
updateUrl({ target: 'linux', arch: 'x86_64', current: '1.0.0', base: ORIGIN }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.version).toBe('1.1.0');
|
||||
expect(body.notes).toBe('Tessera 1.1.0');
|
||||
});
|
||||
|
||||
it('Test 14 (base mit Schlussstrich): url wird aus dem Origin ohne Schlussstrich gebildet', async () => {
|
||||
writeManifest(linuxEntry(SIG), { channel: 'live', updateVersion: '1.1.0' });
|
||||
const res = await fetch(
|
||||
updateUrl({ target: 'linux', arch: 'x86_64', current: '1.0.0', base: `${ORIGIN}/` }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.url).toBe(`${ORIGIN}/api-proxy/desktop/download/linux`);
|
||||
});
|
||||
|
||||
it('Test 15 (ohne Signatur): Standard-Manifest -> 204 ohne Body', async () => {
|
||||
const res = await fetch(
|
||||
updateUrl({ target: 'linux', arch: 'x86_64', current: '1.0.0', base: ORIGIN }),
|
||||
);
|
||||
expect(res.status).toBe(204);
|
||||
expect(await res.text()).toBe('');
|
||||
});
|
||||
|
||||
it('Test 16 (updateVersion fehlt oder ungueltig trotz Signatur): 204', async () => {
|
||||
writeManifest(linuxEntry(SIG));
|
||||
const resMissing = await fetch(
|
||||
updateUrl({ target: 'linux', arch: 'x86_64', current: '1.0.0', base: ORIGIN }),
|
||||
);
|
||||
expect(resMissing.status).toBe(204);
|
||||
|
||||
writeManifest(linuxEntry(SIG), { channel: 'beta', updateVersion: '1.1.0-beta.abc1234' });
|
||||
const resInvalid = await fetch(
|
||||
updateUrl({ target: 'linux', arch: 'x86_64', current: '1.0.0', base: ORIGIN }),
|
||||
);
|
||||
expect(resInvalid.status).toBe(204);
|
||||
});
|
||||
|
||||
it('Test 17 (Plattform/Architektur): darwin, windows ohne Eintrag, aarch64 und fehlendes target -> 204', async () => {
|
||||
writeManifest(linuxEntry(SIG), { channel: 'live', updateVersion: '1.1.0' });
|
||||
const cases: Record<string, string>[] = [
|
||||
{ target: 'darwin', arch: 'x86_64', current: '1.0.0', base: ORIGIN },
|
||||
{ target: 'windows', arch: 'x86_64', current: '1.0.0', base: ORIGIN },
|
||||
{ target: 'linux', arch: 'aarch64', current: '1.0.0', base: ORIGIN },
|
||||
{ arch: 'x86_64', current: '1.0.0', base: ORIGIN },
|
||||
];
|
||||
for (const query of cases) {
|
||||
const res = await fetch(updateUrl(query));
|
||||
expect(res.status, JSON.stringify(query)).toBe(204);
|
||||
}
|
||||
});
|
||||
|
||||
it('Test 18 (base-Validierung, HTTP): fehlendes, fremdes oder unreines base -> 400', async () => {
|
||||
writeManifest(linuxEntry(SIG), { channel: 'live', updateVersion: '1.1.0' });
|
||||
const missing = await fetch(updateUrl({ target: 'linux', arch: 'x86_64', current: '1.0.0' }));
|
||||
expect(missing.status).toBe(400);
|
||||
const bad = [
|
||||
'ftp://host',
|
||||
'https://user:pw@host',
|
||||
'https://host/pfad',
|
||||
'https://host/?x=1',
|
||||
'https://host/#f',
|
||||
'kein url',
|
||||
];
|
||||
for (const base of bad) {
|
||||
const res = await fetch(
|
||||
updateUrl({ target: 'linux', arch: 'x86_64', current: '1.0.0', base }),
|
||||
);
|
||||
expect(res.status, base).toBe(400);
|
||||
}
|
||||
});
|
||||
|
||||
it('Test 19 (safeOrigin direkt): nur reine http(s)-Origins, kleingeschrieben, ohne Schlussstrich', () => {
|
||||
expect(safeOrigin('https://tessera.example.com')).toBe('https://tessera.example.com');
|
||||
expect(safeOrigin('http://localhost:3000/')).toBe('http://localhost:3000');
|
||||
expect(safeOrigin('HTTPS://Tessera.Example.com')).toBe('https://tessera.example.com');
|
||||
expect(safeOrigin(['https://a', 'https://b'])).toBeNull();
|
||||
expect(safeOrigin(undefined)).toBeNull();
|
||||
expect(safeOrigin('')).toBeNull();
|
||||
expect(safeOrigin('https://host/pfad')).toBeNull();
|
||||
expect(safeOrigin('javascript:alert(1)')).toBeNull();
|
||||
});
|
||||
|
||||
it('Test 20 (Manifest fehlt): update -> 204', async () => {
|
||||
fs.rmSync(path.join(tempDir, 'manifest.json'));
|
||||
const res = await fetch(
|
||||
updateUrl({ target: 'linux', arch: 'x86_64', current: '1.0.0', base: ORIGIN }),
|
||||
);
|
||||
expect(res.status).toBe(204);
|
||||
});
|
||||
|
||||
it('Test 21 (Manifest-Validierung): signature als Zahl macht den Eintrag ungueltig -- download/linux 404', async () => {
|
||||
// Am `writeManifest()`-Helper vorbei (dessen Typ verlangt einen String).
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, 'manifest.json'),
|
||||
JSON.stringify({
|
||||
version: '1.1.0',
|
||||
channel: 'dev',
|
||||
commit: 'abc1234',
|
||||
buildTime: '2026-09-16T00:00:00Z',
|
||||
files: {
|
||||
linux: { name: PACKAGE_NAME, size: packageSize, sha256: packageSha256, signature: 123 },
|
||||
},
|
||||
}),
|
||||
);
|
||||
const res = await fetch(`${baseUrl}/desktop/download/linux`);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,9 +4,10 @@ import type {
|
||||
DesktopManifest,
|
||||
DesktopManifestFile,
|
||||
DesktopPlatform,
|
||||
DesktopUpdateResponse,
|
||||
} from '@tessera/shared';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
/**
|
||||
* Wertevorrat der Plattformen (Phase 18, D-10). Geschlossen -- eine dritte
|
||||
@@ -35,10 +36,58 @@ function isValidManifestFileEntry(entry: unknown): entry is DesktopManifestFile
|
||||
typeof candidate.name === 'string' &&
|
||||
typeof candidate.size === 'number' &&
|
||||
typeof candidate.sha256 === 'string' &&
|
||||
SHA256_HEX_RE.test(candidate.sha256)
|
||||
SHA256_HEX_RE.test(candidate.sha256) &&
|
||||
(candidate.signature === undefined || typeof candidate.signature === 'string')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Form von `updateVersion` im Manifest: `X.Y.Z` (live/dev) oder
|
||||
* `X.Y.Z-beta.g<sha7>` (beta) -- exakt die Form, die desktop-collect.sh
|
||||
* schreibt und die der Client-Comparator (`beta_commit`) erwartet.
|
||||
*/
|
||||
const UPDATE_VERSION_RE = /^\d+\.\d+\.\d+(-beta\.g[0-9a-f]{7})?$/;
|
||||
|
||||
/**
|
||||
* RFC-3339-Zeitstempel. `pub_date` geht nur in die Antwort, wenn `buildTime`
|
||||
* diese Form hat -- ein unparsebares Datum liesse `check()` im Client
|
||||
* scheitern (das Plugin deserialisiert `pub_date` strikt).
|
||||
*/
|
||||
const RFC3339_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;
|
||||
|
||||
/**
|
||||
* Validiert den Query-Parameter `base` von `GET /desktop/update` (T-KGC-02):
|
||||
* nur ein reiner http/https-Origin -- kein Pfad ausser `/`, keine Query, kein
|
||||
* Fragment, keine Zugangsdaten. Rueckgabe ist `url.origin` (Host
|
||||
* kleingeschrieben, ohne Schlussstrich), sonst `null`.
|
||||
*
|
||||
* `base` wird NUR zum Bauen der Rueckgabe-URL fuer den Anfragenden verwendet,
|
||||
* nie serverseitig abgerufen (kein SSRF). Ein Angreifer koennte damit
|
||||
* hoechstens seinen eigenen Client auf einen fremden Download lenken -- den
|
||||
* die Signaturpruefung im Client ablehnt.
|
||||
*/
|
||||
export function safeOrigin(base: unknown): string | null {
|
||||
if (typeof base !== 'string' || base.length === 0 || base.length > 2048) {
|
||||
return null;
|
||||
}
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(base);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||
return null;
|
||||
}
|
||||
if (url.username !== '' || url.password !== '') {
|
||||
return null;
|
||||
}
|
||||
if (url.pathname !== '/' || url.search !== '' || url.hash !== '') {
|
||||
return null;
|
||||
}
|
||||
return url.origin;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class DesktopService {
|
||||
private readonly logger = new Logger(DesktopService.name);
|
||||
@@ -78,6 +127,10 @@ export class DesktopService {
|
||||
this.logger.warn(`manifest.json unter ${manifestPath} hat unerwartete Form`);
|
||||
return null;
|
||||
}
|
||||
if (parsed.updateVersion !== undefined && typeof parsed.updateVersion !== 'string') {
|
||||
this.logger.warn(`manifest.json unter ${manifestPath} hat ein ungueltiges updateVersion`);
|
||||
return null;
|
||||
}
|
||||
for (const platform of PLATFORMS) {
|
||||
const entry = parsed.files[platform];
|
||||
if (entry !== undefined && !isValidManifestFileEntry(entry)) {
|
||||
@@ -120,6 +173,67 @@ export class DesktopService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* `GET /desktop/update` (quick-260917-kgc): Antwort im dynamischen Format
|
||||
* von `tauri-plugin-updater` -- Pflichtfelder `version` (SemVer), `url`
|
||||
* (absolut) und `signature`; optional `pub_date` (RFC 3339) und `notes`.
|
||||
* `null` bedeutet "kein Update" und wird im Controller zu 204 ohne Body.
|
||||
*
|
||||
* `current` wird bewusst nicht ausgewertet: die Entscheidung "neuer?" trifft
|
||||
* der Comparator im Client, die API kennt den Client-Commit nicht. Die
|
||||
* absolute `url` entsteht aus dem validierten `origin` des Anfragenden
|
||||
* (`safeOrigin`), nie aus einem serverseitigen Abruf. `/desktop/latest`
|
||||
* bleibt fuer die Web-Oberflaeche mit relativen URLs.
|
||||
*/
|
||||
getUpdate(input: {
|
||||
target: unknown;
|
||||
arch: unknown;
|
||||
origin: string;
|
||||
}): DesktopUpdateResponse | null {
|
||||
// (1) Plattform per Whitelist -- vor jedem Dateisystemzugriff.
|
||||
if (!PLATFORMS.includes(input.target as DesktopPlatform)) {
|
||||
return null;
|
||||
}
|
||||
const platform = input.target as DesktopPlatform;
|
||||
|
||||
// (2) Es gibt nur x86_64-Pakete.
|
||||
if (input.arch !== 'x86_64') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// (3) Manifest holen.
|
||||
const manifest = this.getManifest();
|
||||
if (!manifest) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// (4) updateVersion muss vorhanden sein und die erwartete Form haben.
|
||||
if (
|
||||
typeof manifest.updateVersion !== 'string' ||
|
||||
!UPDATE_VERSION_RE.test(manifest.updateVersion)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// (5) Eintrag der Plattform mit Signatur -- ohne Signatur kein Update.
|
||||
const entry = manifest.files[platform];
|
||||
if (!entry || typeof entry.signature !== 'string' || entry.signature.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// (6) Antwort im Plugin-Format.
|
||||
const response: DesktopUpdateResponse = {
|
||||
version: manifest.updateVersion,
|
||||
url: `${input.origin}/api-proxy/desktop/download/${platform}`,
|
||||
signature: entry.signature,
|
||||
notes: `Tessera ${manifest.updateVersion}`,
|
||||
};
|
||||
if (typeof manifest.buildTime === 'string' && RFC3339_RE.test(manifest.buildTime)) {
|
||||
response.pub_date = manifest.buildTime;
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* `GET /desktop/download/:platform` (D-10, T-18-01): Reihenfolge ist die
|
||||
* Sicherheitseigenschaft -- Whitelist VOR jedem Dateisystemzugriff, der
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as XLSX from 'xlsx';
|
||||
import { ExportRow } from './dkv.types';
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ export class DkvParserService {
|
||||
let text: string;
|
||||
try {
|
||||
text = await this.extractText(buffer);
|
||||
} catch (err) {
|
||||
} catch {
|
||||
this.logger.error('DKV PDF text extraction failed');
|
||||
throw new Error('DKV invoice parsing failed — text extraction error');
|
||||
}
|
||||
@@ -98,7 +98,7 @@ export class DkvParserService {
|
||||
// Anchor on VEHICLE: marker — each block extends until next VEHICLE: or end
|
||||
// Kennzeichen format: "GP-JL 728E", "GP ML 720", etc.
|
||||
const vehicleBlockPattern =
|
||||
/VEHICLE:\s+([A-Z0-9 ._\-]+?)\s+CARD NO\.:\s+(\S+)([\s\S]*?)(?=VEHICLE:|$)/g;
|
||||
/VEHICLE:\s+([A-Z0-9 ._-]+?)\s+CARD NO\.:\s+(\S+)([\s\S]*?)(?=VEHICLE:|$)/g;
|
||||
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = vehicleBlockPattern.exec(text)) !== null) {
|
||||
|
||||
@@ -133,7 +133,7 @@ function parseDkvText(text: string): DkvVehicleBlock[] {
|
||||
// Anchor on VEHICLE: marker — each block extends until next VEHICLE: or end of text
|
||||
// Kennzeichen can contain letters, digits, hyphens, spaces, dots (e.g. "GP-JL 728E")
|
||||
const vehicleBlockPattern =
|
||||
/VEHICLE:\s+([A-Z0-9 ._\-]+?)\s+CARD NO\.:\s+(\S+)([\s\S]*?)(?=VEHICLE:|$)/g;
|
||||
/VEHICLE:\s+([A-Z0-9 ._-]+?)\s+CARD NO\.:\s+(\S+)([\s\S]*?)(?=VEHICLE:|$)/g;
|
||||
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = vehicleBlockPattern.exec(text)) !== null) {
|
||||
|
||||
@@ -219,7 +219,7 @@ export class DkvController {
|
||||
) {
|
||||
const tenantId = this._requireTenant(req);
|
||||
|
||||
if (!file || !file.buffer) {
|
||||
if (!file?.buffer) {
|
||||
throw new BadRequestException('No CSV file uploaded (field name must be "file")');
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as fs from 'fs';
|
||||
import * as fs from 'node:fs';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { DkvService } from './dkv.service';
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@ import {
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { CryptoService } from '../crypto/crypto.service';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { forSystem, forTenant } from '../prisma/prisma-tenant.extension';
|
||||
@@ -513,7 +513,7 @@ export class DkvService {
|
||||
smtpError = (err as Error).message;
|
||||
this.logger.warn(`DKV SMTP send attempt ${attempt}/3 failed: ${smtpError}`);
|
||||
if (attempt < 3) {
|
||||
await _delay(Math.pow(2, attempt) * 1000); // 2s, 4s
|
||||
await _delay(2 ** attempt * 1000); // 2s, 4s
|
||||
} else {
|
||||
smtpStatus = 'Versand fehlgeschlagen';
|
||||
// D-16: file stays locally available for manual download
|
||||
@@ -707,7 +707,7 @@ export class DkvService {
|
||||
filename.includes('/') ||
|
||||
filename.includes('\\') ||
|
||||
filename.includes('..') ||
|
||||
!/^(RG-DKV-|DKV_)[\w\-]+\.xlsx$/.test(filename)
|
||||
!/^(RG-DKV-|DKV_)[\w-]+\.xlsx$/.test(filename)
|
||||
) {
|
||||
throw new BadRequestException('Invalid export filename');
|
||||
}
|
||||
@@ -806,9 +806,9 @@ export class DkvService {
|
||||
): string {
|
||||
if (fromPdf) return fromPdf.replace(/\//g, '-');
|
||||
// DKV email subjects carry the invoice number with slashes: "26/650869002/002"
|
||||
const m = subject?.match(/(\d{2}[\/\-]\d{9}[\/\-]\d{3})/);
|
||||
const m = subject?.match(/(\d{2}[/-]\d{9}[/-]\d{3})/);
|
||||
if (m?.[1]) return m[1].replace(/\//g, '-');
|
||||
return `email-${String(uid).replace(/[^a-zA-Z0-9\-]/g, '_')}`;
|
||||
return `email-${String(uid).replace(/[^a-zA-Z0-9-]/g, '_')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -880,5 +880,5 @@ function _formatDateYYMMDD(date: string): string {
|
||||
* Example: "GP JL 740E" == "GP-JL 740E" == "GPJL740E" after normalization.
|
||||
*/
|
||||
function _normalizeKennzeichen(k: string): string {
|
||||
return k.toUpperCase().replace(/[\s\-\.]/g, '');
|
||||
return k.toUpperCase().replace(/[\s\-.]/g, '');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
ArrayMinSize,
|
||||
ArrayUnique,
|
||||
IsArray,
|
||||
IsUUID,
|
||||
} from 'class-validator';
|
||||
|
||||
/**
|
||||
* DTO for `PUT /favorites/order` (260917-jdd).
|
||||
*
|
||||
* `ids` is the FULL id list of a widget's favorites, in the desired display
|
||||
* order — the service requires an exact match against the caller's existing
|
||||
* favorites for this widget (no partial reorder, no foreign/unknown ids).
|
||||
* `ArrayMaxSize(500)` is a DoS cap for the `updateMany` loop inside the
|
||||
* transaction (T-JDD-05); a widget has a handful of links in practice.
|
||||
*/
|
||||
export class ReorderFavoritesDto {
|
||||
@IsUUID()
|
||||
widgetId!: string;
|
||||
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ArrayMaxSize(500)
|
||||
@ArrayUnique()
|
||||
@IsUUID('all', { each: true })
|
||||
ids!: string[];
|
||||
}
|
||||
@@ -8,12 +8,14 @@ import {
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
Req,
|
||||
Res,
|
||||
} from '@nestjs/common';
|
||||
import { Request, Response } from 'express';
|
||||
import { CreateFavoriteDto } from './dto/create-favorite.dto';
|
||||
import { ReorderFavoritesDto } from './dto/reorder-favorites.dto';
|
||||
import { UpdateFavoriteDto } from './dto/update-favorite.dto';
|
||||
import { FavoritesService } from './favorites.service';
|
||||
|
||||
@@ -35,6 +37,8 @@ import { FavoritesService } from './favorites.service';
|
||||
* Routes:
|
||||
* - GET /favorites?widgetId= — list favorites for a widget instance
|
||||
* - POST /favorites — create a favorite (triggers server-side icon discovery)
|
||||
* - PUT /favorites/order — reorder favorites for a widget instance (260917-jdd)
|
||||
* - GET /favorites/:id/icon — stream a favorite's stored icon bytes
|
||||
* - PATCH /favorites/:id — update a favorite (ownership verified in service)
|
||||
* - DELETE /favorites/:id — delete a favorite (ownership verified in service)
|
||||
*/
|
||||
@@ -77,6 +81,23 @@ export class FavoritesController {
|
||||
return this.favoritesService.create(tenantId, userId, dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /favorites/order — persists the display order for a widget's
|
||||
* favorites (260917-jdd). Declared BEFORE the `:id` routes below on
|
||||
* purpose (NestJS route order — a later `:id` route would otherwise
|
||||
* shadow the literal segment "order"; precedent tenders.controller.ts
|
||||
* Z. 636-648).
|
||||
*/
|
||||
@Put('order')
|
||||
async reorder(
|
||||
@Body() dto: ReorderFavoritesDto,
|
||||
@Req() req: Request,
|
||||
) {
|
||||
const { userId, tenantId } = this.extractContext(req);
|
||||
|
||||
return this.favoritesService.reorder(tenantId, userId, dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /favorites/:id/icon — streams the stored icon bytes for a favorite
|
||||
* owned by the caller, from Tessera's own origin. This avoids the browser
|
||||
@@ -101,6 +122,12 @@ export class FavoritesController {
|
||||
|
||||
res.setHeader('Content-Type', contentType);
|
||||
res.setHeader('Cache-Control', 'public, max-age=86400');
|
||||
// 260917-jdd: die Bytes kommen jetzt auch von Hosts ohne gueltiges
|
||||
// Zertifikat. Als <img>-Unterressource ignoriert der Browser diese
|
||||
// Header, aber ein direkt im Tab geoeffnetes SVG laeuft damit ohne
|
||||
// Skript und ohne Tessera-Origin (T-JDD-02).
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
res.setHeader('Content-Security-Policy', "default-src 'none'; sandbox");
|
||||
res.send(body);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { BadRequestException, HttpException, NotFoundException } from '@nestjs/common';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { FavoritesService } from './favorites.service';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { forTenant, withTenantTransaction } from '../prisma/prisma-tenant.extension';
|
||||
|
||||
/**
|
||||
* FavoritesService.spec — NEU (260911-gwh). Der Bereich `favorites` hatte
|
||||
@@ -14,9 +14,18 @@ import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
* Modellzugriff scheitert mit "Cannot read properties of undefined" (die
|
||||
* dkv-Form der Falsifizierung, siehe auth.service.spec.ts:280). Der
|
||||
* GEBUNDENE Klient hat ausschliesslich `favoriteLink`/`widgetInstance`.
|
||||
*
|
||||
* 260917-jdd: `withTenantTransaction` kommt zum Mock hinzu (Muster
|
||||
* groups.service.spec.ts Z. 30-35/296-299) — `prisma.__withTenantTransaction`
|
||||
* reicht den gebundenen Klienten als `tx` durch und protokolliert den
|
||||
* Aufruf. Der Fake bekommt zusaetzlich `updateMany` auf `favoriteLink` fuer
|
||||
* `reorder()`.
|
||||
*/
|
||||
vi.mock('../prisma/prisma-tenant.extension', () => ({
|
||||
forTenant: vi.fn((unboundClient: any, tenantId: string) => unboundClient.__makeBoundClient(tenantId)),
|
||||
withTenantTransaction: vi.fn((unboundClient: any, tenantId: string, fn: (tx: any) => any) =>
|
||||
unboundClient.__withTenantTransaction(tenantId, fn),
|
||||
),
|
||||
}));
|
||||
|
||||
interface FakeFavoriteRow {
|
||||
@@ -40,7 +49,7 @@ interface FakeWidgetRow {
|
||||
|
||||
interface BoundCall {
|
||||
tenantId: string;
|
||||
model: 'favoriteLink' | 'widgetInstance';
|
||||
model: 'favoriteLink' | 'widgetInstance' | '$transaction';
|
||||
method: string;
|
||||
}
|
||||
|
||||
@@ -117,6 +126,20 @@ function makeFakePrisma(favoriteRows: FakeFavoriteRow[] = [], widgetRows: FakeWi
|
||||
favorites.delete(where.id);
|
||||
return row;
|
||||
},
|
||||
// 260917-jdd: reorder() — filtert nach tenantId sowie, falls in
|
||||
// `where` vorhanden, id/userId/widgetId; wendet `data` auf jede
|
||||
// Treffer-Zeile an; liefert { count }.
|
||||
updateMany: async ({ where, data }: any) => {
|
||||
boundCallLog.push({ tenantId, model: 'favoriteLink', method: 'updateMany' });
|
||||
let rows = Array.from(favorites.values()).filter((f) => f.tenantId === tenantId);
|
||||
if (where?.id) rows = rows.filter((f) => f.id === where.id);
|
||||
if (where?.userId) rows = rows.filter((f) => f.userId === where.userId);
|
||||
if (where?.widgetId) rows = rows.filter((f) => f.widgetId === where.widgetId);
|
||||
for (const row of rows) {
|
||||
favorites.set(row.id, { ...row, ...data, updatedAt: new Date() });
|
||||
}
|
||||
return { count: rows.length };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -146,6 +169,10 @@ function makeFakePrisma(favoriteRows: FakeFavoriteRow[] = [], widgetRows: FakeWi
|
||||
widgetInstance: makeScopedWidgetInstance(tenantId),
|
||||
};
|
||||
},
|
||||
__withTenantTransaction(tenantId: string, fn: (tx: any) => any) {
|
||||
boundCallLog.push({ tenantId, model: '$transaction', method: 'withTenantTransaction' });
|
||||
return fn(fake.__makeBoundClient(tenantId));
|
||||
},
|
||||
};
|
||||
return fake;
|
||||
}
|
||||
@@ -505,4 +532,97 @@ describe('FavoritesService — Bindung an forTenant() (260911-gwh)', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('reorder (260917-jdd)', () => {
|
||||
const makeAltbestand = () =>
|
||||
makeFakePrisma([
|
||||
{ id: 'f1', userId: 'user-a1', tenantId: 't1', widgetId: 'widget-a1', title: 'A', url: 'https://a.invalid', iconUrl: null, position: 0 },
|
||||
{ id: 'f2', userId: 'user-a1', tenantId: 't1', widgetId: 'widget-a1', title: 'B', url: 'https://b.invalid', iconUrl: null, position: 0 },
|
||||
{ id: 'f3', userId: 'user-a1', tenantId: 't1', widgetId: 'widget-a1', title: 'C', url: 'https://c.invalid', iconUrl: null, position: 0 },
|
||||
{ id: 'f9', userId: 'user-a2', tenantId: 't1', widgetId: 'widget-a1', title: 'D', url: 'https://d.invalid', iconUrl: null, position: 0 },
|
||||
]);
|
||||
|
||||
it('setzt position 0/1/2 in der uebergebenen Reihenfolge und liefert die Liste so sortiert', async () => {
|
||||
const prisma = makeAltbestand();
|
||||
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||
|
||||
const result = await service.reorder('t1', 'user-a1', {
|
||||
widgetId: 'widget-a1',
|
||||
ids: ['f3', 'f1', 'f2'],
|
||||
} as any);
|
||||
|
||||
expect(result.map((r: any) => r.id)).toEqual(['f3', 'f1', 'f2']);
|
||||
expect(prisma.__favorites.get('f3').position).toBe(0);
|
||||
expect(prisma.__favorites.get('f1').position).toBe(1);
|
||||
expect(prisma.__favorites.get('f2').position).toBe(2);
|
||||
expect(prisma.__favorites.get('f9').position).toBe(0);
|
||||
expect(vi.mocked(withTenantTransaction)).toHaveBeenCalledWith(prisma, 't1', expect.any(Function));
|
||||
expectBoundCall(prisma, 't1', 'favoriteLink', 'updateMany');
|
||||
});
|
||||
|
||||
it('fremde id (user-a2) -> BadRequestException, KEINE Position geaendert', async () => {
|
||||
const prisma = makeAltbestand();
|
||||
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||
|
||||
await expect(
|
||||
service.reorder('t1', 'user-a1', { widgetId: 'widget-a1', ids: ['f3', 'f1', 'f9'] } as any),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
expect(prisma.__favorites.get('f1').position).toBe(0);
|
||||
expect(prisma.__favorites.get('f2').position).toBe(0);
|
||||
expect(prisma.__favorites.get('f3').position).toBe(0);
|
||||
});
|
||||
|
||||
it('unbekannte id -> BadRequestException', async () => {
|
||||
const prisma = makeAltbestand();
|
||||
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||
|
||||
await expect(
|
||||
service.reorder('t1', 'user-a1', { widgetId: 'widget-a1', ids: ['f3', 'f1', 'f-fehlt'] } as any),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('Teilmenge (2 von 3) -> BadRequestException', async () => {
|
||||
const prisma = makeAltbestand();
|
||||
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||
|
||||
await expect(
|
||||
service.reorder('t1', 'user-a1', { widgetId: 'widget-a1', ids: ['f1', 'f2'] } as any),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('doppelte ids -> BadRequestException OHNE withTenantTransaction-Aufruf', async () => {
|
||||
const prisma = makeAltbestand();
|
||||
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||
|
||||
vi.mocked(withTenantTransaction).mockClear();
|
||||
await expect(
|
||||
service.reorder('t1', 'user-a1', { widgetId: 'widget-a1', ids: ['f1', 'f1', 'f2'] } as any),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
expect(vi.mocked(withTenantTransaction).mock.calls.length).toBe(0);
|
||||
});
|
||||
|
||||
it('fremder Mandant (t2 auf t1-Zeilen) -> BadRequestException, Positionen unveraendert', async () => {
|
||||
const prisma = makeAltbestand();
|
||||
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||
|
||||
await expect(
|
||||
service.reorder('t2', 'user-a1', { widgetId: 'widget-a1', ids: ['f1', 'f2', 'f3'] } as any),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
expect(prisma.__favorites.get('f1').position).toBe(0);
|
||||
expect(prisma.__favorites.get('f2').position).toBe(0);
|
||||
expect(prisma.__favorites.get('f3').position).toBe(0);
|
||||
});
|
||||
|
||||
it('Wachhund: 0 forTenant-Aufrufe, genau 1 withTenantTransaction-Aufruf fuer den Happy Path', async () => {
|
||||
const prisma = makeAltbestand();
|
||||
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||
|
||||
vi.mocked(forTenant).mockClear();
|
||||
vi.mocked(withTenantTransaction).mockClear();
|
||||
await service.reorder('t1', 'user-a1', { widgetId: 'widget-a1', ids: ['f3', 'f1', 'f2'] } as any);
|
||||
|
||||
expect(vi.mocked(forTenant).mock.calls.length).toBe(0);
|
||||
expect(vi.mocked(withTenantTransaction).mock.calls.length).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,8 +6,9 @@ import {
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { forTenant, withTenantTransaction } from '../prisma/prisma-tenant.extension';
|
||||
import { CreateFavoriteDto } from './dto/create-favorite.dto';
|
||||
import { ReorderFavoritesDto } from './dto/reorder-favorites.dto';
|
||||
import { UpdateFavoriteDto } from './dto/update-favorite.dto';
|
||||
import { IconDiscoveryService, normalizeUrl } from './icon-discovery.service';
|
||||
|
||||
@@ -37,6 +38,8 @@ import { IconDiscoveryService, normalizeUrl } from './icon-discovery.service';
|
||||
* - Every query is scoped by userId (prevents cross-user access).
|
||||
* - list() additionally scopes by widgetId so each widget instance has its own set.
|
||||
* - update() and remove() verify userId ownership before mutating.
|
||||
* - reorder() runs as one withTenantTransaction() (T-JDD-03) and scopes
|
||||
* every updateMany by userId AND widgetId (see reorder() doc below).
|
||||
*
|
||||
* `create()` prueft zusaetzlich, dass das Ziel-Widget dem Aufrufer gehoert
|
||||
* (T-GWH-05): der Fremdschluessel `FavoriteLink.widgetId` prueft an der
|
||||
@@ -171,6 +174,74 @@ export class FavoritesService {
|
||||
await tenantPrisma.favoriteLink.delete({ where: { id } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists the display order of a user's favorites for one widget
|
||||
* instance (260917-jdd, PUT /favorites/order).
|
||||
*
|
||||
* Laeuft als EINE Transaktion ueber `withTenantTransaction()` — die
|
||||
* einzige gemessene atomare Form fuer einen Mehrschritt-Zugriff
|
||||
* (prisma-tenant.extension.ts Z. 33-49/104-109); die Array-Form von
|
||||
* `$transaction` auf einem mit `forTenant()` gebundenen Klienten ist
|
||||
* gemessen NICHT atomar, die interaktive Form auf dem gebundenen Klienten
|
||||
* faellt unter Last aus (siehe dortige Messung). `withTenantTransaction()`
|
||||
* setzt KEINE Benutzerdimension in der Sitzung (nur `app.current_tenant`)
|
||||
* — die Regel auf `FavoriteLink` faellt deshalb in ihren `IS NULL`-Zweig
|
||||
* und zeigt den ganzen Mandanten. Darum traegt JEDE Bedingung unten
|
||||
* `userId` UND `widgetId` selbst (zweites Netz, wie der Kopfkommentar
|
||||
* dieser Klasse es fuer alle Methoden vorsieht).
|
||||
*
|
||||
* `updateMany` statt `update({ where: { id } })`, weil `update` nur nach
|
||||
* `id` filtern koennte — der Ownership-Check muesste dann als separater
|
||||
* Lese-Schritt VOR dem Schreiben stehen, mit derselben TOCTOU-Luecke wie
|
||||
* ein fehlendes zweites Netz. `updateMany` traegt die Bedingung direkt in
|
||||
* der Schreiboperation und liefert `count`, das sofort geprueft wird.
|
||||
*
|
||||
* Existenzorakel-Vermeidung (T-JDD-06): EINE BadRequestException mit
|
||||
* DERSELBEN Meldung fuer fremde id, unbekannte id, Teilmenge sowie
|
||||
* fremdes/unbekanntes Widget oder Mandant — kein Fall verraet, welcher
|
||||
* Grund zutraf (Muster T-GWH-05).
|
||||
*
|
||||
* Altbestand: alle Zeilen mit `position = 0` (vor diesem Plan gab es
|
||||
* keine Sortierung) normalisiert sich beim ERSTEN Aufruf zu `0..n-1` —
|
||||
* kein Migrations- oder Sonderpfad noetig.
|
||||
*/
|
||||
async reorder(tenantId: string, userId: string, dto: ReorderFavoritesDto) {
|
||||
if (new Set(dto.ids).size !== dto.ids.length) {
|
||||
throw new BadRequestException('ids must match the favorites of this widget exactly');
|
||||
}
|
||||
|
||||
return withTenantTransaction(this.prisma, tenantId, async (tx: any) => {
|
||||
const existing = await tx.favoriteLink.findMany({
|
||||
where: { userId, widgetId: dto.widgetId },
|
||||
select: { id: true },
|
||||
});
|
||||
const existingIds = new Set(existing.map((r: { id: string }) => r.id));
|
||||
|
||||
if (
|
||||
existing.length !== dto.ids.length ||
|
||||
dto.ids.some((id) => !existingIds.has(id))
|
||||
) {
|
||||
throw new BadRequestException('ids must match the favorites of this widget exactly');
|
||||
}
|
||||
|
||||
for (const [index, id] of dto.ids.entries()) {
|
||||
const { count } = await tx.favoriteLink.updateMany({
|
||||
where: { id, userId, widgetId: dto.widgetId },
|
||||
data: { position: index },
|
||||
});
|
||||
|
||||
if (count !== 1) {
|
||||
throw new BadRequestException('ids must match the favorites of this widget exactly');
|
||||
}
|
||||
}
|
||||
|
||||
return tx.favoriteLink.findMany({
|
||||
where: { userId, widgetId: dto.widgetId },
|
||||
orderBy: [{ position: 'asc' }, { title: 'asc' }],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the raw bytes of a favorite's stored icon, scoped to the
|
||||
* requesting user (T-08-06 — same ownership check as update/remove).
|
||||
|
||||
@@ -1,4 +1,22 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
/**
|
||||
* 260917-jdd — `undici` wird gemockt, damit KEIN Test tatsaechlich ins Netz
|
||||
* geht: die produktive Datei ruft ab jetzt `undiciFetch` statt des globalen
|
||||
* `fetch` auf, mit einem Modul-Singleton-`Agent` als `dispatcher`. Die
|
||||
* Mock-Klasse zeichnet nur die uebergebenen `options` auf; `fetch` delegiert
|
||||
* ZUR LAUFZEIT (Pfeilfunktion, nicht beim Laden aufgeloest) an
|
||||
* `globalThis.fetch`, damit alle bestehenden `vi.stubGlobal('fetch', …)`-
|
||||
* Tests wortgleich gruen bleiben.
|
||||
*/
|
||||
vi.mock('undici', () => ({
|
||||
Agent: class Agent {
|
||||
constructor(public readonly options: unknown) {}
|
||||
},
|
||||
fetch: (...args: unknown[]) => (globalThis.fetch as any)(...args),
|
||||
}));
|
||||
|
||||
import { Agent } from 'undici';
|
||||
import {
|
||||
IconDiscoveryService,
|
||||
isPublicHttpUrl,
|
||||
@@ -203,3 +221,69 @@ describe('IconDiscoveryService.discoverFavoriteIconUrl (unchanged behaviour)', (
|
||||
expect(typeof result).toBe('string');
|
||||
});
|
||||
});
|
||||
|
||||
describe('IconDiscoveryService — Dispatcher (260917-jdd)', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('discoverFavoriteIconUrl uebergibt den tolerante-TLS-Agent als dispatcher und redirect: manual', async () => {
|
||||
const html = '<html><head><link rel="icon" href="https://ctl.de/fav.png" /></head></html>';
|
||||
const fetchSpy = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: {
|
||||
get: (n: string) =>
|
||||
n.toLowerCase() === 'content-type' ? 'text/html; charset=utf-8' : null,
|
||||
},
|
||||
text: async () => html,
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchSpy);
|
||||
|
||||
const service = new IconDiscoveryService();
|
||||
await service.discoverFavoriteIconUrl('http://8.8.8.8');
|
||||
|
||||
const init = fetchSpy.mock.calls[0][1];
|
||||
expect(init.dispatcher).toBeInstanceOf(Agent);
|
||||
expect(init.dispatcher.options).toEqual({ connect: { rejectUnauthorized: false } });
|
||||
expect(init.redirect).toBe('manual');
|
||||
});
|
||||
|
||||
it('fetchIconBytes uebergibt denselben tolerante-TLS-Agent als dispatcher und redirect: manual', async () => {
|
||||
const fetchSpy = vi.fn().mockResolvedValue(mockResponse({ contentType: 'image/png' }));
|
||||
vi.stubGlobal('fetch', fetchSpy);
|
||||
|
||||
const service = new IconDiscoveryService();
|
||||
await service.fetchIconBytes('http://8.8.8.8/favicon.ico');
|
||||
|
||||
const init = fetchSpy.mock.calls[0][1];
|
||||
expect(init.dispatcher).toBeInstanceOf(Agent);
|
||||
expect(init.dispatcher.options).toEqual({ connect: { rejectUnauthorized: false } });
|
||||
expect(init.redirect).toBe('manual');
|
||||
});
|
||||
|
||||
it('Discovery und fetchIconBytes teilen DENSELBEN Agent (Modul-Singleton)', async () => {
|
||||
const html = '<html><head></head></html>';
|
||||
const fetchSpy = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: {
|
||||
get: (n: string) =>
|
||||
n.toLowerCase() === 'content-type' ? 'text/html; charset=utf-8' : null,
|
||||
},
|
||||
text: async () => html,
|
||||
})
|
||||
.mockResolvedValueOnce(mockResponse({ contentType: 'image/png' }));
|
||||
vi.stubGlobal('fetch', fetchSpy);
|
||||
|
||||
const service = new IconDiscoveryService();
|
||||
await service.discoverFavoriteIconUrl('http://8.8.8.8');
|
||||
await service.fetchIconBytes('http://8.8.8.8/favicon.ico');
|
||||
|
||||
const calls = fetchSpy.mock.calls;
|
||||
expect(calls[0][1].dispatcher).toBe(calls[1][1].dispatcher);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { lookup } from 'dns/promises';
|
||||
import { isIP } from 'net';
|
||||
import { lookup } from 'node:dns/promises';
|
||||
import { isIP } from 'node:net';
|
||||
import { Agent, fetch as undiciFetch, type Response as UndiciResponse } from 'undici';
|
||||
|
||||
/**
|
||||
* Server-side favicon / icon discovery with SSRF protection (T-08-05).
|
||||
@@ -12,6 +13,9 @@ import { isIP } from 'net';
|
||||
* - 4000 ms AbortController timeout per request
|
||||
* - 200 000 character HTML cap to prevent memory exhaustion (T-08-09)
|
||||
* - Blocked hostnames: localhost, .local, 0.0.0.0
|
||||
* - 260917-jdd: Zertifikatsfehler des Zielhosts werden toleriert (siehe
|
||||
* LENIENT_TLS_AGENT unten) — DNS-Pruefung, Redirect-Limit, Timeout und
|
||||
* Groessendeckel bleiben davon unberuehrt.
|
||||
*/
|
||||
|
||||
const FALLBACK_ICON_PATH = '/favicon.ico';
|
||||
@@ -21,6 +25,27 @@ const MAX_REDIRECTS = 2;
|
||||
const MAX_HTML_CHARS = 200000;
|
||||
const MAX_ICON_BYTES = 1_000_000;
|
||||
|
||||
/**
|
||||
* 260917-jdd — Ziel ist ein Bildchen, kein Geheimnis: selbstsignierte,
|
||||
* abgelaufene oder falsch benannte Zertifikate sollen das Symbol eines
|
||||
* Favoriten nicht verhindern. Dieser Dispatcher gilt AUSSCHLIESSLICH fuer
|
||||
* die beiden Aufrufe in dieser Datei (Dispatcher pro Aufruf, keine
|
||||
* prozessweite Abschaltung der Zertifikatspruefung — insbesondere NICHT
|
||||
* ueber die Node-Umgebungsvariable, die mit NODE_TLS_ beginnt).
|
||||
*
|
||||
* Der Dispatcher wirkt nur zusammen mit undicis EIGENEM `fetch` — Nodes
|
||||
* globales `fetch` ignoriert einen Agent aus dem npm-Paket (andere Klasse,
|
||||
* Node 24 buendelt intern undici 7.25.0). Gemessen 2026-09-17 gegen
|
||||
* self-signed.badssl.com: `undiciFetch(url, { dispatcher: new Agent(...) })`
|
||||
* -> Status 200; `globalThis.fetch` derselben URL -> DEPTH_ZERO_SELF_SIGNED_CERT.
|
||||
* Deshalb der Modulimport oben statt des globalen `fetch`.
|
||||
*
|
||||
* DNS-Pruefung (isPublicHttpUrl), Redirect-Limit (MAX_REDIRECTS), Timeout
|
||||
* und Groessendeckel (MAX_ICON_BYTES/MAX_HTML_CHARS) bleiben davon
|
||||
* unberuehrt (T-JDD-01).
|
||||
*/
|
||||
const LENIENT_TLS_AGENT = new Agent({ connect: { rejectUnauthorized: false } });
|
||||
|
||||
type FetchHtmlResult = {
|
||||
html: string;
|
||||
finalUrl: string;
|
||||
@@ -243,7 +268,7 @@ function extractIconFromHtml(html: string, baseUrl: string): string | null {
|
||||
async function fetchWithRedirectGuard(
|
||||
pageUrl: URL,
|
||||
options: { accept: string; timeoutMs: number; userAgent?: string },
|
||||
): Promise<{ response: Response; finalUrl: URL } | null> {
|
||||
): Promise<{ response: UndiciResponse; finalUrl: URL } | null> {
|
||||
let currentUrl = pageUrl;
|
||||
|
||||
for (let redirectCount = 0; redirectCount <= MAX_REDIRECTS; redirectCount++) {
|
||||
@@ -255,7 +280,8 @@ async function fetchWithRedirectGuard(
|
||||
const timeout = setTimeout(() => controller.abort(), options.timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetch(currentUrl.toString(), {
|
||||
const response = await undiciFetch(currentUrl.toString(), {
|
||||
dispatcher: LENIENT_TLS_AGENT, // 260917-jdd: siehe Kommentar an der Konstante
|
||||
redirect: 'manual', // SSRF: follow manually so each hop is re-validated
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
|
||||
@@ -1044,7 +1044,7 @@ describe('GroupsService — Bindung an forTenant()/withTenantTransaction() (2609
|
||||
it('reassignDefaultBeforeDelete() bindet die drei Lesezugriffe UND die Transaktion an denselben Mandanten', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new GroupsService(prisma as any);
|
||||
const def = await service.create('t1', { name: DEFAULT_GROUP_NAME });
|
||||
await service.create('t1', { name: DEFAULT_GROUP_NAME });
|
||||
const toDelete = await service.create('t1', { name: 'Zu loeschen' });
|
||||
await service.update('t1', toDelete.id, { isDefault: true });
|
||||
|
||||
|
||||
@@ -440,7 +440,7 @@ export class GroupsService {
|
||||
const group = await tenantPrisma.group.findFirst({
|
||||
where: { id: groupId, tenantId },
|
||||
});
|
||||
if (!group || !group.isDefault) {
|
||||
if (!group?.isDefault) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ vi.mock('imapflow', () => ({
|
||||
}));
|
||||
|
||||
function makeReadable(text: string): NodeJS.ReadableStream {
|
||||
const { Readable } = require('stream') as typeof import('stream');
|
||||
const { Readable } = require('node:stream') as typeof import('stream');
|
||||
return Readable.from([Buffer.from(text, 'utf8')]);
|
||||
}
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ describe('LdapConfigService — Bind-Passwort verschluesselt at rest', () => {
|
||||
|
||||
const written = prisma.ldapConfig.create.mock.calls[0][0].data;
|
||||
expect(written.encryptedBindPassword).toBe(
|
||||
'aa11:bb22:' + Buffer.from('geheim').toString('hex'),
|
||||
`aa11:bb22:${Buffer.from('geheim').toString('hex')}`,
|
||||
);
|
||||
expect(JSON.stringify(written)).not.toContain('geheim');
|
||||
// Die alte Klartext-Spalte darf nicht wieder auftauchen.
|
||||
@@ -95,7 +95,7 @@ describe('LdapConfigService — Bind-Passwort verschluesselt at rest', () => {
|
||||
await service.updateConfig('t1', { bindPassword: 'neu' } as any);
|
||||
const first = prisma.ldapConfig.update.mock.calls[0][0].data;
|
||||
expect(first.encryptedBindPassword).toBe(
|
||||
'aa11:bb22:' + Buffer.from('neu').toString('hex'),
|
||||
`aa11:bb22:${Buffer.from('neu').toString('hex')}`,
|
||||
);
|
||||
|
||||
await service.updateConfig('t1', { serverUrl: 'ldap://anders' } as any);
|
||||
@@ -113,7 +113,7 @@ describe('LdapConfigService — Bind-Passwort verschluesselt at rest', () => {
|
||||
it('gibt Aufrufern weiterhin ein entschluesseltes bindPassword', async () => {
|
||||
prisma.ldapConfig.findUnique.mockResolvedValue({
|
||||
...CONFIG_ROW,
|
||||
encryptedBindPassword: 'aa11:bb22:' + Buffer.from('geheim').toString('hex'),
|
||||
encryptedBindPassword: `aa11:bb22:${Buffer.from('geheim').toString('hex')}`,
|
||||
});
|
||||
|
||||
const config: any = await service.getConfig('t1');
|
||||
@@ -156,7 +156,7 @@ describe('LdapConfigService — Bind-Passwort verschluesselt at rest', () => {
|
||||
{
|
||||
id: 'b',
|
||||
tenantId: 't2',
|
||||
encryptedBindPassword: 'aa11:bb22:' + Buffer.from('schon').toString('hex'),
|
||||
encryptedBindPassword: `aa11:bb22:${Buffer.from('schon').toString('hex')}`,
|
||||
},
|
||||
{ id: 'c', tenantId: 't3', encryptedBindPassword: null },
|
||||
]);
|
||||
@@ -167,7 +167,7 @@ describe('LdapConfigService — Bind-Passwort verschluesselt at rest', () => {
|
||||
const call = prisma.ldapConfig.update.mock.calls[0][0];
|
||||
expect(call.where).toEqual({ id: 'a' });
|
||||
expect(call.data.encryptedBindPassword).toBe(
|
||||
'aa11:bb22:' + Buffer.from('klartext').toString('hex'),
|
||||
`aa11:bb22:${Buffer.from('klartext').toString('hex')}`,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -176,7 +176,7 @@ describe('LdapConfigService — Bind-Passwort verschluesselt at rest', () => {
|
||||
{
|
||||
id: 'a',
|
||||
tenantId: 't1',
|
||||
encryptedBindPassword: 'aa11:bb22:' + Buffer.from('x').toString('hex'),
|
||||
encryptedBindPassword: `aa11:bb22:${Buffer.from('x').toString('hex')}`,
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -354,7 +354,7 @@ describe('LdapConfigService — Bindung an forTenant() (260909-ipc)', () => {
|
||||
const call = boundClient.ldapConfig.update.mock.calls[0][0];
|
||||
expect(call.where).toEqual({ id: 'alt' });
|
||||
expect(call.data.encryptedBindPassword).toBe(
|
||||
'aa11:bb22:' + Buffer.from('klartext').toString('hex'),
|
||||
`aa11:bb22:${Buffer.from('klartext').toString('hex')}`,
|
||||
);
|
||||
// Der rohe Client schreibt NICHT.
|
||||
expect(prisma.ldapConfig.update).not.toHaveBeenCalled();
|
||||
|
||||
@@ -1545,7 +1545,7 @@ describe('LdapService.syncBoundGroupsForTenant — Rekonziliation gegen das Verz
|
||||
isDefault: false,
|
||||
},
|
||||
];
|
||||
prisma.group.update = vi.fn((args: any) => {
|
||||
prisma.group.update = vi.fn((_args: any) => {
|
||||
const err: any = new Error('Unique constraint');
|
||||
err.code = 'P2002';
|
||||
// A P2002 on the OTHER unique index this update() can hit —
|
||||
@@ -1793,7 +1793,7 @@ describe('LdapService.syncBoundGroupsForTenant — Rekonziliation gegen das Verz
|
||||
];
|
||||
mockSearch.mockImplementation((_baseDn: string, opts: any) => {
|
||||
const swept = sweptGuid(opts.filter);
|
||||
if (swept && swept.equals(guidBuffer)) {
|
||||
if (swept?.equals(guidBuffer)) {
|
||||
return Promise.reject(new Error('directory unavailable'));
|
||||
}
|
||||
return Promise.resolve({
|
||||
|
||||
@@ -322,13 +322,13 @@ export class LdapService {
|
||||
const dn = entry.dn;
|
||||
const isOu = /^ou=/i.test(dn);
|
||||
const record = entry as unknown as Record<string, unknown>;
|
||||
const rawName = isOu ? record['ou'] : record['cn'];
|
||||
const rawName = isOu ? record.ou : record.cn;
|
||||
const name = Array.isArray(rawName)
|
||||
? String(rawName[0])
|
||||
: rawName
|
||||
? String(rawName)
|
||||
: dn;
|
||||
const guidValue = record['objectGUID'];
|
||||
const guidValue = record.objectGUID;
|
||||
const guidHex =
|
||||
!isOu && Buffer.isBuffer(guidValue)
|
||||
? guidValue.toString('hex')
|
||||
@@ -401,7 +401,7 @@ export class LdapService {
|
||||
mappedData[mapping.tesseraField] = String(resolved);
|
||||
}
|
||||
}
|
||||
return { username: mappedData['username']?.toLowerCase(), mappedData };
|
||||
return { username: mappedData.username?.toLowerCase(), mappedData };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -483,13 +483,13 @@ export class LdapService {
|
||||
if (existing) {
|
||||
let emailToWrite: string | undefined;
|
||||
let emailConflict: LdapEmailConflict | undefined;
|
||||
if (mappedData['email']) {
|
||||
if (mappedData.email) {
|
||||
const decision = await this.resolveEmailForWrite(
|
||||
mappedData['email'],
|
||||
mappedData.email,
|
||||
existing.id,
|
||||
);
|
||||
if (decision.collides) {
|
||||
emailConflict = { account: username, email: mappedData['email'] };
|
||||
emailConflict = { account: username, email: mappedData.email };
|
||||
} else {
|
||||
emailToWrite = decision.email;
|
||||
}
|
||||
@@ -498,11 +498,11 @@ export class LdapService {
|
||||
await tenantPrisma.user.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
...(mappedData['displayName'] && {
|
||||
displayName: mappedData['displayName'],
|
||||
...(mappedData.displayName && {
|
||||
displayName: mappedData.displayName,
|
||||
}),
|
||||
...(emailToWrite && { email: emailToWrite }),
|
||||
...(mappedData['username'] && { username }),
|
||||
...(mappedData.username && { username }),
|
||||
ldapDn: dn,
|
||||
isActive: true,
|
||||
},
|
||||
@@ -511,16 +511,16 @@ export class LdapService {
|
||||
}
|
||||
|
||||
let createEmail: string | undefined =
|
||||
mappedData['email'] || `${username}@ldap.local`;
|
||||
mappedData.email || `${username}@ldap.local`;
|
||||
let emailConflict: LdapEmailConflict | undefined;
|
||||
if (mappedData['email']) {
|
||||
if (mappedData.email) {
|
||||
const decision = await this.resolveEmailForWrite(
|
||||
mappedData['email'],
|
||||
mappedData.email,
|
||||
null,
|
||||
);
|
||||
if (decision.collides) {
|
||||
createEmail = undefined;
|
||||
emailConflict = { account: username, email: mappedData['email'] };
|
||||
emailConflict = { account: username, email: mappedData.email };
|
||||
} else {
|
||||
createEmail = decision.email;
|
||||
}
|
||||
@@ -529,7 +529,7 @@ export class LdapService {
|
||||
await this.userService.create({
|
||||
username,
|
||||
...(createEmail && { email: createEmail }),
|
||||
displayName: mappedData['displayName'],
|
||||
displayName: mappedData.displayName,
|
||||
role: 'USER',
|
||||
tenantId,
|
||||
ldapDn: dn,
|
||||
@@ -592,9 +592,9 @@ export class LdapService {
|
||||
|
||||
const entries = Array.from(entriesByDn.values()).map((entry) => ({
|
||||
dn: entry.dn,
|
||||
username: first(entry['sAMAccountName']),
|
||||
displayName: first(entry['displayName']) || first(entry['cn']),
|
||||
email: first(entry['mail']),
|
||||
username: first(entry.sAMAccountName),
|
||||
displayName: first(entry.displayName) || first(entry.cn),
|
||||
email: first(entry.mail),
|
||||
}));
|
||||
|
||||
// Flag entries already present for this tenant (by ldapDn or username) in
|
||||
@@ -724,10 +724,10 @@ export class LdapService {
|
||||
// account is still created and counted, this manual-import path's
|
||||
// display stays as-is.
|
||||
let createEmail: string | undefined =
|
||||
mappedData['email'] || `${username}@ldap.local`;
|
||||
if (mappedData['email']) {
|
||||
mappedData.email || `${username}@ldap.local`;
|
||||
if (mappedData.email) {
|
||||
const decision = await this.resolveEmailForWrite(
|
||||
mappedData['email'],
|
||||
mappedData.email,
|
||||
null,
|
||||
);
|
||||
createEmail = decision.collides ? undefined : decision.email;
|
||||
@@ -736,7 +736,7 @@ export class LdapService {
|
||||
await this.userService.create({
|
||||
username,
|
||||
...(createEmail && { email: createEmail }),
|
||||
displayName: mappedData['displayName'],
|
||||
displayName: mappedData.displayName,
|
||||
role: 'USER',
|
||||
tenantId,
|
||||
ldapDn: dn,
|
||||
@@ -813,14 +813,14 @@ export class LdapService {
|
||||
|
||||
const entry = searchEntries[0];
|
||||
const record = entry as unknown as Record<string, unknown>;
|
||||
const guidValue = record['objectGUID'];
|
||||
const guidValue = record.objectGUID;
|
||||
if (!Buffer.isBuffer(guidValue)) {
|
||||
result.errors.push(`${dn}: objectGUID not readable`);
|
||||
continue;
|
||||
}
|
||||
const ldapObjectGuid = guidValue.toString('hex');
|
||||
|
||||
const rawName = record['cn'];
|
||||
const rawName = record.cn;
|
||||
const name = Array.isArray(rawName)
|
||||
? String(rawName[0])
|
||||
: rawName
|
||||
@@ -1436,7 +1436,7 @@ export class LdapService {
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const backfillGuid = backfillRecord['objectGUID'];
|
||||
const backfillGuid = backfillRecord.objectGUID;
|
||||
if (!Buffer.isBuffer(backfillGuid)) {
|
||||
result.errors.push(
|
||||
`Gruppe ${group.name}: Alt-Bindung ${group.ldapDn} ohne lesbaren objectGUID`,
|
||||
@@ -1492,7 +1492,7 @@ export class LdapService {
|
||||
if (hit) {
|
||||
// 3. Rename/DN reconciliation (SC-3).
|
||||
const hitRecord = hit as unknown as Record<string, unknown>;
|
||||
const rawName = hitRecord['cn'];
|
||||
const rawName = hitRecord.cn;
|
||||
const name = Array.isArray(rawName)
|
||||
? String(rawName[0])
|
||||
: rawName
|
||||
|
||||
@@ -76,7 +76,7 @@ describe('forTenant() — Array-Form von $transaction (WINDOWS #20)', () => {
|
||||
const scoped = forTenant(fakePrisma, 'tenant-a') as any;
|
||||
|
||||
let queryCallCount = 0;
|
||||
const query = (args: unknown) => {
|
||||
const query = (_args: unknown) => {
|
||||
queryCallCount += 1;
|
||||
return fakeQueryResult;
|
||||
};
|
||||
@@ -97,7 +97,7 @@ describe('forTenant() — Array-Form von $transaction (WINDOWS #20)', () => {
|
||||
|
||||
it('setzt den Mandantenkontext ueber ein getaggtes $executeRaw-Template, nicht ueber zusammengebauten Text', async () => {
|
||||
const fakePrisma: any = {
|
||||
$transaction: vi.fn((arg: unknown) => Promise.resolve(['set-config-result', 'query-result'])),
|
||||
$transaction: vi.fn((_arg: unknown) => Promise.resolve(['set-config-result', 'query-result'])),
|
||||
$extends: (config: any) => ({
|
||||
async __invoke(args: unknown, query: (args: unknown) => unknown) {
|
||||
return config.query.$allOperations({ args, query });
|
||||
@@ -286,7 +286,7 @@ describe('withTenantTransaction() — interaktive Callback-Form auf dem UNgebund
|
||||
it('setzt den Mandantenkontext als erste Anweisung DIREKT AUF tx, nicht auf dem aeusseren Client', async () => {
|
||||
const setConfigCalls: unknown[] = [];
|
||||
const fakeTx: any = {
|
||||
$executeRaw: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => {
|
||||
$executeRaw: vi.fn((_strings: TemplateStringsArray, ...values: unknown[]) => {
|
||||
setConfigCalls.push(values);
|
||||
return Promise.resolve(1);
|
||||
}),
|
||||
|
||||
@@ -145,7 +145,13 @@ import { PrismaClient } from '@prisma/client';
|
||||
* Mandanten), nicht zu einer Luecke. `withTenantTransaction()` bekommt
|
||||
* KEINEN dritten Parameter: kein Nutzer-CRUD-Aufrufer nutzt diese Funktion
|
||||
* (nur `groups`, ein Verwaltungsweg) — ein unbenutzter Parameter waere
|
||||
* Spekulation ohne heutigen Aufrufer.
|
||||
* Spekulation ohne heutigen Aufrufer. Nachtrag (260917-jdd):
|
||||
* `favorites.service.ts` (`reorder`) ist seither der erste Nutzer-CRUD-
|
||||
* Aufrufer — er kommt OHNE Benutzerdimension in der Sitzung aus und
|
||||
* traegt `userId` UND `widgetId` in jeder Bedingung innerhalb der
|
||||
* Transaktion selbst (zweites Netz). Ein dritter Parameter kommt erst,
|
||||
* wenn ein Aufrufer die Benutzerdimension INNERHALB der Transaktion
|
||||
* braucht.
|
||||
*
|
||||
* SYSTEMKONTEXT (Etappe 3c, 260914-eym):
|
||||
*
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { CosinexAdapter } from './cosinex.adapter';
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import AdmZip from 'adm-zip';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { buildDoeNoticeUrl, DoeOpenDataAdapter } from './doe-opendata.adapter';
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { CryptoService } from '../../crypto/crypto.service';
|
||||
import * as cheerio from 'cheerio';
|
||||
import { createHash } from 'crypto';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import { ExchangeInboxProvider } from '../../inbox/exchange-inbox.provider';
|
||||
import { ImapProvider } from '../../inbox/imap.provider';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { NetServerAdapter } from './netserver.adapter';
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { RssAdapter } from './rss.adapter';
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { createHash } from 'crypto';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { XMLParser } from 'fast-xml-parser';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import type { RawTenderRecord, SourceType } from '../tender.types';
|
||||
|
||||
@@ -235,8 +235,8 @@ describe('TenderDigestScheduler — Multi-Tenant via findMany (Pitfall 1)', () =
|
||||
});
|
||||
|
||||
it('never uses findFirst anywhere in the module source — findMany over all due users is mandatory (Pitfall 1)', async () => {
|
||||
const { readFileSync } = await import('fs');
|
||||
const { join } = await import('path');
|
||||
const { readFileSync } = await import('node:fs');
|
||||
const { join } = await import('node:path');
|
||||
const source = readFileSync(join(__dirname, 'tender-digest.scheduler.ts'), 'utf8');
|
||||
expect(source).not.toMatch(/findFirst/);
|
||||
});
|
||||
|
||||
@@ -168,7 +168,7 @@ export class TenderDigestScheduler implements OnModuleInit {
|
||||
// Kein Konto, oder ein Konto ohne Adresse (WINDOWS #15, kollidierte
|
||||
// AD-Adresse) -- die Zugehoerigkeit funktioniert, nur der
|
||||
// Mailversand wird uebersprungen (zugesagtes Verhalten).
|
||||
if (!user || !user.email) continue;
|
||||
if (!user?.email) continue;
|
||||
|
||||
const sections = groupMatchesByProfile(matches);
|
||||
const sent = await this.mail.sendDigest({ email: user.email }, user.tenantId, sections);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createHash } from 'crypto';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
/**
|
||||
* tenderFingerprint — pure, deterministic cross-source dedup key (SCHEMA-03,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { TenderIngestionService } from './tender-ingestion.service';
|
||||
|
||||
|
||||
@@ -150,7 +150,7 @@ export class TenderMatchingService {
|
||||
// Kein Konto, oder ein Konto ohne Adresse (WINDOWS #15, kollidierte
|
||||
// AD-Adresse) -- die Zugehoerigkeit funktioniert, nur der
|
||||
// Mailversand wird uebersprungen (zugesagtes Verhalten).
|
||||
if (!user || !user.email) continue;
|
||||
if (!user?.email) continue;
|
||||
|
||||
const sent = await this.mail.sendInstant(
|
||||
{ email: user.email },
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import AdmZip from 'adm-zip';
|
||||
import { XMLParser } from 'fast-xml-parser';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { RawTenderRecord, SourceType } from './tender.types';
|
||||
import { TenderNormalizerService } from './tender-normalizer.service';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { createHash } from 'crypto';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { divisionOf } from './cpv/cpv-catalog';
|
||||
import { bundeslandFromRegion } from './geo/nuts-bundesland';
|
||||
import type { NormalizedTenderFields, RawTenderRecord } from './tender.types';
|
||||
|
||||
@@ -64,7 +64,7 @@ function makeFakePrisma() {
|
||||
|
||||
const tenderRssFeedSource = {
|
||||
findMany: async ({ where, orderBy }: any = {}) => {
|
||||
let all = [...rows.values()].filter((row) => matchesWhere(row, where));
|
||||
const all = [...rows.values()].filter((row) => matchesWhere(row, where));
|
||||
if (orderBy?.createdAt === 'asc') {
|
||||
all.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { ModuleRegistryService } from '../module-registry/module-registry.service';
|
||||
import { TenderSchedulerService } from './tender-scheduler.service';
|
||||
|
||||
@@ -16,8 +16,8 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { Role } from '@prisma/client';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { Response } from 'express';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
@@ -293,7 +293,7 @@ export class UserController {
|
||||
@UploadedFile() file: any,
|
||||
@CurrentUser() currentUser: any,
|
||||
) {
|
||||
if (!file || !file.buffer) {
|
||||
if (!file?.buffer) {
|
||||
throw new BadRequestException('No file provided');
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user