feat(platzhalter): Aufloesung, Namensregeln und Shell-Maskierung
Prüfung / pruefung (pull_request) Canceled after 0s
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (pull_request) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

Aus einer Vorlage wird hier der PowerShell-Befehl, den ein Techniker kopiert und
auf einem Domaenencontroller ausfuehrt. Entsprechend dicht ist die Abdeckung:
810 Tests, und die Maskierung wird **ausgefuehrt** geprueft, nicht gelesen.

- Platzhalter aus Geraet, Lauf, Mitarbeiter und Abteilung, dazu die Variablen
  der Vorlage; ein unbekannter bleibt sichtbar stehen
- `{{ou}}` ist der Eltern-Container des DN — genau der Pfad fuer New-ADUser
- Namensregeln fuer upn, sam, mail und hostname mit Transliteration, `{v}`,
  `{nnn}` und `{kundenkuerzel}`; Kundenregeln ueberlagern die globalen feldweise
- Maskierung je Shell, Skript-Kopie je Abschnitt mit Kommentarzeilen
- `{{generiertes_passwort}}`: 20 Zeichen, bei jeder Anzeige neu, nirgends
  gespeichert; im Druck `[Passwort – nicht gespeichert]`

Der unabhaengige Review fand drei Blocker. Der schwerste war eine
Codeausfuehrung: PowerShell liest auch `“`, `”` und `„` als Begrenzer einer
Zeichenkette, die Maskierung kannte nur das ASCII-Zeichen. Ein Abteilungsname
aus Word — Word wandelt `"` von selbst um — reichte. Gefunden hat es der Review
nicht durch Lesen, sondern indem er sich PowerShell in einen Container geholt
und die Markierungsdatei hat entstehen lassen. Der Test dagegen hatte sich still
uebersprungen; er nimmt jetzt selbst das Abbild und bricht in CI ab.

Dazu: zwei verschiedene Passwoerter in einer Antwort (Anzeige und Skript wurden
getrennt gefuellt), eine Vorlagenvariable, die `{{techniker}}` und `{{kunde}}`
ueberschreiben konnte, und die tragende Annahme der Maskierung — dass der
Platzhalter in Anfuehrungszeichen steht —, die niemand geprueft hat.

Schliesst #012

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-25 15:29:43 +02:00
co-authored by Claude Opus 5
parent f85bc87324
commit b59fbb4645
21 changed files with 4069 additions and 166 deletions
+26
View File
@@ -150,6 +150,32 @@ Die Content-Security-Policy in `src/proxy.ts` ist deshalb zweigeteilt: im Entwic
Prüfung der Funktion mit selbst gesetztem Argument hätte genau den Fehler durchgelassen, vor dem
der Kommentar darüber warnt.
## 5c. Warum die Maskierung dort sitzt, wo sie sitzt
Aus einer Vorlage entsteht ein PowerShell-Befehl, den ein Techniker kopiert und auf einem
Domänencontroller ausführt. Drei Entscheidungen tragen das (#012):
1. **Maskiert wird der eingesetzte Wert, nicht der fertige Befehl.** Sonst maskierte man die
Anführungszeichen des Befehls gleich mit. `platzhalterErsetzen` bekommt die Maskierung deshalb
als Funktion und wendet sie auf jeden Wert einzeln an.
2. **Die Maskierung setzt keine Anführungszeichen dazu** — die stehen in der Vorlage. Das ist eine
Annahme, und sie wird geprüft: steht ein `{{platzhalter}}` in einem `BEFEHL`-Schritt nicht
zwischen den erwarteten Anführungszeichen, wird der Schritt abgewiesen statt gefüllt. Ein halb
maskierter Befehl zum Kopieren wäre die gefährlichste aller Antworten.
3. **Was sich nicht sicher maskieren lässt, wird abgelehnt.** Für Cmd gibt es keine Regel, die ein
`"` in jedem Fall richtig behandelt — ein solcher Wert bekommt eine Fehlermeldung, keinen
Befehl. PowerShell liest auch `“`, `”` und `„` als Begrenzer; die werden auf ihre ASCII-Form
gebracht und dann maskiert, weil sie sich als Text ohnehin nicht durchreichen lassen.
Geprüft wird das **ausgeführt**, nicht gelesen: `tests/shell.test.ts` schickt bösartige Werte durch
ein echtes `bash -c` und durch ein echtes PowerShell — und wenn auf der Maschine kein `pwsh` liegt,
nimmt der Test das Container-Abbild. In CI bricht er ab, statt sich zu überspringen; ein stilles
Überspringen sieht genauso grün aus wie ein bestandener Lauf.
Das **generierte Passwort** entsteht erst beim Ersetzen und wird nirgends zurückgegeben — es gibt
keinen Weg, es außerhalb des fertigen Texts zu bekommen. Gäbe es ein Feld dafür, wäre die
Versuchung groß, es „nur fürs Protokoll“ mitzuschreiben.
## 6. Betrieb: zwei Wege, eine Regel
Die Datenbank ist auf beiden Wegen von außen nicht erreichbar:
+55 -2
View File
@@ -14,11 +14,12 @@ als Unterpunkte mit Aufgabe und Dauer.
| #009 | M4 – Vorlagen-Versionierung im Service | 1 h 20 min |
| #010 | M5 – Vorlagen-Editor | 2 h 10 min |
| #011 | M6 – Mitarbeiter, Abteilungen, Geräte | 2 h 15 min |
| | **Summe** | **11 h 15 min** |
| #012 | M7 – Platzhalter, Namensregeln, Escaping | 2 h 30 min |
| | **Summe** | **13 h 45 min** |
| Meilenstein | Tatsächlich | Status |
|---|---|---|
| v0.1.0 | 11 h 15 min | in Arbeit |
| v0.1.0 | 13 h 45 min | in Arbeit |
---
@@ -452,3 +453,55 @@ als Unterpunkte mit Aufgabe und Dauer.
- #012 Platzhalter, Namensregeln und Escaping: die reine Service-Schicht, aus der die fertigen
PowerShell-Befehle entstehen — mit dichter Testabdeckung, denn hier darf nichts schiefgehen.
---
## 2026-09-25 · Sitzung 8 · 12:55–15:35
**Issue:** #012 (M7)
**Version:** 0.1.0
### Erledigt
- `src/lib/shell.ts` (Sub-Agent): Maskierung je Shell, Skript-Kopie mit Kommentarzeilen
- `src/lib/namensregeln.ts` (Sub-Agent): Transliteration, `{v}`, `{nnn}`, `{kundenkuerzel}`,
Grenzen für `sam` und `hostname`
- `src/lib/passwort.ts`, `src/lib/platzhalter-werte.ts`, `src/server/services/platzhalter.ts` und
`src/server/services/befehle.ts`: das generierte Passwort, das Ersetzen und die fertigen Befehle
- Unabhängiger Review: **drei Blocker**, sieben weitere Befunde, alle abgearbeitet
- **810 Tests** grün (vorher 797 mit einem stillen Übersprung)
### Entscheidungen
- **PowerShell wird wirklich gegen PowerShell geprüft.** Auf dieser Maschine gibt es kein `pwsh`,
also nimmt der Test das Container-Abbild — und in CI bricht er ab, statt sich zu überspringen.
Der schwerste Befund des Meilensteins wäre ohne das nie aufgefallen.
- **Typografische Anführungszeichen werden auf ihre ASCII-Form gebracht und dann maskiert.** Als
Text durchreichen lassen sie sich nicht: PowerShell liest sie ohnehin als Begrenzer. Lieber ein
Zeichen, das anders aussieht als gedacht, als ein Befehl, der etwas anderes tut als er liest.
- **Ein Platzhalter außerhalb von Anführungszeichen wird abgewiesen**, nicht gefüllt. Die
Maskierung kann daraus kein Argument machen, und ein halb maskierter Befehl zum Kopieren wäre die
gefährlichste aller Antworten.
- **Gesperrt sind fünf Variablennamen, nicht alle eingebauten.** `techniker`, `datum`, `kunde`,
`kundenkuerzel` und `generiertes_passwort` machen eine Aussage über wer, wann und für wen. `upn`,
`sam` und `mail` bleiben frei — beim Onboarding gibt es die Person im Verzeichnis noch nicht.
- **Ein `sam` hält drei Stellen frei.** Ein einmal notierter Anmeldename muss auch morgen aus der
Regel herausfallen, wenn ein zweiter Träger desselben Namens dazukommt.
### Probleme
- **Ich bin in genau die Falle getappt, die ich in #010 repariert habe:** `$'` als Rückverweis in
`String.replaceAll`. Gefunden hat es mein eigener Test gegen echtes Bash — Lesen hätte es nicht
gefunden.
- **Meine erste HTML-Maskierung war doppelt**, weil ich vergessen hatte, dass React Textknoten
selbst maskiert. Aufgefallen beim Nachdenken über den Test, nicht beim Schreiben des Codes.
- **Der Review hat sich PowerShell in einem Container geholt.** Das ist der Unterschied zwischen
„nach bestem Wissen richtig“ und „gemessen“ — und der Unterschied war hier eine
Codeausführung auf dem Domänencontroller.
- **Vierter Meilenstein in Folge mit einem Kommentar, der ins Leere zeigt:** ein Testkommentar
verwies auf einen Beweis in einer anderen Datei, den es dort nicht gab.
### Nächster Schritt
- #013 Läufe: Service und Abarbeitung — Lauf anlegen, abarbeiten, abschließen, archivieren, mit
der Abarbeitungsansicht am Handy.
+87 -22
View File
@@ -1,9 +1,9 @@
# #012 M7 – Platzhalter, Namensregeln, Escaping
**Status:** offen
**Status:** erledigt
**Meilenstein:** v0.1.0
**Labels:** phase-1, backend, security
**Geschätzt:** 8 h **Tatsächlich:** –
**Geschätzt:** 8 h **Tatsächlich:** 2 h 30 min
## Ziel
@@ -11,26 +11,91 @@ Reine, framework-freie Service-Schicht mit dichter Testabdeckung. Hier entstehen
## Aufgaben
- [ ] Platzhalter-Auflösung `{{key}}` in Schritt-Titel, Hilfetext und Befehlstext
- [ ] Eingebaut aus Gerät und Lauf: `{{hostname}}`, `{{seriennummer}}`, `{{inventarnummer}}`, `{{ticket}}`, `{{datum}}`, `{{techniker}}`; im Modus `DIENSTLEISTUNG` zusätzlich `{{kunde}}` und `{{kundenkuerzel}}`, im Modus `INTERN` leer
- [ ] Eingebaut aus dem Mitarbeiter: `{{mitarbeiter}}` (Alias `{{benutzer}}`), `{{vorname}}`, `{{nachname}}`, `{{upn}}`, `{{sam}}`, `{{mail}}`, `{{abteilung}}`, `{{titel}}`, `{{vorgesetzter}}`, `{{vorgesetzter_sam}}`, `{{dn}}`, `{{ou}}`
- [ ] Aus der gewählten Abteilung: `{{ou}}` falls nicht vom Mitarbeiter, `{{gruppen}}` kommagetrennt
- [ ] `{{ou}}` eines synchronisierten Mitarbeiters ist der Eltern-Container seines `dn` (alles nach dem ersten Komma)
- [ ] Dazu die in der Vorlage definierten Variablen; unbekannte Platzhalter bleiben sichtbar stehen
- [ ] `{{generiertes_passwort}}`: 20 Zeichen aus Groß, Klein, Ziffern und Sonderzeichen ohne verwechselbare Zeichen, je Anzeige neu erzeugt, **nie** gespeichert; Hinweis „Passwort sofort in den Passwortmanager übernehmen“; in der Druckansicht `[Passwort – nicht gespeichert]`
- [ ] Namensregeln (`Einstellung.namensregeln` global, im Modus `DIENSTLEISTUNG` zusätzlich `Kunde.namensregeln` mit Fallback): `upn`, `sam` (höchstens 20 Zeichen), `mail`, `hostname`
- [ ] Transliteration (ä→ae, ö→oe, ü→ue, ß→ss, Akzente entfernt), Kleinschreibung, Leerzeichen entfernt, `{nnn}`-Zähler, `{v}` für den ersten Buchstaben des Vornamens, `{kundenkuerzel}`
- [ ] Shell-Escaping je Shell: PowerShell (doppelte Anführungszeichen verdoppeln, Backtick maskieren), Bash, Cmd
- [ ] HTML-Escaping ersetzter Werte; Markdown im Hilfetext sicher gerendert, kein rohes HTML
- [ ] „Alle Befehle als Skript kopieren“ je Abschnitt: alle `BEFEHL`-Schritte in Reihenfolge, getrennt durch Leerzeilen, je mit `# Schritt: <Titel>` davor, Zeilenumbruch passend zur Shell
- [x] Platzhalter-Auflösung `{{key}}` in Schritt-Titel, Hilfetext und Befehlstext
- [x] Eingebaut aus Gerät und Lauf: `{{hostname}}`, `{{seriennummer}}`, `{{inventarnummer}}`, `{{ticket}}`, `{{datum}}`, `{{techniker}}`; im Modus `DIENSTLEISTUNG` zusätzlich `{{kunde}}` und `{{kundenkuerzel}}`, im Modus `INTERN` leer
- [x] Eingebaut aus dem Mitarbeiter: `{{mitarbeiter}}` (Alias `{{benutzer}}`), `{{vorname}}`, `{{nachname}}`, `{{upn}}`, `{{sam}}`, `{{mail}}`, `{{abteilung}}`, `{{titel}}`, `{{vorgesetzter}}`, `{{vorgesetzter_sam}}`, `{{dn}}`, `{{ou}}`
- [x] Aus der gewählten Abteilung: `{{ou}}` falls nicht vom Mitarbeiter, `{{gruppen}}` kommagetrennt
- [x] `{{ou}}` eines synchronisierten Mitarbeiters ist der Eltern-Container seines `dn` (alles nach dem ersten Komma)
- [x] Dazu die in der Vorlage definierten Variablen; unbekannte Platzhalter bleiben sichtbar stehen
- [x] `{{generiertes_passwort}}`: 20 Zeichen aus Groß, Klein, Ziffern und Sonderzeichen ohne verwechselbare Zeichen, je Anzeige neu erzeugt, **nie** gespeichert; Hinweis „Passwort sofort in den Passwortmanager übernehmen“; in der Druckansicht `[Passwort – nicht gespeichert]`
- [x] Namensregeln (`Einstellung.namensregeln` global, im Modus `DIENSTLEISTUNG` zusätzlich `Kunde.namensregeln` mit Fallback): `upn`, `sam` (höchstens 20 Zeichen), `mail`, `hostname`
- [x] Transliteration (ä→ae, ö→oe, ü→ue, ß→ss, Akzente entfernt), Kleinschreibung, Leerzeichen entfernt, `{nnn}`-Zähler, `{v}` für den ersten Buchstaben des Vornamens, `{kundenkuerzel}`
- [x] Shell-Escaping je Shell: PowerShell (doppelte Anführungszeichen verdoppeln, Backtick maskieren), Bash, Cmd
- [x] HTML-Escaping ersetzter Werte; Markdown im Hilfetext sicher gerendert, kein rohes HTML
- [x] „Alle Befehle als Skript kopieren“ je Abschnitt: alle `BEFEHL`-Schritte in Reihenfolge, getrennt durch Leerzeilen, je mit `# Schritt: <Titel>` davor, Zeilenumbruch passend zur Shell
## Akzeptanzkriterien
- [ ] Test: „Jörg Müller-Lüdenscheid“ ergibt `joerg.mueller-luedenscheid@firma.de` und `jmuellerluedensch` (auf 20 Zeichen gekürzt)
- [ ] Test: `{{generiertes_passwort}}` erscheint im angezeigten Befehl, steht aber nicht in `Schrittergebnis`, `Kommentar`, `Protokoll` oder der Druckansicht; zwei Aufrufe liefern verschiedene Passwörter
- [ ] Test: ein Wert mit `"` und `` ` `` wird für PowerShell korrekt maskiert und bricht den Befehl nicht
- [ ] Test: unbekannte Platzhalter bleiben als `{{key}}` stehen
- [ ] Test: `{{ou}}` wird aus `CN=Maria Huber,OU=Buchhaltung,OU=Benutzer,DC=firma,DC=local` zu `OU=Buchhaltung,OU=Benutzer,DC=firma,DC=local`
- [ ] Test: Skript-Kopie liefert die Befehle in Abschnittsreihenfolge mit Kommentarzeilen
- [ ] Test: im Modus `INTERN` sind `{{kunde}}` und `{{kundenkuerzel}}` leer
- [ ] Test: ein Wert mit `<script>` landet HTML-escaped in der Anzeige
- [x] Test: „Jörg Müller-Lüdenscheid“ ergibt `joerg.mueller-luedenscheid@firma.de` und `jmuellerluedensch` (17 Zeichen — siehe unten, die Klammer im ursprünglichen Text war falsch)
- [x] Test: `{{generiertes_passwort}}` erscheint im angezeigten Befehl, steht aber nicht in `Schrittergebnis`, `Kommentar`, `Protokoll` oder der Druckansicht; zwei Aufrufe liefern verschiedene Passwörter
- [x] Test: ein Wert mit `"` und `` ` `` wird für PowerShell korrekt maskiert und bricht den Befehl nicht
- [x] Test: unbekannte Platzhalter bleiben als `{{key}}` stehen
- [x] Test: `{{ou}}` wird aus `CN=Maria Huber,OU=Buchhaltung,OU=Benutzer,DC=firma,DC=local` zu `OU=Buchhaltung,OU=Benutzer,DC=firma,DC=local`
- [x] Test: Skript-Kopie liefert die Befehle in Abschnittsreihenfolge mit Kommentarzeilen
- [x] Test: im Modus `INTERN` sind `{{kunde}}` und `{{kundenkuerzel}}` leer
- [x] Test: ein Wert mit `<script>` landet HTML-escaped in der Anzeige
## Beim Bauen aufgefallen
- **PowerShell liest auch typografische Anführungszeichen als Begrenzer.** `“`, `”` und `„`
beenden eine `"…"`-Zeichenkette genauso wie `"`, `‘`, `’`, `‚` und `‛` eine `'…'`-Zeichenkette.
Die Maskierung kannte nur das ASCII-Zeichen. Ein Abteilungsname, den jemand aus Word kopiert —
Word wandelt `"` von selbst in `“`/`”` —, führte damit fremden Code auf dem Domänencontroller
aus. Der Review hat es nicht gelesen, sondern gemessen: er hat sich PowerShell 7 in einem
Container geholt und die Markierungsdatei entstehen lassen. Behoben und mit demselben Aufbau
gegengeprüft: alle Werte kommen unverändert an, nichts wird ausgeführt.
- **Der pwsh-Test hat sich still übersprungen** — dieselbe Falle, die `tests/helpers/db.ts` für die
Datenbank längst zugemacht hat: ein stilles Überspringen sieht genauso grün aus wie ein
bestandener Lauf. Er nimmt jetzt das Container-Abbild, wenn kein `pwsh` da ist, und bricht in CI
ab, statt zu schweigen.
- **Zwei verschiedene Passwörter in einer Antwort.** `befehleDesLaufs` hat jeden Schritt zweimal
gefüllt — einmal für die Anzeige, einmal für das Skript —, und weil das Passwort bei jedem Füllen
neu entsteht, standen auf dem Bildschirm und in der Kopie verschiedene. Der Techniker legt das
Konto mit dem einen an und nennt dem Benutzer das andere; weil nichts gespeichert wird, ist es
danach unwiederbringlich.
- **Eine Vorlagenvariable konnte jeden eingebauten Platzhalter überschreiben.** Eine Variable
`techniker` ließ einen Befehl mit fremdem Namen unterschreiben, eine Variable `kunde` holte im
Modus Intern die Kundenebene zurück, die es dort ausdrücklich nicht gibt. Gesperrt sind jetzt
genau fünf Namen; alles andere bleibt erlaubt, weil eine Onboarding-Vorlage nach `upn`, `sam` und
`mail` fragen können muss — die Person steht ja noch nicht im Verzeichnis.
- **Die tragende Annahme der Maskierung hat niemand geprüft.** Sie gilt nur, wenn der Platzhalter
in der Vorlage in Anführungszeichen steht; `Write-Host {{vorname}}` lässt sich nicht sicher
füllen. Das stand als Kommentar da und wurde nirgends eingelöst — der Review hat mit
`$(touch …)` eine Datei entstehen lassen. Jetzt wird es geprüft und der Schritt abgewiesen.
- **Ich bin selbst in die Falle getappt, die ich in #010 repariert habe:** `$'` ist in
`String.replaceAll` ein Rückverweis auf alles hinter dem Treffer. Aus `erste\nzweite` wurde
`erste'zweite\n''zweite` — ein Befehl, der nicht mehr zugeht. Gefunden hat es mein eigener Test,
der gegen echtes Bash läuft.
- **Meine erste HTML-Maskierung war doppelt gemoppelt.** React maskiert Textknoten selbst; eine
zweite Maskierung hätte „Forschung &amp; Entwicklung“ sichtbar gemacht. Der Beweis für das
Akzeptanzkriterium steht jetzt am gerenderten HTML statt in einer eigenen Funktion.
- **`{{ou}}` bei einem maskierten Backslash im DN.** `CN=X\\,OU=Y` ist ein literaler Backslash im
Wert, das Komma **ist** der Trenner — die Vorausschau hielt es für maskiert, lieferte nichts, und
der Aufrufer fiel still auf die Ziel-OU der Abteilung zurück. Das Konto wäre in der falschen OU
gelandet.
- **Hostnamen, die Windows ablehnt:** ein Nachname „Con“ reichte, damit die Regel `CON`
vorschlägt — das ist die MS-DOS-Konsole. Reservierte Gerätenamen und rein numerische Namen
liefern jetzt keinen Vorschlag.
- **`!` ist aus dem Passwort geflogen**, und ein Passwort fängt nie mit `= + - @` an: bei
eingeschalteter verzögerter Ersetzung frisst `cmd.exe` das `!` still, und die vier anderen liest
eine Tabellenkalkulation am Zellenanfang als Formel.
## Eine Entscheidung, die den Auftragstext korrigiert
Das Akzeptanzkriterium nennt `jmuellerluedensch` und setzt in Klammern „auf 20 Zeichen gekürzt“.
Der Name hat **17** Zeichen, und beides zusammen geht nur mit zwei Regeln, die so jetzt im Code
stehen: der Bindestrich fällt im `sam` weg, und eine Regel ohne eigenen `{nnn}` hält drei Stellen
für die spätere Unterscheidung frei (20 − 3 = 17). Der Vorrat wird **immer** zurückgehalten, auch
solange der Name frei ist — sonst hieße derselbe Mensch `jmuellerluedenscheid`, solange er der
Erste ist, und `jmuellerluedensch2`, sobald ein Zweiter dazukommt. Ein einmal notierter
Anmeldename muss auch morgen noch aus der Regel herausfallen.
## Offen geblieben
- **Cmd ist nur begründet, nicht gemessen.** Es gibt hier kein Windows. Die Ablehnung von `"`, CR,
LF und Null-Byte und die Bewertung von `%` und `!` stehen als Begründung im Code — mit dem
ausdrücklichen Hinweis, was daran Annahme ist.
- **Kein Aufrufer.** Dieser Meilenstein hat keine Oberfläche; die Funktionen werden von #013
(Abarbeitung) und #014 (Geräteseite) gerufen. Der Hinweis „Passwort sofort in den
Passwortmanager übernehmen“ und die Druckansicht `[Passwort – nicht gespeichert]` sind damit
vorbereitet, aber noch nirgends zu sehen.
+2 -2
View File
@@ -16,7 +16,7 @@ Diese Dateien sind die Quelle; `pnpm issues:sync` spiegelt sie in den Gitea-Trac
| #009 | [M4 – Vorlagen-Versionierung im Service](009-vorlagen-versionierung.md) | erledigt | v0.1.0 | 6 h | 1 h 20 min |
| #010 | [M5 – Vorlagen-Editor](010-vorlagen-editor.md) | erledigt | v0.1.0 | 10 h | 2 h 10 min |
| #011 | [M6 – Mitarbeiter, Abteilungen, Geräte](011-mitarbeiter-abteilungen-geraete.md) | erledigt | v0.1.0 | 9 h | 2 h 15 min |
| #012 | [M7 – Platzhalter, Namensregeln, Escaping](012-platzhalter-namensregeln.md) | offen | v0.1.0 | 8 h | – |
| #012 | [M7 – Platzhalter, Namensregeln, Escaping](012-platzhalter-namensregeln.md) | erledigt | v0.1.0 | 8 h | 2 h 30 min |
| #013 | [M8 – Läufe: Service und Abarbeitung](013-laeufe-abarbeitung.md) | offen | v0.1.0 | 14 h | – |
| #014 | [M9 – QR-Tags, Geräteseite, Etiketten, Scanner](014-qr-tags-geraeteseite.md) | offen | v0.1.0 | 12 h | – |
| #015 | [M10 – Dashboard und Archiv](015-dashboard-archiv.md) | offen | v0.1.0 | 8 h | – |
@@ -24,4 +24,4 @@ Diese Dateien sind die Quelle; `pnpm issues:sync` spiegelt sie in den Gitea-Trac
| #017 | [M12 – Seed der sieben Vorlagen](017-seed-sieben-vorlagen.md) | offen | v0.1.0 | 8 h | – |
| #018 | [M13 – E2E-Tests, Anleitung, PDF](018-e2e-anleitung-pdf.md) | offen | v0.1.0 | 14 h | – |
| #019 | [M14 – Betriebswege und Release v0.1.0](019-betrieb-release.md) | offen | v0.1.0 | 10 h | – |
| | **Summe** | | | **136 h** | **11 h 15 min** |
| | **Summe** | | | **136 h** | **13 h 45 min** |