pillarz 0.1.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 (122) hide show
  1. pillarz-0.1.0/CHANGELOG.md +536 -0
  2. pillarz-0.1.0/LICENSE +661 -0
  3. pillarz-0.1.0/MANIFEST.in +7 -0
  4. pillarz-0.1.0/PKG-INFO +71 -0
  5. pillarz-0.1.0/README.md +36 -0
  6. pillarz-0.1.0/env.example +93 -0
  7. pillarz-0.1.0/pillarz/__init__.py +618 -0
  8. pillarz-0.1.0/pillarz/__main__.py +176 -0
  9. pillarz-0.1.0/pillarz/_api/__init__.py +15 -0
  10. pillarz-0.1.0/pillarz/_api/account.py +66 -0
  11. pillarz-0.1.0/pillarz/_api/admin.py +55 -0
  12. pillarz-0.1.0/pillarz/_api/admin_links.py +24 -0
  13. pillarz-0.1.0/pillarz/_api/admin_log.py +41 -0
  14. pillarz-0.1.0/pillarz/_api/admin_sessions.py +43 -0
  15. pillarz-0.1.0/pillarz/_api/admin_settings.py +68 -0
  16. pillarz-0.1.0/pillarz/_api/admin_themes.py +96 -0
  17. pillarz-0.1.0/pillarz/_api/admin_users.py +357 -0
  18. pillarz-0.1.0/pillarz/_api/common.py +37 -0
  19. pillarz-0.1.0/pillarz/_api/me.py +203 -0
  20. pillarz-0.1.0/pillarz/_api/tokens.py +98 -0
  21. pillarz-0.1.0/pillarz/_auth/__init__.py +4 -0
  22. pillarz-0.1.0/pillarz/_auth/accounts.py +336 -0
  23. pillarz-0.1.0/pillarz/_auth/devices.py +51 -0
  24. pillarz-0.1.0/pillarz/_auth/flows.py +123 -0
  25. pillarz-0.1.0/pillarz/_auth/identity.py +66 -0
  26. pillarz-0.1.0/pillarz/_auth/linking.py +253 -0
  27. pillarz-0.1.0/pillarz/_auth/oidc.py +482 -0
  28. pillarz-0.1.0/pillarz/_auth/oidc_accounts.py +197 -0
  29. pillarz-0.1.0/pillarz/_auth/oidc_routes.py +363 -0
  30. pillarz-0.1.0/pillarz/_auth/passwords.py +146 -0
  31. pillarz-0.1.0/pillarz/_auth/roles.py +64 -0
  32. pillarz-0.1.0/pillarz/_auth/routes.py +398 -0
  33. pillarz-0.1.0/pillarz/_auth/secretbox.py +52 -0
  34. pillarz-0.1.0/pillarz/_auth/sessions.py +514 -0
  35. pillarz-0.1.0/pillarz/_auth/setup.py +217 -0
  36. pillarz-0.1.0/pillarz/_auth/stepup.py +46 -0
  37. pillarz-0.1.0/pillarz/_auth/throttle.py +193 -0
  38. pillarz-0.1.0/pillarz/_auth/tokens.py +126 -0
  39. pillarz-0.1.0/pillarz/_db/__init__.py +1 -0
  40. pillarz-0.1.0/pillarz/_db/clock.py +67 -0
  41. pillarz-0.1.0/pillarz/_db/connection.py +338 -0
  42. pillarz-0.1.0/pillarz/_db/m0001_initial.py +194 -0
  43. pillarz-0.1.0/pillarz/_db/m0002_sessions.py +19 -0
  44. pillarz-0.1.0/pillarz/_db/m0003_oidc_origin.py +27 -0
  45. pillarz-0.1.0/pillarz/_db/migrations.py +274 -0
  46. pillarz-0.1.0/pillarz/_events.py +128 -0
  47. pillarz-0.1.0/pillarz/_i18n/__init__.py +11 -0
  48. pillarz-0.1.0/pillarz/_i18n/babel-js.cfg +4 -0
  49. pillarz-0.1.0/pillarz/_i18n/babel.cfg +8 -0
  50. pillarz-0.1.0/pillarz/_i18n/catalogs.py +221 -0
  51. pillarz-0.1.0/pillarz/_i18n/jinja.py +41 -0
  52. pillarz-0.1.0/pillarz/_i18n/js.py +43 -0
  53. pillarz-0.1.0/pillarz/_i18n/request.py +113 -0
  54. pillarz-0.1.0/pillarz/_i18n/tools.py +200 -0
  55. pillarz-0.1.0/pillarz/_protocol.py +351 -0
  56. pillarz-0.1.0/pillarz/_runtime.py +86 -0
  57. pillarz-0.1.0/pillarz/_settings.py +272 -0
  58. pillarz-0.1.0/pillarz/_themes.py +218 -0
  59. pillarz-0.1.0/pillarz/_web/__init__.py +1 -0
  60. pillarz-0.1.0/pillarz/_web/app.py +82 -0
  61. pillarz-0.1.0/pillarz/_web/context.py +14 -0
  62. pillarz-0.1.0/pillarz/_web/csp.py +148 -0
  63. pillarz-0.1.0/pillarz/_web/errors.py +169 -0
  64. pillarz-0.1.0/pillarz/_web/gate.py +150 -0
  65. pillarz-0.1.0/pillarz/_web/guard.py +253 -0
  66. pillarz-0.1.0/pillarz/_web/headers.py +63 -0
  67. pillarz-0.1.0/pillarz/_web/net.py +48 -0
  68. pillarz-0.1.0/pillarz/_web/pages.py +30 -0
  69. pillarz-0.1.0/pillarz/_web/render.py +341 -0
  70. pillarz-0.1.0/pillarz/_web/static.py +40 -0
  71. pillarz-0.1.0/pillarz/accounts.py +106 -0
  72. pillarz-0.1.0/pillarz/config.py +377 -0
  73. pillarz-0.1.0/pillarz/db.py +375 -0
  74. pillarz-0.1.0/pillarz/events.py +228 -0
  75. pillarz-0.1.0/pillarz/i18n.py +126 -0
  76. pillarz-0.1.0/pillarz/locale/en/LC_MESSAGES/pillarz-js.po +1119 -0
  77. pillarz-0.1.0/pillarz/locale/en/LC_MESSAGES/pillarz.po +1894 -0
  78. pillarz-0.1.0/pillarz/locale/pillarz-js.pot +1106 -0
  79. pillarz-0.1.0/pillarz/locale/pillarz.pot +1816 -0
  80. pillarz-0.1.0/pillarz/settings.py +33 -0
  81. pillarz-0.1.0/pillarz/static/pillarz/css/base.css +587 -0
  82. pillarz-0.1.0/pillarz/static/pillarz/favicon.svg +1 -0
  83. pillarz-0.1.0/pillarz/static/pillarz/js/account.js +337 -0
  84. pillarz-0.1.0/pillarz/static/pillarz/js/admin-links.js +158 -0
  85. pillarz-0.1.0/pillarz/static/pillarz/js/admin.js +627 -0
  86. pillarz-0.1.0/pillarz/static/pillarz/js/api.js +96 -0
  87. pillarz-0.1.0/pillarz/static/pillarz/js/forms.js +206 -0
  88. pillarz-0.1.0/pillarz/static/pillarz/js/i18n.js +158 -0
  89. pillarz-0.1.0/pillarz/static/pillarz/js/token.js +148 -0
  90. pillarz-0.1.0/pillarz/static/pillarz/js/ui.js +540 -0
  91. pillarz-0.1.0/pillarz/static/pillarz/vendor/lucide/LICENSE +43 -0
  92. pillarz-0.1.0/pillarz/templates/pillarz/account.html +26 -0
  93. pillarz-0.1.0/pillarz/templates/pillarz/admin.html +48 -0
  94. pillarz-0.1.0/pillarz/templates/pillarz/bare.html +25 -0
  95. pillarz-0.1.0/pillarz/templates/pillarz/base.html +38 -0
  96. pillarz-0.1.0/pillarz/templates/pillarz/error.html +15 -0
  97. pillarz-0.1.0/pillarz/templates/pillarz/icons.svg +66 -0
  98. pillarz-0.1.0/pillarz/templates/pillarz/login.html +39 -0
  99. pillarz-0.1.0/pillarz/templates/pillarz/partials/account/delete.html +5 -0
  100. pillarz-0.1.0/pillarz/templates/pillarz/partials/account/devices.html +9 -0
  101. pillarz-0.1.0/pillarz/templates/pillarz/partials/account/language.html +15 -0
  102. pillarz-0.1.0/pillarz/templates/pillarz/partials/account/login.html +56 -0
  103. pillarz-0.1.0/pillarz/templates/pillarz/partials/account/look.html +17 -0
  104. pillarz-0.1.0/pillarz/templates/pillarz/partials/account/profile.html +17 -0
  105. pillarz-0.1.0/pillarz/templates/pillarz/partials/flash.html +8 -0
  106. pillarz-0.1.0/pillarz/templates/pillarz/partials/nav.html +34 -0
  107. pillarz-0.1.0/pillarz/templates/pillarz/plain.html +16 -0
  108. pillarz-0.1.0/pillarz/templates/pillarz/setup.html +51 -0
  109. pillarz-0.1.0/pillarz/templates/pillarz/token.html +22 -0
  110. pillarz-0.1.0/pillarz/testing/__init__.py +236 -0
  111. pillarz-0.1.0/pillarz/testing/_web.py +113 -0
  112. pillarz-0.1.0/pillarz/testing/fakeidp.py +362 -0
  113. pillarz-0.1.0/pillarz/text.py +81 -0
  114. pillarz-0.1.0/pillarz/web.py +246 -0
  115. pillarz-0.1.0/pillarz.egg-info/PKG-INFO +71 -0
  116. pillarz-0.1.0/pillarz.egg-info/SOURCES.txt +120 -0
  117. pillarz-0.1.0/pillarz.egg-info/dependency_links.txt +1 -0
  118. pillarz-0.1.0/pillarz.egg-info/requires.txt +10 -0
  119. pillarz-0.1.0/pillarz.egg-info/top_level.txt +1 -0
  120. pillarz-0.1.0/pyproject.toml +58 -0
  121. pillarz-0.1.0/requirements.in +13 -0
  122. pillarz-0.1.0/setup.cfg +4 -0
@@ -0,0 +1,536 @@
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.1.0] — 2026-10-30
13
+
14
+ **0.1.0 – Fundament.** Gegenüber 0.1.0.dev1 (Gerüst, Datenbank, Migrationen, Web-Rahmen, i18n) kommen
15
+ Sitzungen und Passwort-Anmeldung, die Ersteinrichtung, Konten mit Rollen und Events, Admin-Oberfläche und
16
+ Kontoseite, Einstellungen, authentik (OIDC) mit Verknüpfung und RP-Logout sowie die App-Hooks dazu. Damit deckt
17
+ pillarz ab, was labelz heute in `security.py`, `oidc.py`, `linking.py`, `events.py`, `themes.py`, im allgemeinen
18
+ Teil von `settings.py` und in den Konten-/Admin-Teilen von `api/` hat. Nachweis: `examples/minimal` und die
19
+ Referenz-Umstellung `tests/adoption/labelz/` (siehe Upgrade). Breaking-Abschnitt in zwei Teilen: gegenüber
20
+ 0.1.0.dev1 und gegenüber dem heutigen Verhalten von labelz.
21
+
22
+ ### Added
23
+
24
+ **Sitzungen und Passwort (ADR 0005)**
25
+ - Serverseitige Sitzungen in `pz_sessions`: Cookie `__Host-<name>` bei reinem HTTPS (sonst `<name>`), `HttpOnly`,
26
+ `SameSite=Lax`, 256 Bit Zufall, in der Datenbank nur der SHA-256. Eine Abfrage je Request (statische Dateien
27
+ ohne), absolute Dauer `SESSION_DAYS`, optionaler Leerlauf `IDLE_TIMEOUT_MINUTES` (0 = aus, sonst mindestens 5;
28
+ endet nie zu früh, höchstens eine Minute zu spät), `last_seen_at` höchstens 1×/60 s; Anfragen mit
29
+ `X-PZ-Background: 1` (`api(…, {background: true})`) verlängern nicht. Neue Sitzung bei jeder Anmeldung
30
+ (Fixation), gesperrte Konten sind sofort abgemeldet.
31
+ - **Aufräumen beim Start** (P-43): abgelaufene und wegen Leerlauf ungültige Sitzungen, abgelaufene Flows, alte
32
+ Bremsen-Einträge und erledigte Einmal-Tokens (> 30 Tage) werden gelöscht – still, `user.signed_out` mit
33
+ `reason="expired"` folgt mit 0.2.
34
+ - Fremde oder ungültige Cookie-Werte unter dem Cookienamen (z. B. labelz' Starlette-Cookie `labelz`, Müll,
35
+ gelöschte/abgelaufene Sitzungen) werden still verworfen: anonym, Antwort löscht das Cookie, kein
36
+ Protokolleintrag (P-106).
37
+ - `pillarz.web.session_id(request)`: öffentliche, stabile Kennung der Sitzung (`pz_sessions.public_id`, nicht
38
+ der Token-Hash), neu nach jeder Anmeldung, `None` ohne Sitzung (P-104).
39
+ - Seiten `GET/POST /login`, `POST /logout`, `GET/POST /setup` (ohne JavaScript) und JSON `POST
40
+ /api/pz/me/password`, `POST /api/pz/me/logout-all` (`{"keep_current": true}`), `POST /api/pz/me/reauth`,
41
+ `GET /api/pz/me/sessions`, `DELETE /api/pz/me/sessions/{session_id}`. `/api/pz/me/*` nimmt nur Sitzungen an.
42
+ `/login`, `/logout`, `/setup`, `/auth/` und `/api/pz/` sind `no-store` (Referrer-Policy wie überall
43
+ `same-origin`).
44
+ - Kopfzeile für Abgemeldete: „Anmelden“ bzw. „Einrichten“, solange `/setup` offen ist (auch auf Fehlerseiten,
45
+ nicht auf `/login`/`/setup` selbst).
46
+ - Protokoll auf der Konsole: hat niemand Logging eingerichtet (root-Logger ohne Handler, z. B. nur uvicorn),
47
+ schreibt pillarz die Einträge von `pillarz` und dem App-Logger ab INFO einzeilig auf stderr – so sieht der
48
+ Betreiber im Container z. B. die Warnung „Einrichtung offen“. Mit eigener Logging-Konfiguration nicht.
49
+ - Passwortregeln K-4 (10–200 Zeichen, nicht gleich dem Benutzernamen, kein Platzhalter, App-Liste
50
+ `Pillarz(weak_passwords=…)`); scrypt wie labelz mit Rehash schwächerer Parameter bei der Anmeldung.
51
+ - **Anmeldebremse in der Datenbank** (`pz_throttle`, über alle Worker/Container): 5 Fehlversuche je IP
52
+ (IPv6 je /64) und je Benutzername in 10 min, 100 insgesamt; für Anmeldung, Passwortwechsel, `reauth`.
53
+ `pillarz.web.throttled(key, limit, window_seconds)` und `hit(key)` für eigene Bremsen der App (eigener
54
+ Namensraum, Fenster ≤ 24 h).
55
+ - Schritt-hoch (Server-Teil): `reauth_at` je Sitzung, frisch für 5 min nach Anmeldung oder `POST
56
+ /api/pz/me/reauth`; pillarz-Aktionen antworten sonst 403 mit `reauth` (Methoden). Für App-Routen noch nicht
57
+ öffentlich.
58
+ - Geräteliste: Gerät aus dem User-Agent („Firefox auf Linux“), Methode, angemeldet seit, zuletzt aktiv, IP,
59
+ „dieses Gerät“.
60
+ - Kurzlebiger Vorab-Zustand für Anmeldeabläufe (`pz_flows`, Cookie `<name>_flow`, einmalig, 10 min).
61
+
62
+ **Ersteinrichtung (ADR 0015)**
63
+ - `/setup` ist offen nur ohne `ADMIN_PASSWORD`, mit Methode `password` und ohne Konten; `/login` leitet dann
64
+ dorthin, danach antwortet `/setup` 404 und leitet nach dem Anlegen auf `/admin`. Atomar über
65
+ `pz_meta.setup_done` (zwei gleichzeitige Versuche → ein Admin, der zweite 409). Optional `SETUP_TOKEN`
66
+ (konstante Zeit, gebremst). Startwarnung (Stufe Warnung) und stündliche Wiederholung, solange offen;
67
+ Warnhinweis auf der Seite bei `https://`-Adresse ohne Token.
68
+ - Erstes Admin-Konto aus `ADMIN_USERNAME`/`ADMIN_PASSWORD` beim Start (nur leere Datenbank).
69
+
70
+ **Konten, Rollen, Events (ADR 0006)**
71
+ - Rollen `admin`, `user`, `guest`. `pillarz.accounts` (lesend): `User`, `get_user`, `find_user`, `list_users`,
72
+ `count_active_admins`. `require_member` (user + admin) für normale App-Seiten, `require_user` lässt auch Gäste
73
+ durch; `require_admin`/`is_admin` rechnen mit der wirksamen Rolle (befristete authentik-Admin-Rechte der
74
+ Sitzung, Deckel `max_role` eines Authenticators).
75
+ - `pz.on(event, order=…)`: tx-Events `user.created`, `user.updated`, `user.deactivating`, `user.reactivated`,
76
+ `user.deleting` (in der pillarz-Transaktion, `ev.conn`; `ev.veto(text)` bei created/deactivating/deleting →
77
+ 409, alles zurückgerollt, auch Schreibzugriffe anderer Handler), Nach-Commit-Events `user.deleted` (`result`),
78
+ `user.signed_in` (`session_id`, `method`), `user.signed_out` (`session_ids`, `reason`) – Fehler dort werden
79
+ protokolliert und geschluckt (K-8). `user.created` mit `source` = `admin`, `setup`, `env`, `cli`, `oidc`.
80
+ `user.delete_preview` (Hinweiszeilen, `ev.block(text)`; läuft beim Löschen ein zweites Mal in der
81
+ Lösch-Transaktion – rein lesend halten). Nutzlasten in `pillarz.events` (`READ_EVENTS` exportiert). Derselbe
82
+ Handler zweimal für dasselbe Event → `ValueError`.
83
+ - `connect()` in einem tx-Event-Handler liefert auf demselben Thread die laufende Transaktion (auch
84
+ verschachtelt). Threads in tx-Handlern sind verboten: andere Threads warten höchstens `TX_WAIT_SECONDS` (10 s)
85
+ und bekommen dann `pillarz.db.TxThreadError` statt zu verklemmen.
86
+ - `pz.delete_input(DeleteInput(key, kind="user|choice|bool|text", label, required, default, help, options,
87
+ on_self, self_default, max_length, visible))` – Zusatzangaben im Löschdialog, serverseitig geprüft, als
88
+ `ev.inputs` (`MappingProxyType`). `kind="user"` wird bei Selbstlöschung nie abgefragt und braucht dann
89
+ `self_default` (Wert oder `fn(user, conn)`), sonst bricht `create_app()` ab; `visible=fn(user, conn)` fragt
90
+ nur, wenn es etwas zu entscheiden gibt.
91
+ - **Konto selbst löschen** (F-3): `Pillarz(self_delete=True)` (Standard), `GET /api/pz/me/delete`, `DELETE
92
+ /api/pz/me` mit `{"inputs", "confirm": <benutzername>}` – Schritt-hoch, letzter aktiver Admin gesperrt, Cookie
93
+ gelöscht, Ziel `/login?hint=account-deleted` (bzw. RP-Logout). `self_delete=False` → Abschnitt fehlt,
94
+ Endpunkte 404.
95
+
96
+ **Admin-Oberfläche und Kontoseite**
97
+ - `/admin` mit Reitern Konten (anlegen, bearbeiten, Rolle, sperren, Passwort setzen, Sitzungen beenden,
98
+ Löschdialog mit Zusatzangaben, Vorschau und „Stattdessen sperren“), authentik-Links, Geräte, Themes,
99
+ Einstellungen, Protokoll (Filter Bereich, Konto, nur Probleme, Blättern); Hash-Routing `/admin#<key>[-<id>]`.
100
+ `pz.admin_tab(AdminTab(key, label, order, icon, template, module, visible))` – ES-Modul mit `init(panel, ctx)`;
101
+ belegte Schlüssel: `users`, `links`, `sessions`, `themes`, `settings`, `log`.
102
+ - `/account` (alle Rollen): Profil, Darstellung (Theme, hell/dunkel/auto), Sprache, Anmeldung (Passwort,
103
+ authentik), Geräte, Konto löschen; `pz.account_section(AccountSection(…))` mit `init(section, ctx)`. Abschnitte
104
+ inaktiver Methoden fehlen, ihre Endpunkte antworten 404. `GET/PATCH /api/pz/me`, `GET /api/pz/themes`.
105
+ - Admin-API unter `/api/pz/admin/…` (Konten, Sitzungen, Themes, Einstellungen, Protokoll); `GET
106
+ /api/pz/admin/users` liefert `elevated_only`, `…/users/{id}/delete` `reauth` (`{needed, methods}`).
107
+
108
+ **Einstellungen**
109
+ - `pz.setting(Setting(key, default, kind="text|int|bool|choice|custom", label, help, ui, validate, options,
110
+ required, max_length, min, max))`; `kind="custom"` verlangt `validate`. Öffentlich
111
+ `pillarz.settings.get/all/update` (`SettingError` mit `key`). pillarz-Schlüssel `site_name`, `default_theme`,
112
+ `default_locale` wirken im Seitenrahmen (Zwischenspeicher je Prozess, höchstens 5 s alt). Meldungen am Feld,
113
+ Werte werden nie still gekappt; „alt → neu“ im Protokoll.
114
+ - `pz.settings_section(SettingsSection(key, label, order, keys, template, module))` – App-Abschnitt im Reiter
115
+ „Einstellungen“, speichert über `ctx.save()` (`PATCH /api/pz/admin/settings`, 422 mit `field`); ein Schlüssel
116
+ in zwei Abschnitten oder ein nicht angemeldeter Schlüssel → Startabbruch.
117
+
118
+ **authentik / OIDC (ADR 0008)**
119
+ - Anmeldung über `/auth/login` → `/auth/callback` (Authorization Code + PKCE, `state`/`nonce` in `pz_flows`,
120
+ ID-Token mit den Verfahren der Discovery – HS256 nur, wenn der Anbieter es meldet –, Userinfo). **Kontozuordnung
121
+ nur über die verknüpfte `sub`, nie über die E-Mail-Adresse** (Leitung 2026-10-30); unbekannte `sub` → Anlage
122
+ bei Gruppe (Adresse aus authentik, wenn frei; bestätigt nur bei `email_verified`) → sonst 403. Gruppen
123
+ `OIDC_USER_GROUPS` (`user`), **neu** `OIDC_GUEST_GROUPS` (`guest`), `OIDC_ADMIN_GROUPS` (befristete
124
+ Admin-Rechte je Sitzung, `OIDC_ADMIN_HOURS`; die Rolle am Konto ändert die Anmeldung nie). Knopf „Mit
125
+ <OIDC_LABEL> anmelden“, `#boot.auth` (`{"password": bool, "oidc": {"enabled", "label"}}`).
126
+ - pillarz-Migration `0003_users_oidc_origin`: `users.created_via` (Quelle der Anlage wie `user.created`
127
+ `source`; `NULL` = vorher/übernommen = lokal) und `users.oidc_unlinked_sub` (private Spalten).
128
+ - **RP-initiated Logout:** `POST /logout` einer authentik-Sitzung leitet zu `end_session_endpoint` mit
129
+ `id_token_hint`, `client_id` und `post_logout_redirect_uri=<PUBLIC_URL>/login?hint=logged-out`; JSON-Antworten von
130
+ `DELETE /api/pz/me` (`hint=account-deleted`) und `DELETE /api/pz/me/sessions/{id}` für die **aktuelle** Sitzung
131
+ tragen das Ziel in `redirect`. GET, weil authentik POST mit 403 (CSRF) ablehnt; Restrisiko ADR 0008. Schalter
132
+ `OIDC_LOGOUT` (Standard 1). „Überall abmelden“ und fremde Sitzungen beenden bleibt lokal.
133
+ - **Schritt-hoch per authentik:** `/auth/login?reauth=1&next=…` mit `prompt=login`, `max_age=300` (authentik 2026.8
134
+ wertet `max_age=0` nicht aus und erzwingt `prompt=login` nur einmal je Sitzung, ADR 0008); frisch nur bei `auth_time`
135
+ ≤ 5 min (+ 60 s Spielraum) und demselben Konto – eine jüngere authentik-Anmeldung reicht ohne neue Abfrage. Hat ein
136
+ Konto Passwort und authentik, bietet der Dialog beide Wege („Mit <OIDC_LABEL> bestätigen“).
137
+ - **Falscher `OIDC_ISSUER` ist auffindbar:** Discovery-Prüfung beim Start im Hintergrund (Timeout 5 s, hält den Start
138
+ nicht auf, bricht ihn nicht ab, nicht im Testmodus). Jeder Fehlschlag steht mit Adresse und Status im Protokoll,
139
+ getrennt nach falsch eingerichtet (404, anderer Issuer, kein Discovery-Dokument, Zertifikat) und nicht erreichbar
140
+ (Verbindung, Timeout, 5xx), je Ursache höchstens einmal pro Minute. `/admin` zeigt dazu einen Hinweis; `/auth/login`
141
+ meldet bei falscher Einstellung „nicht eingerichtet“ statt „nicht erreichbar“.
142
+ - Kontenliste: Chip „Admin, befristet bis HH:MM“ für Nutzer und Gäste mit laufenden befristeten Admin-Rechten; neues
143
+ Feld `elevated_until` in `GET /api/pz/admin/users`.
144
+ - **Zertifikate:** Die OIDC-Abrufe vertrauen dem Zertifikatsspeicher des Systems statt dem certifi-Bündel von httpx,
145
+ `OIDC_CA_FILE` kommt zusätzlich dazu.
146
+ - **Verknüpfung** (labelz E-35–E-37): Admin-Einmal-Link (`pz_tokens`, Art `oidc_link`, 7 Tage, nur im Fragment
147
+ `/auth/link#…`, optional erwarteter Benutzername), Einlöse-Seite, `POST /api/pz/link/info|start`, `POST/DELETE
148
+ /api/pz/admin/users/{id}/oidc-link`, `PATCH …/users/{id}` mit `oidc_unlink`; Selbst verknüpfen/lösen auf der
149
+ Kontoseite (`POST /api/pz/me/oidc-link|oidc-unlink` mit Passwort). Beide Verknüpfungswege verlangen bei authentik
150
+ eine neue Anmeldung (`prompt=login`, `max_age=60`). **Über authentik entstandene Konten (`created_via = 'oidc'`) lassen sich
151
+ nicht lösen** – 409, die UI bietet es nicht an („Zugang entziehen = Konto sperren“); `oidc_created` in den
152
+ Konto-JSONs. Nach dem Lösen legt dieselbe `sub` kein neues Konto an, bis sie wieder verknüpft ist.
153
+
154
+ **Hooks für Apps**
155
+ - **`pz.context(fn)`** (P-9): `fn(request, user) -> dict` liefert Werte für den Template-Kontext jeder Seite
156
+ und für Routen über `pillarz.web.request_context(request)` (danach auch `request.state.app_context`) – z. B.
157
+ Navigations-Badges wie labelz' `nav_answers`. Nur für Angemeldete, höchstens einmal je Request und erst bei
158
+ Bedarf (erstes `render()`/`request_context()`). Mehrere Haken in Anmeldereihenfolge, `render(**ctx)` hat
159
+ Vorrang; von pillarz belegte Schlüssel → Fehler. Eine Ausnahme im Haken ergibt 500 mit Fehlerseite und
160
+ Protokolleintrag.
161
+ - **Request-Authenticators** (P-99): `@pz.request_authenticator(name, max_role="user", methods=("GET","HEAD"))`
162
+ mit Rückgabe `None`, Konto-ID oder `pillarz.web.AuthResult(user_id, context)`; `pillarz.web.auth_context(request)`.
163
+ Läuft vor dem Gate, nur ohne Sitzung, sieht den Pfad; Rolle gedeckelt, pillarz-Seiten und `/api/pz/*` → 403,
164
+ keine Nebenwirkungen (keine Sitzung, kein Cookie, kein `last_seen`, kein `user.signed_in`).
165
+ - `pz.secret_user_column(name)` (S-N6): Spalte von `users` als geheim melden – sie erscheint nie in Kontozeile,
166
+ Templates oder JSON.
167
+ - `pillarz.db`: `scalar`, `execute`, `TxThreadError`; jedes Symbol in `__all__` mit Docstring (P-100).
168
+ - `ui.js`: `numProblem(input)`, `fieldError(input, text)`/`clearFieldError(input)` (mit `aria-invalid`,
169
+ `aria-describedby`), `validationMessage(input)` (`data-msg-required|pattern|type`), `clearToasts(type)`;
170
+ `ui.dialog()` meldet ungültige Felder am ersten Feld. Theme-Token `--pz-input-line` (≥ 3:1) und
171
+ `--pz-accent-text` (≥ 4,5:1), `/themes.css` liefert `--t-accent-text` je Theme und Modus.
172
+
173
+ **Testkit, Beispiel, Referenz-Umstellung, CI**
174
+ - `pillarz.testing.make_user()` (liefert `User`), `login()` (echter `POST /login`), `login_as()` (Sitzung direkt
175
+ in der Datenbank, echte `Set-Cookie`-Antwort), `PASSWORD`; `FakeIdP` (simulierter authentik: Discovery, JWKS,
176
+ Token, Userinfo, `end_session_endpoint` mit Prüfung von `id_token_hint` (POST → 403 wie authentik), `auth_time`;
177
+ `max_age` und `prompt=login` entscheidet er wie authentik 2026.8 (`markers`, `login_ids`, `logins`);
178
+ prüft `response_type`, `scope`, `code_challenge_method`; Schalter `algs`, `auth_time`,
179
+ `registered_post_logout`, `end_session_post`; `idp.env()`, `idp.install()`, `idp.serve()`),
180
+ `oidc_login(client, idp, claims, next=…, reauth=…)`, `oidc_callback(…)`.
181
+ - `examples/minimal`: `require_member`, `/password`, `/notes`, Events mit Zusatzangabe `heir` (nur bei
182
+ freigegebenen Designs), Vorschau und Veto, App-Reiter „Designs“, Einstellungs-Abschnitt „Notizen“,
183
+ App-Einstellungen `greeting` und `designs_per_page`, `auth_methods=("password", "oidc")`.
184
+ - **Referenz-Umstellung labelz, Schritt 1** (P-84) in `tests/adoption/labelz/`: synthetische labelz-0.13-
185
+ Datenbank, Vorlage der App-Migration (`migration.py`) und eine Mini-App mit labelz' Andockpunkten; läuft auf
186
+ SQLite und PostgreSQL (siehe Upgrade).
187
+ - CI: zusätzlicher Testlauf mit **Python 3.13** (SQLite, gleiche Hash-Pins; 3.12 bleibt Hauptversion für Locks,
188
+ PostgreSQL-Job und Build), Build wartet auf ihn (P-87).
189
+ - `python -m tests.browser_smoke`: Ersteinrichtung, Anmelden, Kontoseite, Admin-Reiter, Nutzer-Sicht,
190
+ Selbstlöschung, Passwort ändern, Abmelden per Tastatur; zweite Instanz mit authentik gegen `FakeIdP`
191
+ (RP-Logout, Admin-Dialoge Link erstellen/zurückziehen/Verknüpfung lösen).
192
+
193
+ ### Changed
194
+ - `require_*`, `current_user`, `is_admin`, `via` arbeiten auf der echten Sitzung (in 0.1.0.dev1 waren sie
195
+ Hüllen ohne Anmeldung) – siehe Breaking.
196
+ - HTML-Formulare (Nicht-GET außerhalb `/api/` mit `Accept: text/html`) bekommen bei Ablehnung oder Fehler die
197
+ Fehlerseite statt JSON – auch bei frühen Ablehnungen des Guards (CSRF, Größe); 401 → `/login`. `fetch` mit
198
+ `Accept: application/json` und `/api/` bleiben JSON.
199
+ - Fehlerseiten: eigener Titel je Code („Kein Zugriff“, „Seite nicht gefunden“, „Gerade nicht erreichbar“),
200
+ Erklärung mit nächstem Schritt statt Standardmeldung (eigene Meldungen der App bleiben); angemeldet zeigen
201
+ 403/404 die Navigation.
202
+ - Toasts: gleiche Texte zusammengefasst (×N), höchstens zwei, Erfolg 4 s; Fehler am Feld entfernen offene
203
+ Erfolgsmeldungen.
204
+ - Die einmalige Rückmeldung (Flash) wird erst verbraucht, wenn eine Seite mit Rahmen sie zeigt.
205
+ - Protokolltexte der Anmeldung enthalten die IP nicht mehr; sie steht nur in der Spalte `ip`.
206
+ - Leeres `PUBLIC_URLS` warnt im Testmodus nicht mehr (gemischt http/https weiterhin immer).
207
+ - `env.example` vollständig für 0.1.0 (Test: jede gelesene Variable steht dort).
208
+
209
+ ### Fixed
210
+ - Firefox/Zen über http zu einer Nicht-localhost-Adresse: Anmelden und Einrichten scheiterten mit „Anfrage von
211
+ fremder Seite abgelehnt.“ – `Referrer-Policy: no-referrer` auf no-store-Seiten ließ Firefox beim Formular-POST
212
+ `Origin: null` senden (ohne `Sec-Fetch-Site`). Diese Seiten haben jetzt `same-origin`;
213
+ `testing.assert_security_headers` verlangt `same-origin`.
214
+ - `pillarz.db.reset()` (nur Tests) scheiterte auf SQLite an App-Schemata mit mehrstufigen Fremdschlüsseln auf
215
+ `users` („no such table: main.users“, z. B. labelz) – löscht jetzt ohne Fremdschlüsselprüfung.
216
+
217
+ ### Security
218
+ - Gleiche Meldung und Antwortzeit für „falsch“, „unbekannt“ und „gesperrt“ (Dummy-Hash mit aktuellen
219
+ Parametern); gesperrtes Konto + richtiges Passwort → dasselbe 401, Grund nur im Protokoll (S-N5).
220
+ - Bremse ohne Wettlauf: Versuche werden vor scrypt atomar reserviert (SQLite `BEGIN IMMEDIATE`, PG
221
+ `pg_advisory_xact_lock`), 40 parallele Fehlversuche ergeben höchstens 5 Prüfungen. scrypt höchstens viermal
222
+ gleichzeitig je Prozess; sind alle Plätze > 2 s belegt: 503 mit `Retry-After` (zählt nicht als Fehlversuch).
223
+ - Kein Aussperren durch Fremde: Passwortbestätigungen angemeldeter Sitzungen zählen unter `confirm:<user_id>`;
224
+ Bremsschlüssel nur aus gültigen Benutzernamen; greift ein Konto- oder der globale Schlüssel, warnt das
225
+ Protokoll.
226
+ - Passwortwechsel rotiert das Sitzungstoken und beendet die anderen Sitzungen in einer Transaktion; ein erstes
227
+ eigenes Passwort (Konto ohne Passwort) verlangt eine frische Sitzung.
228
+ - Rollenwechsel erhöht `session_epoch`; wer zum Admin befördert wird, verliert alle Sitzungen.
229
+ - Admin-Aktionen „Admin-Rolle vergeben“, „Passwort/E-Mail eines Admin-Kontos ändern“ und „Konto löschen“
230
+ brauchen Schritt-hoch (403 mit `reauth`). **Befristete Admins** (nur authentik-Gruppe) dürfen keine
231
+ Admin-Konten anlegen, befördern, herabstufen, sperren, entsperren, löschen oder deren Verknüpfung ändern – Rolle
232
+ und Sperre eines Admin-Kontos auch dann nicht, wenn es gesperrt ist (403, Warnung im Protokoll).
233
+ - Letzter aktiver Admin: Herabstufen, Sperren und Löschen (auch selbst) gesperrt, gezählt mit Sperre in der
234
+ Transaktion. K-9: eigenes Passwort, eigene E-Mail und eigene Verknüpfung nicht über `/api/pz/admin/…` (403).
235
+ - **Keine authentik-Zuordnung über die E-Mail-Adresse** (authentiks Standard-Mapping meldet `email_verified`
236
+ immer `true` – sonst Übernahme fremder Konten, auch von Admins). Link-Prüfung gebremst (10 / 10 min je IP,
237
+ IPv6 je /64), Link-Anlage 20 / min je Admin.
238
+ - ID-Token: nur Verfahren aus `id_token_signing_alg_values_supported` (ohne Angabe nur RS256), `azp` geprüft,
239
+ wenn vorhanden, `nbf` mit Spielraum, `iat` Pflicht; kaputtes JWKS und Nicht-String-Token → 400 statt 500. In
240
+ `pz_sessions` liegt es AES-256-GCM-verschlüsselt (Schlüssel per HKDF aus `SECRET_KEY`).
241
+ - `/auth/callback` verbraucht einen Flow nur bei passendem `state`; ohne passenden Flow kein Angreifertext im
242
+ Protokoll (eine feste, entprellte Zeile), Anbieter-`error` erst nach gültigem Flow. Flows gebremst je IP (/64)
243
+ und insgesamt (3000 / 10 min). Einlösen, Selbst-Verknüpfen und Lösen mit `BEGIN IMMEDIATE` (kein 500 bei
244
+ Wettlauf). Selbst-Lösen nimmt der laufenden Sitzung befristete Admin-Rechte und das ID-Token.
245
+ - `OIDC_ISSUER` mit `http://` nur für localhost, sonst Startabbruch.
246
+ - IDs > 2⁶³−1 in Pfad, Query und `DeleteInput(kind="user")` → 404/422 statt 500.
247
+ - Anmeldemethode `password` aus: `POST /login`, `/api/pz/me/password`, `/api/pz/me/reauth` → 404.
248
+
249
+ ### Breaking
250
+
251
+ **Gegenüber 0.1.0.dev1**
252
+ - `current_user()` / `request.state.user` ist ein `pillarz.accounts.User` (eingefrorene Datenklasse der
253
+ öffentlichen Spalten: `user.id`, `user.username`, `user.role`, `user.is_guest` …) statt eines dicts – ebenso
254
+ die Rückgabe von `require_*`, `user` in Templates, `NavItem.visible/badge`, `pz.boot(request, user)`. Keine
255
+ internen Spalten (`theme`, `mode`, `oidc_*`, `password_hash`).
256
+ - pillarz-Migration `0003_users_oidc_origin` ergänzt `users.created_via` und `users.oidc_unlinked_sub` (privat,
257
+ additiv) – App-Code, der `SELECT * FROM users` 1:1 in eigene Strukturen kopiert, sieht zwei Spalten mehr.
258
+ - `ui.readNum` begrenzt und rundet nicht mehr still: Werte außerhalb von `min`/`max` oder (bei `int`) mit
259
+ Nachkommastellen ergeben `NaN`; den Grund liefert `numProblem(input)`.
260
+
261
+ **Gegenüber labelz (für die Umstellung)**
262
+ - **K-4:** neue Passwörter brauchen 10–200 Zeichen (labelz: 8). Bestehende kürzere melden sich weiter an und
263
+ bekommen einmal den Hinweis „bitte ändere es“.
264
+ - **K-15:** ein schwaches `ADMIN_PASSWORD` (oder ungültiger `ADMIN_USERNAME`) bei leerer Datenbank bricht den
265
+ Start ab (Exit-Code ≠ 0); labelz legte dann nur kein Konto an. Ohne `ADMIN_PASSWORD` ist bei leerer Datenbank
266
+ `/setup` offen.
267
+ - **K-8:** `user.signed_out` läuft **nach** dem Commit; ein Fehler im Handler verhindert das Abmelden nicht
268
+ (labelz räumte im Logout-Request selbst auf).
269
+ - **Alle Sitzungen enden beim Umstieg einmal:** labelz' Cookie-Sitzungen gelten nicht, das alte Cookie wird
270
+ verworfen und gelöscht.
271
+ - Gesperrtes Konto + richtiges Passwort → 401 wie ein falsches Passwort (labelz: 403 „gesperrt“).
272
+ - Rollen statt `is_admin`: `users.role` (`admin|user|guest`); `is_admin` liest pillarz nicht.
273
+ - **Keine authentik-Zuordnung per E-Mail mehr** (labelz: bestätigte Adresse, `OIDC_TRUST_EMAIL`).
274
+ `OIDC_TRUST_EMAIL` entfällt (gesetzt → Startwarnung, keine Wirkung). Konten ohne verknüpfte `sub` verbindet nur
275
+ noch ein Admin-Link oder die Selbst-Verknüpfung; bis dahin endet ihre authentik-Anmeldung mit 403 (oder – mit
276
+ Gruppe – in einem neuen Konto).
277
+ - Durch authentik-Anmeldung entstandene Konten lassen sich nicht entkoppeln (labelz: mit Passwort möglich);
278
+ nach dem Lösen legt dieselbe `sub` kein neues Konto an (labelz: Neuanlage per Gruppe).
279
+ - Verknüpfen verlangt bei authentik eine neue Anmeldung (`prompt=login`, `max_age=60`); RP-Logout statt nur lokal abmelden.
280
+
281
+ ### Upgrade
282
+ - **Von 0.1.0.dev1:** `user["x"]` → `user.x`; ob ein Konto ein Passwort hat, liefert `GET /api/pz/me`
283
+ (`has_password`). Eigene `kind="custom"`-Einstellungen mit `validate=` anmelden. Preview-Handler rein lesend
284
+ halten; Texte von Vorschau und `DeleteInput.label` mit `lazy()` und Katalogeintrag der App. Ob die Rolle eines
285
+ Erben passt, prüft die App im `user.deleting`-Handler.
286
+ - **authentik:** Das Image braucht `ca-certificates`; eine eigene CA dort einspielen oder über `OIDC_CA_FILE` angeben.
287
+ `OIDC_TRUST_EMAIL` aus der Umgebung entfernen. Am Provider je `PUBLIC_URLS`-Eintrag die Redirect-URI `<url>/auth/callback` und die
288
+ Post-Logout-Redirect-URIs `<url>/login?hint=logged-out` und `<url>/login?hint=account-deleted` eintragen;
289
+ ohne `PUBLIC_URLS` keine authentik-Anmeldung (Startwarnung). Apps, die authentik anbieten, nehmen `"oidc"` in
290
+ `auth_methods` auf.
291
+ - **Ersteinrichtung:** neue Instanzen ohne `ADMIN_*` erst einrichten, dann öffentlich erreichbar machen – oder
292
+ `SETUP_TOKEN` bzw. `ADMIN_*` setzen (`env.example`).
293
+ - **labelz (Vorlage `tests/adoption/labelz/migration.py`, Architektur §5.4):** App-Migrationen
294
+ `0001_legacy` (bisheriges `create_schema()`) und `0002_pillarz_adoption`: `is_admin = 1` → `role = 'admin'`
295
+ (Spalte bleibt stehen), `oidc_links` → `pz_tokens` (`kind='oidc_link'`, `data.expected_username` über
296
+ `json_object_sql`, dann `DROP TABLE`), `themes` → `pz_themes`, `meta['settings']` → `pz_settings`
297
+ (`site_name`, `default_theme` + registrierte App-Settings; `meta` selbst bleibt), `events` der letzten 30 Tage
298
+ → `pz_log`. pillarz' `0001_initial` übernimmt `users` additiv (nie neu gebaut), setzt `email_confirmed = 1` für
299
+ vorhandene Adressen und `setup_done`. Die Vorlage setzt `created_via = 'oidc'` für reine authentik-Konten
300
+ (verknüpft, ohne Passwort) – diese Annahme trifft das App-Team. Der Test beweist auf SQLite und PostgreSQL: alte Passwörter (auch 8 und
301
+ 9 Zeichen, alte scrypt-Parameter) melden sich an, authentik-Konten per `sub` (nie per Adresse), offene Links
302
+ bleiben einlösbar,
303
+ verbrauchte/abgelaufene nicht, Admin aus `is_admin`, `/setup` zu, Themes/Einstellungen/Protokoll übernommen,
304
+ alle Zeilen der Fachtabellen und alle Fremdschlüssel unverändert, Trockenlauf ändert nichts. Eigene geheime
305
+ Altspalten mit `pz.secret_user_column(name)` melden.
306
+ - Apps, die bisher eigene Sitzungs-Cookies unter `<name>` setzen, verlieren diese Sitzungen einmal.
307
+
308
+ ## [0.1.0.dev1] — 2026-10-29
309
+
310
+ ### Added
311
+ - Paket und Auslieferung: `pyproject.toml` (setuptools, Version aus `pillarz.__version__`), Laufzeit-
312
+ Abhängigkeiten als Bereiche, exakte Locks mit Hashes (`requirements.txt`, `requirements-dev.txt`,
313
+ erzeugt von `scripts/lock-deps.sh`). CI baut bei jedem Push Wheel + Sdist und lädt sie bei `v*`-Tags
314
+ öffentlich nach **pypi.org** hoch (nur PyPI, keine Forgejo-Registry). Lizenz **`AGPL-3.0-or-later`**
315
+ (`LICENSE` in Repo, sdist und Wheel; SPDX-Ausdruck nach PEP 639), Kurzbeschreibung `README.md`.
316
+ - `pillarz.Pillarz(name=…, title=…, version=…, package_dir=…, migrations=…, auth_methods=…,
317
+ weak_passwords=…)` mit `create_app()`, `on_startup()`, `on_shutdown()`, `migrate()` und `config`.
318
+ Genau eine Instanz je Prozess; Registrieren nach `create_app()` wirft `RuntimeError`. Datenklassen
319
+ `NavItem`, `AdminTab`, `AccountSection`, `DeleteInput`, `Setting`, `Theme` vorerst nur als Hüllen
320
+ (Felder folgen mit 0.1-Paketen B–F).
321
+ - `pillarz.config.Config.from_env()`: alle 0.1-Variablen (siehe `env.example`), gelesen beim Erzeugen
322
+ von `Pillarz`, nicht beim Import. Startabbruch (`ConfigError`) bei `SECRET_KEY` < 32 Zeichen,
323
+ `SETUP_TOKEN` < 16 Zeichen, ungültigen `PUBLIC_URLS` oder ohne aktive Anmeldemethode; harmlose
324
+ Fehler als Warnung in `Config.warnings` (beim Start protokolliert).
325
+ - `pillarz.db`: `Conn`/`connect()` aus labelz (`?`-Platzhalter, dict-Zeilen, eine Transaktion je
326
+ `connect()`), DDL-Platzhalter `ddl()` (`{pk}`, `{blob}`, `{bigint}`), `now()`/`now_plus()`/`stamp()`
327
+ über eine zentrale Uhr, Migrations-Helfer `has_table`, `has_column`, `add_column`, `create_index`,
328
+ `json_object_sql`, `upsert_sql`, `applied_migrations`, dazu `IntegrityError`, `ping`, `reset`
329
+ (nur Tests) und die labelz-Grundbausteine `all_rows`/`one`/`insert`/`update`/`delete`/`get` mit
330
+ ID-Grenzen.
331
+ - PostgreSQL-Verbindungspool je Prozess (`PG_POOL_SIZE`, Standard 5, `0` = ohne Pool).
332
+ - Versionierte Migrationen je Komponente (`pz_migrations`): pillarz zuerst, dann die App; eine
333
+ Transaktion je Schritt, Sperre gegen parallele Worker, Startabbruch bei Fehlern und bei unbekannten
334
+ (neueren) Einträgen. `python -m pillarz migrate [--dry-run|--status] <modul:objekt>`.
335
+ - `python -m pillarz create-admin <modul:objekt>`: erstes Admin-Konto interaktiv anlegen (nur bei leerer
336
+ Datenbank, Passwortregeln K-4: 10–200 Zeichen, nicht gleich dem Benutzernamen, kein Platzhalter).
337
+ - pillarz-Migration `0001_initial`: Tabelle `users` (öffentliche Spalten `id, username, name, email,
338
+ role, active, locale, region, created_at, last_login, session_epoch`) und private Tabellen
339
+ `pz_sessions`, `pz_flows`, `pz_throttle`, `pz_tokens`, `pz_settings`, `pz_themes`, `pz_log`, `pz_meta`.
340
+ - `pillarz.testing`: `configure()`, `clock` (`freeze`/`advance`), `reset_runtime()` – Grundstock des
341
+ Testkits.
342
+ - `pillarz.i18n` (ADR 0011): `_`, `ngettext`, `pgettext`, `lazy`, `current_locale()` und der
343
+ Kontextmanager `use_locale(sprache)`. msgids sind deutsch; Kataloge
344
+ `<package_dir>/locale/<sprache>/LC_MESSAGES/<name>.po` (Python + Jinja) und `<name>-js.po` (JS)
345
+ werden ohne `.mo` direkt aus den `.po` geladen. Kette **App → pillarz → Quelltext**: eine App kann
346
+ pillarz-Texte mit gleicher msgid umformulieren und weitere Sprachen liefern. `fuzzy`/leere Einträge
347
+ zählen als fehlend. `lazy("…")` liefert ein Objekt, das erst bei `str()`/im Template übersetzt (für
348
+ JSON vorher `str()`).
349
+ - Sprachwahl je Request: Konto (`users.locale`) → `Accept-Language` → `DEFAULT_LOCALE`, jeweils nur
350
+ unterstützte Sprachen. Unterstützt sind `de` plus die Sprachen der App-Kataloge – hat die App keine
351
+ Kataloge, die von pillarz (`en`). Ohne Request (Start, Jobs) gilt die Standardsprache.
352
+ - Jinja: `_`, `gettext`, `ngettext`, `pgettext`, `npgettext` und `{% trans %}` in allen Templates
353
+ (`newstyle`: Werte als Schlüsselwörter, `{{ _("Hallo %(name)s", name=…) }}` – escaped; ein
354
+ wörtliches `%` als `%%`).
355
+ - JavaScript: `/static/pillarz/js/i18n.js` mit `t(msgid, params)`, `nt(singular, plural, n, params)`,
356
+ `pt(kontext, msgid, params)`; Platzhalter `{name}`, bei `nt` ist `{n}` automatisch gesetzt. Die
357
+ Texte kommen aus dem JSON-Block `<script type="application/json" id="i18n">` der Seite (nur die
358
+ JS-Domänen der aktuellen Sprache, CSP-konform); Pluralregeln aus dem Katalog, ohne `eval`.
359
+ - `scripts/i18n.sh extract|update|init <sprache>|check` (Babel) – auch als Vorlage für Apps
360
+ (`PACKAGE_DIR`, `DOMAIN`). Schlüsselwörter: Python `_`, `gettext`, `ngettext`, `pgettext`,
361
+ `npgettext`, `lazy`, `_l`; JS `t`, `nt`, `pt`; Übersetzerhinweise mit `# TRANSLATORS:`.
362
+ - Englischer Katalog für alle pillarz-Texte (`pillarz/locale/en/LC_MESSAGES/pillarz.po`).
363
+ - `pillarz.testing.check_catalogs(package_dir=None, name=None, *, locales=None)`: Liste fehlender,
364
+ `fuzzy` oder leerer Übersetzungen, fehlender Pluralformen, unpassender `%(…)`-Platzhalter und
365
+ fehlender Kataloge (ohne Argumente: die aktuelle App). `pillarz.testing.untranslated()`: sammelt
366
+ Texte, die in einer anderen Sprache als Deutsch unübersetzt angezeigt wurden.
367
+ - `examples/minimal`: kleinste App mit einer App-Migration.
368
+ - Web-Rahmen (0.1-Paket B): `create_app()` setzt Guard (reine ASGI-Middleware: Request-ID, CSRF über
369
+ `Sec-Fetch-Site`/`Origin`, Body-Grenzen 1 MiB bzw. Upload-Registry, 411 ohne Länge), das Gate
370
+ **„default deny“** (globale Dependency: jede Route ohne `Depends(pillarz.web.public)` bzw.
371
+ `@pillarz.web.public_route` verlangt eine Anmeldung – anonym 401, bei HTML-GET `303 /login?next=…`),
372
+ Sicherheits-Header auf jede Antwort (`nosniff`, `Referrer-Policy: same-origin`, CSP, HSTS nur wenn alle
373
+ `PUBLIC_URLS` https sind, `X-Request-ID`, `Vary: Cookie`), Fehler-Handler wie labelz (JSON für `/api/…`
374
+ und Nicht-GET, Fehlerseite für 403/404, Pfad-Validierung → 404, `UnicodeEncodeError` → 422,
375
+ `psycopg_pool.PoolTimeout` → 503 mit `Retry-After: 5`), Static mit `no-cache` + ETag unter `/static/`
376
+ (App) und `/static/pillarz/`, die öffentlichen Seiten `/healthz` und `/themes.css`.
377
+ - Neue Registrierungen an `Pillarz`: `nav(NavItem)`, `csp(**direktiven)` (nur Ergänzungen; `'unsafe-*'`
378
+ außer `'wasm-unsafe-eval'`, `*`, reine Schemata in `script-src`/`default-src` und Änderungen an
379
+ `object-src`/`base-uri`/`frame-ancestors` → `ValueError` beim Start), `upload_limit(pfad|re, bytes)`,
380
+ `no_store(pfad|re)`, `boot(fn)`, `log_source(logger, bereich, label)`, `theme(Theme)`, `icons(pfad)`.
381
+ Pfadregeln: String mit `/` am Ende = Präfix, sonst exakt; `re.compile(…)` muss den ganzen Pfad treffen.
382
+ - `NavItem(key, href, label, icon=None, order=100, visible=None, badge=None, badge_href=None,
383
+ badge_label=None)` und `Theme(id, name, light, dark)` mit Feldern.
384
+ - `pillarz.web`: `public`, `public_route`, `render`, `fail`, `csp_extend`, `read_form`, `client_ip`,
385
+ `base_url`, `current_user`, `is_admin`, `via`, `require_user`, `require_member`, `require_admin`,
386
+ `require_role(*rollen)`. Bis Paket D gibt es keine Sitzungen: `require_*` antworten anonym immer 401;
387
+ `throttled`/`hit` sind Hüllen (`NotImplementedError`).
388
+ - `pillarz.text`: `oneline`, `multiline`, `required_line`, `safe_next`, `to_json` (aus labelz).
389
+ - Templates `pillarz/base.html` (Blöcke `title`, `head`, `body_class`, `main_class`, `content`,
390
+ `footer_extra`, `scripts`, `icons`, dazu `topbar` und `footer`), `pillarz/error.html`,
391
+ `pillarz/plain.html` (Karte ohne Navigation, Blöcke `card`, `card_class`), Icon-Sprite
392
+ `pillarz/icons.svg` (Lucide, ISC). Template-Kette App → pillarz; Namen unter `pillarz/…` kommen immer
393
+ aus pillarz. Kontext: `user`, `is_admin`, `site_name`, `app_name`, `version` (anonym nur ein
394
+ Cache-Buster), `themes_rev`, `theme_id`, `mode`, `locale`, `region`, `nav`, `nav_items`, `flash`,
395
+ `boot`, `page`, `help`, `i18n`, `_`/`ngettext`/`pgettext` (Jinja-i18n, newstyle).
396
+ - Static: `/static/pillarz/css/base.css` (alles in `@layer pillarz`, Tokens `--pz-*` aus `--t-*`,
397
+ Komponenten `.btn`, `.icon-btn`, `.field`, `.banner-*`, `.card`, `.chip`, `.tabs`, `.tbl`, `.menu`,
398
+ `.dlg`, `.toasts`, `.topbar` …), `js/api.js` (`api`, `upload`, `postQuietly`, `ApiError`), `js/ui.js`
399
+ (`boot`, `pageData`, `region`, `readJson`, `h`, `icon`, `button`, `iconButton`, `toast`, `showError`,
400
+ `trapFocus`, `dialog`, `confirmDialog`, `promptDialog`, `field`, `numInput`, `readNum`, `selectEl`,
401
+ `checkbox`, `parseNum`, `fmtNum`, `fmtNumber`, `fmtDate`, `relTime`, `debounce`, `closeMenu`,
402
+ `menuIsOpen`, `attachMenu`, `menuButton`), `js/i18n.js` (`t`, `nt`, `pt`), `favicon.svg`.
403
+ - Themes: labelz' fünf eingebaute (Papier, Wald, Meer, Schiefer, Beere) + `pz.theme()` + eigene aus
404
+ `pz_themes`; `/themes.css` nur aus geprüften Hex-Werten, Akzentschrift nach WCAG-Kontrast.
405
+ - Protokoll: Einträge der Logger `pillarz` und `<name>` ab INFO in `pz_log`, mit `request_id`,
406
+ `actor_id` und `ip` aus dem Request (K-13); `extra={"pz_actor": id}` setzt das Konto selbst.
407
+ Aufbewahrung `LOG_RETENTION_DAYS` (neu, Standard 90) und höchstens 20 000 Einträge (K-14).
408
+ - `pillarz.testing.route_audit(app)` (jede nicht öffentliche Route anonym → 401/303→`/login`/404,
409
+ dazu Routen auf von pillarz belegten URLs und doppelte Routen) und `assert_security_headers(resp)`.
410
+ - `tests/browser_smoke.py`: manueller Browser-Smoke mit Playwright (Konsolen-/CSP-Fehler, Überlauf bei
411
+ 360 px, Screenshots); `playwright` steht in `requirements-dev.txt`.
412
+ - i18n im Web-Rahmen (Paket C in B): der Guard setzt die Sprache je Request – vor CSRF/Body-Prüfung aus
413
+ `Accept-Language`, nach der Identität mit der Kontosprache (`user["locale"]`) – und setzt sie bewusst
414
+ nicht zurück, damit auch der 500er-Handler in der Sprache des Requests antwortet. Alle Templates
415
+ bekommen `_`, `gettext`, `ngettext`, `pgettext`, `npgettext`, `{% trans %}` und `current_locale()`;
416
+ `#i18n` enthält die JS-Texte der Sprache (`{"locale", "plural", "messages"}`). Kataloge werden beim
417
+ Start geladen (kaputte `.po` → Startabbruch). Englische Texte für alle Seiten und Meldungen aus B.
418
+ - **Protokoll in der Standardsprache:** `pz_log`-Einträge stehen immer in `DEFAULT_LOCALE`, nicht in
419
+ der Sprache des auslösenden Requests. Log-Texte als `lazy("…")` mit `%`-Argumenten loggen
420
+ (`log.warning(lazy("Import von %s fehlgeschlagen"), name)`) – dann sind sie extrahierbar, und der
421
+ Protokoll-Handler übersetzt sie beim Schreiben unter `use_locale(None)`. Andere Handler (Konsole)
422
+ formatieren in der aktuellen Sprache. `Config.warnings` sind weiterhin `str` mit dem deutschen
423
+ Quelltext und werden beim Protokollieren übersetzt.
424
+ - `tests/browser_smoke.py --locales de,en` (Standard): je Sprache `<html lang>`, `#i18n`-Block und
425
+ Übersetzung durch `i18n.js` unter der CSP.
426
+ - `pillarz.db.requires_fk_off` (Dekorator für Migrationsschritte) und
427
+ `pillarz.db.rebuild_table_sqlite(conn, table, create_sql, columns=None)` (ADR 0003): Tabellenumbau auf
428
+ SQLite nach dem 12-Schritte-Verfahren. Markierte Schritte laufen auf SQLite mit
429
+ `PRAGMA foreign_keys = OFF` (vor `BEGIN` gesetzt) und `PRAGMA foreign_key_check` vor dem Commit –
430
+ Verstöße rollen zurück (`MigrationError`). Ohne die Markierung verweigert `rebuild_table_sqlite` den
431
+ Umbau, weil `DROP TABLE` sonst `ON DELETE CASCADE` in Kindtabellen auslöst. Auf PostgreSQL hat die
432
+ Markierung keine Wirkung; `rebuild_table_sqlite` gibt es dort nicht (`ALTER TABLE` nutzen).
433
+ - `pillarz.testing.is_test_dir(pfad)`.
434
+ - Beim Start: Fehler im Protokoll, wenn es Konten, aber kein aktives Admin-Konto gibt und
435
+ `OIDC_ADMIN_GROUPS` leer ist (Aussperrung nach der Übernahme einer Datenbank sichtbar machen).
436
+ - **Seiten ohne Rahmen** (P-98): `render(request, template, page=…, chrome=False)` mit einem Template,
437
+ das `pillarz/bare.html` erweitert (Blöcke `title`, `head`, `body_class`, `content`, `scripts`; JSON
438
+ `#page`, `#i18n`; `data-theme`/`data-mode`/`data-version` am `<html>`). Gleiche Header und CSP wie
439
+ jede Seite, aber ohne Navigation, Badges, Topbar, Fuß, Flash, Toasts, Hilfe, `#boot` und ohne
440
+ pillarz-CSS/-JS (`base.css` bei Bedarf selbst im Block `head`). Falscher Rahmen (`base.html` mit
441
+ `chrome=False`, `bare.html` ohne, gar kein Rahmen mit `chrome=False`) → `RuntimeError` beim Rendern.
442
+ Beispiel: `/print` in `examples/minimal` (auch im Browser-Smoke).
443
+ - **Protokoll: App-Quelle** (P-107): Logger unter `<name>` ohne eigene `pz.log_source()`-Zuordnung
444
+ landen in der Quelle `<name>` (Anzeigename = `title`) statt in `system`; bildet die App `<name>`
445
+ selbst ab, gilt ihre Zuordnung.
446
+ - `pz.public_mount(pfad)`: eine eingehängte Unter-App (`app.mount`) ohne Anmeldung erreichbar machen.
447
+ - **WebSockets** (FastAPI-`@router.websocket`): Gate und Guard gelten auch dort – `Origin` muss zum
448
+ `Host` passen (fehlender `Origin` = kein Browser, erlaubt), Identität wie bei HTTP
449
+ (`websocket.state.user`), ohne Anmeldung bzw. bei fremdem Origin Schließen mit 1008. `public`
450
+ funktioniert wie bei HTTP-Routen.
451
+ - `HEAD` wird für alle Routen wie `GET` beantwortet (ohne Inhalt), z. B. `HEAD /healthz`.
452
+ - `pillarz.testing.flush_log()`: wartet, bis das Protokoll geschrieben ist (vor dem Lesen von `pz_log`).
453
+ - `route_audit()` prüft WebSocket-Routen (anonym → 1008) und meldet Mounts (außer Static und
454
+ `pz.public_mount`), `Host`- und Starlette-Routen (`Route`/`WebSocketRoute`), an denen der Gate nicht
455
+ läuft. Eingebundene Router werden mit der ganzen Kette ausgewertet.
456
+
457
+ ### Changed
458
+ - **`pillarz.testing.configure()` erzwingt ein frisches temporäres `DATA_DIR`** (`<tmp>/pillarz-test-*`,
459
+ ein gesetztes `DATA_DIR` wird überschrieben, `DB_PATH` entfernt) und leert `POSTGRES_*` – außer bei
460
+ `PZ_TEST_PG=1`. Apps, die ihre Tests gegen PostgreSQL fahren, setzen `PZ_TEST_PG=1` und eine
461
+ Datenbank, deren Name `test` als Wortteil trägt (`labelz_test`, `pillarz_test_…`).
462
+ `pillarz.db.reset()` verweigert sonst (SQLite: Datei außerhalb eines Testverzeichnisses).
463
+ Unterprozesse eines Testlaufs übernehmen das Verzeichnis des Elternprozesses.
464
+ - `python -m pillarz migrate --status|--dry-run` legen keine fehlende SQLite-Datei mehr an (Meldung,
465
+ alle Schritte offen); `migrations.status()`/Trockenlauf ohne Datenbank → `MigrationError`.
466
+ - Wartet eine Migration auf SQLite länger als 120 s auf die Dateisperre, gibt es einen
467
+ `MigrationError` mit klarer Meldung statt `sqlite3.OperationalError`.
468
+ - `.secret_key` entsteht bei mehreren gleichzeitig startenden Prozessen genau einmal (Temp-Datei 0600 +
469
+ `os.link`); eine für Gruppe/andere lesbare Schlüsseldatei ergibt eine Warnung.
470
+ - **Protokoll schreibt im Hintergrund:** der Handler ist ein `QueueHandler`, ein eigener Thread schreibt
471
+ die Einträge stapelweise. Loggen blockiert weder den Event-Loop noch eine offene Transaktion
472
+ (vorher auf SQLite 5 s Sperre, verlorener Eintrag und danach „database is locked“; auf PG eine zweite
473
+ Pool-Verbindung). Einträge sind unabhängig von der Transaktion des Aufrufers (bleiben bei Rollback).
474
+ Beim Herunterfahren wird die Warteschlange geleert; Tests lesen `pz_log` nach `testing.flush_log()`.
475
+ - Protokoll-Obergrenze 20 000 Einträge gilt getrennt für `auth`/`admin` und alle übrigen Quellen.
476
+ - `log_once` drosselt nach festen Schlüsseln (Routen-Template statt Pfad) und verdrängt bei Überlauf
477
+ die ältesten Schlüssel, statt alle zu vergessen.
478
+ - HTML-GET mit unerwartetem Fehler bekommt die Fehlerseite (500) statt JSON.
479
+ - Templates: `autoescape` für alle Templates (unabhängig von der Endung).
480
+ - Body-Grenzen gelten für alle Methoden (auch `GET`/`HEAD`/`OPTIONS`); ein `Content-Length` ohne
481
+ gültige Zahl ergibt 411.
482
+
483
+ ### Fixed
484
+ - Validatoren (Benutzername, Mail, SQL-Bezeichner, Migrationsnamen, App-/Navigations-/Theme-Kennungen,
485
+ Farben, CSP-Quellen, Sprachcode) ließen ein abschließendes `\n` durch (`re.match` mit `$`) – jetzt
486
+ `fullmatch`.
487
+ - PostgreSQL-Pool: kam nach einer toten Verbindung kein Ersatz (`PoolTimeout`), wurde die tote
488
+ Verbindung ein zweites Mal an den Pool zurückgegeben (bei gesunden Verbindungen ein `ValueError`, der
489
+ den `PoolTimeout` überdeckt).
490
+ - Ersteinrichtung auf SQLite mit mehreren Prozessen: „database is locked“ → `SetupClosed`.
491
+ - `scripts/release.sh`: mit `SKIP_TESTS=1` fehlte der Interpreter für den Build-Schritt.
492
+
493
+ ### Security
494
+ - `SECRET_KEY` aus der Umgebung: bekannte Vorlagenwerte (`replace-with-…`, `changeme`, `example` …) und
495
+ Werte mit weniger als 10 verschiedenen Zeichen brechen den Start ab.
496
+ - Passwörter mit einzelnen UTF-16-Surrogaten lehnt `password_problem` ab (nicht kodier-/hashbar).
497
+ - CI: Versionsnummer aus dem Tag erst gegen PEP 440 prüfen und nie in Python-Quelltext einsetzen;
498
+ PyPI-Token (`PYPI_TOKEN`) nur über die Umgebung an `twine`, nie in der Kommandozeile;
499
+ probe-install installiert von pypi.org und vergleicht den Hash dort mit dem im publish-Job
500
+ hochgeladenen Wheel; pip-audit auch über `requirements-dev.txt`; kein Cache-Speichern aus
501
+ Pull-Requests.
502
+ - Review Paket B: Mounts, `Host`- und Starlette-Routen liefen am Gate vorbei – der Guard weist anonyme
503
+ Anfragen an sie jetzt ab (401 bzw. `303 /login`), außer pillarz' Static und `pz.public_mount()`.
504
+ - `public` zählt nur noch als direkte Abhängigkeit der Route bzw. eines Routers der Kette – eine
505
+ Hilfs-Dependency, die intern `Depends(public)` nutzt, macht Routen nicht mehr öffentlich. Umgekehrt
506
+ wird `public` an verschachtelt eingebundenen Routern (`include_router(…, dependencies=[…])`) erkannt.
507
+ - WebSocket-Verbindungen liefen am Guard vorbei (kein Origin-Check, keine Identität).
508
+ - CSP-Ergänzungen: `javascript:` überall abgelehnt; `worker-src`/`child-src` nur `'self'`/`blob:`; Hosts
509
+ bei reinem HTTPS nur `https://` (`connect-src` zusätzlich `wss://`), `ws(s)://` nur in `connect-src`;
510
+ Hashes in `style-src` abgelehnt (sie schalteten `'unsafe-inline'` still ab).
511
+ - Pfade in Logzeilen einzeilig und gekürzt (`oneline(…)[:200]`, keine Log-Injection auf stdout).
512
+ - `PoolTimeout`/`HTTPException` aus der Identitätsauflösung im Guard → 503 mit `Retry-After` bzw. die
513
+ passende Fehlerantwort statt 500.
514
+ - Template-Namensraum: `./pillarz/…` und ähnliche Schreibweisen umgingen den Schutz der
515
+ pillarz-Templates; ein App-Ordner `templates/pillarz/` bricht den Start jetzt ab.
516
+
517
+ ### Upgrade
518
+ - **Default deny:** Routen einer App sind ab 0.1-Paket B ohne Anmeldung gesperrt. Öffentliche Routen
519
+ (Webhooks, Startseite ohne Login …) brauchen `Depends(pillarz.web.public)` bzw. `@public_route`;
520
+ `pillarz.testing.route_audit(app)` im Testlauf der App zeigt vergessene Stellen.
521
+ - App-Ordner `static/pillarz/` ist verboten (`create_app()` bricht ab); `static/logo.svg` und
522
+ `static/favicon.svg` der App ersetzen Marke bzw. Favicon von pillarz.
523
+ - App-Ordner `templates/pillarz/` ist ebenso verboten (`create_app()` bricht ab).
524
+ - `render(**ctx)` lehnt von pillarz belegte Kontextschlüssel ab (`user`, `boot`, `nav_items`, `i18n`,
525
+ `version`, `help`, `flash`, `_` … → `ValueError`).
526
+ - Eigene Mounts (`app.mount`) sind anonym gesperrt; öffentliche Unter-Apps mit `pz.public_mount(pfad)`
527
+ freigeben. `app.add_route()` statt FastAPI-Routen meldet `route_audit()`.
528
+ - Tests, die `pz_log` lesen, vorher `pillarz.testing.flush_log()` aufrufen.
529
+ - Eine vorhandene `users`-Tabelle (z. B. labelz 0.12) wird übernommen: `0001_initial` ergänzt fehlende
530
+ Spalten (`role`, `email_confirmed`, `locale`, `region`, `updated_at` …), setzt `email_confirmed = 1`
531
+ für Konten mit Adresse und `pz_meta.setup_done`, wenn es Konten gibt. Datenumbau (`is_admin` →
532
+ `role`, `oidc_links` → `pz_tokens`, `themes` → `pz_themes`, `meta.settings` → `pz_settings`) ist
533
+ Sache einer App-Migration, die nach `0001_initial` läuft (Architektur §5.4).
534
+ - Installation in einer App: normal von pypi.org, exakter Pin mit Hash (`pillarz==X.Y.Z
535
+ --hash=sha256:…` im Lock der App), Installation mit `--require-hashes` – kein zusätzlicher Index, kein
536
+ Token im Image-Build.