pillarz 0.1.0.dev1__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. pillarz-0.2.0/CHANGELOG.md +722 -0
  2. {pillarz-0.1.0.dev1/pillarz.egg-info → pillarz-0.2.0}/PKG-INFO +1 -1
  3. pillarz-0.2.0/env.example +118 -0
  4. pillarz-0.2.0/pillarz/__init__.py +677 -0
  5. {pillarz-0.1.0.dev1 → pillarz-0.2.0}/pillarz/__main__.py +2 -2
  6. pillarz-0.2.0/pillarz/_api/__init__.py +15 -0
  7. pillarz-0.2.0/pillarz/_api/account.py +106 -0
  8. pillarz-0.2.0/pillarz/_api/admin.py +55 -0
  9. pillarz-0.2.0/pillarz/_api/admin_links.py +24 -0
  10. pillarz-0.2.0/pillarz/_api/admin_log.py +41 -0
  11. pillarz-0.2.0/pillarz/_api/admin_sessions.py +52 -0
  12. pillarz-0.2.0/pillarz/_api/admin_settings.py +71 -0
  13. pillarz-0.2.0/pillarz/_api/admin_themes.py +103 -0
  14. pillarz-0.2.0/pillarz/_api/admin_users.py +357 -0
  15. pillarz-0.2.0/pillarz/_api/common.py +51 -0
  16. pillarz-0.2.0/pillarz/_api/me.py +321 -0
  17. pillarz-0.2.0/pillarz/_api/tokens.py +98 -0
  18. pillarz-0.2.0/pillarz/_auth/__init__.py +4 -0
  19. pillarz-0.2.0/pillarz/_auth/accounts.py +347 -0
  20. pillarz-0.2.0/pillarz/_auth/devices.py +51 -0
  21. pillarz-0.2.0/pillarz/_auth/flows.py +131 -0
  22. pillarz-0.2.0/pillarz/_auth/identity.py +66 -0
  23. pillarz-0.2.0/pillarz/_auth/linking.py +256 -0
  24. pillarz-0.2.0/pillarz/_auth/oidc.py +593 -0
  25. pillarz-0.2.0/pillarz/_auth/oidc_accounts.py +197 -0
  26. pillarz-0.2.0/pillarz/_auth/oidc_routes.py +392 -0
  27. pillarz-0.2.0/pillarz/_auth/passwords.py +146 -0
  28. pillarz-0.2.0/pillarz/_auth/roles.py +64 -0
  29. pillarz-0.2.0/pillarz/_auth/routes.py +426 -0
  30. pillarz-0.2.0/pillarz/_auth/secretbox.py +72 -0
  31. pillarz-0.2.0/pillarz/_auth/sessions.py +594 -0
  32. pillarz-0.2.0/pillarz/_auth/setup.py +223 -0
  33. pillarz-0.2.0/pillarz/_auth/stepup.py +49 -0
  34. pillarz-0.2.0/pillarz/_auth/throttle.py +193 -0
  35. pillarz-0.2.0/pillarz/_auth/tokens.py +126 -0
  36. pillarz-0.2.0/pillarz/_avatars.py +74 -0
  37. {pillarz-0.1.0.dev1 → pillarz-0.2.0}/pillarz/_db/connection.py +106 -9
  38. {pillarz-0.1.0.dev1 → pillarz-0.2.0}/pillarz/_db/m0001_initial.py +1 -1
  39. pillarz-0.2.0/pillarz/_db/m0002_sessions.py +19 -0
  40. pillarz-0.2.0/pillarz/_db/m0003_oidc_origin.py +27 -0
  41. pillarz-0.2.0/pillarz/_db/m0004_oidc_unlinked.py +26 -0
  42. pillarz-0.2.0/pillarz/_db/m0005_avatars.py +25 -0
  43. {pillarz-0.1.0.dev1 → pillarz-0.2.0}/pillarz/_db/migrations.py +6 -2
  44. pillarz-0.2.0/pillarz/_events.py +128 -0
  45. pillarz-0.2.0/pillarz/_help.py +157 -0
  46. {pillarz-0.1.0.dev1 → pillarz-0.2.0}/pillarz/_i18n/catalogs.py +26 -3
  47. pillarz-0.2.0/pillarz/_i18n/formats.py +202 -0
  48. {pillarz-0.1.0.dev1 → pillarz-0.2.0}/pillarz/_i18n/jinja.py +6 -2
  49. {pillarz-0.1.0.dev1 → pillarz-0.2.0}/pillarz/_i18n/js.py +6 -1
  50. {pillarz-0.1.0.dev1 → pillarz-0.2.0}/pillarz/_i18n/request.py +10 -0
  51. pillarz-0.2.0/pillarz/_images.py +463 -0
  52. {pillarz-0.1.0.dev1 → pillarz-0.2.0}/pillarz/_protocol.py +49 -7
  53. pillarz-0.2.0/pillarz/_settings.py +279 -0
  54. {pillarz-0.1.0.dev1 → pillarz-0.2.0}/pillarz/_themes.py +113 -6
  55. pillarz-0.2.0/pillarz/_updates.py +267 -0
  56. {pillarz-0.1.0.dev1 → pillarz-0.2.0}/pillarz/_web/app.py +18 -3
  57. {pillarz-0.1.0.dev1 → pillarz-0.2.0}/pillarz/_web/errors.py +58 -8
  58. {pillarz-0.1.0.dev1 → pillarz-0.2.0}/pillarz/_web/gate.py +16 -0
  59. {pillarz-0.1.0.dev1 → pillarz-0.2.0}/pillarz/_web/guard.py +22 -9
  60. {pillarz-0.1.0.dev1 → pillarz-0.2.0}/pillarz/_web/headers.py +13 -4
  61. pillarz-0.2.0/pillarz/_web/pages.py +81 -0
  62. {pillarz-0.1.0.dev1 → pillarz-0.2.0}/pillarz/_web/render.py +139 -37
  63. pillarz-0.2.0/pillarz/_whatsnew.py +257 -0
  64. pillarz-0.2.0/pillarz/accounts.py +127 -0
  65. {pillarz-0.1.0.dev1 → pillarz-0.2.0}/pillarz/config.py +102 -4
  66. {pillarz-0.1.0.dev1 → pillarz-0.2.0}/pillarz/db.py +45 -7
  67. pillarz-0.2.0/pillarz/events.py +228 -0
  68. {pillarz-0.1.0.dev1 → pillarz-0.2.0}/pillarz/i18n.py +7 -1
  69. pillarz-0.2.0/pillarz/locale/en/LC_MESSAGES/pillarz-js.po +1351 -0
  70. pillarz-0.2.0/pillarz/locale/en/LC_MESSAGES/pillarz.po +2341 -0
  71. pillarz-0.2.0/pillarz/locale/pillarz-js.pot +1340 -0
  72. pillarz-0.2.0/pillarz/locale/pillarz.pot +2225 -0
  73. pillarz-0.2.0/pillarz/settings.py +33 -0
  74. {pillarz-0.1.0.dev1 → pillarz-0.2.0}/pillarz/static/pillarz/css/base.css +249 -14
  75. pillarz-0.2.0/pillarz/static/pillarz/js/account.js +538 -0
  76. pillarz-0.2.0/pillarz/static/pillarz/js/admin-links.js +157 -0
  77. pillarz-0.2.0/pillarz/static/pillarz/js/admin.js +771 -0
  78. pillarz-0.2.0/pillarz/static/pillarz/js/avatar.js +126 -0
  79. pillarz-0.2.0/pillarz/static/pillarz/js/contrast.js +74 -0
  80. pillarz-0.2.0/pillarz/static/pillarz/js/forms.js +128 -0
  81. pillarz-0.2.0/pillarz/static/pillarz/js/help.js +591 -0
  82. {pillarz-0.1.0.dev1 → pillarz-0.2.0}/pillarz/static/pillarz/js/i18n.js +11 -0
  83. pillarz-0.2.0/pillarz/static/pillarz/js/token.js +148 -0
  84. {pillarz-0.1.0.dev1 → pillarz-0.2.0}/pillarz/static/pillarz/js/ui.js +298 -28
  85. pillarz-0.2.0/pillarz/static/pillarz/js/whatsnew.js +107 -0
  86. pillarz-0.2.0/pillarz/templates/pillarz/account.html +30 -0
  87. pillarz-0.2.0/pillarz/templates/pillarz/admin.html +48 -0
  88. {pillarz-0.1.0.dev1 → pillarz-0.2.0}/pillarz/templates/pillarz/bare.html +1 -1
  89. {pillarz-0.1.0.dev1 → pillarz-0.2.0}/pillarz/templates/pillarz/base.html +6 -3
  90. {pillarz-0.1.0.dev1 → pillarz-0.2.0}/pillarz/templates/pillarz/error.html +5 -2
  91. {pillarz-0.1.0.dev1 → pillarz-0.2.0}/pillarz/templates/pillarz/icons.svg +1 -0
  92. pillarz-0.2.0/pillarz/templates/pillarz/login.html +41 -0
  93. pillarz-0.2.0/pillarz/templates/pillarz/partials/account/delete.html +5 -0
  94. pillarz-0.2.0/pillarz/templates/pillarz/partials/account/devices.html +9 -0
  95. pillarz-0.2.0/pillarz/templates/pillarz/partials/account/language.html +26 -0
  96. pillarz-0.2.0/pillarz/templates/pillarz/partials/account/login.html +56 -0
  97. pillarz-0.2.0/pillarz/templates/pillarz/partials/account/look.html +17 -0
  98. pillarz-0.2.0/pillarz/templates/pillarz/partials/account/profile.html +33 -0
  99. {pillarz-0.1.0.dev1 → pillarz-0.2.0}/pillarz/templates/pillarz/partials/nav.html +6 -2
  100. {pillarz-0.1.0.dev1 → pillarz-0.2.0}/pillarz/templates/pillarz/plain.html +2 -0
  101. pillarz-0.2.0/pillarz/templates/pillarz/setup.html +60 -0
  102. pillarz-0.2.0/pillarz/templates/pillarz/token.html +22 -0
  103. pillarz-0.2.0/pillarz/testing/__init__.py +273 -0
  104. pillarz-0.2.0/pillarz/testing/_checks.py +158 -0
  105. {pillarz-0.1.0.dev1 → pillarz-0.2.0}/pillarz/testing/_web.py +3 -2
  106. pillarz-0.2.0/pillarz/testing/browser.py +354 -0
  107. pillarz-0.2.0/pillarz/testing/fakeidp.py +460 -0
  108. pillarz-0.2.0/pillarz/testing/run_parallel.py +539 -0
  109. {pillarz-0.1.0.dev1 → pillarz-0.2.0}/pillarz/web.py +106 -24
  110. {pillarz-0.1.0.dev1 → pillarz-0.2.0/pillarz.egg-info}/PKG-INFO +1 -1
  111. pillarz-0.2.0/pillarz.egg-info/SOURCES.txt +135 -0
  112. pillarz-0.1.0.dev1/CHANGELOG.md +0 -240
  113. pillarz-0.1.0.dev1/env.example +0 -83
  114. pillarz-0.1.0.dev1/pillarz/__init__.py +0 -309
  115. pillarz-0.1.0.dev1/pillarz/_auth/__init__.py +0 -2
  116. pillarz-0.1.0.dev1/pillarz/_auth/passwords.py +0 -74
  117. pillarz-0.1.0.dev1/pillarz/_auth/setup.py +0 -57
  118. pillarz-0.1.0.dev1/pillarz/_web/pages.py +0 -30
  119. pillarz-0.1.0.dev1/pillarz/locale/en/LC_MESSAGES/pillarz-js.po +0 -86
  120. pillarz-0.1.0.dev1/pillarz/locale/en/LC_MESSAGES/pillarz.po +0 -622
  121. pillarz-0.1.0.dev1/pillarz/locale/pillarz-js.pot +0 -83
  122. pillarz-0.1.0.dev1/pillarz/locale/pillarz.pot +0 -588
  123. pillarz-0.1.0.dev1/pillarz/testing/__init__.py +0 -149
  124. pillarz-0.1.0.dev1/pillarz.egg-info/SOURCES.txt +0 -69
  125. {pillarz-0.1.0.dev1 → pillarz-0.2.0}/LICENSE +0 -0
  126. {pillarz-0.1.0.dev1 → pillarz-0.2.0}/MANIFEST.in +0 -0
  127. {pillarz-0.1.0.dev1 → pillarz-0.2.0}/README.md +0 -0
  128. {pillarz-0.1.0.dev1 → pillarz-0.2.0}/pillarz/_db/__init__.py +0 -0
  129. {pillarz-0.1.0.dev1 → pillarz-0.2.0}/pillarz/_db/clock.py +0 -0
  130. {pillarz-0.1.0.dev1 → pillarz-0.2.0}/pillarz/_i18n/__init__.py +0 -0
  131. {pillarz-0.1.0.dev1 → pillarz-0.2.0}/pillarz/_i18n/babel-js.cfg +0 -0
  132. {pillarz-0.1.0.dev1 → pillarz-0.2.0}/pillarz/_i18n/babel.cfg +0 -0
  133. {pillarz-0.1.0.dev1 → pillarz-0.2.0}/pillarz/_i18n/tools.py +0 -0
  134. {pillarz-0.1.0.dev1 → pillarz-0.2.0}/pillarz/_runtime.py +0 -0
  135. {pillarz-0.1.0.dev1 → pillarz-0.2.0}/pillarz/_web/__init__.py +0 -0
  136. {pillarz-0.1.0.dev1 → pillarz-0.2.0}/pillarz/_web/context.py +0 -0
  137. {pillarz-0.1.0.dev1 → pillarz-0.2.0}/pillarz/_web/csp.py +0 -0
  138. {pillarz-0.1.0.dev1 → pillarz-0.2.0}/pillarz/_web/net.py +0 -0
  139. {pillarz-0.1.0.dev1 → pillarz-0.2.0}/pillarz/_web/static.py +0 -0
  140. {pillarz-0.1.0.dev1 → pillarz-0.2.0}/pillarz/static/pillarz/favicon.svg +0 -0
  141. {pillarz-0.1.0.dev1 → pillarz-0.2.0}/pillarz/static/pillarz/js/api.js +0 -0
  142. {pillarz-0.1.0.dev1 → pillarz-0.2.0}/pillarz/static/pillarz/vendor/lucide/LICENSE +0 -0
  143. {pillarz-0.1.0.dev1 → pillarz-0.2.0}/pillarz/templates/pillarz/partials/flash.html +0 -0
  144. {pillarz-0.1.0.dev1 → pillarz-0.2.0}/pillarz/text.py +0 -0
  145. {pillarz-0.1.0.dev1 → pillarz-0.2.0}/pillarz.egg-info/dependency_links.txt +0 -0
  146. {pillarz-0.1.0.dev1 → pillarz-0.2.0}/pillarz.egg-info/requires.txt +0 -0
  147. {pillarz-0.1.0.dev1 → pillarz-0.2.0}/pillarz.egg-info/top_level.txt +0 -0
  148. {pillarz-0.1.0.dev1 → pillarz-0.2.0}/pyproject.toml +0 -0
  149. {pillarz-0.1.0.dev1 → pillarz-0.2.0}/requirements.in +0 -0
  150. {pillarz-0.1.0.dev1 → pillarz-0.2.0}/setup.cfg +0 -0
@@ -0,0 +1,722 @@
1
+ # Changelog
2
+
3
+ Alle nennenswerten Änderungen an pillarz. Dieses CHANGELOG richtet sich an **App-Entwickler**
4
+ (Breaking Changes, neue Hooks, Upgrade-Hinweise), nicht an Endnutzer. Format nach
5
+ [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), Versionen nach SemVer (ADR 0002): vor 1.0
6
+ darf sich die öffentliche API zwischen 0.x-Versionen ohne Deprecation-Frist ändern, jede solche
7
+ Änderung steht unter „Breaking“. Abschnitte: Added, Changed, Deprecated, Removed, Fixed, Security,
8
+ Breaking, Upgrade.
9
+
10
+ ## [Unreleased]
11
+
12
+ ## [0.2.0] — 2026-10-31
13
+
14
+ ### Security
15
+ - **Selbst-Verknüpfen mit authentik verlangt das Passwort (hoch, nur Entwicklungsstand 0.2).** Eine frische Sitzung reichte, und wer den
16
+ Browser kurz hatte, konnte sein eigenes authentik-Konto anhängen. `POST /api/pz/me/oidc-link` erwartet bei Konten mit Passwort
17
+ `{"password": …}` (fehlt es: 422 mit `field="current"`, falsch: 422 und Bremse); Schritt-hoch gilt nur noch für Konten ohne Passwort.
18
+ Verknüpfen und das erste Passwort eines Kontos stehen als Warnung im Protokoll, ab 0.3 zusätzlich Mail.
19
+ - **Schritt-hoch per stiller authentik-Anmeldung umgehbar (hoch, in 0.1.0 ausgeliefert).** `sessions.start` setzte immer
20
+ `reauth_at = now`. Wer den Browser eines Opfers hatte (unbeaufsichtigt, XSS), öffnete `/auth/login`, authentik antwortete
21
+ still, und die neue Sitzung galt als frisch – auch für Admin-Aktionen, das erste Passwort eines authentik-Kontos und die
22
+ Selbstlöschung. Jetzt `reauth_at = min(now, auth_time)`, ohne Claim `NULL`; Passwort- und Einrichtungs-Anmeldung behalten
23
+ `now`. **Apps mit authentik auf 0.1.0 sollten aktualisieren.**
24
+ - Frische endet mit der echten Anmeldung: Schritt-hoch `max_age=120` (vorher 300), `check_auth_time` folgt; Verknüpfen
25
+ `max_age=60`. `prompt=login` entfällt überall (wirkte je authentik-Sitzung nur einmal).
26
+ - Toter Anbieter legt die App nicht mehr lahm: Discovery-Fehlschläge bleiben 30 s gemerkt, höchstens eine Abfrage zugleich, die
27
+ IP-Bremse für Anmeldevorgänge greift vor der Discovery (`flows.reserve`).
28
+ - Neue Konten aus authentik: Adresse **immer unbestätigt** (`email_confirmed = 0`), auch mit `email_verified`.
29
+ - Jede entkoppelte `sub` bleibt gesperrt, bis sie neu verknüpft wird (Tabelle `pz_oidc_unlinked`, vorher nur die letzte).
30
+ - Offene Verknüpfungslinks enden bei jedem Rollenwechsel.
31
+ - Befristete Admins beenden keine Sitzungen permanenter Admins und ziehen deren Links nicht zurück (403, protokolliert).
32
+ - ID-Token: an das Konto gebunden gespeichert (`v2.`, `v1.` bleibt lesbar), mit `OIDC_LOGOUT=0` gar nicht mehr. Warnungen bei
33
+ `sub` mit `@`, Client-Secret unter 32 Bytes und geändertem `OIDC_ISSUER`. Unter einem https-Issuer gelten nur https-Endpunkte.
34
+ `GET /auth/login?reauth=1` lehnt `Sec-Fetch-Site: cross-site` ab (403).
35
+ - `POST /api/pz/me/email` ist gebremst: 10 Versuche je Stunde und Konto (429). Die Antwort 422/200 verrät vergebene Adressen, und eine freie
36
+ Adresse lässt sich besetzen; das schließt erst 0.3 ganz (Adresse erst nach Bestätigung, `email_change`).
37
+ - **`auth_time`:** Werte weiter als 60 s in der Zukunft machen eine Sitzung nicht frisch. Verknüpfen prüft die Zeit jetzt auch (höchstens 60 s plus Spielraum); ohne Claim wird nicht verknüpft.
38
+ - **`GET /auth/login` mit laufender Sitzung** ersetzt sie nicht mehr, sondern leitet zum Ziel, auch bei fremder Herkunft. Wer das Konto wechseln will, meldet sich zuerst ab.
39
+ - **Adressen:** Steuer- und Formatzeichen (Unicode Cc, Cf) gelten nicht als E-Mail-Adresse; NUL führte auf PostgreSQL zu 500.
40
+ - **Update-Check:** Antworten bis 4 MiB (vorher 16), unkomprimiert (`Accept-Encoding: identity`, `iter_raw`), Gesamtfrist auch bis zu den Kopfzeilen, tief verschachteltes JSON gilt als Fehler.
41
+ - Ein fehlgeschlagener Login schreibt nur noch Eingaben ins Protokoll, die ein Benutzername sein können (kein Passwort aus dem falschen Feld).
42
+ - `testing.make_user` und `testing.login_as` verweigern außerhalb des Testmodus.
43
+ - Höchstens 50 eigene Themes (409), `/themes.css` rechnet nur bei geänderten Farben neu.
44
+
45
+ ### Added
46
+ - **`busy(el, work)`** in `ui.js` (P-115): dauert es über 300 ms, trägt der Bereich `aria-busy` und zeigt „Lade …“; ein Fehler steht danach im Bereich.
47
+ Alle Admin-Reiter und die Kontoseite nutzen es.
48
+ - **Kontenliste** (P-80): Suche (Name, Benutzername, E-Mail) und Filter nach Rolle, Status und Anmeldemethode.
49
+ - **Theme-Editor** (P-112): je Modus eine Live-Zeile je Paar (Schrift auf Hintergrund/Fläche/Akzent, Akzent als Text) im regionalen Zahlenformat,
50
+ Hex-Feld neben jedem Farbwähler (`#rgb`, `#rrggbb`), Speichern gesperrt mit Grund. `/static/pillarz/js/contrast.js` rechnet wie
51
+ `_themes.contrast_pairs`.
52
+ - **`user.signed_out` mit `reason="expired"`** (P-43, P-51): abgelaufene und wegen Leerlauf ungültige Sitzungen melden sich jetzt, je Konto
53
+ gebündelt und nicht exakt zum Zeitpunkt – beim Start (`prune`), beim nächsten Zugriff der Sitzung und nebenbei bei einer Anmeldung.
54
+ Apps, die Daten an `session_id` binden, räumen damit auch verwaiste Sitzungen auf.
55
+ - **`pz.health_check(name, fn)`** (P-65), auch als Dekorator: eigene Prüfungen in `/healthz`. `False` oder Ausnahme → 503 mit
56
+ `{"ok": false}`; Namen und Fehlertexte nur im Protokoll. pillarz prüft höchstens alle 5 s und nie gleichzeitig (je Prozess).
57
+ - **Avatar** (P-56, ADR 0012, K-11): Kontoseite „Profil“, `POST`/`DELETE /api/pz/me/avatar`, `GET /avatars/<id>?v=…` (nur Sitzung). Der
58
+ Browser schneidet zu und verkleinert auf 256 × 256 (`/static/pillarz/js/avatar.js`: `prepareAvatar`, `uploadAvatar`, `removeAvatar`),
59
+ der Server prüft die Struktur (`pillarz/_images.py`, aus labelz, ≤ 256 KiB, ≤ 1024 px, Metadaten weg, keine Pixeldekodierung, keine neue
60
+ Abhängigkeit). Auf **jeder** Antwort unter `/avatars/`: `nosniff`, `Content-Security-Policy: default-src 'none'; sandbox`,
61
+ `Content-Disposition: inline`, CORP same-origin; `Content-Type` nur aus der Prüfung. Tabelle `pz_avatars` (Migration `0005_avatars`), fällt
62
+ mit dem Konto weg. **Sichtbarkeit (Leitung, S2-L11):** Nutzer und Admins sehen alle Bilder, Gäste nur das eigene (fremde: 404). Für Apps: `pillarz.accounts.avatar_url(request, id)`, `avatar_urls(request, ids)` (nennen fremde Bilder Gästen gar nicht erst), `avatar_initials`, `boot.user.avatar`, Template-Kontext
63
+ `user_avatar`/`user_initials`, Klasse `.avatar` in `base.css`. `/avatars/` nimmt keine App-Authenticators an.
64
+ - **E-Mail selbst ändern** (F-4, P-54): Kontoseite „Profil“ und `POST /api/pz/me/email {"email": …}` für alle Rollen, mit frischem
65
+ Schritt-hoch; die neue Adresse gilt als unbestätigt (`email_confirmed = 0`, Bestätigung per Mail folgt mit 0.3), leer entfernt sie, eine
66
+ schon vergebene bekommt eine allgemeine Meldung. `user.updated` läuft, die Sitzungen bleiben. `accounts`-JSON trägt `email_confirmed`.
67
+ - **Schritt-hoch für Apps** (P-94): `pillarz.web.require_fresh` (Dependency, 403 mit `{"error", "reauth": [wege]}`) und `is_fresh`; im
68
+ Browser `withFresh(fn, next)`, `ensureFresh(reauth, next)` und `stepUpDialog(methods, next)` aus `ui.js` – der gemeinsame Dialog bietet
69
+ nur die Wege an, die das Konto hat, und wiederholt die Anfrage nach der Bestätigung.
70
+ - **Regionale Formate** (P-57, P-61, ADR 0011 §8): `pillarz.i18n.format_number/format_money/format_date/format_datetime/
71
+ format_relative/current_region`, Jinja-Filter `number`, `money`, `date`, `datetime`, `relative`, im Browser `fmtNumber`, neu `fmtMoney`,
72
+ `fmtDate`, `relTime` aus `ui.js`. Region (`de_DE`, `de_CH`, `en_GB`, `en_US`, `fr_FR`) getrennt von der Sprache: Konto (`PATCH
73
+ /api/pz/me {"region": …}`, Auswahl auf der Kontoseite) → Einstellung `default_region` („Zahlen und Datum (Standard)“) → aus der Sprache →
74
+ `DEFAULT_REGION` → `de_DE`. `<html data-region="de-DE">` auf allen Seiten, auch ohne Rahmen. Ein Browser-Test vergleicht
75
+ Babel und `Intl`.
76
+ - **Testkit: paralleler Lauf** (P-86, aus labelz): `python -m pillarz.testing.run_parallel [-b] [-j N] [--durations N] [Module …]`
77
+ verteilt Testklassen auf Worker-Prozesse (je Worker eigenes `DATA_DIR`/`TMPDIR`, bei `POSTGRES_DB` eigene Datenbank
78
+ `<name>_w<n>`), längste zuerst, Dauern in `.test-durations.json` (in `.gitignore` aufnehmen). Klassen mit `parallel_alone =
79
+ True` laufen zum Schluss allein. `scripts/test.sh`, `tests.pg_embedded` und die CI nutzen ihn (SQLite 154 s → 28 s lokal).
80
+ - **Testkit: Browser-Basis** (P-109, aus labelz): `pillarz.testing.browser.BrowserTest` (nur mit `PZ_BROWSER=1`; Playwright bleibt
81
+ Entwicklungs-Abhängigkeit der App): App als Unterprozess auf SQLite, Wächter auf jeder Seite (Konsole, Skriptfehler, CSP, HTTP ≥ 400,
82
+ fremde Hosts; `self.allow(status, pfad)` für Absicht; `wait_for_function` mit String ist gesperrt), `login_as(name, role=…)` setzt
83
+ ein gültiges Sitzungscookie ohne Login-Dialog, `self.shot()` mit `PZ_SHOTS`.
84
+ - **Testkit: Prüfungen:** `testing.check_forbidden_columns(paths, {"is_admin": "role"}, allow=[…])` findet Altspalten in SQL
85
+ (P-105); `testing.lint_templates()` meldet Syntaxfehler, Verweise auf fehlende Templates und Blöcke, die es in der Elternkette nicht
86
+ gibt (P-86/P-4); `testing.untranslated(strict=True, ignore=[…])` lässt den Block bei fehlenden Texten fehlschlagen (P-114).
87
+ - **`DEV_MODE=1`** (P-114): fehlende Übersetzungen stehen je Text einmal als Warnung im Log (Logger der App, nicht im Protokoll der
88
+ Admins; im Testmodus immer) und – JS-Texte – in der Browser-Konsole. Im Betrieb keine Warnung und keine Zusatzkosten je Request.
89
+ - `examples/minimal` hat einen vollständigen EN-Katalog, auch Hook-Texte (P-83); ihre Seiten laufen in DE und EN ohne Warnung.
90
+ - `pillarz.testing.FakeIdP`: Schalter `wrong_signature` und `unreachable`, `id_token_hook(token, claims)` für eigene
91
+ Fälschungen, `requests` (abgefragte Adressen), `visit_end_session(url, browser)`.
92
+ - Protokoll: Entzug befristeter Admin-Rechte (Schritt-hoch ohne Gruppe, Lösen der Verknüpfung) und Vergabe beim Schritt-hoch. Das
93
+ Ablaufen der Frist bekommt bewusst keinen Eintrag.
94
+ - Die Kopfzeile „Anmelden“ führt nach der Anmeldung zur aktuellen Seite zurück (`/login?next=…`).
95
+ - **Was ist neu** (P-71): `Pillarz(changelog=pfad)` (relativ zu `package_dir`) zeigt jedem Konto nach einem Update
96
+ einmalig die Neuerungen aus dem CHANGELOG **der App** (Keep a Changelog; nur Added, Changed, Deprecated, Removed,
97
+ Fixed, Security – andere Abschnitte fallen weg). Abschnittstitel übersetzt; liegt daneben `CHANGELOG.<sprache>.md`,
98
+ gewinnt sie je Version (fehlende Versionen aus der Hauptdatei). `boot.whats_new`, `boot.changelog`, `POST
99
+ /api/pz/me/whats-new/seen` (merkt immer die laufende Version, setzt nie herab), `GET /api/pz/me/whats-new` (letzte
100
+ Versionen, Klick auf die Version im Fuß, P-62). Nur für echte Sitzungen, nie auf Seiten ohne Rahmen, Anmelde- und
101
+ Fehlerseiten; neuer Template-Block `whatsnew` (leer = auf dieser Seite kein Dialog, z. B. ein Editor).
102
+ - **Hilfe-Popover** (P-76, P-108): `pz.help_texts(fn)` mit `fn(request, user, admin, page=…)` oder – weiter gültig –
103
+ `fn(request, user, admin)`; `page` = Template-Name ohne Endung, `render(…, help_page=…)` überschreibt. Anker
104
+ `data-help="<schlüssel>"`, auch in später eingefügten Dialogen; `help.js` exportiert `helpButton`, `initHelp`,
105
+ `closeHelp`. Einträge als Text oder `{"text", "long", "term", "terms", "admin"}`, Schlüssel `glossary` = Glossar.
106
+ pillarz bringt Texte für Konto- und Admin-Seite mit (`pz.account.*`, `pz.admin.*`, DE + EN). Neue Template-Blöcke
107
+ `help_json`, `chrome_scripts`. `pillarz.testing.help_problems(html)` meldet Anker ohne Text.
108
+ - **Update-Check** (P-75): Admins sehen im Fuß und im Reiter „Einstellungen“, wenn es eine neuere stabile Version der
109
+ App gibt (`boot.update`, Template-Kontext `pz_update`). Quelle PyPI (`UPDATE_CHECK_PACKAGE`) oder `UPDATE_CHECK_URL`,
110
+ optional `UPDATE_CHECK_TOKEN`, Abschalten mit `UPDATE_CHECK=0`. Alle 6 h, nach Fehler nach 15 min; der Abruf läuft im
111
+ Hintergrund, ein DB-Claim lässt genau einen Worker abrufen, kein Request wartet darauf. Vorabversionen und
112
+ zurückgezogene Releases zählen nicht.
113
+ - **`AccountSection(column=1|2)`:** Spalte der Kontoseite ab 1024 px. Ohne Angabe links bei `order` unter 40, sonst rechts; einspaltig steht Spalte 1 vor Spalte 2.
114
+ - **`stepUpDialog(methods, next, {beforeLeave})`**, ebenso `ensureFresh` und `withFresh` als drittes Argument: läuft, bevor die Bestätigung bei authentik die Seite verlässt, zum Merken von Eingaben (`sessionStorage`, nie Passwörter). `prepareAvatar(source, crop)`, `uploadAvatar(source, crop)`, neu `loadAvatar(file)` und `defaultCrop(w, h)` in `avatar.js`.
115
+
116
+ ### Changed
117
+ - Kontoseite ab 1024 px zweispaltig ohne Lücken (P-113, Aufbau siehe unten); die Tab-Folge bleibt die Folge der Spalten. Eigene Abschnitte
118
+ sollten keine festen Höhen setzen.
119
+ - `POST`/`PUT /api/pz/admin/themes` lehnt Farben unter 4,5:1 ab (422, `field: "colors"`). Vorhandene eigene Themes bleiben nutzbar, bis sie jemand
120
+ speichert.
121
+ - **Protokoll** (P-110, P-111): Je Ablauf genau ein Eintrag (auch Admin-Änderung mit Lösen der Verknüpfung). Der Akteur steht einmal vorn in
122
+ der Zeile (`@name`, Spalte `actor_id`), der Text nennt nur noch das betroffene Konto: „hat sich abgemeldet“ statt „x hat sich abgemeldet
123
+ · @x“, „Konto lina geändert (name)“ statt „… (admin)“. Bestehende Einträge bleiben. Texte ändern sich dadurch – wer sie auswertet,
124
+ passt die Suche an. Für App-Einträge gilt dieselbe Regel: `log.info(_l("hat X gemacht"))` in einem Request trägt den Akteur von selbst,
125
+ außerhalb `extra={"pz_actor": id}`. Die Konsole hängt die Kennung an (`… (#7)`).
126
+ - **Breaking (0.x):** `POST /api/pz/me/oidc-unlink` nimmt kein `password` mehr, sondern verlangt einen frischen Schritt-hoch (403 mit
127
+ `reauth`); Lösen bleibt Konten mit Passwort vorbehalten. `POST /api/pz/me/oidc-link` verlangt bei Konten mit Passwort weiter `{"password": …}`,
128
+ Konten ohne Passwort verbinden mit frischem Schritt-hoch (siehe Security). Das erste eigene Passwort und die Selbstlöschung laufen über denselben Dialog.
129
+ - `relTime()` richtet sich nach der **Sprache** der Seite statt nach der Region und sagt immer „vor 1 Tag“ statt „gestern“ (wie der
130
+ Filter `relative`). `fmtDate()` zeigt die Uhrzeit im kurzen Format der Region (`2:05 PM`, `09:05`).
131
+ - `FakeIdP`: authentik-Sitzungen gelten **je Browser** (`testing.oidc_login`/`oidc_callback` übergeben den Client,
132
+ `issue_code(…, browser=client)`; beim HTTP-Server ein Cookie). `auth_times`, `markers`, `login_ids` sind Sichten darauf und
133
+ funktionieren weiter. Der Abmelde-Endpunkt folgt authentiks `EndSessionView`: ungültiger Hint, Rückleitung ohne Hint oder nicht
134
+ eingetragene Rückleitung → 400, die Sitzung bleibt; `registered_post_logout=set()` = keine eingetragen (Rückleitung wird
135
+ ignoriert). `logouts` hat zusätzlich `status`, `cause`, `ended`. Ein Abmelde-Aufruf ohne Browser beendet nichts.
136
+ - `testing.login_as(…, method="oidc")` legt eine frische Sitzung an (wie eine Anmeldung gerade eben).
137
+ - TLS-Fehler der Discovery nennen den Grund von OpenSSL (z. B. „Missing Authority Key Identifier“: Python ab 3.13 prüft mit
138
+ `VERIFY_X509_STRICT`).
139
+ - `pillarz/base.html`: Version im Fuß trägt `id="foot-version"` und wird mit CHANGELOG zum Knopf.
140
+ - `plain.html` und `error.html` enthalten keinen `#help`-Block mehr und laden weder `help.js` noch `whatsnew.js`.
141
+ - **Testkit:** `run_parallel` endet mit **Exit 5**, wenn keine Tests liefen (auch: alle Klassen übersprungen). `PZ_BROWSER=1`/`-b` schlägt fehl, wenn
142
+ Playwright oder Chromium fehlen, statt still zu überspringen; ohne `PZ_BROWSER` wird weiter übersprungen. CI hat den Job `test-browser` mit
143
+ Chromium, `build` hängt daran.
144
+ - **Kontoseite (UX-Review 0.2):** zwei feste Spalten statt CSS-Spalten, ein Feldfehler verschiebt nur die eigene Spalte. Das Markup ist jetzt `.account-grid > .account-col > .card`; wer `#acc-root > .card` selektiert, passt das an. „Speichern“ im Profil schickt erst die Adresse, dann den Namen, und nur, was sich geändert hat.
145
+ - **Schritt-hoch-Dialog:** ein Titel für alle Fälle, der Weg über authentik steht als Alternative im Inhalt statt im Fuß. Nach der Rückkehr von authentik zeigt die Seite einen `#flash`; im Profil stehen Name und Adresse wieder im Formular.
146
+ - **Sprache und Zahlenformat:** Per Tastatur speichert erst Enter oder das Verlassen des Felds, danach stehen Fokus und Position wieder da; Maus und Touch speichern sofort. `formats.region_options()` trennt `label` und `sample`; das Muster steht unter dem Feld.
147
+ - **Profilbild:** Zuschnitt-Dialog vor dem Hochladen (ziehen, Pfeiltasten, „Anderes Bild …“), Standard im Hochformat oben statt mittig.
148
+ - **Update-Hinweis** als `banner-info` vorn im Reiter; der Chip im Fuß führt zu `/admin?update=1#settings` und setzt den Fokus darauf.
149
+ - **`/setup`:** Fehler am Feld (`.field-error`), das Banner nur noch für Fehler ohne Feld; ein Fehler bei den Passwörtern markiert beide, geleerte Felder werden erklärt. Text beim Anmeldefehler und bei einer vergebenen Adresse geändert (Tests auf den alten Wortlaut anpassen).
150
+ - **Darstellung:** Eingabefelder mit 2-px-Fokusrahmen, gesperrte Konten ohne Deckkraft mit Schloss am Chip, Trefferflächen im Fuß und bei „Stattdessen sperren“ größer, Grund für gesperrtes „Speichern“ im Fuß des Theme-Editors. Hilfe-Popover: Tab am Ende geht beim Auslöser weiter.
151
+
152
+ ### Fixed
153
+ - Aktuelle Sitzung im Admin-Reiter „Geräte“ beenden meldet auch bei authentik ab (`redirect` in der Antwort).
154
+ - Abmelden nach einem `SECRET_KEY`-Wechsel endet nicht mehr still nur lokal: Weiterleitung zum Anbieter ohne Hint und ohne
155
+ Rückleitung, Warnung im Protokoll. Intern: `secretbox.open_` wirft `Undecryptable`, `None` heißt nur „nichts gespeichert“.
156
+ - Formular-POST (`Accept: text/html`): Validierungsfehler und belegter Datenbank-Pool liefern HTML statt JSON; `/login` und `/setup`
157
+ zeigen ihr Formular noch einmal.
158
+ - Regionale Kataloge (`pt_BR`): `<html lang>` ist jetzt gültiges BCP 47 (`pt-BR`) – vorher warf `new Intl.RelativeTimeFormat` beim Laden von
159
+ `ui.js`, und die Seite blieb ohne JavaScript.
160
+ - `user.signed_out(reason="expired")` beim Start läuft nach den `pz.on_startup`-Hooks; vorher gingen die Ereignisse an eine nicht vorbereitete App.
161
+ - Formate wie ADR 0011 §8: Babel rundet jetzt wie `Intl` kaufmännisch (`2.125` → `2,13 €` auf beiden Seiten), und `format_relative` hat dieselben
162
+ Einheiten und dieselbe Rundung wie `relTime` (auch bei 90 s, 59,5 min und 6,6 Tagen).
163
+ - Eine durch `POST /login` ersetzte Sitzung meldet sich jetzt mit `user.signed_out(reason="logout")`. Das Aufräumen in `sessions.start` nimmt höchstens 100 Sitzungen, den Rest `prune`; Ablauf-Ereignisse sind
164
+ at-most-once (Prozessende zwischen Commit und Event verliert sie).
165
+ - `fmtDate("2026-10-25")` bleibt westlich von UTC derselbe Tag und zeigt keine Uhrzeit. `fmtNumber`/`fmtMoney` geben für `null`, `""`, `true` und `false` „–“ wie die Filter. `showError` schweigt bei `err.cancelled`.
166
+ - `mark_seen` und der Update-Check lesen und schreiben nicht mehr in einer deferred Transaktion (auf SQLite „database is locked“ unter Last).
167
+ - Update-Check: 200 ohne stabile Version (meist falscher Paketname) gibt eine Warnung im Protokoll und einen Hinweis in den Einstellungen statt „aktuell“; `status` trägt `no_version`.
168
+ - `testing.browser.Server` bindet den Port im Elternprozess und vererbt den Socket (`uvicorn --fd`): Liefen zwei Testläufe gleichzeitig, konnte `/healthz` vom fremden Server antworten und der Test in einer fremden App landen.
169
+
170
+ ### Breaking
171
+ - `forms.js` exportiert `reauthWithPassword` und `reauthElsewhere` nicht mehr; stattdessen `stepUpDialog`, `ensureFresh`, `withFresh` aus `ui.js`.
172
+ - `RESERVED_CONTEXT` enthält zusätzlich `pz_update`, `user_avatar` und `user_initials`: Ein `pz.context`-Haken mit diesen Namen führt beim Rendern zu einem Fehler.
173
+ - `run_parallel` endet ohne Tests mit Exit 5; `-b` schlägt ohne Chromium fehl.
174
+
175
+ ### Upgrade
176
+ - 0.2: Migration `0005_avatars` läuft beim Start. `.test-durations.json` in die `.gitignore` der App, wenn sie `run_parallel` nutzt. Wer
177
+ `/api/pz/me/oidc-unlink` selbst aufruft, schickt kein `password` mehr und legt `withFresh()` darum; `/api/pz/me/oidc-link` bleibt bei
178
+ Konten mit Passwort bei `{"password": …}` (S2-H1), ohne Passwort `withFresh()`. `forms.js` exportiert
179
+ `ensureFresh`/`withFresh` nicht mehr, sie stehen in `ui.js`.
180
+ - **Migration `0004_oidc_unlinked`** läuft automatisch und übernimmt `users.oidc_unlinked_sub`; die Spalte bleibt ungenutzt stehen
181
+ (ADR 0003: `users` nur additiv).
182
+ - **Altkonten vor Migration 0003** haben `created_via = NULL` und gelten als lokal angelegt, also als entkoppelbar. Wer reine
183
+ authentik-Konten davor schützen will, setzt in der App-Migration `created_via = 'oidc'` (Vorlage:
184
+ `tests/adoption/labelz/migration.py`).
185
+ - **Nach dem Update ist jede authentik-Sitzung zunächst so frisch wie ihre authentik-Anmeldung:** sensible Aktionen fragen öfter
186
+ nach. Fehlt `auth_time` im ID-Token, lassen sie sich gar nicht mehr bestätigen (Warnung im Protokoll) – Scope-Mapping prüfen.
187
+ - Am Provider prüfen: „Subject mode“ = „Hashed User ID“ oder „User UUID“; Signaturschlüssel gewählt (RS256); `post_logout_redirect_uri`
188
+ eingetragen (sonst Fehlerseite bei authentik nach dem Abmelden und die Sitzung dort bleibt). Details in `env.example` und ADR 0008.
189
+ - Images brauchen `ca-certificates`. Eigene CAs unter Python ab 3.13 mit `VERIFY_X509_STRICT` testen; die Meldung nennt den Grund.
190
+ - Tests mit `FakeIdP`: siehe „Changed“, insbesondere die Abmelde-Regeln und Sitzungen je Browser. Ein zweiter `TestClient` ist bei
191
+ authentik nun eine eigene Anmeldung (`idp.logins`).
192
+ - `CHANGELOG.md` der App ist ab `Pillarz(changelog=…)` für Endnutzer sichtbar: Einträge entsprechend schreiben.
193
+ - Konten ohne `seen_version` (NULL, auch alle aus 0.1) sehen beim nächsten Besuch **alle** Versionen des CHANGELOG
194
+ einmal (wie labelz F-16). Wer das nicht will, setzt `seen_version` in der App-Migration auf die laufende Version.
195
+ - Update-Check: ist die App nicht als Distribution `<name>` installiert (typisch im Container mit Quelltext), fragt
196
+ pillarz nichts ab, bis `UPDATE_CHECK_PACKAGE` oder `UPDATE_CHECK_URL` gesetzt ist.
197
+
198
+ ## [0.1.0] — 2026-10-30
199
+
200
+ **0.1.0 – Fundament.** Gegenüber 0.1.0.dev1 (Gerüst, Datenbank, Migrationen, Web-Rahmen, i18n) kommen
201
+ Sitzungen und Passwort-Anmeldung, die Ersteinrichtung, Konten mit Rollen und Events, Admin-Oberfläche und
202
+ Kontoseite, Einstellungen, authentik (OIDC) mit Verknüpfung und RP-Logout sowie die App-Hooks dazu. Damit deckt
203
+ pillarz ab, was labelz heute in `security.py`, `oidc.py`, `linking.py`, `events.py`, `themes.py`, im allgemeinen
204
+ Teil von `settings.py` und in den Konten-/Admin-Teilen von `api/` hat. Nachweis: `examples/minimal` und die
205
+ Referenz-Umstellung `tests/adoption/labelz/` (siehe Upgrade). Breaking-Abschnitt in zwei Teilen: gegenüber
206
+ 0.1.0.dev1 und gegenüber dem heutigen Verhalten von labelz.
207
+
208
+ ### Added
209
+
210
+ **Sitzungen und Passwort (ADR 0005)**
211
+ - Serverseitige Sitzungen in `pz_sessions`: Cookie `__Host-<name>` bei reinem HTTPS (sonst `<name>`), `HttpOnly`,
212
+ `SameSite=Lax`, 256 Bit Zufall, in der Datenbank nur der SHA-256. Eine Abfrage je Request (statische Dateien
213
+ ohne), absolute Dauer `SESSION_DAYS`, optionaler Leerlauf `IDLE_TIMEOUT_MINUTES` (0 = aus, sonst mindestens 5;
214
+ endet nie zu früh, höchstens eine Minute zu spät), `last_seen_at` höchstens 1×/60 s; Anfragen mit
215
+ `X-PZ-Background: 1` (`api(…, {background: true})`) verlängern nicht. Neue Sitzung bei jeder Anmeldung
216
+ (Fixation), gesperrte Konten sind sofort abgemeldet.
217
+ - **Aufräumen beim Start** (P-43): abgelaufene und wegen Leerlauf ungültige Sitzungen, abgelaufene Flows, alte
218
+ Bremsen-Einträge und erledigte Einmal-Tokens (> 30 Tage) werden gelöscht – still, `user.signed_out` mit
219
+ `reason="expired"` folgt mit 0.2.
220
+ - Fremde oder ungültige Cookie-Werte unter dem Cookienamen (z. B. labelz' Starlette-Cookie `labelz`, Müll,
221
+ gelöschte/abgelaufene Sitzungen) werden still verworfen: anonym, Antwort löscht das Cookie, kein
222
+ Protokolleintrag (P-106).
223
+ - `pillarz.web.session_id(request)`: öffentliche, stabile Kennung der Sitzung (`pz_sessions.public_id`, nicht
224
+ der Token-Hash), neu nach jeder Anmeldung, `None` ohne Sitzung (P-104).
225
+ - Seiten `GET/POST /login`, `POST /logout`, `GET/POST /setup` (ohne JavaScript) und JSON `POST
226
+ /api/pz/me/password`, `POST /api/pz/me/logout-all` (`{"keep_current": true}`), `POST /api/pz/me/reauth`,
227
+ `GET /api/pz/me/sessions`, `DELETE /api/pz/me/sessions/{session_id}`. `/api/pz/me/*` nimmt nur Sitzungen an.
228
+ `/login`, `/logout`, `/setup`, `/auth/` und `/api/pz/` sind `no-store` (Referrer-Policy wie überall
229
+ `same-origin`).
230
+ - Kopfzeile für Abgemeldete: „Anmelden“ bzw. „Einrichten“, solange `/setup` offen ist (auch auf Fehlerseiten,
231
+ nicht auf `/login`/`/setup` selbst).
232
+ - Protokoll auf der Konsole: hat niemand Logging eingerichtet (root-Logger ohne Handler, z. B. nur uvicorn),
233
+ schreibt pillarz die Einträge von `pillarz` und dem App-Logger ab INFO einzeilig auf stderr – so sieht der
234
+ Betreiber im Container z. B. die Warnung „Einrichtung offen“. Mit eigener Logging-Konfiguration nicht.
235
+ - Passwortregeln K-4 (10–200 Zeichen, nicht gleich dem Benutzernamen, kein Platzhalter, App-Liste
236
+ `Pillarz(weak_passwords=…)`); scrypt wie labelz mit Rehash schwächerer Parameter bei der Anmeldung.
237
+ - **Anmeldebremse in der Datenbank** (`pz_throttle`, über alle Worker/Container): 5 Fehlversuche je IP
238
+ (IPv6 je /64) und je Benutzername in 10 min, 100 insgesamt; für Anmeldung, Passwortwechsel, `reauth`.
239
+ `pillarz.web.throttled(key, limit, window_seconds)` und `hit(key)` für eigene Bremsen der App (eigener
240
+ Namensraum, Fenster ≤ 24 h).
241
+ - Schritt-hoch (Server-Teil): `reauth_at` je Sitzung, frisch für 5 min nach Anmeldung oder `POST
242
+ /api/pz/me/reauth`; pillarz-Aktionen antworten sonst 403 mit `reauth` (Methoden). Für App-Routen noch nicht
243
+ öffentlich.
244
+ - Geräteliste: Gerät aus dem User-Agent („Firefox auf Linux“), Methode, angemeldet seit, zuletzt aktiv, IP,
245
+ „dieses Gerät“.
246
+ - Kurzlebiger Vorab-Zustand für Anmeldeabläufe (`pz_flows`, Cookie `<name>_flow`, einmalig, 10 min).
247
+
248
+ **Ersteinrichtung (ADR 0015)**
249
+ - `/setup` ist offen nur ohne `ADMIN_PASSWORD`, mit Methode `password` und ohne Konten; `/login` leitet dann
250
+ dorthin, danach antwortet `/setup` 404 und leitet nach dem Anlegen auf `/admin`. Atomar über
251
+ `pz_meta.setup_done` (zwei gleichzeitige Versuche → ein Admin, der zweite 409). Optional `SETUP_TOKEN`
252
+ (konstante Zeit, gebremst). Startwarnung (Stufe Warnung) und stündliche Wiederholung, solange offen;
253
+ Warnhinweis auf der Seite bei `https://`-Adresse ohne Token.
254
+ - Erstes Admin-Konto aus `ADMIN_USERNAME`/`ADMIN_PASSWORD` beim Start (nur leere Datenbank).
255
+
256
+ **Konten, Rollen, Events (ADR 0006)**
257
+ - Rollen `admin`, `user`, `guest`. `pillarz.accounts` (lesend): `User`, `get_user`, `find_user`, `list_users`,
258
+ `count_active_admins`. `require_member` (user + admin) für normale App-Seiten, `require_user` lässt auch Gäste
259
+ durch; `require_admin`/`is_admin` rechnen mit der wirksamen Rolle (befristete authentik-Admin-Rechte der
260
+ Sitzung, Deckel `max_role` eines Authenticators).
261
+ - `pz.on(event, order=…)`: tx-Events `user.created`, `user.updated`, `user.deactivating`, `user.reactivated`,
262
+ `user.deleting` (in der pillarz-Transaktion, `ev.conn`; `ev.veto(text)` bei created/deactivating/deleting →
263
+ 409, alles zurückgerollt, auch Schreibzugriffe anderer Handler), Nach-Commit-Events `user.deleted` (`result`),
264
+ `user.signed_in` (`session_id`, `method`), `user.signed_out` (`session_ids`, `reason`) – Fehler dort werden
265
+ protokolliert und geschluckt (K-8). `user.created` mit `source` = `admin`, `setup`, `env`, `cli`, `oidc`.
266
+ `user.delete_preview` (Hinweiszeilen, `ev.block(text)`; läuft beim Löschen ein zweites Mal in der
267
+ Lösch-Transaktion – rein lesend halten). Nutzlasten in `pillarz.events` (`READ_EVENTS` exportiert). Derselbe
268
+ Handler zweimal für dasselbe Event → `ValueError`.
269
+ - `connect()` in einem tx-Event-Handler liefert auf demselben Thread die laufende Transaktion (auch
270
+ verschachtelt). Threads in tx-Handlern sind verboten: andere Threads warten höchstens `TX_WAIT_SECONDS` (10 s)
271
+ und bekommen dann `pillarz.db.TxThreadError` statt zu verklemmen.
272
+ - `pz.delete_input(DeleteInput(key, kind="user|choice|bool|text", label, required, default, help, options,
273
+ on_self, self_default, max_length, visible))` – Zusatzangaben im Löschdialog, serverseitig geprüft, als
274
+ `ev.inputs` (`MappingProxyType`). `kind="user"` wird bei Selbstlöschung nie abgefragt und braucht dann
275
+ `self_default` (Wert oder `fn(user, conn)`), sonst bricht `create_app()` ab; `visible=fn(user, conn)` fragt
276
+ nur, wenn es etwas zu entscheiden gibt.
277
+ - **Konto selbst löschen** (F-3): `Pillarz(self_delete=True)` (Standard), `GET /api/pz/me/delete`, `DELETE
278
+ /api/pz/me` mit `{"inputs", "confirm": <benutzername>}` – Schritt-hoch, letzter aktiver Admin gesperrt, Cookie
279
+ gelöscht, Ziel `/login?hint=account-deleted` (bzw. RP-Logout). `self_delete=False` → Abschnitt fehlt,
280
+ Endpunkte 404.
281
+
282
+ **Admin-Oberfläche und Kontoseite**
283
+ - `/admin` mit Reitern Konten (anlegen, bearbeiten, Rolle, sperren, Passwort setzen, Sitzungen beenden,
284
+ Löschdialog mit Zusatzangaben, Vorschau und „Stattdessen sperren“), authentik-Links, Geräte, Themes,
285
+ Einstellungen, Protokoll (Filter Bereich, Konto, nur Probleme, Blättern); Hash-Routing `/admin#<key>[-<id>]`.
286
+ `pz.admin_tab(AdminTab(key, label, order, icon, template, module, visible))` – ES-Modul mit `init(panel, ctx)`;
287
+ belegte Schlüssel: `users`, `links`, `sessions`, `themes`, `settings`, `log`.
288
+ - `/account` (alle Rollen): Profil, Darstellung (Theme, hell/dunkel/auto), Sprache, Anmeldung (Passwort,
289
+ authentik), Geräte, Konto löschen; `pz.account_section(AccountSection(…))` mit `init(section, ctx)`. Abschnitte
290
+ inaktiver Methoden fehlen, ihre Endpunkte antworten 404. `GET/PATCH /api/pz/me`, `GET /api/pz/themes`.
291
+ - Admin-API unter `/api/pz/admin/…` (Konten, Sitzungen, Themes, Einstellungen, Protokoll); `GET
292
+ /api/pz/admin/users` liefert `elevated_only`, `…/users/{id}/delete` `reauth` (`{needed, methods}`).
293
+
294
+ **Einstellungen**
295
+ - `pz.setting(Setting(key, default, kind="text|int|bool|choice|custom", label, help, ui, validate, options,
296
+ required, max_length, min, max))`; `kind="custom"` verlangt `validate`. Öffentlich
297
+ `pillarz.settings.get/all/update` (`SettingError` mit `key`). pillarz-Schlüssel `site_name`, `default_theme`,
298
+ `default_locale` wirken im Seitenrahmen (Zwischenspeicher je Prozess, höchstens 5 s alt). Meldungen am Feld,
299
+ Werte werden nie still gekappt; „alt → neu“ im Protokoll.
300
+ - `pz.settings_section(SettingsSection(key, label, order, keys, template, module))` – App-Abschnitt im Reiter
301
+ „Einstellungen“, speichert über `ctx.save()` (`PATCH /api/pz/admin/settings`, 422 mit `field`); ein Schlüssel
302
+ in zwei Abschnitten oder ein nicht angemeldeter Schlüssel → Startabbruch.
303
+
304
+ **authentik / OIDC (ADR 0008)**
305
+ - Anmeldung über `/auth/login` → `/auth/callback` (Authorization Code + PKCE, `state`/`nonce` in `pz_flows`,
306
+ ID-Token mit den Verfahren der Discovery – HS256 nur, wenn der Anbieter es meldet –, Userinfo). **Kontozuordnung
307
+ nur über die verknüpfte `sub`, nie über die E-Mail-Adresse** (Leitung 2026-10-30); unbekannte `sub` → Anlage
308
+ bei Gruppe (Adresse aus authentik, wenn frei; bestätigt nur bei `email_verified`) → sonst 403. Gruppen
309
+ `OIDC_USER_GROUPS` (`user`), **neu** `OIDC_GUEST_GROUPS` (`guest`), `OIDC_ADMIN_GROUPS` (befristete
310
+ Admin-Rechte je Sitzung, `OIDC_ADMIN_HOURS`; die Rolle am Konto ändert die Anmeldung nie). Knopf „Mit
311
+ <OIDC_LABEL> anmelden“, `#boot.auth` (`{"password": bool, "oidc": {"enabled", "label"}}`).
312
+ - pillarz-Migration `0003_users_oidc_origin`: `users.created_via` (Quelle der Anlage wie `user.created`
313
+ `source`; `NULL` = vorher/übernommen = lokal) und `users.oidc_unlinked_sub` (private Spalten).
314
+ - **RP-initiated Logout:** `POST /logout` einer authentik-Sitzung leitet zu `end_session_endpoint` mit
315
+ `id_token_hint`, `client_id` und `post_logout_redirect_uri=<PUBLIC_URL>/login?hint=logged-out`; JSON-Antworten von
316
+ `DELETE /api/pz/me` (`hint=account-deleted`) und `DELETE /api/pz/me/sessions/{id}` für die **aktuelle** Sitzung
317
+ tragen das Ziel in `redirect`. GET, weil authentik POST mit 403 (CSRF) ablehnt; Restrisiko ADR 0008. Schalter
318
+ `OIDC_LOGOUT` (Standard 1). „Überall abmelden“ und fremde Sitzungen beenden bleibt lokal.
319
+ - **Schritt-hoch per authentik:** `/auth/login?reauth=1&next=…` mit `prompt=login`, `max_age=300` (authentik 2026.8
320
+ wertet `max_age=0` nicht aus und erzwingt `prompt=login` nur einmal je Sitzung, ADR 0008); frisch nur bei `auth_time`
321
+ ≤ 5 min (+ 60 s Spielraum) und demselben Konto – eine jüngere authentik-Anmeldung reicht ohne neue Abfrage. Hat ein
322
+ Konto Passwort und authentik, bietet der Dialog beide Wege („Mit <OIDC_LABEL> bestätigen“).
323
+ - **Falscher `OIDC_ISSUER` ist auffindbar:** Discovery-Prüfung beim Start im Hintergrund (Timeout 5 s, hält den Start
324
+ nicht auf, bricht ihn nicht ab, nicht im Testmodus). Jeder Fehlschlag steht mit Adresse und Status im Protokoll,
325
+ getrennt nach falsch eingerichtet (404, anderer Issuer, kein Discovery-Dokument, Zertifikat) und nicht erreichbar
326
+ (Verbindung, Timeout, 5xx), je Ursache höchstens einmal pro Minute. `/admin` zeigt dazu einen Hinweis; `/auth/login`
327
+ meldet bei falscher Einstellung „nicht eingerichtet“ statt „nicht erreichbar“.
328
+ - Kontenliste: Chip „Admin, befristet bis HH:MM“ für Nutzer und Gäste mit laufenden befristeten Admin-Rechten; neues
329
+ Feld `elevated_until` in `GET /api/pz/admin/users`.
330
+ - **Zertifikate:** Die OIDC-Abrufe vertrauen dem Zertifikatsspeicher des Systems statt dem certifi-Bündel von httpx,
331
+ `OIDC_CA_FILE` kommt zusätzlich dazu.
332
+ - **Verknüpfung** (labelz E-35–E-37): Admin-Einmal-Link (`pz_tokens`, Art `oidc_link`, 7 Tage, nur im Fragment
333
+ `/auth/link#…`, optional erwarteter Benutzername), Einlöse-Seite, `POST /api/pz/link/info|start`, `POST/DELETE
334
+ /api/pz/admin/users/{id}/oidc-link`, `PATCH …/users/{id}` mit `oidc_unlink`; Selbst verknüpfen/lösen auf der
335
+ Kontoseite (`POST /api/pz/me/oidc-link|oidc-unlink` mit Passwort). Beide Verknüpfungswege verlangen bei authentik
336
+ eine neue Anmeldung (`prompt=login`, `max_age=60`). **Über authentik entstandene Konten (`created_via = 'oidc'`) lassen sich
337
+ nicht lösen** – 409, die UI bietet es nicht an („Zugang entziehen = Konto sperren“); `oidc_created` in den
338
+ Konto-JSONs. Nach dem Lösen legt dieselbe `sub` kein neues Konto an, bis sie wieder verknüpft ist.
339
+
340
+ **Hooks für Apps**
341
+ - **`pz.context(fn)`** (P-9): `fn(request, user) -> dict` liefert Werte für den Template-Kontext jeder Seite
342
+ und für Routen über `pillarz.web.request_context(request)` (danach auch `request.state.app_context`) – z. B.
343
+ Navigations-Badges wie labelz' `nav_answers`. Nur für Angemeldete, höchstens einmal je Request und erst bei
344
+ Bedarf (erstes `render()`/`request_context()`). Mehrere Haken in Anmeldereihenfolge, `render(**ctx)` hat
345
+ Vorrang; von pillarz belegte Schlüssel → Fehler. Eine Ausnahme im Haken ergibt 500 mit Fehlerseite und
346
+ Protokolleintrag.
347
+ - **Request-Authenticators** (P-99): `@pz.request_authenticator(name, max_role="user", methods=("GET","HEAD"))`
348
+ mit Rückgabe `None`, Konto-ID oder `pillarz.web.AuthResult(user_id, context)`; `pillarz.web.auth_context(request)`.
349
+ Läuft vor dem Gate, nur ohne Sitzung, sieht den Pfad; Rolle gedeckelt, pillarz-Seiten und `/api/pz/*` → 403,
350
+ keine Nebenwirkungen (keine Sitzung, kein Cookie, kein `last_seen`, kein `user.signed_in`).
351
+ - `pz.secret_user_column(name)` (S-N6): Spalte von `users` als geheim melden – sie erscheint nie in Kontozeile,
352
+ Templates oder JSON.
353
+ - `pillarz.db`: `scalar`, `execute`, `TxThreadError`; jedes Symbol in `__all__` mit Docstring (P-100).
354
+ - `ui.js`: `numProblem(input)`, `fieldError(input, text)`/`clearFieldError(input)` (mit `aria-invalid`,
355
+ `aria-describedby`), `validationMessage(input)` (`data-msg-required|pattern|type`), `clearToasts(type)`;
356
+ `ui.dialog()` meldet ungültige Felder am ersten Feld. Theme-Token `--pz-input-line` (≥ 3:1) und
357
+ `--pz-accent-text` (≥ 4,5:1), `/themes.css` liefert `--t-accent-text` je Theme und Modus.
358
+
359
+ **Testkit, Beispiel, Referenz-Umstellung, CI**
360
+ - `pillarz.testing.make_user()` (liefert `User`), `login()` (echter `POST /login`), `login_as()` (Sitzung direkt
361
+ in der Datenbank, echte `Set-Cookie`-Antwort), `PASSWORD`; `FakeIdP` (simulierter authentik: Discovery, JWKS,
362
+ Token, Userinfo, `end_session_endpoint` mit Prüfung von `id_token_hint` (POST → 403 wie authentik), `auth_time`;
363
+ `max_age` und `prompt=login` entscheidet er wie authentik 2026.8 (`markers`, `login_ids`, `logins`);
364
+ prüft `response_type`, `scope`, `code_challenge_method`; Schalter `algs`, `auth_time`,
365
+ `registered_post_logout`, `end_session_post`; `idp.env()`, `idp.install()`, `idp.serve()`),
366
+ `oidc_login(client, idp, claims, next=…, reauth=…)`, `oidc_callback(…)`.
367
+ - `examples/minimal`: `require_member`, `/password`, `/notes`, Events mit Zusatzangabe `heir` (nur bei
368
+ freigegebenen Designs), Vorschau und Veto, App-Reiter „Designs“, Einstellungs-Abschnitt „Notizen“,
369
+ App-Einstellungen `greeting` und `designs_per_page`, `auth_methods=("password", "oidc")`.
370
+ - **Referenz-Umstellung labelz, Schritt 1** (P-84) in `tests/adoption/labelz/`: synthetische labelz-0.13-
371
+ Datenbank, Vorlage der App-Migration (`migration.py`) und eine Mini-App mit labelz' Andockpunkten; läuft auf
372
+ SQLite und PostgreSQL (siehe Upgrade).
373
+ - CI: zusätzlicher Testlauf mit **Python 3.13** (SQLite, gleiche Hash-Pins; 3.12 bleibt Hauptversion für Locks,
374
+ PostgreSQL-Job und Build), Build wartet auf ihn (P-87).
375
+ - `python -m tests.browser_smoke`: Ersteinrichtung, Anmelden, Kontoseite, Admin-Reiter, Nutzer-Sicht,
376
+ Selbstlöschung, Passwort ändern, Abmelden per Tastatur; zweite Instanz mit authentik gegen `FakeIdP`
377
+ (RP-Logout, Admin-Dialoge Link erstellen/zurückziehen/Verknüpfung lösen).
378
+
379
+ ### Changed
380
+ - `require_*`, `current_user`, `is_admin`, `via` arbeiten auf der echten Sitzung (in 0.1.0.dev1 waren sie
381
+ Hüllen ohne Anmeldung) – siehe Breaking.
382
+ - HTML-Formulare (Nicht-GET außerhalb `/api/` mit `Accept: text/html`) bekommen bei Ablehnung oder Fehler die
383
+ Fehlerseite statt JSON – auch bei frühen Ablehnungen des Guards (CSRF, Größe); 401 → `/login`. `fetch` mit
384
+ `Accept: application/json` und `/api/` bleiben JSON.
385
+ - Fehlerseiten: eigener Titel je Code („Kein Zugriff“, „Seite nicht gefunden“, „Gerade nicht erreichbar“),
386
+ Erklärung mit nächstem Schritt statt Standardmeldung (eigene Meldungen der App bleiben); angemeldet zeigen
387
+ 403/404 die Navigation.
388
+ - Toasts: gleiche Texte zusammengefasst (×N), höchstens zwei, Erfolg 4 s; Fehler am Feld entfernen offene
389
+ Erfolgsmeldungen.
390
+ - Die einmalige Rückmeldung (Flash) wird erst verbraucht, wenn eine Seite mit Rahmen sie zeigt.
391
+ - Protokolltexte der Anmeldung enthalten die IP nicht mehr; sie steht nur in der Spalte `ip`.
392
+ - Leeres `PUBLIC_URLS` warnt im Testmodus nicht mehr (gemischt http/https weiterhin immer).
393
+ - `env.example` vollständig für 0.1.0 (Test: jede gelesene Variable steht dort).
394
+
395
+ ### Fixed
396
+ - Firefox/Zen über http zu einer Nicht-localhost-Adresse: Anmelden und Einrichten scheiterten mit „Anfrage von
397
+ fremder Seite abgelehnt.“ – `Referrer-Policy: no-referrer` auf no-store-Seiten ließ Firefox beim Formular-POST
398
+ `Origin: null` senden (ohne `Sec-Fetch-Site`). Diese Seiten haben jetzt `same-origin`;
399
+ `testing.assert_security_headers` verlangt `same-origin`.
400
+ - `pillarz.db.reset()` (nur Tests) scheiterte auf SQLite an App-Schemata mit mehrstufigen Fremdschlüsseln auf
401
+ `users` („no such table: main.users“, z. B. labelz) – löscht jetzt ohne Fremdschlüsselprüfung.
402
+
403
+ ### Security
404
+ - Gleiche Meldung und Antwortzeit für „falsch“, „unbekannt“ und „gesperrt“ (Dummy-Hash mit aktuellen
405
+ Parametern); gesperrtes Konto + richtiges Passwort → dasselbe 401, Grund nur im Protokoll (S-N5).
406
+ - Bremse ohne Wettlauf: Versuche werden vor scrypt atomar reserviert (SQLite `BEGIN IMMEDIATE`, PG
407
+ `pg_advisory_xact_lock`), 40 parallele Fehlversuche ergeben höchstens 5 Prüfungen. scrypt höchstens viermal
408
+ gleichzeitig je Prozess; sind alle Plätze > 2 s belegt: 503 mit `Retry-After` (zählt nicht als Fehlversuch).
409
+ - Kein Aussperren durch Fremde: Passwortbestätigungen angemeldeter Sitzungen zählen unter `confirm:<user_id>`;
410
+ Bremsschlüssel nur aus gültigen Benutzernamen; greift ein Konto- oder der globale Schlüssel, warnt das
411
+ Protokoll.
412
+ - Passwortwechsel rotiert das Sitzungstoken und beendet die anderen Sitzungen in einer Transaktion; ein erstes
413
+ eigenes Passwort (Konto ohne Passwort) verlangt eine frische Sitzung.
414
+ - Rollenwechsel erhöht `session_epoch`; wer zum Admin befördert wird, verliert alle Sitzungen.
415
+ - Admin-Aktionen „Admin-Rolle vergeben“, „Passwort/E-Mail eines Admin-Kontos ändern“ und „Konto löschen“
416
+ brauchen Schritt-hoch (403 mit `reauth`). **Befristete Admins** (nur authentik-Gruppe) dürfen keine
417
+ Admin-Konten anlegen, befördern, herabstufen, sperren, entsperren, löschen oder deren Verknüpfung ändern – Rolle
418
+ und Sperre eines Admin-Kontos auch dann nicht, wenn es gesperrt ist (403, Warnung im Protokoll).
419
+ - Letzter aktiver Admin: Herabstufen, Sperren und Löschen (auch selbst) gesperrt, gezählt mit Sperre in der
420
+ Transaktion. K-9: eigenes Passwort, eigene E-Mail und eigene Verknüpfung nicht über `/api/pz/admin/…` (403).
421
+ - **Keine authentik-Zuordnung über die E-Mail-Adresse** (authentiks Standard-Mapping meldet `email_verified`
422
+ immer `true` – sonst Übernahme fremder Konten, auch von Admins). Link-Prüfung gebremst (10 / 10 min je IP,
423
+ IPv6 je /64), Link-Anlage 20 / min je Admin.
424
+ - ID-Token: nur Verfahren aus `id_token_signing_alg_values_supported` (ohne Angabe nur RS256), `azp` geprüft,
425
+ wenn vorhanden, `nbf` mit Spielraum, `iat` Pflicht; kaputtes JWKS und Nicht-String-Token → 400 statt 500. In
426
+ `pz_sessions` liegt es AES-256-GCM-verschlüsselt (Schlüssel per HKDF aus `SECRET_KEY`).
427
+ - `/auth/callback` verbraucht einen Flow nur bei passendem `state`; ohne passenden Flow kein Angreifertext im
428
+ Protokoll (eine feste, entprellte Zeile), Anbieter-`error` erst nach gültigem Flow. Flows gebremst je IP (/64)
429
+ und insgesamt (3000 / 10 min). Einlösen, Selbst-Verknüpfen und Lösen mit `BEGIN IMMEDIATE` (kein 500 bei
430
+ Wettlauf). Selbst-Lösen nimmt der laufenden Sitzung befristete Admin-Rechte und das ID-Token.
431
+ - `OIDC_ISSUER` mit `http://` nur für localhost, sonst Startabbruch.
432
+ - IDs > 2⁶³−1 in Pfad, Query und `DeleteInput(kind="user")` → 404/422 statt 500.
433
+ - Anmeldemethode `password` aus: `POST /login`, `/api/pz/me/password`, `/api/pz/me/reauth` → 404.
434
+
435
+ ### Breaking
436
+
437
+ **Gegenüber 0.1.0.dev1**
438
+ - `current_user()` / `request.state.user` ist ein `pillarz.accounts.User` (eingefrorene Datenklasse der
439
+ öffentlichen Spalten: `user.id`, `user.username`, `user.role`, `user.is_guest` …) statt eines dicts – ebenso
440
+ die Rückgabe von `require_*`, `user` in Templates, `NavItem.visible/badge`, `pz.boot(request, user)`. Keine
441
+ internen Spalten (`theme`, `mode`, `oidc_*`, `password_hash`).
442
+ - pillarz-Migration `0003_users_oidc_origin` ergänzt `users.created_via` und `users.oidc_unlinked_sub` (privat,
443
+ additiv) – App-Code, der `SELECT * FROM users` 1:1 in eigene Strukturen kopiert, sieht zwei Spalten mehr.
444
+ - `ui.readNum` begrenzt und rundet nicht mehr still: Werte außerhalb von `min`/`max` oder (bei `int`) mit
445
+ Nachkommastellen ergeben `NaN`; den Grund liefert `numProblem(input)`.
446
+
447
+ **Gegenüber labelz (für die Umstellung)**
448
+ - **K-4:** neue Passwörter brauchen 10–200 Zeichen (labelz: 8). Bestehende kürzere melden sich weiter an und
449
+ bekommen einmal den Hinweis „bitte ändere es“.
450
+ - **K-15:** ein schwaches `ADMIN_PASSWORD` (oder ungültiger `ADMIN_USERNAME`) bei leerer Datenbank bricht den
451
+ Start ab (Exit-Code ≠ 0); labelz legte dann nur kein Konto an. Ohne `ADMIN_PASSWORD` ist bei leerer Datenbank
452
+ `/setup` offen.
453
+ - **K-8:** `user.signed_out` läuft **nach** dem Commit; ein Fehler im Handler verhindert das Abmelden nicht
454
+ (labelz räumte im Logout-Request selbst auf).
455
+ - **Alle Sitzungen enden beim Umstieg einmal:** labelz' Cookie-Sitzungen gelten nicht, das alte Cookie wird
456
+ verworfen und gelöscht.
457
+ - Gesperrtes Konto + richtiges Passwort → 401 wie ein falsches Passwort (labelz: 403 „gesperrt“).
458
+ - Rollen statt `is_admin`: `users.role` (`admin|user|guest`); `is_admin` liest pillarz nicht.
459
+ - **Keine authentik-Zuordnung per E-Mail mehr** (labelz: bestätigte Adresse, `OIDC_TRUST_EMAIL`).
460
+ `OIDC_TRUST_EMAIL` entfällt (gesetzt → Startwarnung, keine Wirkung). Konten ohne verknüpfte `sub` verbindet nur
461
+ noch ein Admin-Link oder die Selbst-Verknüpfung; bis dahin endet ihre authentik-Anmeldung mit 403 (oder – mit
462
+ Gruppe – in einem neuen Konto).
463
+ - Durch authentik-Anmeldung entstandene Konten lassen sich nicht entkoppeln (labelz: mit Passwort möglich);
464
+ nach dem Lösen legt dieselbe `sub` kein neues Konto an (labelz: Neuanlage per Gruppe).
465
+ - Verknüpfen verlangt bei authentik eine neue Anmeldung (`prompt=login`, `max_age=60`); RP-Logout statt nur lokal abmelden.
466
+
467
+ ### Upgrade
468
+ - **Von 0.1.0.dev1:** `user["x"]` → `user.x`; ob ein Konto ein Passwort hat, liefert `GET /api/pz/me`
469
+ (`has_password`). Eigene `kind="custom"`-Einstellungen mit `validate=` anmelden. Preview-Handler rein lesend
470
+ halten; Texte von Vorschau und `DeleteInput.label` mit `lazy()` und Katalogeintrag der App. Ob die Rolle eines
471
+ Erben passt, prüft die App im `user.deleting`-Handler.
472
+ - **authentik:** Das Image braucht `ca-certificates`; eine eigene CA dort einspielen oder über `OIDC_CA_FILE` angeben.
473
+ `OIDC_TRUST_EMAIL` aus der Umgebung entfernen. Am Provider je `PUBLIC_URLS`-Eintrag die Redirect-URI `<url>/auth/callback` und die
474
+ Post-Logout-Redirect-URIs `<url>/login?hint=logged-out` und `<url>/login?hint=account-deleted` eintragen;
475
+ ohne `PUBLIC_URLS` keine authentik-Anmeldung (Startwarnung). Apps, die authentik anbieten, nehmen `"oidc"` in
476
+ `auth_methods` auf.
477
+ - **Ersteinrichtung:** neue Instanzen ohne `ADMIN_*` erst einrichten, dann öffentlich erreichbar machen – oder
478
+ `SETUP_TOKEN` bzw. `ADMIN_*` setzen (`env.example`).
479
+ - **labelz (Vorlage `tests/adoption/labelz/migration.py`, Architektur §5.4):** App-Migrationen
480
+ `0001_legacy` (bisheriges `create_schema()`) und `0002_pillarz_adoption`: `is_admin = 1` → `role = 'admin'`
481
+ (Spalte bleibt stehen), `oidc_links` → `pz_tokens` (`kind='oidc_link'`, `data.expected_username` über
482
+ `json_object_sql`, dann `DROP TABLE`), `themes` → `pz_themes`, `meta['settings']` → `pz_settings`
483
+ (`site_name`, `default_theme` + registrierte App-Settings; `meta` selbst bleibt), `events` der letzten 30 Tage
484
+ → `pz_log`. pillarz' `0001_initial` übernimmt `users` additiv (nie neu gebaut), setzt `email_confirmed = 1` für
485
+ vorhandene Adressen und `setup_done`. Die Vorlage setzt `created_via = 'oidc'` für reine authentik-Konten
486
+ (verknüpft, ohne Passwort) – diese Annahme trifft das App-Team. Der Test beweist auf SQLite und PostgreSQL: alte Passwörter (auch 8 und
487
+ 9 Zeichen, alte scrypt-Parameter) melden sich an, authentik-Konten per `sub` (nie per Adresse), offene Links
488
+ bleiben einlösbar,
489
+ verbrauchte/abgelaufene nicht, Admin aus `is_admin`, `/setup` zu, Themes/Einstellungen/Protokoll übernommen,
490
+ alle Zeilen der Fachtabellen und alle Fremdschlüssel unverändert, Trockenlauf ändert nichts. Eigene geheime
491
+ Altspalten mit `pz.secret_user_column(name)` melden.
492
+ - Apps, die bisher eigene Sitzungs-Cookies unter `<name>` setzen, verlieren diese Sitzungen einmal.
493
+
494
+ ## [0.1.0.dev1] — 2026-10-29
495
+
496
+ ### Added
497
+ - Paket und Auslieferung: `pyproject.toml` (setuptools, Version aus `pillarz.__version__`), Laufzeit-
498
+ Abhängigkeiten als Bereiche, exakte Locks mit Hashes (`requirements.txt`, `requirements-dev.txt`,
499
+ erzeugt von `scripts/lock-deps.sh`). CI baut bei jedem Push Wheel + Sdist und lädt sie bei `v*`-Tags
500
+ öffentlich nach **pypi.org** hoch (nur PyPI, keine Forgejo-Registry). Lizenz **`AGPL-3.0-or-later`**
501
+ (`LICENSE` in Repo, sdist und Wheel; SPDX-Ausdruck nach PEP 639), Kurzbeschreibung `README.md`.
502
+ - `pillarz.Pillarz(name=…, title=…, version=…, package_dir=…, migrations=…, auth_methods=…,
503
+ weak_passwords=…)` mit `create_app()`, `on_startup()`, `on_shutdown()`, `migrate()` und `config`.
504
+ Genau eine Instanz je Prozess; Registrieren nach `create_app()` wirft `RuntimeError`. Datenklassen
505
+ `NavItem`, `AdminTab`, `AccountSection`, `DeleteInput`, `Setting`, `Theme` vorerst nur als Hüllen
506
+ (Felder folgen mit 0.1-Paketen B–F).
507
+ - `pillarz.config.Config.from_env()`: alle 0.1-Variablen (siehe `env.example`), gelesen beim Erzeugen
508
+ von `Pillarz`, nicht beim Import. Startabbruch (`ConfigError`) bei `SECRET_KEY` < 32 Zeichen,
509
+ `SETUP_TOKEN` < 16 Zeichen, ungültigen `PUBLIC_URLS` oder ohne aktive Anmeldemethode; harmlose
510
+ Fehler als Warnung in `Config.warnings` (beim Start protokolliert).
511
+ - `pillarz.db`: `Conn`/`connect()` aus labelz (`?`-Platzhalter, dict-Zeilen, eine Transaktion je
512
+ `connect()`), DDL-Platzhalter `ddl()` (`{pk}`, `{blob}`, `{bigint}`), `now()`/`now_plus()`/`stamp()`
513
+ über eine zentrale Uhr, Migrations-Helfer `has_table`, `has_column`, `add_column`, `create_index`,
514
+ `json_object_sql`, `upsert_sql`, `applied_migrations`, dazu `IntegrityError`, `ping`, `reset`
515
+ (nur Tests) und die labelz-Grundbausteine `all_rows`/`one`/`insert`/`update`/`delete`/`get` mit
516
+ ID-Grenzen.
517
+ - PostgreSQL-Verbindungspool je Prozess (`PG_POOL_SIZE`, Standard 5, `0` = ohne Pool).
518
+ - Versionierte Migrationen je Komponente (`pz_migrations`): pillarz zuerst, dann die App; eine
519
+ Transaktion je Schritt, Sperre gegen parallele Worker, Startabbruch bei Fehlern und bei unbekannten
520
+ (neueren) Einträgen. `python -m pillarz migrate [--dry-run|--status] <modul:objekt>`.
521
+ - `python -m pillarz create-admin <modul:objekt>`: erstes Admin-Konto interaktiv anlegen (nur bei leerer
522
+ Datenbank, Passwortregeln K-4: 10–200 Zeichen, nicht gleich dem Benutzernamen, kein Platzhalter).
523
+ - pillarz-Migration `0001_initial`: Tabelle `users` (öffentliche Spalten `id, username, name, email,
524
+ role, active, locale, region, created_at, last_login, session_epoch`) und private Tabellen
525
+ `pz_sessions`, `pz_flows`, `pz_throttle`, `pz_tokens`, `pz_settings`, `pz_themes`, `pz_log`, `pz_meta`.
526
+ - `pillarz.testing`: `configure()`, `clock` (`freeze`/`advance`), `reset_runtime()` – Grundstock des
527
+ Testkits.
528
+ - `pillarz.i18n` (ADR 0011): `_`, `ngettext`, `pgettext`, `lazy`, `current_locale()` und der
529
+ Kontextmanager `use_locale(sprache)`. msgids sind deutsch; Kataloge
530
+ `<package_dir>/locale/<sprache>/LC_MESSAGES/<name>.po` (Python + Jinja) und `<name>-js.po` (JS)
531
+ werden ohne `.mo` direkt aus den `.po` geladen. Kette **App → pillarz → Quelltext**: eine App kann
532
+ pillarz-Texte mit gleicher msgid umformulieren und weitere Sprachen liefern. `fuzzy`/leere Einträge
533
+ zählen als fehlend. `lazy("…")` liefert ein Objekt, das erst bei `str()`/im Template übersetzt (für
534
+ JSON vorher `str()`).
535
+ - Sprachwahl je Request: Konto (`users.locale`) → `Accept-Language` → `DEFAULT_LOCALE`, jeweils nur
536
+ unterstützte Sprachen. Unterstützt sind `de` plus die Sprachen der App-Kataloge – hat die App keine
537
+ Kataloge, die von pillarz (`en`). Ohne Request (Start, Jobs) gilt die Standardsprache.
538
+ - Jinja: `_`, `gettext`, `ngettext`, `pgettext`, `npgettext` und `{% trans %}` in allen Templates
539
+ (`newstyle`: Werte als Schlüsselwörter, `{{ _("Hallo %(name)s", name=…) }}` – escaped; ein
540
+ wörtliches `%` als `%%`).
541
+ - JavaScript: `/static/pillarz/js/i18n.js` mit `t(msgid, params)`, `nt(singular, plural, n, params)`,
542
+ `pt(kontext, msgid, params)`; Platzhalter `{name}`, bei `nt` ist `{n}` automatisch gesetzt. Die
543
+ Texte kommen aus dem JSON-Block `<script type="application/json" id="i18n">` der Seite (nur die
544
+ JS-Domänen der aktuellen Sprache, CSP-konform); Pluralregeln aus dem Katalog, ohne `eval`.
545
+ - `scripts/i18n.sh extract|update|init <sprache>|check` (Babel) – auch als Vorlage für Apps
546
+ (`PACKAGE_DIR`, `DOMAIN`). Schlüsselwörter: Python `_`, `gettext`, `ngettext`, `pgettext`,
547
+ `npgettext`, `lazy`, `_l`; JS `t`, `nt`, `pt`; Übersetzerhinweise mit `# TRANSLATORS:`.
548
+ - Englischer Katalog für alle pillarz-Texte (`pillarz/locale/en/LC_MESSAGES/pillarz.po`).
549
+ - `pillarz.testing.check_catalogs(package_dir=None, name=None, *, locales=None)`: Liste fehlender,
550
+ `fuzzy` oder leerer Übersetzungen, fehlender Pluralformen, unpassender `%(…)`-Platzhalter und
551
+ fehlender Kataloge (ohne Argumente: die aktuelle App). `pillarz.testing.untranslated()`: sammelt
552
+ Texte, die in einer anderen Sprache als Deutsch unübersetzt angezeigt wurden.
553
+ - `examples/minimal`: kleinste App mit einer App-Migration.
554
+ - Web-Rahmen (0.1-Paket B): `create_app()` setzt Guard (reine ASGI-Middleware: Request-ID, CSRF über
555
+ `Sec-Fetch-Site`/`Origin`, Body-Grenzen 1 MiB bzw. Upload-Registry, 411 ohne Länge), das Gate
556
+ **„default deny“** (globale Dependency: jede Route ohne `Depends(pillarz.web.public)` bzw.
557
+ `@pillarz.web.public_route` verlangt eine Anmeldung – anonym 401, bei HTML-GET `303 /login?next=…`),
558
+ Sicherheits-Header auf jede Antwort (`nosniff`, `Referrer-Policy: same-origin`, CSP, HSTS nur wenn alle
559
+ `PUBLIC_URLS` https sind, `X-Request-ID`, `Vary: Cookie`), Fehler-Handler wie labelz (JSON für `/api/…`
560
+ und Nicht-GET, Fehlerseite für 403/404, Pfad-Validierung → 404, `UnicodeEncodeError` → 422,
561
+ `psycopg_pool.PoolTimeout` → 503 mit `Retry-After: 5`), Static mit `no-cache` + ETag unter `/static/`
562
+ (App) und `/static/pillarz/`, die öffentlichen Seiten `/healthz` und `/themes.css`.
563
+ - Neue Registrierungen an `Pillarz`: `nav(NavItem)`, `csp(**direktiven)` (nur Ergänzungen; `'unsafe-*'`
564
+ außer `'wasm-unsafe-eval'`, `*`, reine Schemata in `script-src`/`default-src` und Änderungen an
565
+ `object-src`/`base-uri`/`frame-ancestors` → `ValueError` beim Start), `upload_limit(pfad|re, bytes)`,
566
+ `no_store(pfad|re)`, `boot(fn)`, `log_source(logger, bereich, label)`, `theme(Theme)`, `icons(pfad)`.
567
+ Pfadregeln: String mit `/` am Ende = Präfix, sonst exakt; `re.compile(…)` muss den ganzen Pfad treffen.
568
+ - `NavItem(key, href, label, icon=None, order=100, visible=None, badge=None, badge_href=None,
569
+ badge_label=None)` und `Theme(id, name, light, dark)` mit Feldern.
570
+ - `pillarz.web`: `public`, `public_route`, `render`, `fail`, `csp_extend`, `read_form`, `client_ip`,
571
+ `base_url`, `current_user`, `is_admin`, `via`, `require_user`, `require_member`, `require_admin`,
572
+ `require_role(*rollen)`. Bis Paket D gibt es keine Sitzungen: `require_*` antworten anonym immer 401;
573
+ `throttled`/`hit` sind Hüllen (`NotImplementedError`).
574
+ - `pillarz.text`: `oneline`, `multiline`, `required_line`, `safe_next`, `to_json` (aus labelz).
575
+ - Templates `pillarz/base.html` (Blöcke `title`, `head`, `body_class`, `main_class`, `content`,
576
+ `footer_extra`, `scripts`, `icons`, dazu `topbar` und `footer`), `pillarz/error.html`,
577
+ `pillarz/plain.html` (Karte ohne Navigation, Blöcke `card`, `card_class`), Icon-Sprite
578
+ `pillarz/icons.svg` (Lucide, ISC). Template-Kette App → pillarz; Namen unter `pillarz/…` kommen immer
579
+ aus pillarz. Kontext: `user`, `is_admin`, `site_name`, `app_name`, `version` (anonym nur ein
580
+ Cache-Buster), `themes_rev`, `theme_id`, `mode`, `locale`, `region`, `nav`, `nav_items`, `flash`,
581
+ `boot`, `page`, `help`, `i18n`, `_`/`ngettext`/`pgettext` (Jinja-i18n, newstyle).
582
+ - Static: `/static/pillarz/css/base.css` (alles in `@layer pillarz`, Tokens `--pz-*` aus `--t-*`,
583
+ Komponenten `.btn`, `.icon-btn`, `.field`, `.banner-*`, `.card`, `.chip`, `.tabs`, `.tbl`, `.menu`,
584
+ `.dlg`, `.toasts`, `.topbar` …), `js/api.js` (`api`, `upload`, `postQuietly`, `ApiError`), `js/ui.js`
585
+ (`boot`, `pageData`, `region`, `readJson`, `h`, `icon`, `button`, `iconButton`, `toast`, `showError`,
586
+ `trapFocus`, `dialog`, `confirmDialog`, `promptDialog`, `field`, `numInput`, `readNum`, `selectEl`,
587
+ `checkbox`, `parseNum`, `fmtNum`, `fmtNumber`, `fmtDate`, `relTime`, `debounce`, `closeMenu`,
588
+ `menuIsOpen`, `attachMenu`, `menuButton`), `js/i18n.js` (`t`, `nt`, `pt`), `favicon.svg`.
589
+ - Themes: labelz' fünf eingebaute (Papier, Wald, Meer, Schiefer, Beere) + `pz.theme()` + eigene aus
590
+ `pz_themes`; `/themes.css` nur aus geprüften Hex-Werten, Akzentschrift nach WCAG-Kontrast.
591
+ - Protokoll: Einträge der Logger `pillarz` und `<name>` ab INFO in `pz_log`, mit `request_id`,
592
+ `actor_id` und `ip` aus dem Request (K-13); `extra={"pz_actor": id}` setzt das Konto selbst.
593
+ Aufbewahrung `LOG_RETENTION_DAYS` (neu, Standard 90) und höchstens 20 000 Einträge (K-14).
594
+ - `pillarz.testing.route_audit(app)` (jede nicht öffentliche Route anonym → 401/303→`/login`/404,
595
+ dazu Routen auf von pillarz belegten URLs und doppelte Routen) und `assert_security_headers(resp)`.
596
+ - `tests/browser_smoke.py`: manueller Browser-Smoke mit Playwright (Konsolen-/CSP-Fehler, Überlauf bei
597
+ 360 px, Screenshots); `playwright` steht in `requirements-dev.txt`.
598
+ - i18n im Web-Rahmen (Paket C in B): der Guard setzt die Sprache je Request – vor CSRF/Body-Prüfung aus
599
+ `Accept-Language`, nach der Identität mit der Kontosprache (`user["locale"]`) – und setzt sie bewusst
600
+ nicht zurück, damit auch der 500er-Handler in der Sprache des Requests antwortet. Alle Templates
601
+ bekommen `_`, `gettext`, `ngettext`, `pgettext`, `npgettext`, `{% trans %}` und `current_locale()`;
602
+ `#i18n` enthält die JS-Texte der Sprache (`{"locale", "plural", "messages"}`). Kataloge werden beim
603
+ Start geladen (kaputte `.po` → Startabbruch). Englische Texte für alle Seiten und Meldungen aus B.
604
+ - **Protokoll in der Standardsprache:** `pz_log`-Einträge stehen immer in `DEFAULT_LOCALE`, nicht in
605
+ der Sprache des auslösenden Requests. Log-Texte als `lazy("…")` mit `%`-Argumenten loggen
606
+ (`log.warning(lazy("Import von %s fehlgeschlagen"), name)`) – dann sind sie extrahierbar, und der
607
+ Protokoll-Handler übersetzt sie beim Schreiben unter `use_locale(None)`. Andere Handler (Konsole)
608
+ formatieren in der aktuellen Sprache. `Config.warnings` sind weiterhin `str` mit dem deutschen
609
+ Quelltext und werden beim Protokollieren übersetzt.
610
+ - `tests/browser_smoke.py --locales de,en` (Standard): je Sprache `<html lang>`, `#i18n`-Block und
611
+ Übersetzung durch `i18n.js` unter der CSP.
612
+ - `pillarz.db.requires_fk_off` (Dekorator für Migrationsschritte) und
613
+ `pillarz.db.rebuild_table_sqlite(conn, table, create_sql, columns=None)` (ADR 0003): Tabellenumbau auf
614
+ SQLite nach dem 12-Schritte-Verfahren. Markierte Schritte laufen auf SQLite mit
615
+ `PRAGMA foreign_keys = OFF` (vor `BEGIN` gesetzt) und `PRAGMA foreign_key_check` vor dem Commit –
616
+ Verstöße rollen zurück (`MigrationError`). Ohne die Markierung verweigert `rebuild_table_sqlite` den
617
+ Umbau, weil `DROP TABLE` sonst `ON DELETE CASCADE` in Kindtabellen auslöst. Auf PostgreSQL hat die
618
+ Markierung keine Wirkung; `rebuild_table_sqlite` gibt es dort nicht (`ALTER TABLE` nutzen).
619
+ - `pillarz.testing.is_test_dir(pfad)`.
620
+ - Beim Start: Fehler im Protokoll, wenn es Konten, aber kein aktives Admin-Konto gibt und
621
+ `OIDC_ADMIN_GROUPS` leer ist (Aussperrung nach der Übernahme einer Datenbank sichtbar machen).
622
+ - **Seiten ohne Rahmen** (P-98): `render(request, template, page=…, chrome=False)` mit einem Template,
623
+ das `pillarz/bare.html` erweitert (Blöcke `title`, `head`, `body_class`, `content`, `scripts`; JSON
624
+ `#page`, `#i18n`; `data-theme`/`data-mode`/`data-version` am `<html>`). Gleiche Header und CSP wie
625
+ jede Seite, aber ohne Navigation, Badges, Topbar, Fuß, Flash, Toasts, Hilfe, `#boot` und ohne
626
+ pillarz-CSS/-JS (`base.css` bei Bedarf selbst im Block `head`). Falscher Rahmen (`base.html` mit
627
+ `chrome=False`, `bare.html` ohne, gar kein Rahmen mit `chrome=False`) → `RuntimeError` beim Rendern.
628
+ Beispiel: `/print` in `examples/minimal` (auch im Browser-Smoke).
629
+ - **Protokoll: App-Quelle** (P-107): Logger unter `<name>` ohne eigene `pz.log_source()`-Zuordnung
630
+ landen in der Quelle `<name>` (Anzeigename = `title`) statt in `system`; bildet die App `<name>`
631
+ selbst ab, gilt ihre Zuordnung.
632
+ - `pz.public_mount(pfad)`: eine eingehängte Unter-App (`app.mount`) ohne Anmeldung erreichbar machen.
633
+ - **WebSockets** (FastAPI-`@router.websocket`): Gate und Guard gelten auch dort – `Origin` muss zum
634
+ `Host` passen (fehlender `Origin` = kein Browser, erlaubt), Identität wie bei HTTP
635
+ (`websocket.state.user`), ohne Anmeldung bzw. bei fremdem Origin Schließen mit 1008. `public`
636
+ funktioniert wie bei HTTP-Routen.
637
+ - `HEAD` wird für alle Routen wie `GET` beantwortet (ohne Inhalt), z. B. `HEAD /healthz`.
638
+ - `pillarz.testing.flush_log()`: wartet, bis das Protokoll geschrieben ist (vor dem Lesen von `pz_log`).
639
+ - `route_audit()` prüft WebSocket-Routen (anonym → 1008) und meldet Mounts (außer Static und
640
+ `pz.public_mount`), `Host`- und Starlette-Routen (`Route`/`WebSocketRoute`), an denen der Gate nicht
641
+ läuft. Eingebundene Router werden mit der ganzen Kette ausgewertet.
642
+
643
+ ### Changed
644
+ - **`pillarz.testing.configure()` erzwingt ein frisches temporäres `DATA_DIR`** (`<tmp>/pillarz-test-*`,
645
+ ein gesetztes `DATA_DIR` wird überschrieben, `DB_PATH` entfernt) und leert `POSTGRES_*` – außer bei
646
+ `PZ_TEST_PG=1`. Apps, die ihre Tests gegen PostgreSQL fahren, setzen `PZ_TEST_PG=1` und eine
647
+ Datenbank, deren Name `test` als Wortteil trägt (`labelz_test`, `pillarz_test_…`).
648
+ `pillarz.db.reset()` verweigert sonst (SQLite: Datei außerhalb eines Testverzeichnisses).
649
+ Unterprozesse eines Testlaufs übernehmen das Verzeichnis des Elternprozesses.
650
+ - `python -m pillarz migrate --status|--dry-run` legen keine fehlende SQLite-Datei mehr an (Meldung,
651
+ alle Schritte offen); `migrations.status()`/Trockenlauf ohne Datenbank → `MigrationError`.
652
+ - Wartet eine Migration auf SQLite länger als 120 s auf die Dateisperre, gibt es einen
653
+ `MigrationError` mit klarer Meldung statt `sqlite3.OperationalError`.
654
+ - `.secret_key` entsteht bei mehreren gleichzeitig startenden Prozessen genau einmal (Temp-Datei 0600 +
655
+ `os.link`); eine für Gruppe/andere lesbare Schlüsseldatei ergibt eine Warnung.
656
+ - **Protokoll schreibt im Hintergrund:** der Handler ist ein `QueueHandler`, ein eigener Thread schreibt
657
+ die Einträge stapelweise. Loggen blockiert weder den Event-Loop noch eine offene Transaktion
658
+ (vorher auf SQLite 5 s Sperre, verlorener Eintrag und danach „database is locked“; auf PG eine zweite
659
+ Pool-Verbindung). Einträge sind unabhängig von der Transaktion des Aufrufers (bleiben bei Rollback).
660
+ Beim Herunterfahren wird die Warteschlange geleert; Tests lesen `pz_log` nach `testing.flush_log()`.
661
+ - Protokoll-Obergrenze 20 000 Einträge gilt getrennt für `auth`/`admin` und alle übrigen Quellen.
662
+ - `log_once` drosselt nach festen Schlüsseln (Routen-Template statt Pfad) und verdrängt bei Überlauf
663
+ die ältesten Schlüssel, statt alle zu vergessen.
664
+ - HTML-GET mit unerwartetem Fehler bekommt die Fehlerseite (500) statt JSON.
665
+ - Templates: `autoescape` für alle Templates (unabhängig von der Endung).
666
+ - Body-Grenzen gelten für alle Methoden (auch `GET`/`HEAD`/`OPTIONS`); ein `Content-Length` ohne
667
+ gültige Zahl ergibt 411.
668
+
669
+ ### Fixed
670
+ - Validatoren (Benutzername, Mail, SQL-Bezeichner, Migrationsnamen, App-/Navigations-/Theme-Kennungen,
671
+ Farben, CSP-Quellen, Sprachcode) ließen ein abschließendes `\n` durch (`re.match` mit `$`) – jetzt
672
+ `fullmatch`.
673
+ - PostgreSQL-Pool: kam nach einer toten Verbindung kein Ersatz (`PoolTimeout`), wurde die tote
674
+ Verbindung ein zweites Mal an den Pool zurückgegeben (bei gesunden Verbindungen ein `ValueError`, der
675
+ den `PoolTimeout` überdeckt).
676
+ - Ersteinrichtung auf SQLite mit mehreren Prozessen: „database is locked“ → `SetupClosed`.
677
+ - `scripts/release.sh`: mit `SKIP_TESTS=1` fehlte der Interpreter für den Build-Schritt.
678
+
679
+ ### Security
680
+ - `SECRET_KEY` aus der Umgebung: bekannte Vorlagenwerte (`replace-with-…`, `changeme`, `example` …) und
681
+ Werte mit weniger als 10 verschiedenen Zeichen brechen den Start ab.
682
+ - Passwörter mit einzelnen UTF-16-Surrogaten lehnt `password_problem` ab (nicht kodier-/hashbar).
683
+ - CI: Versionsnummer aus dem Tag erst gegen PEP 440 prüfen und nie in Python-Quelltext einsetzen;
684
+ PyPI-Token (`PYPI_TOKEN`) nur über die Umgebung an `twine`, nie in der Kommandozeile;
685
+ probe-install installiert von pypi.org und vergleicht den Hash dort mit dem im publish-Job
686
+ hochgeladenen Wheel; pip-audit auch über `requirements-dev.txt`; kein Cache-Speichern aus
687
+ Pull-Requests.
688
+ - Review Paket B: Mounts, `Host`- und Starlette-Routen liefen am Gate vorbei – der Guard weist anonyme
689
+ Anfragen an sie jetzt ab (401 bzw. `303 /login`), außer pillarz' Static und `pz.public_mount()`.
690
+ - `public` zählt nur noch als direkte Abhängigkeit der Route bzw. eines Routers der Kette – eine
691
+ Hilfs-Dependency, die intern `Depends(public)` nutzt, macht Routen nicht mehr öffentlich. Umgekehrt
692
+ wird `public` an verschachtelt eingebundenen Routern (`include_router(…, dependencies=[…])`) erkannt.
693
+ - WebSocket-Verbindungen liefen am Guard vorbei (kein Origin-Check, keine Identität).
694
+ - CSP-Ergänzungen: `javascript:` überall abgelehnt; `worker-src`/`child-src` nur `'self'`/`blob:`; Hosts
695
+ bei reinem HTTPS nur `https://` (`connect-src` zusätzlich `wss://`), `ws(s)://` nur in `connect-src`;
696
+ Hashes in `style-src` abgelehnt (sie schalteten `'unsafe-inline'` still ab).
697
+ - Pfade in Logzeilen einzeilig und gekürzt (`oneline(…)[:200]`, keine Log-Injection auf stdout).
698
+ - `PoolTimeout`/`HTTPException` aus der Identitätsauflösung im Guard → 503 mit `Retry-After` bzw. die
699
+ passende Fehlerantwort statt 500.
700
+ - Template-Namensraum: `./pillarz/…` und ähnliche Schreibweisen umgingen den Schutz der
701
+ pillarz-Templates; ein App-Ordner `templates/pillarz/` bricht den Start jetzt ab.
702
+
703
+ ### Upgrade
704
+ - **Default deny:** Routen einer App sind ab 0.1-Paket B ohne Anmeldung gesperrt. Öffentliche Routen
705
+ (Webhooks, Startseite ohne Login …) brauchen `Depends(pillarz.web.public)` bzw. `@public_route`;
706
+ `pillarz.testing.route_audit(app)` im Testlauf der App zeigt vergessene Stellen.
707
+ - App-Ordner `static/pillarz/` ist verboten (`create_app()` bricht ab); `static/logo.svg` und
708
+ `static/favicon.svg` der App ersetzen Marke bzw. Favicon von pillarz.
709
+ - App-Ordner `templates/pillarz/` ist ebenso verboten (`create_app()` bricht ab).
710
+ - `render(**ctx)` lehnt von pillarz belegte Kontextschlüssel ab (`user`, `boot`, `nav_items`, `i18n`,
711
+ `version`, `help`, `flash`, `_` … → `ValueError`).
712
+ - Eigene Mounts (`app.mount`) sind anonym gesperrt; öffentliche Unter-Apps mit `pz.public_mount(pfad)`
713
+ freigeben. `app.add_route()` statt FastAPI-Routen meldet `route_audit()`.
714
+ - Tests, die `pz_log` lesen, vorher `pillarz.testing.flush_log()` aufrufen.
715
+ - Eine vorhandene `users`-Tabelle (z. B. labelz 0.12) wird übernommen: `0001_initial` ergänzt fehlende
716
+ Spalten (`role`, `email_confirmed`, `locale`, `region`, `updated_at` …), setzt `email_confirmed = 1`
717
+ für Konten mit Adresse und `pz_meta.setup_done`, wenn es Konten gibt. Datenumbau (`is_admin` →
718
+ `role`, `oidc_links` → `pz_tokens`, `themes` → `pz_themes`, `meta.settings` → `pz_settings`) ist
719
+ Sache einer App-Migration, die nach `0001_initial` läuft (Architektur §5.4).
720
+ - Installation in einer App: normal von pypi.org, exakter Pin mit Hash (`pillarz==X.Y.Z
721
+ --hash=sha256:…` im Lock der App), Installation mit `--require-hashes` – kein zusätzlicher Index, kein
722
+ Token im Image-Build.