pillarz 0.1.0.dev1__py3-none-any.whl

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 (62) hide show
  1. pillarz/__init__.py +309 -0
  2. pillarz/__main__.py +176 -0
  3. pillarz/_auth/__init__.py +2 -0
  4. pillarz/_auth/passwords.py +74 -0
  5. pillarz/_auth/setup.py +57 -0
  6. pillarz/_db/__init__.py +1 -0
  7. pillarz/_db/clock.py +67 -0
  8. pillarz/_db/connection.py +241 -0
  9. pillarz/_db/m0001_initial.py +194 -0
  10. pillarz/_db/migrations.py +272 -0
  11. pillarz/_i18n/__init__.py +11 -0
  12. pillarz/_i18n/babel-js.cfg +4 -0
  13. pillarz/_i18n/babel.cfg +8 -0
  14. pillarz/_i18n/catalogs.py +216 -0
  15. pillarz/_i18n/jinja.py +41 -0
  16. pillarz/_i18n/js.py +43 -0
  17. pillarz/_i18n/request.py +103 -0
  18. pillarz/_i18n/tools.py +200 -0
  19. pillarz/_protocol.py +315 -0
  20. pillarz/_runtime.py +86 -0
  21. pillarz/_themes.py +154 -0
  22. pillarz/_web/__init__.py +1 -0
  23. pillarz/_web/app.py +69 -0
  24. pillarz/_web/context.py +14 -0
  25. pillarz/_web/csp.py +148 -0
  26. pillarz/_web/errors.py +132 -0
  27. pillarz/_web/gate.py +134 -0
  28. pillarz/_web/guard.py +241 -0
  29. pillarz/_web/headers.py +62 -0
  30. pillarz/_web/net.py +48 -0
  31. pillarz/_web/pages.py +30 -0
  32. pillarz/_web/render.py +271 -0
  33. pillarz/_web/static.py +40 -0
  34. pillarz/config.py +336 -0
  35. pillarz/db.py +337 -0
  36. pillarz/i18n.py +126 -0
  37. pillarz/locale/en/LC_MESSAGES/pillarz-js.po +86 -0
  38. pillarz/locale/en/LC_MESSAGES/pillarz.po +622 -0
  39. pillarz/locale/pillarz-js.pot +83 -0
  40. pillarz/locale/pillarz.pot +588 -0
  41. pillarz/static/pillarz/css/base.css +531 -0
  42. pillarz/static/pillarz/favicon.svg +1 -0
  43. pillarz/static/pillarz/js/api.js +96 -0
  44. pillarz/static/pillarz/js/i18n.js +158 -0
  45. pillarz/static/pillarz/js/ui.js +433 -0
  46. pillarz/static/pillarz/vendor/lucide/LICENSE +43 -0
  47. pillarz/templates/pillarz/bare.html +25 -0
  48. pillarz/templates/pillarz/base.html +38 -0
  49. pillarz/templates/pillarz/error.html +14 -0
  50. pillarz/templates/pillarz/icons.svg +66 -0
  51. pillarz/templates/pillarz/partials/flash.html +8 -0
  52. pillarz/templates/pillarz/partials/nav.html +30 -0
  53. pillarz/templates/pillarz/plain.html +16 -0
  54. pillarz/testing/__init__.py +149 -0
  55. pillarz/testing/_web.py +112 -0
  56. pillarz/text.py +81 -0
  57. pillarz/web.py +182 -0
  58. pillarz-0.1.0.dev1.dist-info/METADATA +71 -0
  59. pillarz-0.1.0.dev1.dist-info/RECORD +62 -0
  60. pillarz-0.1.0.dev1.dist-info/WHEEL +5 -0
  61. pillarz-0.1.0.dev1.dist-info/licenses/LICENSE +661 -0
  62. pillarz-0.1.0.dev1.dist-info/top_level.txt +1 -0
pillarz/_db/clock.py ADDED
@@ -0,0 +1,67 @@
1
+ """Zentrale Uhr für alle Zeitstempel von pillarz (`pillarz.db.now()` & Co.).
2
+
3
+ Tests frieren sie über `pillarz.testing.clock.freeze(at)` ein (Ablauf von Sitzungen, Tokens,
4
+ Aufbewahrung des Protokolls), statt `datetime` zu patchen. Im Betrieb liefert sie einfach die
5
+ aktuelle UTC-Zeit. Der eingefrorene Zustand gilt je Prozess (nicht für Unterprozesse).
6
+ """
7
+ import threading
8
+ from contextlib import contextmanager
9
+ from datetime import datetime, timedelta, timezone
10
+
11
+
12
+ def _utc(at: datetime | str) -> datetime:
13
+ if isinstance(at, str):
14
+ at = datetime.fromisoformat(at)
15
+ if not isinstance(at, datetime):
16
+ raise TypeError("Zeitpunkt muss datetime oder ISO-Text sein")
17
+ if at.tzinfo is None: # naive Angaben gelten als UTC
18
+ return at.replace(tzinfo=timezone.utc)
19
+ return at.astimezone(timezone.utc)
20
+
21
+
22
+ class Clock:
23
+ def __init__(self):
24
+ self._frozen: datetime | None = None
25
+ self._lock = threading.Lock()
26
+
27
+ def now(self) -> datetime:
28
+ """Aktuelle Zeit (UTC, mit Zeitzone) – oder der eingefrorene Zeitpunkt."""
29
+ frozen = self._frozen
30
+ return frozen if frozen is not None else datetime.now(timezone.utc)
31
+
32
+ @property
33
+ def frozen(self) -> bool:
34
+ return self._frozen is not None
35
+
36
+ def set(self, at: datetime | str) -> None:
37
+ """Uhr auf einen festen Zeitpunkt stellen (bis `unfreeze()`)."""
38
+ with self._lock:
39
+ self._frozen = _utc(at)
40
+
41
+ def advance(self, delta: timedelta | None = None, **kwargs) -> datetime:
42
+ """Eingefrorene Uhr vorstellen, z. B. `advance(minutes=16)`. Ohne Einfrieren: Fehler."""
43
+ delta = delta if delta is not None else timedelta(**kwargs)
44
+ with self._lock:
45
+ if self._frozen is None:
46
+ raise RuntimeError("Die Uhr ist nicht eingefroren – erst freeze() aufrufen.")
47
+ self._frozen = self._frozen + delta
48
+ return self._frozen
49
+
50
+ def unfreeze(self) -> None:
51
+ with self._lock:
52
+ self._frozen = None
53
+
54
+ @contextmanager
55
+ def freeze(self, at: datetime | str | None = None):
56
+ """Zeit für die Dauer des Blocks festhalten (Standard: jetzt). Liefert die Uhr selbst,
57
+ damit der Block mit `advance()` weiterstellen kann."""
58
+ previous = self._frozen
59
+ self.set(at if at is not None else datetime.now(timezone.utc))
60
+ try:
61
+ yield self
62
+ finally:
63
+ with self._lock:
64
+ self._frozen = previous
65
+
66
+
67
+ clock = Clock()
@@ -0,0 +1,241 @@
1
+ """Verbindung und Transaktion – SQLite (Standard) oder PostgreSQL (wenn POSTGRES_DB gesetzt ist).
2
+
3
+ Portiert aus labelz `app/db.py` (`Conn`, `connect`, `_statements`, `reset`, `ping`), angepasst an ADR 0003:
4
+ - Konfiguration kommt aus der einen `Pillarz`-Instanz (`_runtime`), nicht aus Modul-Globals.
5
+ - SQLite: Verbindung im Autocommit-Modus des Treibers, die Transaktion beginnt ausdrücklich mit
6
+ `BEGIN` (bzw. `BEGIN IMMEDIATE` für den Migrations-Runner). So laufen auch `CREATE`/`ALTER` in der
7
+ Transaktion und werden bei einem Fehler mit zurückgerollt. `busy_timeout` für mehrere Prozesse auf
8
+ einer Datei, im Prozess weiterhin global serialisiert (RLock wie labelz).
9
+ - PostgreSQL: Verbindungspool je Prozess (`psycopg_pool`, `PG_POOL_SIZE`, Standard 5, `0` = eine
10
+ Verbindung je `connect()` wie labelz). Der Pool wird beim ersten `connect()` angelegt und nach einem
11
+ Fork nie geerbt (PID-Prüfung). Ist eine frisch geholte Verbindung tot (PG-Neustart), wird das erste
12
+ Statement **einmal** mit einer neuen Verbindung wiederholt – nie mitten in einer Transaktion.
13
+ """
14
+ import os
15
+ import re
16
+ import sqlite3
17
+ import threading
18
+ from contextlib import contextmanager
19
+
20
+ import psycopg
21
+ from psycopg.rows import dict_row
22
+
23
+ from .. import _runtime
24
+
25
+ # labelz app/db.py: IntegrityError-Tupel – Aufrufer fangen `except IntegrityError:` auf beiden Systemen
26
+ IntegrityError: tuple = (sqlite3.IntegrityError, psycopg.IntegrityError)
27
+
28
+ _lock = threading.RLock()
29
+
30
+ # {pk} (Primärschlüssel), {blob} (Binärspalte), {bigint} (64-Bit-Ganzzahl) sind die einzigen Stellen,
31
+ # an denen sich die Systeme im DDL unterscheiden (labelz PK/BLOB)
32
+ _PLACEHOLDERS = {
33
+ "{pk}": {"sqlite": "INTEGER PRIMARY KEY AUTOINCREMENT",
34
+ "postgres": "BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY"},
35
+ "{blob}": {"sqlite": "BLOB", "postgres": "BYTEA"},
36
+ "{bigint}": {"sqlite": "INTEGER", "postgres": "BIGINT"},
37
+ }
38
+
39
+ SQLITE_BUSY_MS = 5000
40
+ POOL_MAX_IDLE = 300
41
+ POOL_TIMEOUT = 10
42
+ CONNECT_TIMEOUT = 10
43
+
44
+ _IDENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{0,62}$")
45
+
46
+
47
+ def ident(name: str) -> str:
48
+ """Bezeichner (Tabelle/Spalte) prüfen, bevor er in SQL eingesetzt wird – Werte gehen immer als
49
+ Parameter, Bezeichner können das nicht."""
50
+ if not isinstance(name, str) or not _IDENT.fullmatch(name):
51
+ raise ValueError(f"Ungültiger SQL-Bezeichner: {name!r}")
52
+ return name
53
+
54
+
55
+ class Conn:
56
+ """Dünne Hülle: „?“-Platzhalter und dict-Zeilen auf beiden Systemen (labelz `Conn`)."""
57
+
58
+ def __init__(self, raw, postgres: bool, renew=None):
59
+ self.raw, self.postgres = raw, postgres
60
+ # Nur bis zum ersten Statement gesetzt: tauscht eine tote, frisch geholte Verbindung aus
61
+ self._renew = renew
62
+
63
+ def execute(self, sql: str, args: tuple | list = ()):
64
+ if self.postgres:
65
+ # Unser SQL enthält keine wörtlichen ? oder % – Werte gehen nur als Parameter
66
+ sql = sql.replace("%", "%%").replace("?", "%s")
67
+ args = tuple(args)
68
+ if self._renew is not None:
69
+ renew, self._renew = self._renew, None
70
+ try:
71
+ return self.raw.execute(sql, args)
72
+ except psycopg.OperationalError:
73
+ if not (self.raw.closed or self.raw.broken):
74
+ raise # echter Fehler, keine tote Verbindung
75
+ dead, self.raw = self.raw, None
76
+ self.raw = renew(dead) # einmal mit neuer Verbindung, noch nichts ausgeführt
77
+ return self.raw.execute(sql, args)
78
+
79
+ def rows(self, sql: str, args: tuple | list = ()) -> list[dict]:
80
+ return [dict(r) for r in self.execute(sql, args).fetchall()]
81
+
82
+ def row(self, sql: str, args: tuple | list = ()) -> dict | None:
83
+ r = self.execute(sql, args).fetchone()
84
+ return dict(r) if r else None
85
+
86
+ def scalar(self, sql: str, args: tuple | list = ()):
87
+ r = self.execute(sql, args).fetchone()
88
+ return None if r is None else list(dict(r).values())[0]
89
+
90
+ def insert(self, table: str, **fields) -> int:
91
+ """INSERT mit `id`-Spalte; liefert die neue ID (PostgreSQL über `RETURNING id`)."""
92
+ cols = ", ".join(ident(c) for c in fields)
93
+ marks = ", ".join("?" for _ in fields)
94
+ sql = f"INSERT INTO {ident(table)} ({cols}) VALUES ({marks})"
95
+ if self.postgres:
96
+ return self.execute(sql + " RETURNING id", tuple(fields.values())).fetchone()["id"]
97
+ return self.execute(sql, tuple(fields.values())).lastrowid
98
+
99
+ def insert_plain(self, table: str, **fields) -> None:
100
+ """INSERT für Tabellen ohne id-Spalte (zusammengesetzter Schlüssel)."""
101
+ cols = ", ".join(ident(c) for c in fields)
102
+ marks = ", ".join("?" for _ in fields)
103
+ self.execute(f"INSERT INTO {ident(table)} ({cols}) VALUES ({marks})", tuple(fields.values()))
104
+
105
+
106
+ # ------------------------------------------------------------------ PostgreSQL-Pool
107
+ _pool = None
108
+ _pool_pid: int | None = None
109
+ _pool_lock = threading.Lock()
110
+
111
+
112
+ def _pg_kwargs(cfg) -> dict:
113
+ return dict(dbname=cfg.postgres_db, user=cfg.postgres_user, password=cfg.postgres_password,
114
+ host=cfg.postgres_host, port=cfg.postgres_port, connect_timeout=CONNECT_TIMEOUT)
115
+
116
+
117
+ def _get_pool(cfg):
118
+ global _pool, _pool_pid
119
+ with _pool_lock:
120
+ if _pool is not None and _pool_pid != os.getpid():
121
+ # Nach einem Fork: Verbindungen gehören dem Elternprozess – nie anfassen, neu anlegen
122
+ _pool = None
123
+ if _pool is None:
124
+ from psycopg_pool import ConnectionPool
125
+ _pool = ConnectionPool(kwargs={**_pg_kwargs(cfg), "row_factory": dict_row},
126
+ min_size=1, max_size=cfg.pg_pool_size, max_idle=POOL_MAX_IDLE,
127
+ timeout=POOL_TIMEOUT, name=f"pillarz-{cfg.name}", open=True)
128
+ _pool_pid = os.getpid()
129
+ return _pool
130
+
131
+
132
+ def close_pool() -> None:
133
+ """Pool schließen (Lifespan-Ende, Worker-Befehl, Tests). Der nächste `connect()` legt neu an."""
134
+ global _pool, _pool_pid
135
+ with _pool_lock:
136
+ pool, _pool = _pool, None
137
+ mine = _pool_pid == os.getpid()
138
+ _pool_pid = None
139
+ if pool is not None and mine:
140
+ pool.close()
141
+
142
+
143
+ def pool_stats() -> dict | None:
144
+ """Kennzahlen des Pools (für Tests/Diagnose), `None` ohne Pool."""
145
+ pool = _pool
146
+ return pool.get_stats() if pool is not None and _pool_pid == os.getpid() else None
147
+
148
+
149
+ @contextmanager
150
+ def _pg_transaction(cfg):
151
+ if cfg.pg_pool_size <= 0:
152
+ # Rückfall wie labelz: eine kurze eigene Verbindung je Aufruf
153
+ raw = psycopg.connect(**_pg_kwargs(cfg), row_factory=dict_row)
154
+ try:
155
+ yield Conn(raw, True)
156
+ raw.commit()
157
+ except BaseException:
158
+ raw.rollback()
159
+ raise
160
+ finally:
161
+ raw.close()
162
+ return
163
+
164
+ pool = _get_pool(cfg)
165
+
166
+ def renew(dead):
167
+ # Die tote Verbindung ist zurückgegeben, bevor getconn() es erneut versucht: schlägt das fehl
168
+ # (PoolTimeout), ist conn.raw None und der finally-Block gibt nichts doppelt zurück (C-M1)
169
+ pool.putconn(dead) # der Pool verwirft geschlossene/kaputte Verbindungen
170
+ pool.check() # übrige Ruheverbindungen prüfen (nach PG-Neustart alle tot)
171
+ return pool.getconn()
172
+
173
+ conn = Conn(pool.getconn(), True, renew=renew)
174
+ try:
175
+ yield conn
176
+ conn.raw.commit()
177
+ except BaseException:
178
+ if conn.raw is not None:
179
+ try:
180
+ conn.raw.rollback()
181
+ except psycopg.Error:
182
+ pass # tote Verbindung: der Pool verwirft sie beim Zurückgeben
183
+ raise
184
+ finally:
185
+ # Zurück nur im Ruhezustand – der Pool rollt Offenes zurück bzw. verwirft Kaputtes
186
+ if conn.raw is not None:
187
+ pool.putconn(conn.raw)
188
+
189
+
190
+ # ------------------------------------------------------------------ SQLite
191
+ def sqlite_raw(path, *, busy_ms: int = SQLITE_BUSY_MS, foreign_keys: bool = True, uri: bool = False) -> sqlite3.Connection:
192
+ raw = sqlite3.connect(path, isolation_level=None, timeout=busy_ms / 1000, uri=uri)
193
+ raw.row_factory = sqlite3.Row
194
+ raw.execute(f"PRAGMA busy_timeout = {int(busy_ms)}")
195
+ # vor BEGIN – in einer Transaktion wirkungslos; OFF nur für Tabellenumbauten in Migrationen (ADR 0003)
196
+ raw.execute("PRAGMA foreign_keys = ON" if foreign_keys else "PRAGMA foreign_keys = OFF")
197
+ return raw
198
+
199
+
200
+ @contextmanager
201
+ def _sqlite_transaction(cfg, *, immediate: bool, busy_ms: int, foreign_keys: bool = True):
202
+ with _lock:
203
+ raw = sqlite_raw(cfg.db_path, busy_ms=busy_ms, foreign_keys=foreign_keys)
204
+ try:
205
+ raw.execute("BEGIN IMMEDIATE" if immediate else "BEGIN")
206
+ yield Conn(raw, False)
207
+ raw.execute("COMMIT")
208
+ except BaseException:
209
+ if raw.in_transaction:
210
+ raw.execute("ROLLBACK")
211
+ raise
212
+ finally:
213
+ raw.close()
214
+
215
+
216
+ @contextmanager
217
+ def transaction(*, immediate: bool = False, busy_ms: int = SQLITE_BUSY_MS, foreign_keys: bool = True):
218
+ """Eine Transaktion: Commit am Ende, Rollback bei Fehler. `immediate` (nur SQLite) nimmt die
219
+ Schreibsperre der Datei sofort – für den Migrations-Runner; `foreign_keys=False` (nur SQLite)
220
+ schaltet die Fremdschlüssel für Tabellenumbauten ab (Migrationsschritte mit `requires_fk_off`)."""
221
+ cfg = _runtime.config()
222
+ if cfg.postgres:
223
+ with _pg_transaction(cfg) as conn:
224
+ yield conn
225
+ else:
226
+ with _sqlite_transaction(cfg, immediate=immediate, busy_ms=busy_ms, foreign_keys=foreign_keys) as conn:
227
+ yield conn
228
+
229
+
230
+ def is_postgres() -> bool:
231
+ return _runtime.config().postgres
232
+
233
+
234
+ def statements(sql: str, postgres: bool | None = None) -> list[str]:
235
+ """DDL mit Platzhaltern in einzelne Statements für das jeweilige System (labelz `_statements`).
236
+ `--`-Kommentare werden entfernt, getrennt wird an `;` – also keine `;` oder `--` in Literalen."""
237
+ system = "postgres" if (is_postgres() if postgres is None else postgres) else "sqlite"
238
+ for key, values in _PLACEHOLDERS.items():
239
+ sql = sql.replace(key, values[system])
240
+ lines = [line.split("--", 1)[0] for line in sql.splitlines()]
241
+ return [s.strip() for s in "\n".join(lines).split(";") if s.strip()]
@@ -0,0 +1,194 @@
1
+ """pillarz-Migration 0001: Konten (`users`) und die privaten `pz_*`-Tabellen von 0.1.
2
+
3
+ `users` übernimmt Spalten- und Indexnamen von labelz (`app/db.py`, Tabelle `users`), damit eine
4
+ bestehende labelz-Tabelle passt (ADR 0003 „Übernahme bestehender Datenbanken“): Ist die Tabelle schon
5
+ da, ergänzt der Schritt fehlende pillarz-Spalten per `add_column` – generisch, nicht
6
+ labelz-spezifisch. Datenumbau (z. B. `is_admin` → `role`) schreibt das App-Team als eigene
7
+ App-Migration (Architektur §5.4).
8
+
9
+ Alle `pz_*`-Tabellen mit `user_id` löschen per `ON DELETE CASCADE` mit (Konto löschen in einer
10
+ Transaktion, ADR 0006). Spalten der `pz_*`-Tabellen sind privat und dürfen sich additiv ändern.
11
+ """
12
+ from ..db import add_column, create_index, ddl, has_column, has_table, now
13
+ from ..i18n import _
14
+ from .connection import Conn
15
+
16
+ USERS = """
17
+ CREATE TABLE IF NOT EXISTS users (
18
+ id {pk},
19
+ username TEXT NOT NULL, -- eindeutig ohne Groß-/Kleinschreibung (Index unten)
20
+ name TEXT NOT NULL,
21
+ email TEXT UNIQUE, -- klein geschrieben; gehört pillarz, nicht authentik (F-4)
22
+ email_confirmed INTEGER NOT NULL DEFAULT 0, -- 1 = vom Admin/aus authentik/per Mail bestätigt (ADR 0008 §5)
23
+ password_hash TEXT, -- NULL = kein Passwort
24
+ role TEXT NOT NULL DEFAULT 'user', -- admin | user | guest
25
+ active INTEGER NOT NULL DEFAULT 1,
26
+ session_epoch INTEGER NOT NULL DEFAULT 0, -- +1 bei „alle Sitzungen beenden“
27
+ oidc_sub TEXT,
28
+ oidc_name TEXT,
29
+ oidc_email TEXT,
30
+ oidc_unlinked_at TEXT,
31
+ theme TEXT, -- NULL = Standard-Theme aus den Einstellungen
32
+ mode TEXT NOT NULL DEFAULT 'auto', -- auto | light | dark
33
+ locale TEXT, -- NULL = Standard
34
+ region TEXT,
35
+ seen_version TEXT,
36
+ created_at TEXT NOT NULL,
37
+ updated_at TEXT,
38
+ last_login TEXT
39
+ );
40
+ CREATE UNIQUE INDEX IF NOT EXISTS users_username ON users (lower(username));
41
+ CREATE UNIQUE INDEX IF NOT EXISTS users_oidc_sub ON users (oidc_sub) WHERE oidc_sub IS NOT NULL;
42
+ """
43
+
44
+ # Spalten, die einer übernommenen Tabelle fehlen dürfen (Typ wie oben, auf beiden Systemen gültig)
45
+ _ADOPTABLE = {
46
+ "email_confirmed": "INTEGER NOT NULL DEFAULT 0",
47
+ "password_hash": "TEXT",
48
+ "role": "TEXT NOT NULL DEFAULT 'user'",
49
+ "active": "INTEGER NOT NULL DEFAULT 1",
50
+ "session_epoch": "INTEGER NOT NULL DEFAULT 0",
51
+ "oidc_sub": "TEXT",
52
+ "oidc_name": "TEXT",
53
+ "oidc_email": "TEXT",
54
+ "oidc_unlinked_at": "TEXT",
55
+ "theme": "TEXT",
56
+ "mode": "TEXT NOT NULL DEFAULT 'auto'",
57
+ "locale": "TEXT",
58
+ "region": "TEXT",
59
+ "seen_version": "TEXT",
60
+ "updated_at": "TEXT",
61
+ "last_login": "TEXT",
62
+ }
63
+ # Ohne diese Spalten ist es keine Kontentabelle, die pillarz übernehmen kann
64
+ _REQUIRED = ("id", "username", "name", "created_at")
65
+
66
+ PZ_TABLES = """
67
+ -- Serverseitige Sitzungen (ADR 0005). id = SHA-256 (hex) des Cookie-Tokens, nie das Token selbst.
68
+ CREATE TABLE IF NOT EXISTS pz_sessions (
69
+ id TEXT PRIMARY KEY,
70
+ user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
71
+ method TEXT NOT NULL, -- password | oidc | magic_link | passkey | invitation
72
+ created_at TEXT NOT NULL,
73
+ last_seen_at TEXT NOT NULL,
74
+ expires_at TEXT NOT NULL, -- absolut (SESSION_DAYS)
75
+ ip TEXT,
76
+ user_agent TEXT,
77
+ device_label TEXT,
78
+ elevated_until TEXT, -- Admin über authentik-Gruppe, befristet
79
+ reauth_at TEXT, -- letzte frische Anmeldung (Schritt-hoch)
80
+ oidc_sid TEXT,
81
+ oidc_id_token TEXT, -- für RP-initiated Logout (ADR 0008)
82
+ data TEXT NOT NULL DEFAULT '{}' -- JSON: Flash, kleine Merker
83
+ );
84
+ CREATE INDEX IF NOT EXISTS pz_sessions_user ON pz_sessions (user_id, last_seen_at);
85
+ CREATE INDEX IF NOT EXISTS pz_sessions_expires ON pz_sessions (expires_at);
86
+
87
+ -- Vorab-Zustand einer Anmeldung (OIDC state/nonce/PKCE, Verknüpfung, Challenges, MFA-Schritt);
88
+ -- einmalig, kurzlebig (ADR 0005). id = SHA-256 des Flow-Tokens.
89
+ CREATE TABLE IF NOT EXISTS pz_flows (
90
+ id TEXT PRIMARY KEY,
91
+ kind TEXT NOT NULL,
92
+ data TEXT NOT NULL DEFAULT '{}',
93
+ created_at TEXT NOT NULL,
94
+ expires_at TEXT NOT NULL
95
+ );
96
+ CREATE INDEX IF NOT EXISTS pz_flows_expires ON pz_flows (expires_at);
97
+
98
+ -- Anmeldebremse: eine Zeile je Fehlversuch (ADR 0005)
99
+ CREATE TABLE IF NOT EXISTS pz_throttle (
100
+ key TEXT NOT NULL,
101
+ ts TEXT NOT NULL
102
+ );
103
+ CREATE INDEX IF NOT EXISTS pz_throttle_key ON pz_throttle (key, ts);
104
+ CREATE INDEX IF NOT EXISTS pz_throttle_ts ON pz_throttle (ts);
105
+
106
+ -- Einmal-Tokens (ADR 0007): nur der SHA-256-Hash steht in der Datenbank. user_id NULL bei Einladungen.
107
+ CREATE TABLE IF NOT EXISTS pz_tokens (
108
+ id {pk},
109
+ kind TEXT NOT NULL, -- oidc_link | magic_link | invitation | password_reset | email_change
110
+ user_id BIGINT REFERENCES users(id) ON DELETE CASCADE,
111
+ token_hash TEXT NOT NULL UNIQUE,
112
+ data TEXT NOT NULL DEFAULT '{}', -- JSON, z. B. expected_username
113
+ created_by BIGINT REFERENCES users(id) ON DELETE SET NULL,
114
+ created_at TEXT NOT NULL,
115
+ expires_at TEXT NOT NULL,
116
+ used_at TEXT,
117
+ revoked_at TEXT
118
+ );
119
+ -- höchstens ein offener Token je Konto und Art; ein neuer zieht den alten zurück (labelz F-19)
120
+ CREATE UNIQUE INDEX IF NOT EXISTS pz_tokens_open ON pz_tokens (user_id, kind)
121
+ WHERE user_id IS NOT NULL AND used_at IS NULL AND revoked_at IS NULL;
122
+ CREATE INDEX IF NOT EXISTS pz_tokens_user ON pz_tokens (user_id, created_at);
123
+ CREATE INDEX IF NOT EXISTS pz_tokens_kind ON pz_tokens (kind, expires_at);
124
+
125
+ -- Einstellungen: Schlüssel → JSON (pillarz + registrierte App-Einstellungen, Architektur §14.11)
126
+ CREATE TABLE IF NOT EXISTS pz_settings (
127
+ key TEXT PRIMARY KEY,
128
+ value TEXT NOT NULL,
129
+ updated_at TEXT
130
+ );
131
+
132
+ -- Eigene Themes (labelz Tabelle themes)
133
+ CREATE TABLE IF NOT EXISTS pz_themes (
134
+ id TEXT PRIMARY KEY,
135
+ name TEXT NOT NULL,
136
+ colors TEXT NOT NULL -- JSON: {light: {...}, dark: {...}}
137
+ );
138
+
139
+ -- Protokoll (labelz Tabelle events) + request_id, actor_id, ip (K-13). actor_id ohne FK, damit der
140
+ -- Eintrag ein Löschen des Kontos überlebt.
141
+ CREATE TABLE IF NOT EXISTS pz_log (
142
+ id {pk},
143
+ ts TEXT NOT NULL,
144
+ level TEXT NOT NULL,
145
+ source TEXT NOT NULL,
146
+ message TEXT NOT NULL,
147
+ request_id TEXT,
148
+ actor_id BIGINT,
149
+ ip TEXT
150
+ );
151
+ CREATE INDEX IF NOT EXISTS pz_log_ts ON pz_log (ts);
152
+ CREATE INDEX IF NOT EXISTS pz_log_source ON pz_log (source, ts);
153
+ CREATE INDEX IF NOT EXISTS pz_log_actor ON pz_log (actor_id, ts);
154
+
155
+ -- Interne Merker (setup_done – ADR 0015, Update-Check-Ergebnis, Start-Hinweise)
156
+ CREATE TABLE IF NOT EXISTS pz_meta (
157
+ key TEXT PRIMARY KEY,
158
+ value TEXT
159
+ );
160
+ """
161
+
162
+
163
+ def _users(conn: Conn) -> None:
164
+ existed = has_table(conn, "users")
165
+ for statement in ddl(USERS, conn.postgres)[:1]: # nur CREATE TABLE; Indizes nach dem Ergänzen
166
+ conn.execute(statement)
167
+ if existed:
168
+ missing = [c for c in _REQUIRED if not has_column(conn, "users", c)]
169
+ if missing:
170
+ raise RuntimeError(_("Die vorhandene Tabelle users passt nicht (es fehlen %(cols)s) – "
171
+ "pillarz kann sie nicht übernehmen.") % {"cols": ", ".join(missing)})
172
+ if add_column(conn, "users", "email", "TEXT"):
173
+ create_index(conn, "CREATE UNIQUE INDEX IF NOT EXISTS users_email ON users (email)")
174
+ added = {col for col, typ in _ADOPTABLE.items() if add_column(conn, "users", col, typ)}
175
+ if "email_confirmed" in added:
176
+ # Bestandskonten stammen vom Admin – ihre Adressen gelten als bestätigt (ADR 0008 §5)
177
+ conn.execute("UPDATE users SET email_confirmed = 1 WHERE email IS NOT NULL")
178
+ for statement in ddl(USERS, conn.postgres)[1:]:
179
+ create_index(conn, statement)
180
+
181
+
182
+ def initial(conn: Conn) -> None:
183
+ _users(conn)
184
+ for statement in ddl(PZ_TABLES, conn.postgres):
185
+ conn.execute(statement)
186
+ # Übernommene Datenbank mit Konten: die Einrichtungsseite bleibt zu (ADR 0015 §5)
187
+ if conn.scalar("SELECT COUNT(*) FROM users"):
188
+ conn.execute("INSERT INTO pz_meta (key, value) VALUES ('setup_done', ?) ON CONFLICT (key) DO NOTHING",
189
+ (now(),))
190
+
191
+
192
+ STEPS = (
193
+ ("0001_initial", initial),
194
+ )