diff --git a/404.html b/404.html
index c5598493..a32d2771 100644
--- a/404.html
+++ b/404.html
@@ -3,7 +3,8 @@
-
+
+
diff --git a/abnahme-installiert.png b/abnahme-installiert.png
new file mode 100644
index 00000000..989ce49b
Binary files /dev/null and b/abnahme-installiert.png differ
diff --git a/abonnieren.html b/abonnieren.html
index c2f8a582..6f5d94f0 100644
--- a/abonnieren.html
+++ b/abonnieren.html
@@ -5,7 +5,8 @@
DogiCrew 🩵 — DOGFATHER UNIVERSE
-
+
+
diff --git a/agb.html b/agb.html
index 5f5e3eb5..c4ddb96f 100644
--- a/agb.html
+++ b/agb.html
@@ -5,7 +5,8 @@
AGB — DogiCrew-Supporter-Abo — DOGFATHER UNIVERSE
-
+
+
diff --git a/aktion-detail.html b/aktion-detail.html
index 93b11f9f..1645d67d 100644
--- a/aktion-detail.html
+++ b/aktion-detail.html
@@ -5,7 +5,8 @@
Aktion — DOGFATHER UNIVERSE
-
+
+
diff --git a/assets/img/app-symbole/analyse-180.png b/assets/img/app-symbole/analyse-180.png
new file mode 100644
index 00000000..db1bfabf
Binary files /dev/null and b/assets/img/app-symbole/analyse-180.png differ
diff --git a/assets/img/app-symbole/analyse-192-maskable.png b/assets/img/app-symbole/analyse-192-maskable.png
new file mode 100644
index 00000000..c2774ddd
Binary files /dev/null and b/assets/img/app-symbole/analyse-192-maskable.png differ
diff --git a/assets/img/app-symbole/analyse-192.png b/assets/img/app-symbole/analyse-192.png
new file mode 100644
index 00000000..d213e1a4
Binary files /dev/null and b/assets/img/app-symbole/analyse-192.png differ
diff --git a/assets/img/app-symbole/analyse-32.png b/assets/img/app-symbole/analyse-32.png
new file mode 100644
index 00000000..ac88efcf
Binary files /dev/null and b/assets/img/app-symbole/analyse-32.png differ
diff --git a/assets/img/app-symbole/analyse-512-maskable.png b/assets/img/app-symbole/analyse-512-maskable.png
new file mode 100644
index 00000000..9920dd5d
Binary files /dev/null and b/assets/img/app-symbole/analyse-512-maskable.png differ
diff --git a/assets/img/app-symbole/analyse-512.png b/assets/img/app-symbole/analyse-512.png
new file mode 100644
index 00000000..364265d7
Binary files /dev/null and b/assets/img/app-symbole/analyse-512.png differ
diff --git a/assets/img/app-symbole/cloud-180.png b/assets/img/app-symbole/cloud-180.png
new file mode 100644
index 00000000..e22e4643
Binary files /dev/null and b/assets/img/app-symbole/cloud-180.png differ
diff --git a/assets/img/app-symbole/cloud-192-maskable.png b/assets/img/app-symbole/cloud-192-maskable.png
new file mode 100644
index 00000000..2e89a006
Binary files /dev/null and b/assets/img/app-symbole/cloud-192-maskable.png differ
diff --git a/assets/img/app-symbole/cloud-192.png b/assets/img/app-symbole/cloud-192.png
new file mode 100644
index 00000000..2d2b4d70
Binary files /dev/null and b/assets/img/app-symbole/cloud-192.png differ
diff --git a/assets/img/app-symbole/cloud-32.png b/assets/img/app-symbole/cloud-32.png
new file mode 100644
index 00000000..7611f789
Binary files /dev/null and b/assets/img/app-symbole/cloud-32.png differ
diff --git a/assets/img/app-symbole/cloud-512-maskable.png b/assets/img/app-symbole/cloud-512-maskable.png
new file mode 100644
index 00000000..908a984e
Binary files /dev/null and b/assets/img/app-symbole/cloud-512-maskable.png differ
diff --git a/assets/img/app-symbole/cloud-512.png b/assets/img/app-symbole/cloud-512.png
new file mode 100644
index 00000000..5b6a2e07
Binary files /dev/null and b/assets/img/app-symbole/cloud-512.png differ
diff --git a/assets/img/app-symbole/git-180.png b/assets/img/app-symbole/git-180.png
new file mode 100644
index 00000000..adbd8090
Binary files /dev/null and b/assets/img/app-symbole/git-180.png differ
diff --git a/assets/img/app-symbole/git-192-maskable.png b/assets/img/app-symbole/git-192-maskable.png
new file mode 100644
index 00000000..5ea4e18d
Binary files /dev/null and b/assets/img/app-symbole/git-192-maskable.png differ
diff --git a/assets/img/app-symbole/git-192.png b/assets/img/app-symbole/git-192.png
new file mode 100644
index 00000000..d8a421c5
Binary files /dev/null and b/assets/img/app-symbole/git-192.png differ
diff --git a/assets/img/app-symbole/git-32.png b/assets/img/app-symbole/git-32.png
new file mode 100644
index 00000000..a58d1d06
Binary files /dev/null and b/assets/img/app-symbole/git-32.png differ
diff --git a/assets/img/app-symbole/git-512-maskable.png b/assets/img/app-symbole/git-512-maskable.png
new file mode 100644
index 00000000..77d5ae22
Binary files /dev/null and b/assets/img/app-symbole/git-512-maskable.png differ
diff --git a/assets/img/app-symbole/git-512.png b/assets/img/app-symbole/git-512.png
new file mode 100644
index 00000000..a6bb8b78
Binary files /dev/null and b/assets/img/app-symbole/git-512.png differ
diff --git a/assets/img/app-symbole/portal-180.png b/assets/img/app-symbole/portal-180.png
new file mode 100644
index 00000000..2b17a7a6
Binary files /dev/null and b/assets/img/app-symbole/portal-180.png differ
diff --git a/assets/img/app-symbole/portal-192-maskable.png b/assets/img/app-symbole/portal-192-maskable.png
new file mode 100644
index 00000000..4772e9fa
Binary files /dev/null and b/assets/img/app-symbole/portal-192-maskable.png differ
diff --git a/assets/img/app-symbole/portal-192.png b/assets/img/app-symbole/portal-192.png
new file mode 100644
index 00000000..5da168f1
Binary files /dev/null and b/assets/img/app-symbole/portal-192.png differ
diff --git a/assets/img/app-symbole/portal-32.png b/assets/img/app-symbole/portal-32.png
new file mode 100644
index 00000000..38f0a0d6
Binary files /dev/null and b/assets/img/app-symbole/portal-32.png differ
diff --git a/assets/img/app-symbole/portal-512-maskable.png b/assets/img/app-symbole/portal-512-maskable.png
new file mode 100644
index 00000000..96a6c657
Binary files /dev/null and b/assets/img/app-symbole/portal-512-maskable.png differ
diff --git a/assets/img/app-symbole/portal-512.png b/assets/img/app-symbole/portal-512.png
new file mode 100644
index 00000000..37315ce4
Binary files /dev/null and b/assets/img/app-symbole/portal-512.png differ
diff --git a/assets/img/app-symbole/universe-180.png b/assets/img/app-symbole/universe-180.png
new file mode 100644
index 00000000..d4abeb66
Binary files /dev/null and b/assets/img/app-symbole/universe-180.png differ
diff --git a/assets/img/app-symbole/universe-192-maskable.png b/assets/img/app-symbole/universe-192-maskable.png
new file mode 100644
index 00000000..6529e343
Binary files /dev/null and b/assets/img/app-symbole/universe-192-maskable.png differ
diff --git a/assets/img/app-symbole/universe-192.png b/assets/img/app-symbole/universe-192.png
new file mode 100644
index 00000000..40367384
Binary files /dev/null and b/assets/img/app-symbole/universe-192.png differ
diff --git a/assets/img/app-symbole/universe-32.png b/assets/img/app-symbole/universe-32.png
new file mode 100644
index 00000000..30172ded
Binary files /dev/null and b/assets/img/app-symbole/universe-32.png differ
diff --git a/assets/img/app-symbole/universe-512-maskable.png b/assets/img/app-symbole/universe-512-maskable.png
new file mode 100644
index 00000000..8cc3f7a7
Binary files /dev/null and b/assets/img/app-symbole/universe-512-maskable.png differ
diff --git a/assets/img/app-symbole/universe-512.png b/assets/img/app-symbole/universe-512.png
new file mode 100644
index 00000000..91faca49
Binary files /dev/null and b/assets/img/app-symbole/universe-512.png differ
diff --git a/assets/img/app-symbole/verwaltung-180.png b/assets/img/app-symbole/verwaltung-180.png
new file mode 100644
index 00000000..ee086c8b
Binary files /dev/null and b/assets/img/app-symbole/verwaltung-180.png differ
diff --git a/assets/img/app-symbole/verwaltung-192-maskable.png b/assets/img/app-symbole/verwaltung-192-maskable.png
new file mode 100644
index 00000000..264fc5a0
Binary files /dev/null and b/assets/img/app-symbole/verwaltung-192-maskable.png differ
diff --git a/assets/img/app-symbole/verwaltung-192.png b/assets/img/app-symbole/verwaltung-192.png
new file mode 100644
index 00000000..b7de21e5
Binary files /dev/null and b/assets/img/app-symbole/verwaltung-192.png differ
diff --git a/assets/img/app-symbole/verwaltung-32.png b/assets/img/app-symbole/verwaltung-32.png
new file mode 100644
index 00000000..3eaa23cf
Binary files /dev/null and b/assets/img/app-symbole/verwaltung-32.png differ
diff --git a/assets/img/app-symbole/verwaltung-512-maskable.png b/assets/img/app-symbole/verwaltung-512-maskable.png
new file mode 100644
index 00000000..06c59ece
Binary files /dev/null and b/assets/img/app-symbole/verwaltung-512-maskable.png differ
diff --git a/assets/img/app-symbole/verwaltung-512.png b/assets/img/app-symbole/verwaltung-512.png
new file mode 100644
index 00000000..997da3c8
Binary files /dev/null and b/assets/img/app-symbole/verwaltung-512.png differ
diff --git a/assets/img/app-symbole/wdverwalt-180.png b/assets/img/app-symbole/wdverwalt-180.png
new file mode 100644
index 00000000..03a7eb23
Binary files /dev/null and b/assets/img/app-symbole/wdverwalt-180.png differ
diff --git a/assets/img/app-symbole/wdverwalt-192-maskable.png b/assets/img/app-symbole/wdverwalt-192-maskable.png
new file mode 100644
index 00000000..79ba5617
Binary files /dev/null and b/assets/img/app-symbole/wdverwalt-192-maskable.png differ
diff --git a/assets/img/app-symbole/wdverwalt-192.png b/assets/img/app-symbole/wdverwalt-192.png
new file mode 100644
index 00000000..64bb33b8
Binary files /dev/null and b/assets/img/app-symbole/wdverwalt-192.png differ
diff --git a/assets/img/app-symbole/wdverwalt-32.png b/assets/img/app-symbole/wdverwalt-32.png
new file mode 100644
index 00000000..26f8edc0
Binary files /dev/null and b/assets/img/app-symbole/wdverwalt-32.png differ
diff --git a/assets/img/app-symbole/wdverwalt-512-maskable.png b/assets/img/app-symbole/wdverwalt-512-maskable.png
new file mode 100644
index 00000000..9fb4f75a
Binary files /dev/null and b/assets/img/app-symbole/wdverwalt-512-maskable.png differ
diff --git a/assets/img/app-symbole/wdverwalt-512.png b/assets/img/app-symbole/wdverwalt-512.png
new file mode 100644
index 00000000..6fa760cd
Binary files /dev/null and b/assets/img/app-symbole/wdverwalt-512.png differ
diff --git a/assets/img/app-symbole/webdesign-180.png b/assets/img/app-symbole/webdesign-180.png
new file mode 100644
index 00000000..47725094
Binary files /dev/null and b/assets/img/app-symbole/webdesign-180.png differ
diff --git a/assets/img/app-symbole/webdesign-192-maskable.png b/assets/img/app-symbole/webdesign-192-maskable.png
new file mode 100644
index 00000000..fefc49f1
Binary files /dev/null and b/assets/img/app-symbole/webdesign-192-maskable.png differ
diff --git a/assets/img/app-symbole/webdesign-192.png b/assets/img/app-symbole/webdesign-192.png
new file mode 100644
index 00000000..1cac861f
Binary files /dev/null and b/assets/img/app-symbole/webdesign-192.png differ
diff --git a/assets/img/app-symbole/webdesign-32.png b/assets/img/app-symbole/webdesign-32.png
new file mode 100644
index 00000000..db758278
Binary files /dev/null and b/assets/img/app-symbole/webdesign-32.png differ
diff --git a/assets/img/app-symbole/webdesign-512-maskable.png b/assets/img/app-symbole/webdesign-512-maskable.png
new file mode 100644
index 00000000..9d5f4980
Binary files /dev/null and b/assets/img/app-symbole/webdesign-512-maskable.png differ
diff --git a/assets/img/app-symbole/webdesign-512.png b/assets/img/app-symbole/webdesign-512.png
new file mode 100644
index 00000000..307b2ba1
Binary files /dev/null and b/assets/img/app-symbole/webdesign-512.png differ
diff --git a/assets/img/app-symbole/workspace-180.png b/assets/img/app-symbole/workspace-180.png
new file mode 100644
index 00000000..0151c311
Binary files /dev/null and b/assets/img/app-symbole/workspace-180.png differ
diff --git a/assets/img/app-symbole/workspace-192-maskable.png b/assets/img/app-symbole/workspace-192-maskable.png
new file mode 100644
index 00000000..d89ccbdc
Binary files /dev/null and b/assets/img/app-symbole/workspace-192-maskable.png differ
diff --git a/assets/img/app-symbole/workspace-192.png b/assets/img/app-symbole/workspace-192.png
new file mode 100644
index 00000000..3536d645
Binary files /dev/null and b/assets/img/app-symbole/workspace-192.png differ
diff --git a/assets/img/app-symbole/workspace-32.png b/assets/img/app-symbole/workspace-32.png
new file mode 100644
index 00000000..c245765e
Binary files /dev/null and b/assets/img/app-symbole/workspace-32.png differ
diff --git a/assets/img/app-symbole/workspace-512-maskable.png b/assets/img/app-symbole/workspace-512-maskable.png
new file mode 100644
index 00000000..b98e2c35
Binary files /dev/null and b/assets/img/app-symbole/workspace-512-maskable.png differ
diff --git a/assets/img/app-symbole/workspace-512.png b/assets/img/app-symbole/workspace-512.png
new file mode 100644
index 00000000..c7feb930
Binary files /dev/null and b/assets/img/app-symbole/workspace-512.png differ
diff --git a/assets/img/app-symbole/zocker-180.png b/assets/img/app-symbole/zocker-180.png
new file mode 100644
index 00000000..98c931ee
Binary files /dev/null and b/assets/img/app-symbole/zocker-180.png differ
diff --git a/assets/img/app-symbole/zocker-192-maskable.png b/assets/img/app-symbole/zocker-192-maskable.png
new file mode 100644
index 00000000..08fad1d1
Binary files /dev/null and b/assets/img/app-symbole/zocker-192-maskable.png differ
diff --git a/assets/img/app-symbole/zocker-192.png b/assets/img/app-symbole/zocker-192.png
new file mode 100644
index 00000000..3eff40b1
Binary files /dev/null and b/assets/img/app-symbole/zocker-192.png differ
diff --git a/assets/img/app-symbole/zocker-32.png b/assets/img/app-symbole/zocker-32.png
new file mode 100644
index 00000000..75ef9eb9
Binary files /dev/null and b/assets/img/app-symbole/zocker-32.png differ
diff --git a/assets/img/app-symbole/zocker-512-maskable.png b/assets/img/app-symbole/zocker-512-maskable.png
new file mode 100644
index 00000000..9f783d70
Binary files /dev/null and b/assets/img/app-symbole/zocker-512-maskable.png differ
diff --git a/assets/img/app-symbole/zocker-512.png b/assets/img/app-symbole/zocker-512.png
new file mode 100644
index 00000000..c22a6e4a
Binary files /dev/null and b/assets/img/app-symbole/zocker-512.png differ
diff --git a/assets/img/marke/logo-kopf-frei.png b/assets/img/marke/logo-kopf-frei.png
new file mode 100644
index 00000000..da0ea20b
Binary files /dev/null and b/assets/img/marke/logo-kopf-frei.png differ
diff --git a/bewerben-creator.html b/bewerben-creator.html
index f3f6fb40..77f449d9 100644
--- a/bewerben-creator.html
+++ b/bewerben-creator.html
@@ -5,7 +5,8 @@
Streamer gesucht — DOGFATHER UNIVERSE
-
+
+
diff --git a/bewerben-kooperation.html b/bewerben-kooperation.html
index ac685ea0..3f8e6af7 100644
--- a/bewerben-kooperation.html
+++ b/bewerben-kooperation.html
@@ -8,7 +8,8 @@
Kooperation anfragen — DOGFATHER UNIVERSE
-
+
+
diff --git a/bewerben-modi.html b/bewerben-modi.html
index 8dfd0db9..e25deb93 100644
--- a/bewerben-modi.html
+++ b/bewerben-modi.html
@@ -5,7 +5,8 @@
Modi werden — DOGFATHER UNIVERSE
-
+
+
diff --git a/bewerben-scout.html b/bewerben-scout.html
index 2fd6eb00..039cc32e 100644
--- a/bewerben-scout.html
+++ b/bewerben-scout.html
@@ -5,7 +5,8 @@
Scout werden — DOGFATHER UNIVERSE
-
+
+
diff --git a/bewerben.html b/bewerben.html
index b5652197..2a069d3c 100644
--- a/bewerben.html
+++ b/bewerben.html
@@ -5,7 +5,8 @@
Bewerben — DOGFATHER UNIVERSE
-
+
+
diff --git a/blickfang-portfolio-handy.png b/blickfang-portfolio-handy.png
index b29210b4..c2efb706 100644
Binary files a/blickfang-portfolio-handy.png and b/blickfang-portfolio-handy.png differ
diff --git a/blickfang-portfolio-pc.png b/blickfang-portfolio-pc.png
index f1192cb3..6021a4f9 100644
Binary files a/blickfang-portfolio-pc.png and b/blickfang-portfolio-pc.png differ
diff --git a/blickfang-ueber-handy.png b/blickfang-ueber-handy.png
index c301e1b4..2335e15b 100644
Binary files a/blickfang-ueber-handy.png and b/blickfang-ueber-handy.png differ
diff --git a/blickfang-ueber-pc.png b/blickfang-ueber-pc.png
index 8b3853c1..4c7bd6d0 100644
Binary files a/blickfang-ueber-pc.png and b/blickfang-ueber-pc.png differ
diff --git a/casper.html b/casper.html
index 74c19826..0876d70f 100644
--- a/casper.html
+++ b/casper.html
@@ -5,7 +5,8 @@
Casper — DOGFATHER UNIVERSE
-
+
+
diff --git a/community.html b/community.html
index 1294e3ea..b9db42d0 100644
--- a/community.html
+++ b/community.html
@@ -5,7 +5,8 @@
Die Community — DOGFATHER UNIVERSE
-
+
+
diff --git a/creator.html b/creator.html
index 0dcb5579..e82a67ab 100644
--- a/creator.html
+++ b/creator.html
@@ -5,7 +5,8 @@
Creator im Team — DOGFATHER UNIVERSE
-
+
+
diff --git a/dogfather.html b/dogfather.html
index 20e8ce00..3dde4364 100644
--- a/dogfather.html
+++ b/dogfather.html
@@ -5,7 +5,8 @@
Die Person, die DogFather erschuf — DOGFATHER UNIVERSE
-
+
+
diff --git a/entwurf-app-symbole.png b/entwurf-app-symbole.png
new file mode 100644
index 00000000..3bb24441
Binary files /dev/null and b/entwurf-app-symbole.png differ
diff --git a/entwurf-startbildschirm.png b/entwurf-startbildschirm.png
new file mode 100644
index 00000000..26261a2e
Binary files /dev/null and b/entwurf-startbildschirm.png differ
diff --git a/gate.html b/gate.html
index 1bb39255..db5bc6cb 100644
--- a/gate.html
+++ b/gate.html
@@ -5,7 +5,7 @@
Zugang — DOGFATHER UNIVERSE
-
+
-
+
diff --git a/hasidog.html b/hasidog.html
index 6ac2be43..f1d21b4f 100644
--- a/hasidog.html
+++ b/hasidog.html
@@ -5,7 +5,8 @@
HasiDog-Welt — DOGFATHER UNIVERSE
-
+
+
diff --git a/index.html b/index.html
index 4ec0fa1b..659866b6 100644
--- a/index.html
+++ b/index.html
@@ -5,7 +5,8 @@
DOGFATHER UNIVERSE — Streamer. Community. HasiDog. Manager.
-
+
+
diff --git a/kontakt.html b/kontakt.html
index 71f49a6d..32e241bf 100644
--- a/kontakt.html
+++ b/kontakt.html
@@ -5,7 +5,8 @@
Kontakt & Rechtliches — DOGFATHER UNIVERSE
-
+
+
diff --git a/links.html b/links.html
index 929eef94..758566cb 100644
--- a/links.html
+++ b/links.html
@@ -5,7 +5,8 @@
Alle Links — DOGFATHER UNIVERSE
-
+
+
diff --git a/manager.html b/manager.html
index 078dc9bf..1d140ca7 100644
--- a/manager.html
+++ b/manager.html
@@ -5,7 +5,8 @@
Manager DogFather — Spicy Media — DOGFATHER UNIVERSE
-
+
+
diff --git a/manifest-verwaltung.json b/manifest-verwaltung.json
index 40d94ea3..03890e96 100644
--- a/manifest-verwaltung.json
+++ b/manifest-verwaltung.json
@@ -13,25 +13,25 @@
"dir": "ltr",
"icons": [
{
- "src": "/assets/img/icon-192-admin.png",
+ "src": "/assets/img/app-symbole/verwaltung-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
- "src": "/assets/img/icon-512-admin.png",
+ "src": "/assets/img/app-symbole/verwaltung-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
- "src": "/assets/img/icon-192-maskable-admin.png",
+ "src": "/assets/img/app-symbole/verwaltung-192-maskable.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
- "src": "/assets/img/icon-512-maskable-admin.png",
+ "src": "/assets/img/app-symbole/verwaltung-512-maskable.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
diff --git a/manifest.json b/manifest.json
index edeb6a0c..42a1686f 100644
--- a/manifest.json
+++ b/manifest.json
@@ -13,25 +13,25 @@
"dir": "ltr",
"icons": [
{
- "src": "/assets/img/icon-192.png",
+ "src": "/assets/img/app-symbole/universe-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
- "src": "/assets/img/icon-512.png",
+ "src": "/assets/img/app-symbole/universe-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
- "src": "/assets/img/icon-192-maskable.png",
+ "src": "/assets/img/app-symbole/universe-192-maskable.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
- "src": "/assets/img/icon-512-maskable.png",
+ "src": "/assets/img/app-symbole/universe-512-maskable.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
diff --git a/medien-casper.html b/medien-casper.html
index 62e27087..8de85354 100644
--- a/medien-casper.html
+++ b/medien-casper.html
@@ -5,7 +5,8 @@
Medien — Casper — DOGFATHER UNIVERSE
-
+
+
diff --git a/medien-dogfather.html b/medien-dogfather.html
index bb194bb3..f82aeecb 100644
--- a/medien-dogfather.html
+++ b/medien-dogfather.html
@@ -5,7 +5,8 @@
Medien — DogFather — DOGFATHER UNIVERSE
-
+
+
diff --git a/medien-hasidog.html b/medien-hasidog.html
index 59ee626c..102c75a2 100644
--- a/medien-hasidog.html
+++ b/medien-hasidog.html
@@ -5,7 +5,8 @@
Medien — HasiDog — DOGFATHER UNIVERSE
-
+
+
diff --git a/profil.html b/profil.html
index 5af34b56..204592d8 100644
--- a/profil.html
+++ b/profil.html
@@ -5,7 +5,8 @@
Profil — DOGFATHER UNIVERSE
-
+
+
diff --git a/pruef-ampel.png b/pruef-ampel.png
index 1b45d24d..a6327ec9 100644
Binary files a/pruef-ampel.png and b/pruef-ampel.png differ
diff --git a/pruef-aufgaben-computer.png b/pruef-aufgaben-computer.png
index 656c5ee9..32e585aa 100644
Binary files a/pruef-aufgaben-computer.png and b/pruef-aufgaben-computer.png differ
diff --git a/pruef-aufgaben-handy.png b/pruef-aufgaben-handy.png
index e3ed659a..bfbdf9d1 100644
Binary files a/pruef-aufgaben-handy.png and b/pruef-aufgaben-handy.png differ
diff --git a/pruef-aufgabenbrett.png b/pruef-aufgabenbrett.png
index 642a0130..f1e02a8b 100644
Binary files a/pruef-aufgabenbrett.png and b/pruef-aufgabenbrett.png differ
diff --git a/pruef-browser-lauf.txt b/pruef-browser-lauf.txt
new file mode 100644
index 00000000..960a21c7
--- /dev/null
+++ b/pruef-browser-lauf.txt
@@ -0,0 +1,17 @@
+ ok Chromium (Chrome, Edge) 16 Seiten
+ ok Firefox (Gecko) 16 Seiten
+ ok WebKit (Safari) 16 Seiten
+ ok WebKit auf dem Handy (iPhone) 16 Seiten
+ ok 4 von 4 Maschinen wirklich gestartet
+ ok jede Maschine konnte gestartet werden
+ ok 64 Seitenaufrufe ueber alle Maschinen
+ ok 14477 Elemente vermessen
+ ok Chromium: die Startseite ist unauffaellig (0px, 1613 Zeichen)
+ ok Chromium: zu breites Element wird gefunden (1720px)
+ ok Chromium: ein Skriptfehler wird bemerkt (Absichtlicher Testfehler)
+ ok Firefox: die Startseite ist unauffaellig (0px, 1565 Zeichen)
+ ok Firefox: zu breites Element wird gefunden (1720px)
+ ok Firefox: ein Skriptfehler wird bemerkt (Absichtlicher Testfehler)
+ ok WebKit: die Startseite ist unauffaellig (0px, 1565 Zeichen)
+ ok WebKit: zu breites Element wird gefunden (1720px)
+ ok WebKit: ein Skriptfehler wird bemerkt (Absichtlicher Testfehler)
diff --git a/pruef-buehne.png b/pruef-buehne.png
index 0d1d4e5d..f65fcf07 100644
Binary files a/pruef-buehne.png and b/pruef-buehne.png differ
diff --git a/pruef-checkliste.png b/pruef-checkliste.png
index 6e13a0a5..ed05eb24 100644
Binary files a/pruef-checkliste.png and b/pruef-checkliste.png differ
diff --git a/pruef-code.png b/pruef-code.png
new file mode 100644
index 00000000..a4940c63
Binary files /dev/null and b/pruef-code.png differ
diff --git a/pruef-content-computer.png b/pruef-content-computer.png
index be38fe95..9f6c0956 100644
Binary files a/pruef-content-computer.png and b/pruef-content-computer.png differ
diff --git a/pruef-content-creator.png b/pruef-content-creator.png
index 5cdcc1c0..41b134e3 100644
Binary files a/pruef-content-creator.png and b/pruef-content-creator.png differ
diff --git a/pruef-content-handy.png b/pruef-content-handy.png
index c3e95276..80a74911 100644
Binary files a/pruef-content-handy.png and b/pruef-content-handy.png differ
diff --git a/pruef-detail-computer.png b/pruef-detail-computer.png
index 0bacaf69..ab9d70ca 100644
Binary files a/pruef-detail-computer.png and b/pruef-detail-computer.png differ
diff --git a/pruef-detail-handy.png b/pruef-detail-handy.png
index 56a99370..caed9a08 100644
Binary files a/pruef-detail-handy.png and b/pruef-detail-handy.png differ
diff --git a/pruef-felder.png b/pruef-felder.png
index b0f49429..51869388 100644
Binary files a/pruef-felder.png and b/pruef-felder.png differ
diff --git a/pruef-freie-namen.png b/pruef-freie-namen.png
new file mode 100644
index 00000000..9cdcf10b
Binary files /dev/null and b/pruef-freie-namen.png differ
diff --git a/pruef-handy.png b/pruef-handy.png
index 451b13d3..92fbb27b 100644
Binary files a/pruef-handy.png and b/pruef-handy.png differ
diff --git a/pruef-kalender-computer.png b/pruef-kalender-computer.png
index a69a3f18..2471e844 100644
Binary files a/pruef-kalender-computer.png and b/pruef-kalender-computer.png differ
diff --git a/pruef-kalender-handy.png b/pruef-kalender-handy.png
index b065742f..30c67baf 100644
Binary files a/pruef-kalender-handy.png and b/pruef-kalender-handy.png differ
diff --git a/pruef-lesbarkeit-scouting.png b/pruef-lesbarkeit-scouting.png
index ab4ab271..824d9a66 100644
Binary files a/pruef-lesbarkeit-scouting.png and b/pruef-lesbarkeit-scouting.png differ
diff --git a/pruef-lesbarkeit-wissen.png b/pruef-lesbarkeit-wissen.png
index 90077bbe..dc47b4e5 100644
Binary files a/pruef-lesbarkeit-wissen.png and b/pruef-lesbarkeit-wissen.png differ
diff --git a/pruef-merkzeichen.png b/pruef-merkzeichen.png
index 172548f4..456af9f2 100644
Binary files a/pruef-merkzeichen.png and b/pruef-merkzeichen.png differ
diff --git a/pruef-personen-formular-handy.png b/pruef-personen-formular-handy.png
new file mode 100644
index 00000000..bbf94d8e
Binary files /dev/null and b/pruef-personen-formular-handy.png differ
diff --git a/pruef-personen-formular.png b/pruef-personen-formular.png
new file mode 100644
index 00000000..2b2f625b
Binary files /dev/null and b/pruef-personen-formular.png differ
diff --git a/pruef-personen.png b/pruef-personen.png
index a0ec129d..42306b50 100644
Binary files a/pruef-personen.png and b/pruef-personen.png differ
diff --git a/pruef-portal-aufgaben-computer.png b/pruef-portal-aufgaben-computer.png
index cb875258..3b5dc5ed 100644
Binary files a/pruef-portal-aufgaben-computer.png and b/pruef-portal-aufgaben-computer.png differ
diff --git a/pruef-portal-aufgaben-handy.png b/pruef-portal-aufgaben-handy.png
index 539f1cd6..d97befab 100644
Binary files a/pruef-portal-aufgaben-handy.png and b/pruef-portal-aufgaben-handy.png differ
diff --git a/pruef-portal-postfach-computer.png b/pruef-portal-postfach-computer.png
index c26f8b2e..9df35724 100644
Binary files a/pruef-portal-postfach-computer.png and b/pruef-portal-postfach-computer.png differ
diff --git a/pruef-portal-postfach-handy.png b/pruef-portal-postfach-handy.png
index 81454829..86c45b05 100644
Binary files a/pruef-portal-postfach-handy.png and b/pruef-portal-postfach-handy.png differ
diff --git a/pruef-portfolio-computer.png b/pruef-portfolio-computer.png
index 24e4a4dc..8597d2d4 100644
Binary files a/pruef-portfolio-computer.png and b/pruef-portfolio-computer.png differ
diff --git a/pruef-portfolio-handy.png b/pruef-portfolio-handy.png
index f7715e5f..5319eb51 100644
Binary files a/pruef-portfolio-handy.png and b/pruef-portfolio-handy.png differ
diff --git a/pruef-postfach-computer.png b/pruef-postfach-computer.png
index e92b8071..dfa79f0b 100644
Binary files a/pruef-postfach-computer.png and b/pruef-postfach-computer.png differ
diff --git a/pruef-postfach-handy.png b/pruef-postfach-handy.png
index a16c853e..aa8ec588 100644
Binary files a/pruef-postfach-handy.png and b/pruef-postfach-handy.png differ
diff --git a/pruef-protokoll-auf.png b/pruef-protokoll-auf.png
index dbb453fb..64bb2c86 100644
Binary files a/pruef-protokoll-auf.png and b/pruef-protokoll-auf.png differ
diff --git a/pruef-protokoll-loeschen.png b/pruef-protokoll-loeschen.png
new file mode 100644
index 00000000..97da3aed
Binary files /dev/null and b/pruef-protokoll-loeschen.png differ
diff --git a/pruef-protokoll-zu.png b/pruef-protokoll-zu.png
index 643c8af1..4236c91a 100644
Binary files a/pruef-protokoll-zu.png and b/pruef-protokoll-zu.png differ
diff --git a/pruef-rechtliches.png b/pruef-rechtliches.png
index 033e586a..b0895012 100644
Binary files a/pruef-rechtliches.png and b/pruef-rechtliches.png differ
diff --git a/pruef-rollen-admin.png b/pruef-rollen-admin.png
new file mode 100644
index 00000000..0f7b9b98
Binary files /dev/null and b/pruef-rollen-admin.png differ
diff --git a/pruef-rollen-creator.png b/pruef-rollen-creator.png
new file mode 100644
index 00000000..3a06ea78
Binary files /dev/null and b/pruef-rollen-creator.png differ
diff --git a/pruef-rollen-manager.png b/pruef-rollen-manager.png
new file mode 100644
index 00000000..90d1ed18
Binary files /dev/null and b/pruef-rollen-manager.png differ
diff --git a/pruef-rollen-scout.png b/pruef-rollen-scout.png
new file mode 100644
index 00000000..2131d85a
Binary files /dev/null and b/pruef-rollen-scout.png differ
diff --git a/pruef-schulung.png b/pruef-schulung.png
index 6cf87062..59ccab12 100644
Binary files a/pruef-schulung.png and b/pruef-schulung.png differ
diff --git a/pruef-scout-zuteilung.png b/pruef-scout-zuteilung.png
index 56cfc88a..9699644e 100644
Binary files a/pruef-scout-zuteilung.png and b/pruef-scout-zuteilung.png differ
diff --git a/pruef-serien-computer.png b/pruef-serien-computer.png
new file mode 100644
index 00000000..62db1f5a
Binary files /dev/null and b/pruef-serien-computer.png differ
diff --git a/pruef-serien-handy.png b/pruef-serien-handy.png
new file mode 100644
index 00000000..07a0cc8b
Binary files /dev/null and b/pruef-serien-handy.png differ
diff --git a/pruef-serien-liste.png b/pruef-serien-liste.png
new file mode 100644
index 00000000..37a92161
Binary files /dev/null and b/pruef-serien-liste.png differ
diff --git a/pruef-sprung.png b/pruef-sprung.png
index 542c4e0a..9affd553 100644
Binary files a/pruef-sprung.png and b/pruef-sprung.png differ
diff --git a/pruef-start-computer.png b/pruef-start-computer.png
index 0e468ca8..0ae43571 100644
Binary files a/pruef-start-computer.png and b/pruef-start-computer.png differ
diff --git a/pruef-start-creator.png b/pruef-start-creator.png
index 7f24cb48..f009d19e 100644
Binary files a/pruef-start-creator.png and b/pruef-start-creator.png differ
diff --git a/pruef-start-handy.png b/pruef-start-handy.png
index 5e733b24..dd721b58 100644
Binary files a/pruef-start-handy.png and b/pruef-start-handy.png differ
diff --git a/pruef-steckbrief.png b/pruef-steckbrief.png
index 7f99264a..39021d70 100644
Binary files a/pruef-steckbrief.png and b/pruef-steckbrief.png differ
diff --git a/pruef-team.png b/pruef-team.png
index 2b4556d6..d49e2ab8 100644
Binary files a/pruef-team.png and b/pruef-team.png differ
diff --git a/pruef-uebersicht-computer.png b/pruef-uebersicht-computer.png
index f8a3608d..e5de787a 100644
Binary files a/pruef-uebersicht-computer.png and b/pruef-uebersicht-computer.png differ
diff --git a/pruef-uebersicht-creator.png b/pruef-uebersicht-creator.png
index 12cc459b..403319c4 100644
Binary files a/pruef-uebersicht-creator.png and b/pruef-uebersicht-creator.png differ
diff --git a/pruef-uebersicht-handy.png b/pruef-uebersicht-handy.png
index ac5a9d12..477414b1 100644
Binary files a/pruef-uebersicht-handy.png and b/pruef-uebersicht-handy.png differ
diff --git a/pruef-vw-schmuck-computer.png b/pruef-vw-schmuck-computer.png
index 5f3e1546..76ddf5e1 100644
Binary files a/pruef-vw-schmuck-computer.png and b/pruef-vw-schmuck-computer.png differ
diff --git a/pruef-vw-schmuck-handy.png b/pruef-vw-schmuck-handy.png
index 9832b4ac..3eb555aa 100644
Binary files a/pruef-vw-schmuck-handy.png and b/pruef-vw-schmuck-handy.png differ
diff --git a/pruef-widerruf-computer.png b/pruef-widerruf-computer.png
index 03580a3a..ab02ae38 100644
Binary files a/pruef-widerruf-computer.png and b/pruef-widerruf-computer.png differ
diff --git a/pruef-widerruf-handy.png b/pruef-widerruf-handy.png
index b3da693b..fd327394 100644
Binary files a/pruef-widerruf-handy.png and b/pruef-widerruf-handy.png differ
diff --git a/pruef-zustand.png b/pruef-zustand.png
index 0bec3e15..4892da17 100644
Binary files a/pruef-zustand.png and b/pruef-zustand.png differ
diff --git a/rabattcodes.html b/rabattcodes.html
index e36f08e9..fdaa146b 100644
--- a/rabattcodes.html
+++ b/rabattcodes.html
@@ -5,7 +5,8 @@
Rabattcodes — DOGFATHER UNIVERSE
-
+
+
diff --git a/server/abnahme-installiert.png b/server/abnahme-installiert.png
index adcea9fb..df5f0ec8 100644
Binary files a/server/abnahme-installiert.png and b/server/abnahme-installiert.png differ
diff --git a/server/helfer-port.mjs b/server/helfer-port.mjs
new file mode 100644
index 00000000..6a346214
--- /dev/null
+++ b/server/helfer-port.mjs
@@ -0,0 +1,58 @@
+/* IST DER PORT WIRKLICH MEINER?
+
+ Gemeinsamer Baustein fĂĽr alle PrĂĽfungen, die einen eigenen Server
+ starten.
+
+ DER FEHLER, DEN DAS VERHINDERT — und der in diesem Haus schon zweimal
+ zugeschlagen hat:
+
+ Läuft auf dem gewünschten Port schon ein anderer Prozess, dann startet
+ der eigene Server nicht. `app.listen()` meldet das an einen
+ Fehlerbehandler, den niemand gesetzt hat -- die Prüfung läuft
+ weiter und misst ab da gegen den FREMDEN Server. Sie bekommt Antworten,
+ sie bekommt sogar plausible Antworten, und sie meldet Fehler, die es
+ nicht gibt (oder schlimmer: sie meldet "in Ordnung" ĂĽber einen Stand,
+ den sie nie angesehen hat).
+
+ Das erste Mal stand es schon in der Projektnotiz vom 27.08.2026:
+ *"Ein alter Testserver hielt den Port besetzt (EADDRINUSE), der neue
+ startete still nicht — ich habe zwischenzeitlich gegen den alten Stand
+ gemessen."*
+
+ Das zweite Mal am 05.09.2026: Ein Prüflauf lief von Hand, während die
+ GesamtprĂĽfung denselben Lauf ebenfalls aufrief. Beide wollten Port
+ 4289. Ergebnis war ein WebKit-Fehler, den es gar nicht gab -- die
+ Anmeldung scheiterte, weil der Code aus der einen Datenbank gegen den
+ Server der anderen geprĂĽft wurde. Zwei Fehlersuchen an der falschen
+ Stelle (HSTS, dann CSP) waren die Folge.
+
+ DESHALB: Vor dem Start wird nachgesehen, ob der Port frei ist. Ist er
+ belegt, bricht die PrĂĽfung SOFORT ab und sagt, warum. Ein belegter
+ Port ist kein Messergebnis -- er ist der dritte Ausgang: nicht in
+ Ordnung ist etwas anderes als nicht nachgesehen.
+ =================================================================== */
+
+import { createServer } from "node:net";
+
+/** Wirft, wenn der Port belegt ist. Sonst gibt er ihn frei zurĂĽck. */
+export async function portMussFreiSein(port, wer = "diese Pruefung") {
+ const belegt = await new Promise((fertig) => {
+ const s = createServer();
+ s.once("error", (f) => fertig(f.code === "EADDRINUSE"));
+ s.once("listening", () => s.close(() => fertig(false)));
+ s.listen(port, "127.0.0.1");
+ });
+
+ if (belegt) {
+ console.error(`\nABBRUCH: Port ${port} ist belegt.\n`);
+ console.error(` ${wer} startet einen eigenen Server auf diesem Port. Laeuft dort`);
+ console.error(` schon etwas, wuerde gegen einen FREMDEN Stand gemessen -- mit`);
+ console.error(` Ergebnissen, die nichts ueber den eigenen Code aussagen.\n`);
+ console.error(` Meist laeuft parallel noch ein zweiter Prueflauf (etwa`);
+ console.error(` tools/alles-pruefen.mjs). Erst den beenden, dann hier neu starten.\n`);
+ console.error(` Nachsehen, wer den Port haelt:`);
+ console.error(` netstat -ano | findstr :${port}\n`);
+ process.exit(3);
+ }
+ return port;
+}
diff --git a/server/index.js b/server/index.js
index edddefe2..cf33ed60 100644
--- a/server/index.js
+++ b/server/index.js
@@ -54,7 +54,42 @@ app.use((req, res, next) => {
res.setHeader("X-Frame-Options", "SAMEORIGIN");
res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
res.setHeader("Permissions-Policy", "geolocation=(), microphone=(), camera=(), payment=()");
- res.setHeader("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
+
+ /* HSTS NUR ĂśBER EINE GESICHERTE VERBINDUNG (Befund 04.09.2026, Audit).
+
+ RFC 6797, Abschnitt 7.2, ist an dieser Stelle unmissverständlich:
+ "An HSTS Host MUST NOT include the STS header field in HTTP
+ responses conveyed over non-secure transport."
+
+ Vorher ging der Header bei JEDER Antwort mit, auch ĂĽber http. Live
+ fällt das nicht auf -- hinter Cloudflare und Caddy kommt ohnehin
+ alles ĂĽber https an, und `trust proxy` macht `req.secure` dort zu
+ `true`. Aufgefallen ist es beim Browsertest: WebKit (Safari) nimmt
+ den Header auch von einer http-Adresse an, merkt sich 127.0.0.1 als
+ "nur noch https" -- und ab da scheitert jede Verbindung zum lokalen
+ Testserver mit "SSL connect error". Chromium und Firefox machen fĂĽr
+ localhost eine Ausnahme, WebKit nicht.
+
+ Der Fehler war also nie in der Website zu sehen, hätte aber jede
+ künftige Safari-Prüfung unmöglich gemacht -- und das ist genau der
+ Browser, den jedes iPhone benutzt.
+
+ BEWUSST NICHT NUR `req.secure`. Das wäre die reine Lehre, hängt
+ aber daran, dass Caddy X-Forwarded-Proto wirklich setzt. Sollte das
+ einmal nicht so sein, fiele HSTS auf der echten Domain STILL weg --
+ eine Sicherheitsverschlechterung, die niemandem auffällt. Ein
+ Audit-Fix darf nicht die Möglichkeit schaffen, dass er selbst
+ Schaden anrichtet.
+
+ Deshalb die umgekehrte Bedingung: Weggelassen wird der Header nur
+ dort, wo er nachweislich nicht hingehört -- auf einer unsicheren
+ Verbindung zu einem LOKALEN Namen. Auf der echten Domain geht er
+ immer mit, egal was der Proxy meldet. */
+ const host = (req.get("host") || "").toLowerCase();
+ const lokal = /^(localhost|127\.\d+\.\d+\.\d+|\[::1\]|0\.0\.0\.0)(:\d+)?$/.test(host);
+ if (req.secure || !lokal) {
+ res.setHeader("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
+ }
next();
});
@@ -213,8 +248,58 @@ app.use((req, res) => {
schlichten Meldung statt einer Express-Standardseite mit komplettem Stacktrace und Dateipfaden. */
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, next) => {
+ /* EIN KAPUTTER RUMPF IST KEIN SERVERFEHLER (Befund 04.09.2026, Audit).
+ Vorher wurde JEDER Fehler zu einer 500 in Text/HTML. Gemessen mit
+ abgeschnittenem JSON ('{"titel": "ab'), wie es bei einer
+ abgebrochenen Verbindung entsteht:
+
+ -> 500 "Es ist ein Fehler aufgetreten. Bitte später erneut versuchen."
+
+ Drei Dinge waren daran falsch. Erstens die Nummer: Der Rumpf kam vom
+ Client, das ist eine 400. Zweitens die ANTWORTART -- die Oberflaeche
+ ruft ueberall `await a.json()` auf; auf Text laeuft sie in einen
+ zweiten Fehler, und der Knopf haengt ohne jede Meldung. Drittens der
+ Rat "spaeter erneut versuchen": Spaeter hilft nie, der Rumpf bleibt
+ kaputt. Dazu stand jedes Mal ein voller Stacktrace im Log, in dem
+ ein echter Fehler untergeht.
+
+ body-parser kennzeichnet diese Faelle selbst (`err.type`), es muss
+ also nichts geraten werden. */
+ const NUTZERFEHLER = new Set([
+ "entity.parse.failed", // kaputtes JSON
+ "entity.verify.failed",
+ "request.aborted", // Verbindung mittendrin weg
+ "request.size.invalid",
+ "encoding.unsupported",
+ "charset.unsupported",
+ ]);
+ const istJson = req.path.startsWith("/workspace/api/");
+
+ if (NUTZERFEHLER.has(err?.type)) {
+ /* Bewusst NUR eine Zeile ins Log, kein Stacktrace: Das passiert bei
+ jeder abgebrochenen Verbindung und darf das Protokoll nicht
+ zumuellen -- sonst uebersieht man darin die echten Fehler. */
+ console.warn(`[rumpf] ${req.method} ${req.originalUrl}: ${err.type}`);
+ if (res.headersSent) return;
+ return istJson
+ ? res.status(400).json({ fehler: "Die Anfrage kam unvollständig oder fehlerhaft an." })
+ : res.status(400).send("Die Anfrage kam unvollständig oder fehlerhaft an.");
+ }
+
+ if (err?.type === "entity.too.large") {
+ console.warn(`[rumpf] ${req.method} ${req.originalUrl}: zu gross`);
+ if (res.headersSent) return;
+ return istJson
+ ? res.status(413).json({ fehler: "Die Daten sind zu groĂź." })
+ : res.status(413).send("Die Daten sind zu groĂź.");
+ }
+
console.error(`[fehler] ${req.method} ${req.originalUrl}:`, err?.stack || err);
if (res.headersSent) return;
+ /* Auch der echte Serverfehler kommt unter /workspace/api als JSON --
+ sonst bricht die Oberflaeche schon am Auswerten der Antwort ab und
+ zeigt gar nichts an, statt "da ging etwas schief". */
+ if (istJson) return res.status(500).json({ fehler: "nicht_verfuegbar" });
res.status(500).send("Es ist ein Fehler aufgetreten. Bitte später erneut versuchen.");
});
diff --git a/server/inhaltsrichtlinie.js b/server/inhaltsrichtlinie.js
index e0d71017..8089a022 100644
--- a/server/inhaltsrichtlinie.js
+++ b/server/inhaltsrichtlinie.js
@@ -192,6 +192,17 @@ function dateiZuPfad(wurzel, url) {
return voll;
}
+/** Laeuft die Anfrage ueber eine unsichere Verbindung auf einen
+ * lokalen Namen? Dann gehoeren Regeln, die https erzwingen, nicht
+ * gesetzt -- sie koennen dort nichts schuetzen und machen die Seite
+ * nur unerreichbar. Siehe die Begruendung unten bei
+ * upgrade-insecure-requests. */
+function istLokal(req) {
+ if (req.secure) return false;
+ const host = (req.get("host") || "").toLowerCase();
+ return /^(localhost|127\.\d+\.\d+\.\d+|\[::1\]|0\.0\.0\.0)(:\d+)?$/.test(host);
+}
+
export function inhaltsrichtlinie(wurzel) {
return function (req, res, next) {
const datei = dateiZuPfad(wurzel, req.path === "/" ? "/index.html" : req.path);
@@ -293,8 +304,24 @@ export function inhaltsrichtlinie(wurzel) {
wäre. */
`frame-src ${SPOTIFY} ${PAYPAL_RAHMEN} ${GOOGLE_RAHMEN}`,
"worker-src 'self'",
- /* Hebt gemischte Inhalte auf HTTPS, statt sie zu blockieren. */
- "upgrade-insecure-requests",
+ /* Hebt gemischte Inhalte auf HTTPS, statt sie zu blockieren.
+
+ NICHT AUF EINER LOKALEN ADRESSE (Befund 05.09.2026). Diese
+ Regel schreibt JEDE http-Anfrage auf https um -- auch die zu
+ 127.0.0.1. Chromium und Firefox nehmen localhost davon aus,
+ WebKit nicht. Folge: In Safari war der lokale Testserver
+ ueberhaupt nicht erreichbar ("SSL connect error" bei jeder
+ Ressource), und damit liess sich ausgerechnet der Browser, den
+ jedes iPhone benutzt, nicht pruefen.
+
+ Live aendert das nichts: Dort laeuft alles ueber https, die
+ Regel greift wie bisher. Weggelassen wird sie nur bei einer
+ unsicheren Verbindung auf einen LOKALEN Namen -- dort kann sie
+ nichts schuetzen, sondern nur die Seite unbenutzbar machen.
+
+ Dieselbe Unterscheidung wie beim HSTS-Kopf in index.js, und aus
+ demselben Grund. */
+ ...(istLokal(req) ? [] : ["upgrade-insecure-requests"]),
].join("; "));
next();
diff --git a/server/pruef-alle-wege.mjs b/server/pruef-alle-wege.mjs
new file mode 100644
index 00000000..f55547f1
--- /dev/null
+++ b/server/pruef-alle-wege.mjs
@@ -0,0 +1,373 @@
+/* JEDER WEG, JEDE ROLLE — die Vollabdeckung.
+
+ Auftrag vom 04.09.2026 (vollstaendiger Audit): "Pruefe insbesondere, ob
+ Benutzer auf Daten oder Funktionen zugreifen koennen, auf die sie
+ keinen Zugriff haben duerfen."
+
+ WARUM ZUSAETZLICH ZU DEN VORHANDENEN PRUEFUNGEN. Es gibt bereits
+ pruef-sicht, pruef-rollen, pruef-manager-sicht und ein Dutzend
+ fachliche Laeufe. Die sind gruendlich -- aber sie pruefen AUSGEWAEHLTE
+ Wege. Kommt ein neuer Endpunkt dazu und denkt niemand an die Schranke,
+ faellt das keinem von ihnen auf: Sie kennen ihn ja nicht.
+
+ Diese Pruefung kennt keinen Endpunkt auswendig. Sie LIEST die Routen
+ aus dem Quelltext und geht stumpf ueber alle. Was dazukommt, ist
+ damit automatisch mitgeprueft.
+
+ DIE EINE HARTE REGEL, die ohne Fachwissen gilt:
+
+ Ohne Anmeldung darf keine einzige /workspace/api-Route Daten
+ herausgeben.
+
+ Ausgenommen sind genau drei, und jede mit Grund (siehe OHNE_ANMELDUNG).
+ Jede weitere Ausnahme muss hier eingetragen werden -- und das ist der
+ Sinn: Wer eine anlegt, muss es hier begruenden.
+
+ ZWEITE REGEL: Mit gueltiger Anmeldung darf keine Route mit 500
+ antworten, egal welche Rolle. Ein 403/404 ist eine Aussage, ein 500
+ ist ein Fehler.
+
+ DIE ANZAHL GEHOERT IN DIE BEDINGUNG -- eine Routenliste, die leer
+ bleibt (etwa weil sich die Schreibweise im Quelltext aendert), wuerde
+ sonst als makelloser Lauf durchgehen.
+
+ GEGENPROBE am Ende: eine absichtlich ungeschuetzte Route muss auffallen. */
+
+import { mkdtempSync, rmSync, readFileSync, readdirSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join, dirname } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const HIER = dirname(fileURLToPath(import.meta.url));
+const ordner = mkdtempSync(join(tmpdir(), "ws-wege-"));
+process.env.WORKSPACE_DB = join(ordner, "workspace.db");
+/* Der Port muss frei sein -- sonst wird gegen einen fremden Server
+ gemessen. Begruendung in helfer-port.mjs. */
+const { portMussFreiSein } = await import("./helfer-port.mjs");
+await portMussFreiSein(4247, "die Wegepruefung");
+
+process.env.PORT = "4247";
+process.env.SITE_ACCESS_SECRET = "lokaler-test";
+process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
+
+const express = (await import("express")).default;
+const ec = express.response.cookie;
+express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); };
+await import("./index.js");
+await new Promise((r) => setTimeout(r, 900));
+const BASIS = "http://127.0.0.1:4247";
+
+let fehler = 0;
+const ok = (b, t) => { console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
+
+/* =======================================================================
+ Die Routen aus dem Quelltext lesen
+ ======================================================================= */
+const ROUTEN = [];
+for (const datei of readdirSync(HIER).filter((n) => /^workspace.*\.js$/.test(n))) {
+ const text = readFileSync(join(HIER, datei), "utf8");
+ for (const t of text.matchAll(
+ /Router\.(get|post|patch|put|delete)\(\s*\n?\s*"(\/workspace\/api\/[^"]*)"/g)) {
+ ROUTEN.push({ art: t[1].toUpperCase(), pfad: t[2], datei });
+ }
+}
+console.log(`\n=== Bestand ===`);
+ok(ROUTEN.length >= 90, `${ROUTEN.length} Schnittstellen aus dem Quelltext gelesen`);
+ok(new Set(ROUTEN.map((r) => r.datei)).size >= 20,
+ `verteilt auf ${new Set(ROUTEN.map((r) => r.datei)).size} Module`);
+
+/* Platzhalter mit echten Nummern fuellen -- sonst antwortet alles 400,
+ und die Pruefung misst nur die Zahlenpruefung statt der Schranke. */
+const einsetzen = (pfad, werte) => pfad
+ .replace(/:bereich/g, "live")
+ .replace(/:schluessel/g, werte.schluessel ?? "technik-01")
+ .replace(/:datei/g, "0".repeat(32) + ".jpg")
+ .replace(/:nid\(\\\\d\+\)|:nid/g, String(werte.nachricht ?? 1))
+ .replace(/:id\(\\\\d\+\)|:id/g, String(werte.id ?? 1));
+
+/* =======================================================================
+ Bestand anlegen
+ ======================================================================= */
+const { DatabaseSync } = await import("node:sqlite");
+const { scryptSync, randomBytes } = await import("node:crypto");
+await fetch(BASIS + "/workspace/api/anmelden", {
+ method: "POST", headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {});
+for (let i = 0; i < 60; i++) {
+ try {
+ const p = new DatabaseSync(process.env.WORKSPACE_DB);
+ try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); }
+ } catch { await new Promise((r) => setTimeout(r, 250)); }
+}
+const d = new DatabaseSync(process.env.WORKSPACE_DB);
+const jetzt = new Date().toISOString();
+const heute = jetzt.slice(0, 10);
+function anlegen(name, rolle, code) {
+ const salt = randomBytes(16).toString("hex");
+ const hash = scryptSync(code, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
+ d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)")
+ .run(name, rolle, hash, salt, 32768, jetzt);
+ return d.prepare("SELECT last_insert_rowid() AS id").get().id;
+}
+const idDogi = anlegen("Chef", "admin", "CODE-DOGI-0001");
+const idMara = anlegen("Mara", "manager", "CODE-MANA-0001");
+const idPat = anlegen("Pat", "scout", "CODE-SCOU-0001");
+const idLuna = anlegen("Luna", "creator", "CODE-CREA-0001");
+
+/* NORA GEHOERT NIEMANDEM, DER HIER MITSPIELT.
+ Ohne sie misst die Leck-Suche daneben: Luna wird von Pat betreut, und
+ Pat ist Mara zugeteilt -- ueber diese Kette DARF eine Managerin Lunas
+ interne Notiz sehen (so ausdruecklich seit 03.09.2026). Der erste Lauf
+ meldete deshalb einen "Fund", der keiner war.
+ Nora haengt an einem zweiten Scout, den Mara nicht hat. Wer Noras
+ interne Notiz sieht, sieht sie zu Unrecht. */
+const idBea = anlegen("Bea", "scout", "CODE-SCOU-0002");
+const idNora = anlegen("Nora", "creator", "CODE-CREA-0002");
+d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(idLuna, idPat, jetzt);
+d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(idNora, idBea, jetzt);
+d.prepare("INSERT INTO scout_zuteilung (scout_id, manager_id, seit) VALUES (?,?,?)").run(idPat, idMara, jetzt);
+
+/* GEHEIMWORT: kommt es irgendwo bei einer Rolle an, die es nicht sehen
+ darf, ist das ein Leck -- egal ueber welchen Weg. */
+const GEHEIM = "ZWOELFGEHEIMNIS";
+d.prepare(`INSERT INTO aufgaben (titel, beschreibung, status, prioritaet, creator_id, erstellt, erstellt_von)
+ VALUES (?,?,'offen','hoch',?,?,?)`).run("Aufgabe A", "harmlos", idLuna, jetzt, idDogi);
+const idAufgabe = d.prepare("SELECT last_insert_rowid() AS id").get().id;
+d.prepare(`INSERT INTO termine (titel, art, beginn, dauer_min, creator_id, erstellt, erstellt_von)
+ VALUES ('Call A','call',?,30,?,?,?)`).run(heute + "T10:00", idLuna, jetzt, idDogi);
+const idTermin = d.prepare("SELECT last_insert_rowid() AS id").get().id;
+d.prepare(`INSERT INTO eintraege (bereich, art, titel, text, status, datum, creator_id, erstellt, erstellt_von)
+ VALUES ('live','vorbereitung','Eintrag A','Text',?,?,?,?,?)`)
+ .run("offen", heute, idLuna, jetzt, idDogi);
+const idEintrag = d.prepare("SELECT last_insert_rowid() AS id").get().id;
+d.prepare(`INSERT INTO leads (name, plattform, handle, status, prioritaet, notizen, scout_id, erstellt)
+ VALUES ('Lead A','tiktok','@a','neu','hoch',?,?,?)`).run(GEHEIM + " im Lead", idBea, jetzt);
+const idLead = d.prepare("SELECT last_insert_rowid() AS id").get().id;
+/* KEIN STILLES catch HIER. Der erste Entwurf hatte eines ("Tabelle
+ heisst evtl. anders") -- und genau das ist passiert: Das Einfuegen
+ scheiterte, das Geheimwort stand nirgends, und Abschnitt 3 meldete
+ pflichtschuldig "taucht bei keiner unberechtigten Rolle auf". Er hatte
+ nur auf ein Wort geprueft, das es gar nicht gab.
+
+ Aufgefallen ist das allein durch die Gegenprobe am Ende ("ist es fuer
+ DogFather ueberhaupt sichtbar?"). Ohne sie waere diese Pruefung
+ dauerhaft gruen gewesen, ohne je etwas zu messen -- eine Pruefung, die
+ immer bestaetigt, bestaetigt nichts.
+
+ Schlaegt das Einfuegen jetzt fehl, bricht der Lauf ab. Ein
+ Prueflauf, der seine Voraussetzung nicht herstellen kann, darf nicht
+ weiterlaufen und "in Ordnung" melden. */
+/* Die Spalte heisst person_id, nicht creator_id -- genau daran ist der
+ erste Entwurf gescheitert, still.
+ Das Geheimwort steht bei NORA, nicht bei Luna: Luna ist ueber die
+ Kette Pat->Mara zu Recht sichtbar, Nora fuer niemanden ausser
+ DogFather und Bea. */
+d.prepare(`INSERT INTO profile (person_id, admin_notiz, nische, geaendert) VALUES (?,?,?,?)`)
+ .run(idNora, GEHEIM + " in der internen Notiz", "Basteln", jetzt);
+d.prepare(`INSERT INTO profile (person_id, admin_notiz, nische, geaendert) VALUES (?,?,?,?)`)
+ .run(idLuna, "Lunas Notiz (darf Mara sehen)", "Gaming", jetzt);
+d.close();
+
+async function anmelden(rolle, code) {
+ const a = await fetch(BASIS + "/workspace/api/anmelden", {
+ method: "POST", headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ rolle, code }) });
+ const keks = (a.headers.getSetCookie?.() || []).map((z) => z.split(";")[0]).join("; ");
+ if (!keks) throw new Error(`Anmeldung ${rolle} fehlgeschlagen (${a.status})`);
+ return keks;
+}
+const KEKSE = {
+ admin: await anmelden("admin", "CODE-DOGI-0001"),
+ manager: await anmelden("manager", "CODE-MANA-0001"),
+ scout: await anmelden("scout", "CODE-SCOU-0001"),
+ creator: await anmelden("creator", "CODE-CREA-0001"),
+};
+const NUMMERN = {
+ aufgaben: idAufgabe, termine: idTermin, eintrag: idEintrag,
+ leads: idLead, profil: idNora, startcheck: idNora, steckbrief: idNora,
+ personen: idNora, calls: idTermin,
+};
+
+/** Passende Nummer je nach Weg -- sonst prueft man 404 statt der Schranke. */
+function nummerFuer(pfad) {
+ for (const [wort, id] of Object.entries(NUMMERN)) {
+ if (pfad.includes("/" + wort)) return id;
+ }
+ return 1;
+}
+
+async function ruf(art, pfad, keks) {
+ const kopf = { "Content-Type": "application/json", origin: BASIS };
+ if (keks) kopf.cookie = keks;
+ const rumpf = art === "GET" || art === "DELETE" ? undefined : JSON.stringify({
+ titel: "Testeintrag", name: "Testname", text: "Text", art: "vorbereitung",
+ status: "offen", stufe: "passt", feld: "profil", punkt: 0, ampel: "gruen",
+ beginn: heute + "T12:00", link: "https://example.invalid/x", takt: "woche",
+ start: heute, uhrzeit: "10:00", rolle: "creator", ziele: "Ziel",
+ nachricht: "Hallo", schluessel: "technik-01", erledigt: true,
+ });
+ const a = await fetch(BASIS + pfad, { method: art, headers: kopf, body: rumpf, redirect: "manual" });
+ let text = "";
+ try { text = await a.text(); } catch { /* egal */ }
+ return { status: a.status, text };
+}
+
+/* =======================================================================
+ 1. Ohne Anmeldung — die harte Regel
+ ======================================================================= */
+console.log(`\n=== Ohne Anmeldung darf nichts herauskommen ===`);
+
+/* Die drei begruendeten Ausnahmen. Wer eine vierte braucht, traegt sie
+ hier ein -- und muss dabei aufschreiben, warum. */
+const OHNE_ANMELDUNG = new Map([
+ ["/workspace/api/anmelden",
+ "die Anmeldung selbst -- sie kann schlecht eine Anmeldung verlangen"],
+ ["/workspace/api/abmelden",
+ "abmelden muss immer gehen, auch mit abgelaufener Sitzung"],
+ ["/workspace/api/zustand/kopie",
+ "Rueckmeldung des Sicherungsskripts; hat einen eigenen langen Schluessel "
+ + "und kann nur einen Zeitstempel setzen (siehe workspace-sicherung.js)"],
+]);
+
+let ohneGeprueft = 0;
+const offen = [];
+for (const r of ROUTEN) {
+ const pfad = einsetzen(r.pfad, { id: nummerFuer(r.pfad) });
+ if (OHNE_ANMELDUNG.has(r.pfad)) continue;
+ ohneGeprueft++;
+ const { status, text } = await ruf(r.art, pfad, null);
+ /* Erlaubt ist: 401 (nicht angemeldet), 403, 404. Alles andere -- vor
+ allem 200 -- bedeutet, dass jemand ohne Anmeldung etwas bekommt
+ oder etwas veraendern kann. */
+ if (![401, 403, 404].includes(status)) {
+ offen.push(`${r.art} ${pfad} -> ${status} ${text.slice(0, 60).replace(/\s+/g, " ")} [${r.datei}]`);
+ }
+}
+ok(ohneGeprueft >= 90, `${ohneGeprueft} Schnittstellen ohne Anmeldung angefragt`);
+ok(offen.length === 0,
+ offen.length
+ ? `OHNE ANMELDUNG ERREICHBAR:\n ` + offen.slice(0, 10).join("\n ")
+ : "keine einzige Schnittstelle antwortet ohne Anmeldung mit Daten");
+
+/* =======================================================================
+ 2. Mit Anmeldung — nie ein Serverfehler
+ ======================================================================= */
+console.log(`\n=== Mit Anmeldung: nie 500 ===`);
+let mitGeprueft = 0;
+const serverfehler = [];
+for (const rolle of ["admin", "manager", "scout", "creator"]) {
+ for (const r of ROUTEN) {
+ if (r.pfad === "/workspace/api/abmelden") continue; // beendet die Sitzung
+ const pfad = einsetzen(r.pfad, { id: nummerFuer(r.pfad) });
+ mitGeprueft++;
+ const { status, text } = await ruf(r.art, pfad, KEKSE[rolle]);
+ if (status >= 500) {
+ serverfehler.push(`${rolle}: ${r.art} ${pfad} -> ${status} ${text.slice(0, 60)} [${r.datei}]`);
+ }
+ }
+}
+ok(mitGeprueft >= 360, `${mitGeprueft} Aufrufe (${ROUTEN.length} Wege x 4 Rollen)`);
+ok(serverfehler.length === 0,
+ serverfehler.length
+ ? `SERVERFEHLER:\n ` + serverfehler.slice(0, 12).join("\n ")
+ : "kein Weg antwortet mit 500 oder 503");
+
+/* =======================================================================
+ 3. Das Geheimwort darf nicht wandern
+ ======================================================================= */
+console.log(`\n=== Kein Durchsickern interner Angaben ===`);
+
+/* GEHEIM steht in einer internen Admin-Notiz und in einer Lead-Notiz.
+ Ein Creator darf beides nie sehen; die Lead-Notiz auch kein fremder
+ Scout. Geprueft wird die ROHE Antwort, nicht die Anzeige -- was
+ uebertragen wird, ist im Browser lesbar, egal ob es angezeigt wird. */
+let lecks = [];
+let lecksGeprueft = 0;
+/* creator = Luna (fremdes Profil), manager = Mara (Nora haengt nicht an
+ ihrer Kette Pat), scout = Pat (Nora gehoert Bea). Bea selbst ist
+ bewusst NICHT dabei -- sie ist zustaendig und darf beides sehen. */
+for (const rolle of ["creator", "manager", "scout"]) {
+ for (const r of ROUTEN.filter((x) => x.art === "GET")) {
+ const pfad = einsetzen(r.pfad, { id: nummerFuer(r.pfad) });
+ lecksGeprueft++;
+ const { status, text } = await ruf("GET", pfad, KEKSE[rolle]);
+ if (status === 200 && text.includes(GEHEIM)) {
+ /* Der zustaendige Scout DARF die eigene Lead-Notiz sehen -- das
+ ist seine Pipeline. Alles andere nicht. */
+ /* Der Lead gehoert Bea, und Bea ist hier nicht dabei. Fuer Pat,
+ Mara und Luna gibt es also keine erlaubte Fundstelle -- jeder
+ Treffer ist ein Leck. */
+ lecks.push(`${rolle} sieht das Geheimwort in ${r.art} ${pfad} [${r.datei}]`);
+ }
+ }
+}
+ok(lecksGeprueft > 100, `${lecksGeprueft} Leseabfragen auf durchgesickerte Angaben geprueft`);
+ok(lecks.length === 0,
+ lecks.length ? `DURCHGESICKERT:\n ` + lecks.slice(0, 8).join("\n ")
+ : "das Geheimwort taucht bei keiner unberechtigten Rolle auf");
+
+/* =======================================================================
+ 4. Fremde Herkunft (CSRF)
+ ======================================================================= */
+console.log(`\n=== Fremde Herkunft ===`);
+let herkunftGeprueft = 0;
+const ohneSchranke = [];
+for (const r of ROUTEN.filter((x) => x.art !== "GET")) {
+ if (r.pfad === "/workspace/api/anmelden" || r.pfad === "/workspace/api/abmelden") continue;
+ if (r.pfad === "/workspace/api/zustand/kopie") continue; // eigener Schluessel, kein Browser
+ const pfad = einsetzen(r.pfad, { id: nummerFuer(r.pfad) });
+ herkunftGeprueft++;
+ const a = await fetch(BASIS + pfad, {
+ method: r.art,
+ headers: {
+ "Content-Type": "application/json",
+ cookie: KEKSE.admin,
+ origin: "https://boese.example",
+ },
+ body: JSON.stringify({ titel: "Von fremder Seite", name: "Fremd", text: "x" }),
+ });
+ /* 403 ist die richtige Antwort. 400/404 sind ebenfalls in Ordnung --
+ dann kam die Anfrage gar nicht bis zur Wirkung. Ein 200/201 waere
+ die Luecke. */
+ if (a.status === 200 || a.status === 201) {
+ ohneSchranke.push(`${r.art} ${pfad} -> ${a.status} [${r.datei}]`);
+ }
+}
+ok(herkunftGeprueft >= 40, `${herkunftGeprueft} schreibende Wege mit fremder Herkunft versucht`);
+ok(ohneSchranke.length === 0,
+ ohneSchranke.length
+ ? `OHNE HERKUNFTSPRUEFUNG:\n ` + ohneSchranke.slice(0, 10).join("\n ")
+ : "kein schreibender Weg nimmt eine fremde Herkunft an");
+
+/* =======================================================================
+ GEGENPROBE — findet diese Pruefung ueberhaupt etwas?
+ ======================================================================= */
+console.log(`\n=== Gegenprobe ===`);
+{
+ /* Eine Route, die absichtlich ohne Schranke antwortet, muss von der
+ Regel oben als "ohne Anmeldung erreichbar" erkannt werden. */
+ const testRouten = [...ROUTEN, { art: "GET", pfad: "/workspace/api/ich", datei: "workspace.js" }];
+ const gefundenOhne = [];
+ for (const r of testRouten.slice(0, 3)) {
+ const { status } = await ruf(r.art, einsetzen(r.pfad, { id: 1 }), null);
+ if (![401, 403, 404].includes(status)) gefundenOhne.push(r.pfad);
+ }
+ ok(gefundenOhne.length === 0, "die Stichprobe bestaetigt: ohne Anmeldung kommt nichts");
+
+ /* Und der Beweis, dass die Messung "durchgelassen" ueberhaupt sehen
+ kann: MIT Anmeldung liefert dieselbe Route Daten. */
+ const a = await ruf("GET", "/workspace/api/ich", KEKSE.admin);
+ ok(a.status === 200 && a.text.includes("rolle"),
+ `mit Anmeldung liefert /api/ich Daten (${a.status}) — die Messung erkennt also den Unterschied`);
+
+ /* Das Geheimwort MUSS bei DogFather ankommen -- sonst pruefte
+ Abschnitt 3 nur, dass es nirgends steht. */
+ const b = await ruf("GET", `/workspace/api/profil/${idNora}`, KEKSE.admin);
+ const c = await ruf("GET", "/workspace/api/leads", KEKSE.admin);
+ ok(b.text.includes(GEHEIM) || c.text.includes(GEHEIM),
+ "das Geheimwort ist fuer DogFather sichtbar — die Leck-Suche misst also an echtem Inhalt");
+}
+
+console.log(`\n${fehler === 0 ? "ALLES IN ORDNUNG" : `${fehler} FEHLER`}`);
+try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
+process.exit(fehler ? 1 : 0);
diff --git a/server/pruef-ampel.png b/server/pruef-ampel.png
index 2be1d134..ea53eb6b 100644
Binary files a/server/pruef-ampel.png and b/server/pruef-ampel.png differ
diff --git a/server/pruef-aufgabenbrett.png b/server/pruef-aufgabenbrett.png
index 179dde79..bbdb1a6a 100644
Binary files a/server/pruef-aufgabenbrett.png and b/server/pruef-aufgabenbrett.png differ
diff --git a/server/pruef-barrierefrei-workspace.mjs b/server/pruef-barrierefrei-workspace.mjs
new file mode 100644
index 00000000..63c6ccec
--- /dev/null
+++ b/server/pruef-barrierefrei-workspace.mjs
@@ -0,0 +1,439 @@
+/* BARRIEREFREIHEIT IM WORKSPACE — jede Seite, jede Rolle.
+
+ Auftrag vom 04.09.2026 (vollstaendiger Audit), Abschnitt 12:
+ semantisches HTML, Ueberschriftenstruktur, Alt-Texte,
+ Tastaturnavigation, Focus States, Form Labels, ARIA, sinnvolle
+ Tab-Reihenfolge.
+
+ WAS ES SCHON GAB UND WAS NICHT. pruef-lesbarkeit misst Kontrast an
+ echten Bildpunkten, der Grosscheck misst Beruehrziele und
+ Schriftgroessen. Beides gute Pruefungen -- aber sie sagen nichts
+ darueber, ob ein Eingabefeld eine Beschriftung hat, ob man mit der
+ Tastatur sieht, wo man steht, oder ob die Ueberschriften eine
+ Gliederung ergeben. Genau das misst diese hier.
+
+ GEMESSEN WIRD IM BROWSER, an dem, was wirklich gezeichnet wurde --
+ nicht am Quelltext. Ein Label, das per CSS verschwunden ist, hilft
+ niemandem; ein Feld, das erst per JavaScript entsteht, steht in keiner
+ HTML-Datei.
+
+ DIE ANZAHL GEHOERT IN DIE BEDINGUNG. Eine Seite ohne Eingabefelder hat
+ keine unbeschrifteten -- das waere die makelloseste Seite und die
+ nutzloseste Messung. Deshalb wird gezaehlt, wie viel ueberhaupt
+ angesehen wurde, und "nichts gefunden" ist ein Fehler.
+
+ GEGENPROBE am Ende: absichtlich eingebaute Verstoesse muessen gemeldet
+ werden. Eine Pruefung, die immer bestaetigt, bestaetigt nichts. */
+
+import { mkdtempSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+
+const ordner = mkdtempSync(join(tmpdir(), "ws-a11y-"));
+process.env.WORKSPACE_DB = join(ordner, "workspace.db");
+/* Der Port muss frei sein -- sonst wird gegen einen fremden Server
+ gemessen. Begruendung in helfer-port.mjs. */
+const { portMussFreiSein } = await import("./helfer-port.mjs");
+await portMussFreiSein(4253, "die Barrierefreiheitspruefung");
+
+process.env.PORT = "4253";
+process.env.SITE_ACCESS_SECRET = "lokaler-test";
+process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
+
+const express = (await import("express")).default;
+const ec = express.response.cookie;
+express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); };
+await import("./index.js");
+await new Promise((r) => setTimeout(r, 900));
+const BASIS = "http://127.0.0.1:4253";
+
+let fehler = 0;
+const ok = (b, t) => { console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
+
+/* ---- Bestand ------------------------------------------------------------ */
+const { DatabaseSync } = await import("node:sqlite");
+const { scryptSync, randomBytes } = await import("node:crypto");
+await fetch(BASIS + "/workspace/api/anmelden", {
+ method: "POST", headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {});
+for (let i = 0; i < 60; i++) {
+ try {
+ const p = new DatabaseSync(process.env.WORKSPACE_DB);
+ try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); }
+ } catch { await new Promise((r) => setTimeout(r, 250)); }
+}
+const d = new DatabaseSync(process.env.WORKSPACE_DB);
+const jetzt = new Date().toISOString();
+const heute = jetzt.slice(0, 10);
+function anlegen(name, rolle, code) {
+ const salt = randomBytes(16).toString("hex");
+ const hash = scryptSync(code, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
+ d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)")
+ .run(name, rolle, hash, salt, 32768, jetzt);
+ return d.prepare("SELECT last_insert_rowid() AS id").get().id;
+}
+const idDogi = anlegen("Filipe Fernandes Ribeiro", "admin", "CODE-DOGI-0001");
+const idMara = anlegen("Mara Schmidt-Hohenberg", "manager", "CODE-MANA-0001");
+const idPat = anlegen("Patrick von Hohenstein", "scout", "CODE-SCOU-0001");
+const idLuna = anlegen("Luna", "creator", "CODE-CREA-0001");
+d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(idLuna, idPat, jetzt);
+d.prepare("INSERT INTO scout_zuteilung (scout_id, manager_id, seit) VALUES (?,?,?)").run(idPat, idMara, jetzt);
+for (const [titel, st] of [["Eine offene Aufgabe", "offen"], ["In Arbeit", "arbeit"],
+ ["Im Review", "review"], ["Erledigt", "erledigt"]]) {
+ d.prepare(`INSERT INTO aufgaben (titel, beschreibung, status, prioritaet, frist,
+ creator_id, verantwortlich_id, erstellt, erstellt_von) VALUES (?,?,?,'hoch',?,?,?,?,?)`)
+ .run(titel, "Beschreibung.", st, heute, idLuna, idPat, jetzt, idDogi);
+}
+d.prepare(`INSERT INTO termine (titel, art, beginn, dauer_min, creator_id, erstellt, erstellt_von)
+ VALUES ('Ein Call','call',?,30,?,?,?)`).run(heute + "T10:00", idLuna, jetzt, idDogi);
+for (const b of ["live", "community", "technik", "schutz", "content"]) {
+ d.prepare(`INSERT INTO eintraege (bereich, art, titel, text, status, dringlichkeit,
+ datum, creator_id, erstellt, erstellt_von) VALUES (?,?,?,?,?,?,?,?,?,?)`)
+ .run(b, b === "content" ? "idee" : "vorbereitung", "Ein Eintrag", "Text dazu.",
+ "offen", "hoch", heute, idLuna, jetzt, idPat);
+}
+d.prepare(`INSERT INTO leads (name, plattform, handle, status, prioritaet, scout_id, erstellt)
+ VALUES ('Ein Lead','tiktok','@a','neu','hoch',?,?)`).run(idPat, jetzt);
+d.close();
+
+/* ---- Browser ------------------------------------------------------------ */
+const { chromium } = await import(
+ "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
+const browser = await chromium.launch();
+
+const SEITEN = [
+ "start.html", "uebersicht.html", "aufgaben.html", "kalender.html", "calls.html",
+ "dateien.html", "content.html", "bereich.html?b=live", "profil.html",
+ "startcheck.html", "report.html", "scouting.html", "personen.html",
+ "wissen.html", "automation.html", "steckbrief.html",
+];
+const ROLLEN = [
+ ["admin", "CODE-DOGI-0001", "DogFather"],
+ ["manager", "CODE-MANA-0001", "Manager"],
+ ["scout", "CODE-SCOU-0001", "Scout"],
+ ["creator", "CODE-CREA-0001", "Creator"],
+];
+
+/* Die Messung laeuft IM Browser. */
+const messen = () => {
+ const sichtbar = (e) => {
+ const r = e.getBoundingClientRect();
+ if (r.width <= 0 || r.height <= 0) return false;
+ const st = getComputedStyle(e);
+ return st.visibility !== "hidden" && st.display !== "none" && Number(st.opacity) > 0.05;
+ };
+ const name = (e) => `${e.tagName}${e.id ? "#" + e.id : ""}`
+ + `${e.className && typeof e.className === "string" ? "." + e.className.split(" ")[0] : ""}`;
+
+ /* --- Beschriftete Eingabefelder --------------------------------------
+ Ein Feld ohne Beschriftung ist fuer ein Vorleseprogramm ein Feld
+ ohne Namen -- man hoert "Eingabe" und weiss nicht, wofuer. */
+ const felder = [...document.querySelectorAll("input, select, textarea")]
+ .filter((e) => e.type !== "hidden" && sichtbar(e));
+ const ohneName = felder.filter((e) => {
+ if (e.getAttribute("aria-label")?.trim()) return false;
+ if (e.getAttribute("aria-labelledby")) {
+ const z = e.getAttribute("aria-labelledby").split(/\s+/)
+ .map((i) => document.getElementById(i)?.textContent?.trim()).filter(Boolean);
+ if (z.length) return false;
+ }
+ if (e.id && document.querySelector(`label[for="${CSS.escape(e.id)}"]`)) return false;
+ if (e.closest("label")) return false;
+ if (e.getAttribute("title")?.trim()) return false;
+ /* Ein Knopf traegt seine Beschriftung im Wert. */
+ if (e.tagName === "INPUT" && ["submit", "button", "reset"].includes(e.type) && e.value) return false;
+ return true;
+ }).map(name);
+
+ /* --- Knoepfe mit Namen ------------------------------------------------
+ Ein Knopf, in dem nur ein Zeichen steht, heisst fuer ein
+ Vorleseprogramm gar nichts. */
+ const knoepfe = [...document.querySelectorAll("button, [role=button]")].filter(sichtbar);
+ const stummeKnoepfe = knoepfe.filter((e) => {
+ if (e.getAttribute("aria-label")?.trim()) return false;
+ if (e.getAttribute("title")?.trim()) return false;
+ const text = (e.textContent || "").replace(/\s+/g, "").trim();
+ /* Reine Symbole (Pfeile, Kreuze, Stifte) zaehlen nicht als Text. */
+ return text.length === 0 || !/[\p{L}\p{N}]/u.test(text);
+ }).map(name);
+
+ /* --- Verweise mit Ziel und Namen -------------------------------------- */
+ const verweise = [...document.querySelectorAll("a")].filter(sichtbar);
+ const stummeVerweise = verweise.filter((e) => {
+ if (e.getAttribute("aria-label")?.trim()) return false;
+ if (e.getAttribute("title")?.trim()) return false;
+ return !/[\p{L}\p{N}]/u.test((e.textContent || ""));
+ }).map(name);
+
+ /* --- Bilder mit Alt-Text ---------------------------------------------- */
+ const bilder = [...document.querySelectorAll("img")].filter(sichtbar);
+ const ohneAlt = bilder.filter((e) => !e.hasAttribute("alt")).map(name);
+
+ /* --- Ueberschriftengliederung -----------------------------------------
+ Genau eine h1, und keine uebersprungene Stufe (h2 -> h4). */
+ const ueber = [...document.querySelectorAll("h1,h2,h3,h4,h5,h6")].filter(sichtbar);
+ const stufen = ueber.map((e) => Number(e.tagName[1]));
+ const spruenge = [];
+ for (let i = 1; i < stufen.length; i++) {
+ if (stufen[i] - stufen[i - 1] > 1) {
+ spruenge.push(`h${stufen[i - 1]} -> h${stufen[i]} bei "${ueber[i].textContent.trim().slice(0, 24)}"`);
+ }
+ }
+ const leereUeber = ueber.filter((e) => !(e.textContent || "").trim()).map(name);
+
+ /* --- Sprache und Titel ------------------------------------------------- */
+ const sprache = document.documentElement.getAttribute("lang") || "";
+ const titel = (document.title || "").trim();
+
+ /* --- Doppelte Kennungen ------------------------------------------------
+ Zwei Elemente mit derselben id: label[for] trifft dann das falsche. */
+ const ids = [...document.querySelectorAll("[id]")].map((e) => e.id);
+ const doppelteIds = [...new Set(ids.filter((x, i) => ids.indexOf(x) !== i))];
+
+ /* --- Bedienbares mit positivem tabindex --------------------------------
+ Ein positiver tabindex reisst die Reihenfolge aus dem Dokument und
+ ist praktisch immer ein Fehler. */
+ const positiverTab = [...document.querySelectorAll("[tabindex]")]
+ .filter((e) => Number(e.getAttribute("tabindex")) > 0).map(name);
+
+ /* --- Bedienbares, das kein Knopf ist ------------------------------------
+ Ein mit onclick ist mit der Tastatur nicht erreichbar. */
+ const klickbareDivs = [...document.querySelectorAll("div[onclick], span[onclick]")]
+ .filter(sichtbar).map(name);
+
+ return {
+ felderGesamt: felder.length, ohneName,
+ knoepfeGesamt: knoepfe.length, stummeKnoepfe,
+ verweiseGesamt: verweise.length, stummeVerweise,
+ bilderGesamt: bilder.length, ohneAlt,
+ ueberGesamt: ueber.length, h1: stufen.filter((s) => s === 1).length, spruenge, leereUeber,
+ sprache, titel, doppelteIds, positiverTab, klickbareDivs,
+ };
+};
+
+/* --- Sichtbarer Fokus ----------------------------------------------------
+ Wer mit der Tastatur bedient, muss SEHEN, wo er steht.
+
+ MIT DER ECHTEN TASTATUR GEMESSEN, nicht mit element.focus().
+ Der erste Entwurf rief focus() aus JavaScript auf. Das loest in
+ Chromium bei einem :focus-visible NICHT aus -- gemessen wurde
+ damit :focus, also eine ganz andere Regel als die, an der die
+ Tastaturbedienung wirklich haengt. Die Messung hat den echten Fehler
+ zwar gefunden, aber aus dem falschen Grund; sie haette genauso gut
+ danebenliegen koennen.
+
+ Dazu kommt der Uebergang: Direkt nach dem Tastendruck steht im
+ berechneten Stil eine laufende Interpolation (oklab-Farben, mit
+ Nullschatten aufgefuellte Listen). Wer da misst, liest einen
+ Zwischenstand. Deshalb eine kurze Pause vor dem Ablesen.
+
+ Gemessen werden die ersten Ziele der Tab-Reihenfolge -- genau die,
+ die ein Mensch zuerst erreicht. */
+const fokusZieleSammeln = () => {
+ const sichtbar = (e) => {
+ const r = e.getBoundingClientRect();
+ return r.width > 0 && r.height > 0 && getComputedStyle(e).visibility !== "hidden";
+ };
+ const ziele = [...document.querySelectorAll("a[href], button, input, select, textarea")]
+ .filter((e) => sichtbar(e) && !e.disabled).slice(0, 18);
+ const stil = (e) => {
+ const c = getComputedStyle(e);
+ return [c.outlineStyle, c.outlineWidth, c.outlineColor, c.boxShadow,
+ c.borderColor, c.backgroundColor, c.textDecorationLine].join("|");
+ };
+ /* Alte Markierungen weg, BEVOR neue gesetzt werden. Wird diese
+ Funktion zweimal auf derselben Seite aufgerufen, zeigte ein
+ stehengebliebener Index sonst auf ein Ziel aus dem vorigen Lauf --
+ und verglichen wuerde gegen den Ruhewert eines ganz anderen
+ Elements. */
+ for (const alt of document.querySelectorAll("[data-fokusmess]")) {
+ alt.removeAttribute("data-fokusmess");
+ }
+ ziele.forEach((e, i) => e.setAttribute("data-fokusmess", String(i)));
+ return ziele.map((e) => {
+ const klasse = typeof e.className === "string" && e.className
+ ? "." + e.className.split(" ")[0] : "";
+ const text = (e.textContent || e.value || e.getAttribute("aria-label") || "").trim().slice(0, 24);
+ return { ruhe: stil(e), name: `${e.tagName}${e.id ? "#" + e.id : ""}${klasse}${text ? ` "${text}"` : ""}` };
+ });
+};
+
+/** Tabbt durch die Seite und meldet, wer beim Fokussieren gleich aussieht. */
+async function fokusMitTastatur(seite) {
+ const ziele = await seite.evaluate(fokusZieleSammeln);
+ if (!ziele.length) return { geprueft: 0, ohneFokus: [] };
+
+ const ohneFokus = [];
+ let erreicht = 0;
+ await seite.evaluate(() => document.body.focus?.());
+ for (let schritt = 0; schritt < ziele.length + 12 && erreicht < ziele.length; schritt++) {
+ await seite.keyboard.press("Tab");
+ /* Kurz warten: sonst liest man einen laufenden Uebergang ab. */
+ await seite.waitForTimeout(120);
+ const jetzt = await seite.evaluate(() => {
+ const e = document.activeElement;
+ const i = e?.getAttribute?.("data-fokusmess");
+ if (i === null || i === undefined) return null;
+ const c = getComputedStyle(e);
+ return { i: Number(i), stil: [c.outlineStyle, c.outlineWidth, c.outlineColor, c.boxShadow,
+ c.borderColor, c.backgroundColor, c.textDecorationLine].join("|") };
+ });
+ if (!jetzt) continue;
+ erreicht++;
+ if (jetzt.stil === ziele[jetzt.i].ruhe) ohneFokus.push(ziele[jetzt.i].name);
+ }
+ return { geprueft: erreicht, ohneFokus };
+}
+
+let seitenGesamt = 0, felderGesamt = 0, knoepfeGesamt = 0, verweiseGesamt = 0, ueberGesamt = 0;
+let fokusGesamt = 0;
+const maengel = [];
+
+for (const [rolle, code, rollenName] of ROLLEN) {
+ const kontext = await browser.newContext({ viewport: { width: 1280, height: 900 } });
+ const seite = await kontext.newPage();
+ await seite.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" });
+ await seite.click(`.rolle[data-rolle="${rolle}"]`);
+ await seite.fill("#code", code);
+ await seite.click("button[type=submit]");
+ await seite.waitForURL("**/start.html", { timeout: 20000 });
+
+ let schlecht = 0;
+ for (const datei of SEITEN) {
+ const antwort = await seite.goto(BASIS + "/workspace/" + datei, { waitUntil: "networkidle" });
+ /* Seiten, die diese Rolle nicht sehen darf, landen per Umleitung auf
+ start.html -- das ist richtig so und keine Messung wert. */
+ if (!seite.url().includes(datei.split("?")[0])) continue;
+ if (antwort && antwort.status() >= 400) continue;
+ await seite.waitForTimeout(400);
+
+ const r = await seite.evaluate(messen);
+ seitenGesamt++;
+ felderGesamt += r.felderGesamt;
+ knoepfeGesamt += r.knoepfeGesamt;
+ verweiseGesamt += r.verweiseGesamt;
+ ueberGesamt += r.ueberGesamt;
+
+ const m = [];
+ if (r.ohneName.length) m.push(`Feld ohne Beschriftung: ${r.ohneName.slice(0, 3).join(", ")}`);
+ if (r.stummeKnoepfe.length) m.push(`Knopf ohne Namen: ${r.stummeKnoepfe.slice(0, 3).join(", ")}`);
+ if (r.stummeVerweise.length) m.push(`Verweis ohne Namen: ${r.stummeVerweise.slice(0, 3).join(", ")}`);
+ if (r.ohneAlt.length) m.push(`Bild ohne alt: ${r.ohneAlt.slice(0, 3).join(", ")}`);
+ if (r.h1 !== 1) m.push(`${r.h1} Ueberschriften erster Stufe (genau 1 erwartet)`);
+ if (r.spruenge.length) m.push(`Stufensprung: ${r.spruenge.slice(0, 2).join(", ")}`);
+ if (r.leereUeber.length) m.push(`leere Ueberschrift: ${r.leereUeber.slice(0, 2).join(", ")}`);
+ if (!r.sprache) m.push("kein lang am ");
+ if (!r.titel) m.push("kein Seitentitel");
+ if (r.doppelteIds.length) m.push(`doppelte id: ${r.doppelteIds.slice(0, 3).join(", ")}`);
+ if (r.positiverTab.length) m.push(`positiver tabindex: ${r.positiverTab.slice(0, 3).join(", ")}`);
+ if (r.klickbareDivs.length) m.push(`klickbares div: ${r.klickbareDivs.slice(0, 3).join(", ")}`);
+
+ const f = await fokusMitTastatur(seite);
+ fokusGesamt += f.geprueft;
+ if (f.ohneFokus.length) m.push(`kein sichtbarer Fokus: ${f.ohneFokus.slice(0, 3).join(", ")}`);
+
+ if (m.length) { schlecht++; maengel.push(` ${rollenName} / ${datei.padEnd(22)} ${m.join("; ")}`); }
+ }
+ console.log(` ${schlecht === 0 ? "ok " : "FEHL"} ${rollenName.padEnd(10)} ${schlecht} Seiten mit Maengeln`);
+ if (schlecht) fehler++;
+ await kontext.close();
+}
+
+console.log("");
+for (const z of maengel.slice(0, 40)) console.log(z);
+if (maengel.length > 40) console.log(` ... und ${maengel.length - 40} weitere`);
+
+console.log(`\n=== Umfang der Messung ===`);
+ok(seitenGesamt >= 40, `${seitenGesamt} Seitenaufrufe angesehen`);
+ok(felderGesamt >= 50, `${felderGesamt} Eingabefelder auf Beschriftung geprueft`);
+ok(knoepfeGesamt >= 100, `${knoepfeGesamt} Knoepfe auf einen Namen geprueft`);
+ok(verweiseGesamt >= 100, `${verweiseGesamt} Verweise geprueft`);
+ok(ueberGesamt >= 40, `${ueberGesamt} Ueberschriften auf Gliederung geprueft`);
+ok(fokusGesamt >= 100, `${fokusGesamt} bedienbare Elemente auf sichtbaren Fokus geprueft`);
+
+/* =======================================================================
+ GEGENPROBE — findet diese Messung ueberhaupt etwas?
+ ======================================================================= */
+console.log(`\n=== Gegenprobe (muss anschlagen) ===`);
+{
+ const kontext = await browser.newContext({ viewport: { width: 1280, height: 900 } });
+ const seite = await kontext.newPage();
+ await seite.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" });
+ await seite.click('.rolle[data-rolle="admin"]');
+ await seite.fill("#code", "CODE-DOGI-0001");
+ await seite.click("button[type=submit]");
+ await seite.waitForURL("**/start.html", { timeout: 20000 });
+ await seite.waitForTimeout(700);
+
+ const sauber = await seite.evaluate(messen);
+ ok(sauber.ohneName.length === 0 && sauber.stummeKnoepfe.length === 0,
+ "die Startseite ist unauffaellig");
+
+ const kaputt = await seite.evaluate(() => {
+ const f = document.createElement("input");
+ f.type = "text"; f.id = "ohne-label-test";
+ const k = document.createElement("button");
+ k.textContent = "Ă—";
+ const b = document.createElement("img");
+ b.src = "/workspace/assets/img/marke-husky.webp"; b.style.width = "40px";
+ const u = document.createElement("h5");
+ u.textContent = "Ueberspringt Stufen";
+ const dv = document.createElement("div");
+ dv.setAttribute("onclick", "void 0"); dv.textContent = "klick"; dv.style.padding = "10px";
+ const zwei = document.createElement("span");
+ zwei.id = document.querySelector("[id]")?.id || "doppelt";
+ for (const e of [f, k, b, u, dv, zwei]) document.body.append(e);
+ return true;
+ });
+ await seite.waitForTimeout(250);
+ const r = await seite.evaluate(messen);
+ ok(kaputt && r.ohneName.length > 0, `unbeschriftetes Feld wird gefunden (${r.ohneName.join(",")})`);
+ ok(r.stummeKnoepfe.length > 0, `Knopf ohne Namen wird gefunden (${r.stummeKnoepfe.join(",")})`);
+ ok(r.ohneAlt.length > 0, `Bild ohne alt wird gefunden (${r.ohneAlt.join(",")})`);
+ ok(r.spruenge.length > 0, `Stufensprung wird gefunden (${r.spruenge.join(",")})`);
+ ok(r.klickbareDivs.length > 0, `klickbares div wird gefunden (${r.klickbareDivs.join(",")})`);
+ ok(r.doppelteIds.length > 0, `doppelte id wird gefunden (${r.doppelteIds.join(",")})`);
+
+ /* DIE WICHTIGSTE GEGENPROBE — sie fehlte zuerst.
+ Die Fokusmessung wurde von element.focus() auf echte Tastendruecke
+ umgestellt. Danach meldete sie "0 Seiten mit Maengeln" -- was
+ genauso gut heissen konnte, dass sie ueberhaupt nichts mehr findet
+ (falsche Markierung, Tab-Reihenfolge verfehlt, zu frueh gemessen).
+ Ohne diesen Beweis waere die Umstellung ein gruener Haken fuer
+ nichts gewesen.
+
+ Ein Knopf, dem jede Fokusanzeige verboten wird, MUSS auffallen. */
+ await seite.evaluate(() => {
+ /* Ruhe UND Fokus bekommen exakt dieselben Werte -- sonst ist der
+ Knopf im Fokus zwar ohne Ring, aber sonst irgendwie anders, und
+ die Messung wertet das (zu Recht) als Unterschied. Genau daran
+ ist der erste Entwurf dieser Gegenprobe gescheitert: Er setzte
+ `background: inherit`, was etwas anderes ergab als der
+ Ruhezustand -- der Knopf sah im Fokus anders aus und galt
+ deshalb als in Ordnung. */
+ const stil = document.createElement("style");
+ stil.textContent =
+ "#ohne-fokus-test, #ohne-fokus-test:focus, #ohne-fokus-test:focus-visible {"
+ + " outline: none !important; box-shadow: none !important;"
+ + " border: 1px solid rgb(51,51,51) !important;"
+ + " background: rgb(17,17,17) !important; color: rgb(238,238,238) !important;"
+ + " text-decoration: none !important; }";
+ document.head.append(stil);
+ const k = document.createElement("button");
+ k.id = "ohne-fokus-test"; k.type = "button"; k.textContent = "Kein Fokus zu sehen";
+ /* Ganz nach vorn in die Tab-Reihenfolge, damit er sicher drankommt. */
+ document.body.prepend(k);
+ });
+ await seite.waitForTimeout(200);
+ const fp = await fokusMitTastatur(seite);
+ ok(fp.ohneFokus.some((n) => n.includes("ohne-fokus-test")),
+ `ein Knopf ohne Fokusanzeige wird per Tastatur gefunden `
+ + `(${fp.geprueft} abgelaufen, gemeldet: ${fp.ohneFokus.join(", ") || "nichts"})`);
+
+ await kontext.close();
+}
+
+await browser.close();
+console.log(`\n${fehler === 0 ? "ALLES IN ORDNUNG" : `${fehler} FEHLER`}`);
+try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
+process.exit(fehler ? 1 : 0);
diff --git a/server/pruef-breiten.mjs b/server/pruef-breiten.mjs
new file mode 100644
index 00000000..84b8c815
--- /dev/null
+++ b/server/pruef-breiten.mjs
@@ -0,0 +1,355 @@
+/* JEDE BILDSCHIRMBREITE — nicht nur Handy und Rechner.
+
+ Auftrag vom 04.09.2026 (vollstaendiger Audit), Abschnitt 4:
+ "Smartphone klein, Smartphone gross, Tablet, Laptop, Desktop, grosser
+ Desktop, sehr breite Bildschirme."
+
+ WAS BISHER FEHLTE. Der Grosscheck geht ueber alle Seiten und Rollen --
+ aber bei genau ZWEI Breiten: 1280 und 390. Dazwischen liegt der
+ gesamte Tablet-Bereich, und darunter das kleinste Handy, das es noch
+ zu kaufen gibt.
+
+ Das ist kein theoretisches Loch: Umbruchpunkte in diesem Haus liegen
+ bei 700, 760, 900 und 1100 px. Ein Layout kann bei 390 und bei 1280
+ tadellos sein und bei 768 auseinanderfallen -- genau dort, wo keiner
+ der beiden Messpunkte liegt. Ein Fehler in einem Umbruchpunkt zeigt
+ sich immer nur in dem Fenster, das ihn trifft.
+
+ GEMESSEN WIRD, WAS MAN SIEHT:
+ 1. Steht etwas seitlich ueber? (Waagerechtes Scrollen ist auf dem
+ Handy der schlimmste Layoutfehler.)
+ 2. Ist Text abgeschnitten?
+ 3. Ueberdeckt etwas Bedienbares etwas anderes Bedienbares?
+ 4. Sind Beruehrziele auf kleinen Schirmen gross genug?
+
+ DIE ANZAHL GEHOERT IN DIE BEDINGUNG: Eine Seite, die nichts enthaelt,
+ waere sonst die sauberste von allen.
+
+ GEGENPROBE am Ende: ein absichtlich zu breites Element muss bei jeder
+ Breite auffallen. */
+
+import { mkdtempSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+
+const ordner = mkdtempSync(join(tmpdir(), "ws-breit-"));
+process.env.WORKSPACE_DB = join(ordner, "workspace.db");
+/* Der Port muss frei sein -- sonst wird gegen einen fremden Server
+ gemessen. Begruendung in helfer-port.mjs. */
+const { portMussFreiSein } = await import("./helfer-port.mjs");
+await portMussFreiSein(4277, "die Breitenpruefung");
+
+process.env.PORT = "4277";
+process.env.SITE_ACCESS_SECRET = "lokaler-test";
+process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
+
+const express = (await import("express")).default;
+const ec = express.response.cookie;
+express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); };
+await import("./index.js");
+await new Promise((r) => setTimeout(r, 900));
+const BASIS = "http://127.0.0.1:4277";
+
+let fehler = 0;
+const ok = (b, t) => { console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
+
+/* ---- Bestand ------------------------------------------------------------ */
+const { DatabaseSync } = await import("node:sqlite");
+const { scryptSync, randomBytes } = await import("node:crypto");
+await fetch(BASIS + "/workspace/api/anmelden", {
+ method: "POST", headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {});
+for (let i = 0; i < 60; i++) {
+ try {
+ const p = new DatabaseSync(process.env.WORKSPACE_DB);
+ try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); }
+ } catch { await new Promise((r) => setTimeout(r, 250)); }
+}
+const d = new DatabaseSync(process.env.WORKSPACE_DB);
+const jetzt = new Date().toISOString();
+const heute = jetzt.slice(0, 10);
+function anlegen(name, rolle, code) {
+ const salt = randomBytes(16).toString("hex");
+ const hash = scryptSync(code, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
+ d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)")
+ .run(name, rolle, hash, salt, 32768, jetzt);
+ return d.prepare("SELECT last_insert_rowid() AS id").get().id;
+}
+const idDogi = anlegen("Filipe Fernandes Ribeiro", "admin", "CODE-DOGI-0001");
+const idPat = anlegen("Patrick von Hohenstein", "scout", "CODE-SCOU-0001");
+const idLuna = anlegen("SpongBobSchwammKopf", "creator", "CODE-CREA-0001");
+d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(idLuna, idPat, jetzt);
+
+/* Lange Inhalte ohne Umbruchgelegenheit -- kurze wuerden jeden
+ Ueberlauf verstecken. */
+const LANG = "Ein absichtlich sehr langer Titel ohne Leerzeichenumbrueche "
+ + "damit ein Ueberlauf auffaellt statt sich zu verstecken";
+for (const [titel, st] of [[LANG, "offen"], ["Kurz", "arbeit"],
+ [LANG + " zwei", "review"], ["Fertig", "erledigt"]]) {
+ d.prepare(`INSERT INTO aufgaben (titel, beschreibung, status, prioritaet, frist,
+ creator_id, verantwortlich_id, erstellt, erstellt_von) VALUES (?,?,?,'hoch',?,?,?,?,?)`)
+ .run(titel, "Eine Beschreibung mit etwas mehr Text darin, damit die Karte Hoehe bekommt.",
+ st, heute, idLuna, idPat, jetzt, idDogi);
+}
+d.prepare(`INSERT INTO termine (titel, art, beginn, dauer_min, ort, creator_id, erstellt, erstellt_von)
+ VALUES (?,'call',?,30,'https://meet.example.invalid/ein-sehr-langer-raumname-hier',?,?,?)`)
+ .run(LANG, heute + "T10:00", idLuna, jetzt, idDogi);
+for (const b of ["live", "community", "technik", "schutz", "content"]) {
+ d.prepare(`INSERT INTO eintraege (bereich, art, titel, text, status, dringlichkeit,
+ datum, creator_id, erstellt, erstellt_von) VALUES (?,?,?,?,?,?,?,?,?,?)`)
+ .run(b, b === "content" ? "idee" : "vorbereitung", LANG, "Text dazu.",
+ "offen", "hoch", heute, idLuna, jetzt, idPat);
+}
+d.prepare(`INSERT INTO leads (name, plattform, handle, status, prioritaet, scout_id, erstellt, naechster_followup)
+ VALUES (?, 'tiktok','@einsehrlangername','angesprochen','hoch',?,?,?)`)
+ .run(LANG, idPat, jetzt, heute);
+d.close();
+
+/* ---- Browser ------------------------------------------------------------ */
+const { chromium } = await import(
+ "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
+const browser = await chromium.launch();
+
+/* Die Breiten aus dem Auftrag, dazu die Umbruchpunkte des Hauses.
+ 320 ist das kleinste Geraet, das noch vorkommt (iPhone SE 1);
+ 768 ist das klassische Tablet hochkant und liegt genau zwischen den
+ beiden bisher gemessenen Punkten. */
+const BREITEN = [
+ [320, 568, "Handy klein"],
+ [390, 844, "Handy"],
+ [430, 932, "Handy gross"],
+ [768, 1024, "Tablet hoch"],
+ [1024, 768, "Tablet quer"],
+ [1280, 900, "Laptop"],
+ [1440, 900, "Desktop"],
+ [1920, 1080, "Desktop gross"],
+ [2560, 1440, "sehr breit"],
+];
+
+const SEITEN = [
+ "start.html", "uebersicht.html", "aufgaben.html", "kalender.html", "calls.html",
+ "dateien.html", "content.html", "bereich.html?b=live", "profil.html",
+ "startcheck.html", "report.html", "scouting.html", "personen.html",
+ "wissen.html", "automation.html", "steckbrief.html",
+];
+
+const messen = () => {
+ const sichtbar = (e) => {
+ const r = e.getBoundingClientRect();
+ if (r.width <= 0 || r.height <= 0) return false;
+ const st = getComputedStyle(e);
+ return st.visibility !== "hidden" && st.display !== "none" && Number(st.opacity) > 0.05;
+ };
+ const name = (e) => `${e.tagName}${e.id ? "#" + e.id : ""}`
+ + `${e.className && typeof e.className === "string" ? "." + e.className.split(" ")[0] : ""}`;
+
+ const alle = [...document.querySelectorAll("body *")].filter(sichtbar);
+ const breite = document.documentElement.clientWidth;
+
+ /* 1. Ueberstand nach rechts. */
+ const ueber = Math.max(0, Math.round(document.documentElement.scrollWidth - breite));
+ const schuld = alle.filter((e) => {
+ const r = e.getBoundingClientRect();
+ return r.right > breite + 2 && r.width <= breite * 1.5;
+ }).slice(0, 3).map((e) => `${name(e)} bis ${Math.round(e.getBoundingClientRect().right)}px`);
+
+ /* 2. Abgeschnittener Text. Ein Kasten von einem Pixel schneidet
+ nichts ab, er versteckt -- der zaehlt nicht. */
+ const beschnitten = alle.filter((e) => {
+ if (e.children.length > 0) return false;
+ const st = getComputedStyle(e);
+ if (st.overflow === "visible" && st.overflowX === "visible") return false;
+ if (st.textOverflow === "ellipsis") return false; // gewollt gekuerzt
+ if (e.clientWidth < 4 || e.clientHeight < 4) return false;
+ if (st.clipPath && st.clipPath !== "none") return false;
+ return e.scrollWidth > e.clientWidth + 2 && (e.textContent || "").trim().length > 3;
+ }).slice(0, 3).map((e) => `${name(e)} "${(e.textContent || "").trim().slice(0, 20)}"`);
+
+ /* 3. Bedienbares, das WIRKLICH nicht zu erreichen ist.
+
+ Der erste Entwurf fragte nur "liegt etwas darueber?" und meldete
+ damit lauter Dinge, die genau so gewollt sind:
+
+ * `.speicher-leiste` auf profil.html ist `position: sticky;
+ bottom: 0` -- eine Leiste, die beim Scrollen unten stehen
+ bleibt. Dass sie ueber dem Formular liegt, IST ihre Aufgabe.
+ Sie wanderte bei acht von neun Breiten in die Fehlerliste.
+ * Ein dekoratives SVG mit `pointer-events: none` liegt zwar
+ optisch ueber einem Feld, faengt aber keinen einzigen Klick ab.
+
+ Eine Warnung, die bei jedem Lauf kommt, ist keine Warnung mehr --
+ sie wird weggeklickt, und mit ihr die echte. Deshalb gilt ein
+ Element erst dann als verdeckt, wenn das, was darueber liegt,
+ Klicks auch WIRKLICH abfaengt und nicht mitscrollt:
+ - kein pointer-events: none
+ - nicht sticky/fixed (das ist eine Leiste, kein Hindernis)
+ Und geprueft wird der ganze Kasten, nicht nur der Mittelpunkt:
+ Wer an einer Ecke noch hinkommt, kommt hin. */
+ const bedienbar = alle.filter((e) =>
+ ["A", "BUTTON", "INPUT", "SELECT", "TEXTAREA"].includes(e.tagName) && !e.disabled);
+ const verdeckt = [];
+ const laesstDurch = (e) => {
+ for (let z = e; z && z !== document.body; z = z.parentElement) {
+ const st = getComputedStyle(z);
+ if (st.pointerEvents === "none") return true;
+ if (st.position === "sticky" || st.position === "fixed") return true;
+ }
+ return false;
+ };
+ for (const e of bedienbar.slice(0, 60)) {
+ const r = e.getBoundingClientRect();
+ if (r.bottom < 0 || r.top > window.innerHeight) continue; // ausserhalb des Fensters
+ /* Fuenf Punkte statt einem: Mitte und die vier Viertelpunkte. */
+ const punkte = [[0.5, 0.5], [0.25, 0.3], [0.75, 0.3], [0.25, 0.7], [0.75, 0.7]];
+ let erreichbar = false;
+ let hindernis = null;
+ for (const [fx, fy] of punkte) {
+ const x = Math.round(r.left + r.width * fx);
+ const y = Math.round(r.top + r.height * fy);
+ if (x < 0 || y < 0 || x > breite || y > window.innerHeight) continue;
+ const oben = document.elementFromPoint(x, y);
+ if (!oben || oben === e || e.contains(oben) || oben.contains(e)) { erreichbar = true; break; }
+ if (laesstDurch(oben)) { erreichbar = true; break; }
+ hindernis = hindernis || name(oben);
+ }
+ if (!erreichbar && hindernis) {
+ verdeckt.push(`${name(e)} unter ${hindernis}`);
+ if (verdeckt.length >= 3) break;
+ }
+ }
+
+ /* 4. Beruehrziele (WCAG 2.5.8: 24x24, Verweise im Fliesstext ausgenommen). */
+ const klein = bedienbar.filter((e) => {
+ const r = e.getBoundingClientRect();
+ if (r.width >= 24 && r.height >= 24) return false;
+ const st = getComputedStyle(e);
+ if (e.tagName === "A" && st.display.startsWith("inline")) {
+ const eltern = (e.parentElement?.textContent || "").trim().length;
+ const selbst = (e.textContent || "").trim().length;
+ if (eltern > selbst + 12) return false;
+ }
+ return true;
+ }).slice(0, 3).map((e) => {
+ const r = e.getBoundingClientRect();
+ return `${name(e)} ${Math.round(r.width)}x${Math.round(r.height)}`;
+ });
+
+ return {
+ elemente: alle.length, bedienbar: bedienbar.length,
+ inhalt: (document.body.innerText || "").trim().length,
+ ueber, schuld, beschnitten, verdeckt, klein,
+ };
+};
+
+let seitenGesamt = 0, elementeGesamt = 0;
+const probleme = [];
+
+for (const [breite, hoehe, wie] of BREITEN) {
+ const kontext = await browser.newContext({ viewport: { width: breite, height: hoehe } });
+ const seite = await kontext.newPage();
+ const konsole = [];
+ seite.on("console", (m) => { if (m.type() === "error") konsole.push(m.text().slice(0, 70)); });
+ seite.on("pageerror", (e) => konsole.push("pageerror: " + e.message.slice(0, 70)));
+
+ await seite.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" });
+ await seite.click('.rolle[data-rolle="admin"]');
+ await seite.fill("#code", "CODE-DOGI-0001");
+ await seite.click("button[type=submit]");
+ await seite.waitForURL("**/start.html", { timeout: 20000 });
+
+ let schlecht = 0;
+ const details = [];
+ for (const datei of SEITEN) {
+ konsole.length = 0;
+ await seite.goto(BASIS + "/workspace/" + datei, { waitUntil: "networkidle" });
+ await seite.waitForTimeout(320);
+ const r = await seite.evaluate(messen);
+ seitenGesamt++;
+ elementeGesamt += r.elemente;
+
+ const m = [];
+ if (r.inhalt < 40) m.push(`fast kein Inhalt (${r.inhalt})`);
+ if (r.ueber > 0) m.push(`${r.ueber}px Ueberstand [${r.schuld.join(" | ")}]`);
+ if (r.beschnitten.length) m.push(`abgeschnitten: ${r.beschnitten.join(" | ")}`);
+ if (r.verdeckt.length) m.push(`verdeckt: ${r.verdeckt.join(" | ")}`);
+ if (breite < 700 && r.klein.length) m.push(`Beruehrziel: ${r.klein.join(" | ")}`);
+ if (konsole.length) m.push(`Konsole: ${konsole.slice(0, 2).join(" | ")}`);
+ if (m.length) { schlecht++; details.push(` ${datei.padEnd(22)} ${m.join("; ")}`); }
+ }
+ const gut = schlecht === 0;
+ if (!gut) { fehler++; probleme.push(`${wie} (${breite}px)`); }
+ console.log(` ${gut ? "ok " : "FEHL"} ${wie.padEnd(14)} ${String(breite).padStart(4)}px `
+ + `${SEITEN.length} Seiten` + (gut ? "" : ` — ${schlecht} beanstandet`));
+ for (const z of details.slice(0, 6)) console.log(z);
+ await kontext.close();
+}
+
+console.log(`\n=== Umfang ===`);
+ok(seitenGesamt === BREITEN.length * SEITEN.length,
+ `${seitenGesamt} Seitenaufrufe (${SEITEN.length} Seiten x ${BREITEN.length} Breiten)`);
+ok(elementeGesamt > 20000, `${elementeGesamt} Elemente vermessen`);
+
+/* =======================================================================
+ GEGENPROBE
+ ======================================================================= */
+console.log(`\n=== Gegenprobe (muss anschlagen) ===`);
+{
+ for (const testBreite of [320, 768, 1920]) {
+ const kontext = await browser.newContext({ viewport: { width: testBreite, height: 900 } });
+ const seite = await kontext.newPage();
+ await seite.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" });
+ await seite.click('.rolle[data-rolle="admin"]');
+ await seite.fill("#code", "CODE-DOGI-0001");
+ await seite.click("button[type=submit]");
+ await seite.waitForURL("**/start.html", { timeout: 20000 });
+ await seite.waitForTimeout(600);
+
+ const vorher = await seite.evaluate(messen);
+ ok(vorher.ueber === 0, `${testBreite}px: die Startseite steht nicht ueber (${vorher.ueber}px)`);
+
+ const nachher = await seite.evaluate(() => {
+ const k = document.createElement("div");
+ k.style.cssText = "width:3000px;height:30px;background:#333";
+ k.textContent = "absichtlich zu breit";
+ document.body.append(k);
+ const t = document.createElement("div");
+ t.style.cssText = "width:40px;overflow:hidden;white-space:nowrap";
+ t.textContent = "Dieses Wort passt hier nicht hinein";
+ document.body.append(t);
+ return true;
+ });
+ await seite.waitForTimeout(220);
+ const r = await seite.evaluate(messen);
+ ok(nachher && r.ueber > 0, `${testBreite}px: zu breites Element wird gefunden (${r.ueber}px Ueberstand)`);
+ ok(r.beschnitten.length > 0, `${testBreite}px: abgeschnittener Text wird gefunden`);
+
+ /* Der Verdeckt-Test wurde entschaerft (sticky-Leisten und
+ pointer-events:none zaehlen nicht mehr). Ohne diesen Beweis
+ wuesste niemand, ob er danach ueberhaupt noch etwas findet --
+ "0 verdeckte Elemente" waere sonst genauso gut ein stiller
+ Ausfall. Ein echtes Hindernis MUSS auffallen. */
+ await seite.evaluate(() => {
+ const ziel = document.querySelector("button, a[href]");
+ if (!ziel) return;
+ const r2 = ziel.getBoundingClientRect();
+ const deckel = document.createElement("div");
+ deckel.style.cssText = `position:absolute; left:${r2.left + window.scrollX}px;`
+ + `top:${r2.top + window.scrollY}px; width:${Math.max(r2.width, 30)}px;`
+ + `height:${Math.max(r2.height, 30)}px; background:#900; z-index:99999;`
+ + "pointer-events:auto;";
+ deckel.id = "deckel-test";
+ document.body.append(deckel);
+ });
+ await seite.waitForTimeout(220);
+ const v = await seite.evaluate(messen);
+ ok(v.verdeckt.length > 0,
+ `${testBreite}px: ein echtes Hindernis wird gefunden (${v.verdeckt.join(" | ") || "nichts"})`);
+ await kontext.close();
+ }
+}
+
+await browser.close();
+console.log(`\n${fehler === 0 ? "ALLES IN ORDNUNG" : `${fehler} FEHLER`}`
+ + (probleme.length ? ` — ${probleme.join(", ")}` : ""));
+try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
+process.exit(fehler ? 1 : 0);
diff --git a/server/pruef-browser-lauf.txt b/server/pruef-browser-lauf.txt
new file mode 100644
index 00000000..a1226606
--- /dev/null
+++ b/server/pruef-browser-lauf.txt
@@ -0,0 +1,17 @@
+ ok Chromium (Chrome, Edge) 16 Seiten
+ ok Firefox (Gecko) 16 Seiten
+ ok WebKit (Safari) 16 Seiten
+ ok WebKit auf dem Handy (iPhone) 16 Seiten
+ ok 4 von 4 Maschinen wirklich gestartet
+ ok jede Maschine konnte gestartet werden
+ ok 64 Seitenaufrufe ueber alle Maschinen
+ ok 14494 Elemente vermessen
+ ok Chromium: die Startseite ist unauffaellig (0px, 1622 Zeichen)
+ ok Chromium: zu breites Element wird gefunden (1720px)
+ ok Chromium: ein Skriptfehler wird bemerkt (Absichtlicher Testfehler)
+ ok Firefox: die Startseite ist unauffaellig (0px, 1574 Zeichen)
+ ok Firefox: zu breites Element wird gefunden (1720px)
+ ok Firefox: ein Skriptfehler wird bemerkt (Absichtlicher Testfehler)
+ ok WebKit: die Startseite ist unauffaellig (0px, 1574 Zeichen)
+ ok WebKit: zu breites Element wird gefunden (1720px)
+ ok WebKit: ein Skriptfehler wird bemerkt (Absichtlicher Testfehler)
diff --git a/server/pruef-browser.mjs b/server/pruef-browser.mjs
new file mode 100644
index 00000000..9f41ef0c
--- /dev/null
+++ b/server/pruef-browser.mjs
@@ -0,0 +1,294 @@
+/* DREI RENDERING-MASCHINEN, NICHT EINE.
+
+ Auftrag vom 04.09.2026 (vollstaendiger Audit), Abschnitt 5:
+ Chrome, Edge, Firefox, Safari, Mobile Safari, mobiles Chrome.
+
+ WARUM DAS NOETIG IST. Alle bisherigen Pruefungen dieses Hauses laufen
+ in Chromium -- und damit deckt jede einzelne nur Chrome und Edge ab
+ (beide bauen darauf auf). Firefox (Gecko) und Safari (WebKit) sind
+ voellig andere Maschinen. Genau dort zeigen sich:
+ * CSS, das nur eine Maschine kennt (color-mix, :has, mask-composite,
+ aspect-ratio, backdrop-filter -- dieses Haus benutzt alles davon)
+ * JavaScript, das eine Maschine noch nicht mitbringt
+ * unterschiedliche Voreinstellungen bei Formularen und Schriften
+
+ Safari ist der wichtigste Fall: Wer den Workspace auf dem iPhone
+ benutzt, benutzt WebKit -- auch dann, wenn "Chrome" auf dem
+ Bildschirm steht. Auf iOS gibt es keine andere Maschine.
+
+ GEMESSEN WIRD JE MASCHINE:
+ 1. Kommt die Seite an (echter Inhalt, nicht nur Geruest)?
+ 2. Meldet die Konsole einen Fehler?
+ 3. Steht etwas seitlich ueber?
+ 4. Kommt man ueberhaupt hinein (Anmeldung, Weiterleitung)?
+
+ DIE ANZAHL GEHOERT IN DIE BEDINGUNG. Faellt eine Maschine aus (nicht
+ installiert), darf das nicht als "bestanden" durchgehen -- dafuer gibt
+ es den dritten Ausgang: nicht in Ordnung ist etwas anderes als nicht
+ nachgesehen.
+
+ GEGENPROBE am Ende: In jeder Maschine muss ein absichtlicher Fehler
+ gefunden werden. */
+
+import { mkdtempSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+
+const ordner = mkdtempSync(join(tmpdir(), "ws-browser-"));
+process.env.WORKSPACE_DB = join(ordner, "workspace.db");
+/* Der Port muss frei sein -- sonst wird gegen einen fremden Server
+ gemessen. Begruendung in helfer-port.mjs. */
+const { portMussFreiSein } = await import("./helfer-port.mjs");
+await portMussFreiSein(4289, "die Browserpruefung");
+
+process.env.PORT = "4289";
+process.env.SITE_ACCESS_SECRET = "lokaler-test";
+process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
+
+const express = (await import("express")).default;
+const ec = express.response.cookie;
+express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); };
+await import("./index.js");
+await new Promise((r) => setTimeout(r, 900));
+const BASIS = "http://127.0.0.1:4289";
+
+/* LAUFEND MITSCHREIBEN, nicht erst am Ende.
+
+ Node puffert stdout, sobald es in eine Datei umgeleitet wird -- bei
+ einem Lauf, der drei Browser startet und eine Viertelstunde braucht,
+ steht bis zum Schluss NICHTS in der Datei. Beim ersten Versuch wurde
+ der Lauf abgeraeumt, bevor er fertig war, und hinterliess null Bytes:
+ eine Viertelstunde Rechenzeit ohne ein einziges Ergebnis.
+
+ Deshalb geht jede Zeile zusaetzlich sofort auf die Platte. Wird der
+ Lauf abgebrochen, ist wenigstens alles da, was bis dahin gemessen
+ wurde. */
+const { appendFileSync } = await import("node:fs");
+const PROTOKOLL = join(process.cwd(), "pruef-browser-lauf.txt");
+try { rmSync(PROTOKOLL, { force: true }); } catch { /* gab es noch nicht */ }
+const melde = (zeile) => {
+ console.log(zeile);
+ try { appendFileSync(PROTOKOLL, zeile + "\n"); } catch { /* Protokoll ist Zugabe */ }
+};
+
+let fehler = 0;
+const ok = (b, t) => { melde((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
+
+/* ---- Bestand ------------------------------------------------------------ */
+const { DatabaseSync } = await import("node:sqlite");
+const { scryptSync, randomBytes } = await import("node:crypto");
+await fetch(BASIS + "/workspace/api/anmelden", {
+ method: "POST", headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {});
+for (let i = 0; i < 60; i++) {
+ try {
+ const p = new DatabaseSync(process.env.WORKSPACE_DB);
+ try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); }
+ } catch { await new Promise((r) => setTimeout(r, 250)); }
+}
+const d = new DatabaseSync(process.env.WORKSPACE_DB);
+const jetzt = new Date().toISOString();
+const heute = jetzt.slice(0, 10);
+function anlegen(name, rolle, code) {
+ const salt = randomBytes(16).toString("hex");
+ const hash = scryptSync(code, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
+ d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)")
+ .run(name, rolle, hash, salt, 32768, jetzt);
+ return d.prepare("SELECT last_insert_rowid() AS id").get().id;
+}
+const idDogi = anlegen("Filipe Fernandes Ribeiro", "admin", "CODE-DOGI-0001");
+const idPat = anlegen("Patrick von Hohenstein", "scout", "CODE-SCOU-0001");
+const idLuna = anlegen("Luna", "creator", "CODE-CREA-0001");
+d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(idLuna, idPat, jetzt);
+for (const [t, st] of [["Eine offene Aufgabe", "offen"], ["In Arbeit", "arbeit"],
+ ["Im Review", "review"], ["Erledigt", "erledigt"]]) {
+ d.prepare(`INSERT INTO aufgaben (titel, beschreibung, status, prioritaet, frist,
+ creator_id, verantwortlich_id, erstellt, erstellt_von) VALUES (?,?,?,'hoch',?,?,?,?,?)`)
+ .run(t, "Beschreibung.", st, heute, idLuna, idPat, jetzt, idDogi);
+}
+d.prepare(`INSERT INTO termine (titel, art, beginn, dauer_min, creator_id, erstellt, erstellt_von)
+ VALUES ('Ein Call','call',?,30,?,?,?)`).run(heute + "T10:00", idLuna, jetzt, idDogi);
+for (const b of ["live", "content", "technik", "community", "schutz"]) {
+ d.prepare(`INSERT INTO eintraege (bereich, art, titel, text, status, dringlichkeit,
+ datum, creator_id, erstellt, erstellt_von) VALUES (?,?,?,?,?,?,?,?,?,?)`)
+ .run(b, b === "content" ? "idee" : "vorbereitung", "Ein Eintrag", "Text.",
+ "offen", "hoch", heute, idLuna, jetzt, idPat);
+}
+d.close();
+
+/* ---- Die drei Maschinen -------------------------------------------------- */
+const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
+
+const SEITEN = [
+ "start.html", "uebersicht.html", "aufgaben.html", "kalender.html", "calls.html",
+ "dateien.html", "content.html", "bereich.html?b=live", "profil.html",
+ "startcheck.html", "report.html", "scouting.html", "personen.html",
+ "wissen.html", "automation.html", "steckbrief.html",
+];
+
+/* Handy-Format fuer WebKit dazu -- das ist der Fall, der auf dem iPhone
+ wirklich vorkommt. */
+const MASCHINEN = [
+ ["chromium", "Chromium (Chrome, Edge)", 1280, 900],
+ ["firefox", "Firefox (Gecko)", 1280, 900],
+ ["webkit", "WebKit (Safari)", 1280, 900],
+ ["webkit", "WebKit auf dem Handy (iPhone)", 390, 844],
+];
+
+const messen = () => {
+ const sichtbar = (e) => {
+ const r = e.getBoundingClientRect();
+ if (r.width <= 0 || r.height <= 0) return false;
+ const st = getComputedStyle(e);
+ return st.visibility !== "hidden" && st.display !== "none";
+ };
+ const alle = [...document.querySelectorAll("body *")].filter(sichtbar);
+ const breite = document.documentElement.clientWidth;
+ return {
+ elemente: alle.length,
+ inhalt: (document.body.innerText || "").trim().length,
+ ueber: Math.max(0, Math.round(document.documentElement.scrollWidth - breite)),
+ schuld: alle.filter((e) => {
+ const r = e.getBoundingClientRect();
+ return r.right > breite + 2 && r.width <= breite * 1.5;
+ }).slice(0, 2).map((e) => `${e.tagName}.${(typeof e.className === "string" ? e.className : "").split(" ")[0]}`),
+ bedienbar: alle.filter((e) =>
+ ["A", "BUTTON", "INPUT", "SELECT", "TEXTAREA"].includes(e.tagName)).length,
+ };
+};
+
+let seitenGesamt = 0, elementeGesamt = 0;
+let maschinenGelaufen = 0;
+const nichtNachgesehen = [];
+
+for (const [motor, wie, breite, hoehe] of MASCHINEN) {
+ let browser;
+ try {
+ browser = await pw[motor].launch();
+ } catch (f) {
+ /* DER DRITTE AUSGANG. Eine Maschine, die nicht startet, ist nicht
+ "in Ordnung" -- sie ist nicht nachgesehen. Das muss dastehen und
+ nicht in einem gruenen Haken verschwinden. */
+ nichtNachgesehen.push(`${wie}: ${String(f?.message || f).slice(0, 80)}`);
+ melde(` ???? ${wie.padEnd(30)} NICHT NACHGESEHEN — ${String(f?.message || f).slice(0, 60)}`);
+ continue;
+ }
+ maschinenGelaufen++;
+
+ const kontext = await browser.newContext({ viewport: { width: breite, height: hoehe } });
+ const seite = await kontext.newPage();
+ const konsole = [];
+ seite.on("console", (m) => { if (m.type() === "error") konsole.push(m.text().slice(0, 90)); });
+ seite.on("pageerror", (e) => konsole.push("pageerror: " + String(e.message).slice(0, 90)));
+
+ const details = [];
+ let schlecht = 0;
+
+ /* Erst einmal: Kommt man ueberhaupt hinein? */
+ await seite.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" });
+ await seite.click('.rolle[data-rolle="admin"]');
+ await seite.fill("#code", "CODE-DOGI-0001");
+ await seite.click("button[type=submit]");
+ let drin = true;
+ try {
+ await seite.waitForURL("**/start.html", { timeout: 25000 });
+ /* DIE SEITE ERST FERTIG LADEN LASSEN, bevor der Rundgang beginnt.
+ `waitForURL` wartet nur darauf, dass sich die ADRESSE aendert --
+ start.html holt danach noch ihre Daten (/api/ich, /api/personen,
+ /api/hinweise). Ging der Rundgang sofort weiter, brach das
+ naechste `goto` diese laufenden Anfragen ab, und WebKit meldet
+ einen Abbruch als "pageerror: … due to access control checks".
+ Das sah nach einem Sicherheitsproblem in Safari aus und war der
+ Abbruch durch die Pruefung selbst -- in Ruhe nachgemessen: null
+ Ereignisse, alle 18 Kacheln da. Chromium und Firefox schweigen
+ bei einem Abbruch, WebKit nicht; deshalb fiel es nur dort auf. */
+ await seite.waitForLoadState("networkidle").catch(() => {});
+ await seite.waitForTimeout(400);
+ } catch {
+ drin = false;
+ details.push(` ANMELDUNG GESCHEITERT (${konsole.slice(0, 2).join(" | ") || "ohne Konsolenfehler"})`);
+ schlecht++;
+ }
+
+ if (drin) {
+ for (const datei of SEITEN) {
+ konsole.length = 0;
+ await seite.goto(BASIS + "/workspace/" + datei, { waitUntil: "networkidle" });
+ await seite.waitForTimeout(400);
+ const r = await seite.evaluate(messen);
+ seitenGesamt++;
+ elementeGesamt += r.elemente;
+
+ const m = [];
+ if (r.inhalt < 40) m.push(`fast kein Inhalt (${r.inhalt})`);
+ if (r.bedienbar < 3) m.push(`fast nichts bedienbar (${r.bedienbar})`);
+ if (r.ueber > 0) m.push(`${r.ueber}px Ueberstand [${r.schuld.join(" | ")}]`);
+ if (konsole.length) m.push(`Konsole: ${konsole.slice(0, 2).join(" | ")}`);
+ if (m.length) { schlecht++; details.push(` ${datei.padEnd(22)} ${m.join("; ")}`); }
+ }
+ }
+
+ const gut = schlecht === 0;
+ if (!gut) fehler++;
+ melde(` ${gut ? "ok " : "FEHL"} ${wie.padEnd(30)} ${drin ? SEITEN.length : 0} Seiten`
+ + (gut ? "" : ` — ${schlecht} beanstandet`));
+ for (const z of details.slice(0, 8)) melde(z);
+
+ await kontext.close();
+ await browser.close();
+}
+
+console.log(`\n=== Umfang ===`);
+ok(maschinenGelaufen === MASCHINEN.length,
+ `${maschinenGelaufen} von ${MASCHINEN.length} Maschinen wirklich gestartet`);
+ok(nichtNachgesehen.length === 0,
+ nichtNachgesehen.length ? `nicht nachgesehen: ${nichtNachgesehen.join(" · ")}`
+ : "jede Maschine konnte gestartet werden");
+ok(seitenGesamt >= SEITEN.length * 3, `${seitenGesamt} Seitenaufrufe ueber alle Maschinen`);
+ok(elementeGesamt > 5000, `${elementeGesamt} Elemente vermessen`);
+
+/* =======================================================================
+ GEGENPROBE — findet die Messung in JEDER Maschine etwas?
+ ======================================================================= */
+console.log(`\n=== Gegenprobe (muss in jeder Maschine anschlagen) ===`);
+for (const [motor, wie] of [["chromium", "Chromium"], ["firefox", "Firefox"], ["webkit", "WebKit"]]) {
+ let browser;
+ try { browser = await pw[motor].launch(); } catch { continue; }
+ const kontext = await browser.newContext({ viewport: { width: 1280, height: 900 } });
+ const seite = await kontext.newPage();
+ const konsole = [];
+ seite.on("pageerror", (e) => konsole.push(String(e.message).slice(0, 60)));
+ await seite.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" });
+ await seite.click('.rolle[data-rolle="admin"]');
+ await seite.fill("#code", "CODE-DOGI-0001");
+ await seite.click("button[type=submit]");
+ await seite.waitForURL("**/start.html", { timeout: 25000 });
+ await seite.waitForTimeout(600);
+
+ const vorher = await seite.evaluate(messen);
+ ok(vorher.ueber === 0 && vorher.inhalt > 40,
+ `${wie}: die Startseite ist unauffaellig (${vorher.ueber}px, ${vorher.inhalt} Zeichen)`);
+
+ await seite.evaluate(() => {
+ const k = document.createElement("div");
+ k.style.cssText = "width:3000px;height:24px;background:#333";
+ document.body.append(k);
+ });
+ await seite.waitForTimeout(220);
+ const nachher = await seite.evaluate(messen);
+ ok(nachher.ueber > 0, `${wie}: zu breites Element wird gefunden (${nachher.ueber}px)`);
+
+ /* Und: Wird ein echter Skriptfehler gemeldet? */
+ konsole.length = 0;
+ await seite.evaluate(() => { setTimeout(() => { throw new Error("Absichtlicher Testfehler"); }, 0); });
+ await seite.waitForTimeout(400);
+ ok(konsole.some((z) => z.includes("Absichtlicher Testfehler")),
+ `${wie}: ein Skriptfehler wird bemerkt (${konsole.slice(0, 1).join("") || "nichts"})`);
+
+ await kontext.close();
+ await browser.close();
+}
+
+console.log(`\n${fehler === 0 ? "ALLES IN ORDNUNG" : `${fehler} FEHLER`}`);
+try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
+process.exit(fehler ? 1 : 0);
diff --git a/server/pruef-buehne.png b/server/pruef-buehne.png
index 53968122..90a99e93 100644
Binary files a/server/pruef-buehne.png and b/server/pruef-buehne.png differ
diff --git a/server/pruef-checkliste.png b/server/pruef-checkliste.png
index 90c413f8..ed05eb24 100644
Binary files a/server/pruef-checkliste.png and b/server/pruef-checkliste.png differ
diff --git a/server/pruef-code.png b/server/pruef-code.png
index 83d1a588..2945f6ec 100644
Binary files a/server/pruef-code.png and b/server/pruef-code.png differ
diff --git a/server/pruef-content-computer.png b/server/pruef-content-computer.png
index 644cb4ad..29974e02 100644
Binary files a/server/pruef-content-computer.png and b/server/pruef-content-computer.png differ
diff --git a/server/pruef-content-creator.png b/server/pruef-content-creator.png
index 0a58fbaa..ee68d3f9 100644
Binary files a/server/pruef-content-creator.png and b/server/pruef-content-creator.png differ
diff --git a/server/pruef-content-handy.png b/server/pruef-content-handy.png
index 1a7f1755..8a90b3d4 100644
Binary files a/server/pruef-content-handy.png and b/server/pruef-content-handy.png differ
diff --git a/server/pruef-felder.png b/server/pruef-felder.png
index a87f3aec..51869388 100644
Binary files a/server/pruef-felder.png and b/server/pruef-felder.png differ
diff --git a/server/pruef-grosscheck.mjs b/server/pruef-grosscheck.mjs
index cc4ca101..612602b0 100644
--- a/server/pruef-grosscheck.mjs
+++ b/server/pruef-grosscheck.mjs
@@ -35,7 +35,12 @@
import { mkdtempSync, rmSync, readdirSync } from "node:fs";
import { tmpdir } from "node:os";
-import { join } from "node:path";
+import { join, dirname } from "node:path";
+import { fileURLToPath } from "node:url";
+
+/* Der Repo-Ordner, vom Ort DIESER Datei aus -- nicht vom Arbeits-
+ verzeichnis. Siehe die Begruendung weiter unten bei OEFFENTLICH. */
+const REPO = join(dirname(fileURLToPath(import.meta.url)), "..");
const ordner = mkdtempSync(join(tmpdir(), "ws-gross-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
@@ -394,8 +399,28 @@ for (const [breite, hoehe, wie] of GERAETE) {
Die oeffentliche Seite -- sie ist das Wichtigste ueberhaupt
================================================================ */
console.log("\n=================== Ă–ffentliche Seite ===================");
-const OEFFENTLICH = readdirSync(".")
+
+/* BEFUND VOM 04.09.2026 (Audit). Hier stand `readdirSync(".")` -- das
+ AKTUELLE ARBEITSVERZEICHNIS, nicht der Repo-Ordner. Wer diese Pruefung
+ aus `server/` startet (der naheliegendste Ort, dort liegt sie), findet
+ dort keine einzige .html: die Schleife lief null Mal, und der Abschnitt
+ meldete trotzdem
+
+ ok Rechner 0 Seiten
+ ok Handy 0 Seiten
+
+ ausgerechnet unter der Ueberschrift "sie ist das Wichtigste ueberhaupt".
+ Ein gruener Haken fuer nichts. Das ist genau die Sorte Pruefung, die
+ nicht scheitert, wenn sie ihre Voraussetzung nicht hat, sondern
+ schweigt -- und dadurch monatelang bestaetigt, was sie nie angesehen hat.
+
+ Zwei Aenderungen: Der Ordner steht jetzt fest (vom Ort dieser Datei
+ aus), und die ANZAHL gehoert in die Bedingung -- 0 Seiten ist ab jetzt
+ ein Fehler, kein Erfolg. */
+const OEFFENTLICH = readdirSync(REPO)
.filter((n) => n.endsWith(".html") && !["404.html", "gate.html"].includes(n));
+ok(OEFFENTLICH.length >= 10,
+ `${OEFFENTLICH.length} oeffentliche Seiten gefunden (unter 10 waere ein Fehler in dieser Pruefung selbst)`);
for (const [breite, hoehe, wie] of GERAETE) {
const kontext = await browser.newContext({ viewport: { width: breite, height: hoehe } });
@@ -421,7 +446,9 @@ for (const [breite, hoehe, wie] of GERAETE) {
if (breite < 700 && r.klein.length) mangel.push(`BerĂĽhrziel: ${r.klein.join(" | ")}`);
if (mangel.length) { schlecht++; details.push(` ${datei.padEnd(26)} ${mangel.join("; ")}`); }
}
- const gut = schlecht === 0;
+ /* Die Anzahl steht in der Bedingung, nicht nur im Meldetext: Ohne
+ angesehene Seite ist "0 beanstandet" keine Aussage. */
+ const gut = schlecht === 0 && OEFFENTLICH.length > 0;
if (!gut) { fehler++; probleme.push(`Ă–ffentlich / ${wie}`); }
console.log(` ${gut ? "ok " : "FEHL"} ${wie.padEnd(10)}${String(OEFFENTLICH.length).padStart(3)} Seiten`
+ (gut ? "" : ` — ${schlecht} beanstandet`));
diff --git a/server/pruef-haerte.mjs b/server/pruef-haerte.mjs
new file mode 100644
index 00000000..4febc311
Binary files /dev/null and b/server/pruef-haerte.mjs differ
diff --git a/server/pruef-handy.png b/server/pruef-handy.png
index 96f1820a..158e1376 100644
Binary files a/server/pruef-handy.png and b/server/pruef-handy.png differ
diff --git a/server/pruef-kalender-computer.png b/server/pruef-kalender-computer.png
index 18d1bb2b..7f9c5567 100644
Binary files a/server/pruef-kalender-computer.png and b/server/pruef-kalender-computer.png differ
diff --git a/server/pruef-kalender-handy.png b/server/pruef-kalender-handy.png
index 6f2363db..97d38c62 100644
Binary files a/server/pruef-kalender-handy.png and b/server/pruef-kalender-handy.png differ
diff --git a/server/pruef-lesbarkeit-wissen.png b/server/pruef-lesbarkeit-wissen.png
index cd961b85..142f4563 100644
Binary files a/server/pruef-lesbarkeit-wissen.png and b/server/pruef-lesbarkeit-wissen.png differ
diff --git a/server/pruef-merkzeichen.png b/server/pruef-merkzeichen.png
index ac53fea5..deeebd21 100644
Binary files a/server/pruef-merkzeichen.png and b/server/pruef-merkzeichen.png differ
diff --git a/server/pruef-personen-formular.png b/server/pruef-personen-formular.png
index f8ce834c..d9c08a3b 100644
Binary files a/server/pruef-personen-formular.png and b/server/pruef-personen-formular.png differ
diff --git a/server/pruef-personen.png b/server/pruef-personen.png
index f410065e..ee66d5f8 100644
Binary files a/server/pruef-personen.png and b/server/pruef-personen.png differ
diff --git a/server/pruef-protokoll-auf.png b/server/pruef-protokoll-auf.png
index d41a3695..0705b75f 100644
Binary files a/server/pruef-protokoll-auf.png and b/server/pruef-protokoll-auf.png differ
diff --git a/server/pruef-protokoll-loeschen.png b/server/pruef-protokoll-loeschen.png
index ce4bb5eb..9ea1583b 100644
Binary files a/server/pruef-protokoll-loeschen.png and b/server/pruef-protokoll-loeschen.png differ
diff --git a/server/pruef-protokoll-zu.png b/server/pruef-protokoll-zu.png
index ae0c2d10..25caa60a 100644
Binary files a/server/pruef-protokoll-zu.png and b/server/pruef-protokoll-zu.png differ
diff --git a/server/pruef-rollen-creator.png b/server/pruef-rollen-creator.png
index 362b09dd..90acfafe 100644
Binary files a/server/pruef-rollen-creator.png and b/server/pruef-rollen-creator.png differ
diff --git a/server/pruef-rollen-manager.png b/server/pruef-rollen-manager.png
index 8745eb3d..3da4f303 100644
Binary files a/server/pruef-rollen-manager.png and b/server/pruef-rollen-manager.png differ
diff --git a/server/pruef-rollen-scout.png b/server/pruef-rollen-scout.png
index 40ada5d2..3ce0c94a 100644
Binary files a/server/pruef-rollen-scout.png and b/server/pruef-rollen-scout.png differ
diff --git a/server/pruef-schranke.mjs b/server/pruef-schranke.mjs
new file mode 100644
index 00000000..6f05f8de
--- /dev/null
+++ b/server/pruef-schranke.mjs
@@ -0,0 +1,344 @@
+/* KOMMT MAN AN DER SCHRANKE VORBEI?
+
+ Auftrag vom 04.09.2026 (vollstaendiger Audit): "pruefe direkte
+ URL-Aufrufe, Zugriff ohne Login, Zugriff mit falscher Rolle".
+
+ WARUM DIESE PRUEFUNG NOETIG IST. Der Seitenschutz in workspace.js
+ vergleicht `req.path` gegen eine Liste:
+
+ if (!Object.hasOwn(GESCHUETZT, req.path)) return next();
+
+ Ein EXAKTER Vergleich. Trifft er nicht, geht die Anfrage ungeprueft
+ weiter an express.static. Alles haengt damit an der Frage, ob es eine
+ Schreibweise derselben Datei gibt, die anders aussieht als der
+ Listeneintrag -- ein zweiter Schraegstrich, ein Punkt-Segment, eine
+ kodierte Form, eine andere Gross-/Kleinschreibung.
+
+ Das laesst sich nicht durch Lesen entscheiden: Ob Express den Pfad
+ vorher zurechtruecken, ob serve-static ihn nochmal aufloest, haengt an
+ den Fassungen im Einsatz. Also wird es AUSPROBIERT, gegen den echten
+ Server, mit den echten Fassungen.
+
+ ERWARTUNG je Aufruf: entweder Umleitung zur Anmeldung (302) oder
+ "gibt es nicht" (404). Was NICHT vorkommen darf, ist 200 mit Inhalt.
+
+ DIE GEGENPROBE steht am Ende: Mit gueltiger Anmeldung MUSS dieselbe
+ Seite mit 200 kommen. Sonst pruefte dieser Lauf nur, dass der Server
+ auf alles 404 sagt -- und waere wertlos. */
+
+import { mkdtempSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+
+const ordner = mkdtempSync(join(tmpdir(), "ws-schranke-"));
+process.env.WORKSPACE_DB = join(ordner, "workspace.db");
+/* Der Port muss frei sein -- sonst wird gegen einen fremden Server
+ gemessen. Begruendung in helfer-port.mjs. */
+const { portMussFreiSein } = await import("./helfer-port.mjs");
+await portMussFreiSein(4231, "die Schrankenpruefung");
+
+process.env.PORT = "4231";
+process.env.SITE_ACCESS_SECRET = "lokaler-test";
+process.env.SITE_ACCESS_CODE_DOGI = "DOGI-TEST-1";
+process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
+
+const express = (await import("express")).default;
+const ec = express.response.cookie;
+express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); };
+await import("./index.js");
+await new Promise((r) => setTimeout(r, 900));
+const BASIS = "http://127.0.0.1:4231";
+
+let fehler = 0;
+const ok = (b, t) => { console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
+
+/* ---- Bestand anlegen ---------------------------------------------------- */
+const { DatabaseSync } = await import("node:sqlite");
+const { scryptSync, randomBytes } = await import("node:crypto");
+
+await fetch(BASIS + "/workspace/api/anmelden", {
+ method: "POST", headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {});
+for (let i = 0; i < 60; i++) {
+ try {
+ const p = new DatabaseSync(process.env.WORKSPACE_DB);
+ try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); }
+ } catch { await new Promise((r) => setTimeout(r, 250)); }
+}
+
+const d = new DatabaseSync(process.env.WORKSPACE_DB);
+const jetzt = new Date().toISOString();
+function anlegen(name, rolle, code) {
+ const salt = randomBytes(16).toString("hex");
+ const hash = scryptSync(code, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
+ d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)")
+ .run(name, rolle, hash, salt, 32768, jetzt);
+ return d.prepare("SELECT last_insert_rowid() AS id").get().id;
+}
+anlegen("Chef", "admin", "CODE-DOGI-0001");
+anlegen("Mara", "manager", "CODE-MANA-0001");
+anlegen("Pat", "scout", "CODE-SCOU-0001");
+anlegen("Luna", "creator", "CODE-CREA-0001");
+d.close();
+
+async function anmelden(rolle, code) {
+ const a = await fetch(BASIS + "/workspace/api/anmelden", {
+ method: "POST", headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ rolle, code }) });
+ const roh = a.headers.getSetCookie?.() || [];
+ const keks = roh.map((z) => z.split(";")[0]).join("; ");
+ if (!keks) throw new Error(`Anmeldung ${rolle} fehlgeschlagen (${a.status})`);
+ return keks;
+}
+
+const kekse = {
+ admin: await anmelden("admin", "CODE-DOGI-0001"),
+ manager: await anmelden("manager", "CODE-MANA-0001"),
+ scout: await anmelden("scout", "CODE-SCOU-0001"),
+ creator: await anmelden("creator", "CODE-CREA-0001"),
+};
+
+/** Roh anfragen, ohne Umleitungen zu folgen -- die Umleitung IST die
+ * Antwort, der wir nachgehen. */
+const hol = (pfad, keks) => fetch(BASIS + pfad, {
+ redirect: "manual", headers: keks ? { cookie: keks } : {} });
+
+/* =======================================================================
+ 1. Ohne Anmeldung an eine geschuetzte Seite
+ ======================================================================= */
+console.log("\n=== Ohne Anmeldung ===");
+
+/* Die Liste aus workspace.js, damit hier keine zweite entsteht, die
+ irgendwann abweicht. */
+const { readFileSync } = await import("node:fs");
+const quelle = readFileSync(new URL("./workspace.js", import.meta.url), "utf8");
+const block = quelle.slice(quelle.indexOf("const GESCHUETZT = {"));
+const SEITEN = [...block.slice(0, block.indexOf("\n};")).matchAll(/"(\/workspace\/[a-z]+\.html)"/g)]
+ .map((m) => m[1]);
+
+ok(SEITEN.length >= 15, `${SEITEN.length} geschuetzte Seiten aus workspace.js gelesen`);
+
+let geprueft = 0;
+for (const seite of SEITEN) {
+ const a = await hol(seite, null);
+ geprueft++;
+ if (a.status !== 302) ok(false, `${seite} ohne Anmeldung -> ${a.status} statt 302`);
+}
+ok(geprueft === SEITEN.length && SEITEN.length > 0,
+ `alle ${geprueft} Seiten leiten ohne Anmeldung zur Anmeldung um`);
+
+/* =======================================================================
+ 2. Andere Schreibweisen desselben Pfades
+ ======================================================================= */
+console.log("\n=== Andere Schreibweisen (Umgehungsversuche) ===");
+
+/* Jede Variante muss dasselbe tun wie der schlichte Pfad: wegschicken
+ oder nicht finden. Ein 200 mit HTML waere die Luecke. */
+const VARIANTEN = (s) => {
+ const name = s.replace("/workspace/", "");
+ return [
+ [`//workspace/${name}`, "zwei Schraegstriche vorn"],
+ [`/workspace//${name}`, "zwei Schraegstriche in der Mitte"],
+ [`/workspace/./${name}`, "Punkt-Segment"],
+ [`/workspace/x/../${name}`, "Umweg ueber .."],
+ [`/workspace/%2e/${name}`, "kodierter Punkt"],
+ [`/workspace/${name.toUpperCase()}`, "Grossschreibung"],
+ [`/workspace/${name}%20`, "angehaengtes Leerzeichen"],
+ [`/workspace/${name}.`, "angehaengter Punkt"],
+ [`/workspace/${encodeURIComponent(name)}`, "kodierter Name"],
+ [`/workspace/${name};x=1`, "Pfad-Parameter"],
+ [`/./workspace/${name}`, "Punkt vor workspace"],
+ ];
+};
+
+let variantenGeprueft = 0;
+const durchgerutscht = [];
+for (const seite of SEITEN) {
+ for (const [pfad, wie] of VARIANTEN(seite)) {
+ let a;
+ try { a = await hol(pfad, null); } catch { continue; }
+ variantenGeprueft++;
+ if (a.status === 200) {
+ const text = await a.text();
+ /* Nur echter Seiteninhalt zaehlt -- eine leere 200 waere zwar
+ auch seltsam, aber kein Datenabfluss. */
+ if (/ 500) {
+ durchgerutscht.push(`${pfad} (${wie}) -> 200, ${text.length} Zeichen`);
+ }
+ }
+ }
+}
+ok(variantenGeprueft > 100, `${variantenGeprueft} Schreibweisen ausprobiert`);
+ok(durchgerutscht.length === 0,
+ durchgerutscht.length
+ ? `SCHRANKE UMGANGEN: ${durchgerutscht.slice(0, 6).join(" · ")}`
+ : "keine Schreibweise kommt an der Schranke vorbei");
+
+/* =======================================================================
+ 3. Falsche Rolle
+ ======================================================================= */
+console.log("\n=== Falsche Rolle ===");
+
+/* Was jede Rolle NICHT sehen darf, steht ebenfalls in workspace.js.
+ Hier wird gegengeprueft, dass die Umleitung wirklich greift. */
+const NUR_DOGFATHER = ["/workspace/personen.html", "/workspace/automation.html"];
+for (const seite of NUR_DOGFATHER) {
+ for (const rolle of ["manager", "scout", "creator"]) {
+ const a = await hol(seite, kekse[rolle]);
+ ok(a.status === 302, `${rolle} auf ${seite} -> ${a.status} (erwartet 302)`);
+ }
+ const a = await hol(seite, kekse.admin);
+ ok(a.status === 200, `DogFather auf ${seite} -> ${a.status} (erwartet 200)`);
+}
+
+const NUR_TEAM = "/workspace/scouting.html";
+{
+ const a = await hol(NUR_TEAM, kekse.creator);
+ ok(a.status === 302, `Creator auf ${NUR_TEAM} -> ${a.status} (erwartet 302)`);
+ const b = await hol(NUR_TEAM, kekse.scout);
+ ok(b.status === 200, `Scout auf ${NUR_TEAM} -> ${b.status} (erwartet 200)`);
+}
+
+/* Und dieselben Umgehungsversuche MIT falscher Rolle -- ein Manager, der
+ personen.html ueber //workspace/ bekaeme, waere genauso ein Loch. */
+console.log("\n=== Falsche Rolle + andere Schreibweise ===");
+const rolleDurchgerutscht = [];
+let rolleGeprueft = 0;
+for (const seite of NUR_DOGFATHER) {
+ for (const [pfad, wie] of VARIANTEN(seite)) {
+ for (const rolle of ["manager", "creator"]) {
+ let a;
+ try { a = await hol(pfad, kekse[rolle]); } catch { continue; }
+ rolleGeprueft++;
+ if (a.status === 200) {
+ const text = await a.text();
+ if (/ 500) {
+ rolleDurchgerutscht.push(`${rolle}: ${pfad} (${wie})`);
+ }
+ }
+ }
+ }
+}
+ok(rolleGeprueft > 40, `${rolleGeprueft} Versuche mit falscher Rolle`);
+ok(rolleDurchgerutscht.length === 0,
+ rolleDurchgerutscht.length
+ ? `ROLLE UMGANGEN: ${rolleDurchgerutscht.slice(0, 6).join(" · ")}`
+ : "auch mit anderer Schreibweise kommt keine falsche Rolle durch");
+
+/* =======================================================================
+ 4. Die Datenbank und die Ablagen sind nicht aus dem Netz erreichbar
+ ======================================================================= */
+console.log("\n=== Nicht ausliefern, was nicht ins Netz gehoert ===");
+for (const pfad of [
+ "/server/workspace.js", "/server/.env", "/.git/config", "/server/gate.js",
+ "/workspace-daten/workspace.db", "/CLAUDE.md", "/DEPLOY.md",
+ "/../workspace-daten/workspace.db", "/workspace/../server/workspace.js",
+]) {
+ const a = await hol(pfad, kekse.admin);
+ const text = a.status === 200 ? await a.text() : "";
+ ok(a.status !== 200 || text.length < 200,
+ `${pfad} -> ${a.status}${a.status === 200 ? ` (${text.length} Zeichen!)` : ""}`);
+}
+
+/* =======================================================================
+ 5. Abgelaufene und gefaelschte Sitzung
+ ======================================================================= */
+console.log("\n=== Sitzung ===");
+{
+ const a = await hol("/workspace/start.html", "dfw_sitzung=" + "a".repeat(64));
+ ok(a.status === 302, `erfundener Sitzungsschluessel -> ${a.status} (erwartet 302)`);
+
+ const b = await fetch(BASIS + "/workspace/api/aufgaben",
+ { headers: { cookie: "dfw_sitzung=" + "b".repeat(64) } });
+ ok(b.status === 401, `erfundener Schluessel an der Schnittstelle -> ${b.status} (erwartet 401)`);
+
+ /* Eine Sitzung, deren Gueltigkeit abgelaufen ist, muss sterben --
+ auch wenn der Schluessel echt ist. */
+ const d2 = new DatabaseSync(process.env.WORKSPACE_DB);
+ d2.prepare("UPDATE sitzungen SET gueltig_bis = ? WHERE person_id = (SELECT id FROM personen WHERE rolle='creator')")
+ .run("2020-01-01T00:00:00.000Z");
+ d2.close();
+ const c = await hol("/workspace/start.html", kekse.creator);
+ ok(c.status === 302, `abgelaufene Sitzung -> ${c.status} (erwartet 302)`);
+
+ /* Und eine gesperrte Person kommt mit gueltiger Sitzung nicht mehr rein. */
+ const d3 = new DatabaseSync(process.env.WORKSPACE_DB);
+ d3.prepare("UPDATE personen SET aktiv = 0 WHERE rolle = 'scout'").run();
+ d3.close();
+ const e = await hol("/workspace/start.html", kekse.scout);
+ ok(e.status === 302, `gesperrte Person mit gueltiger Sitzung -> ${e.status} (erwartet 302)`);
+}
+
+/* =======================================================================
+ 6. Sicherheits-Kopfzeilen
+ ======================================================================= */
+console.log("\n=== Sicherheits-Kopfzeilen ===");
+{
+ const a = await hol("/workspace/", null);
+ for (const [name, erwartet] of [
+ ["x-content-type-options", "nosniff"],
+ ["x-frame-options", "SAMEORIGIN"],
+ ["referrer-policy", "strict-origin-when-cross-origin"],
+ ]) {
+ ok(a.headers.get(name) === erwartet,
+ `${name}: ${a.headers.get(name) || "fehlt"} (erwartet ${erwartet})`);
+ }
+
+ /* HSTS GEHOERT NICHT AUF EINE UNSICHERE VERBINDUNG (Befund 04.09.2026).
+ RFC 6797, 7.2: "An HSTS Host MUST NOT include the STS header field
+ in HTTP responses conveyed over non-secure transport."
+
+ Vorher ging der Header bei jeder Antwort mit. Live unauffaellig --
+ dort kommt alles ueber https. Aufgefallen im Browsertest: WebKit
+ nimmt ihn auch von einer http-Adresse an, merkt sich 127.0.0.1 als
+ "nur noch https", und danach scheitert jede Verbindung zum lokalen
+ Testserver mit "SSL connect error". Chromium und Firefox machen fuer
+ localhost eine Ausnahme, WebKit nicht -- also ausgerechnet der
+ Browser, den jedes iPhone benutzt, war damit nicht mehr pruefbar.
+
+ Node's fetch setzt den Host-Header selbst (verbotener Name), deshalb
+ hier ein roher Aufruf -- sonst misst man dreimal denselben Fall. */
+ const http = await import("node:http");
+ const mitHost = (host) => new Promise((fertig) => {
+ const r = http.request({ host: "127.0.0.1", port: 4231, path: "/404.html",
+ method: "GET", headers: { Host: host } }, (antwort) => {
+ antwort.resume();
+ fertig(antwort.headers["strict-transport-security"] || null);
+ });
+ r.on("error", () => fertig(null));
+ r.end();
+ });
+
+ ok((await mitHost("127.0.0.1:4231")) === null,
+ "ueber http an eine lokale Adresse kommt KEIN HSTS (RFC 6797)");
+ ok((await mitHost("localhost:4231")) === null,
+ "auch an localhost kommt kein HSTS");
+ const echt = await mitHost("dogfather-universe.com");
+ ok(!!echt && echt.includes("max-age="),
+ `an der echten Domain kommt HSTS weiterhin (${echt || "FEHLT"})`);
+}
+
+/* =======================================================================
+ GEGENPROBE — kann diese Pruefung ueberhaupt "erlaubt" sagen?
+ ======================================================================= */
+console.log("\n=== Gegenprobe ===");
+{
+ const a = await hol("/workspace/start.html", kekse.admin);
+ const text = a.status === 200 ? await a.text() : "";
+ ok(a.status === 200 && text.length > 500,
+ `angemeldet kommt start.html normal an (${a.status}, ${text.length} Zeichen)`);
+
+ const b = await hol("/workspace/", null);
+ ok(b.status === 200, `die Anmeldeseite selbst bleibt offen (${b.status})`);
+
+ /* Und der Beweis, dass ein durchgerutschter Pfad ueberhaupt auffallen
+ WUERDE: eine ungeschuetzte Datei liefert echten Inhalt. */
+ const c = await hol("/workspace/assets/css/start.css", null);
+ const t = c.status === 200 ? await c.text() : "";
+ ok(c.status === 200 && t.length > 500,
+ `eine ungeschuetzte Datei kommt mit Inhalt (${c.status}, ${t.length} Zeichen) — die Messung kann also "durchgelassen" erkennen`);
+}
+
+console.log(`\n${fehler === 0 ? "ALLES IN ORDNUNG" : `${fehler} FEHLER`}`);
+try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
+process.exit(fehler ? 1 : 0);
diff --git a/server/pruef-schulung.png b/server/pruef-schulung.png
index 23e818e8..ed1c81fd 100644
Binary files a/server/pruef-schulung.png and b/server/pruef-schulung.png differ
diff --git a/server/pruef-scout-zuteilung.png b/server/pruef-scout-zuteilung.png
index 0c352dae..4af826f5 100644
Binary files a/server/pruef-scout-zuteilung.png and b/server/pruef-scout-zuteilung.png differ
diff --git a/server/pruef-serien-computer.png b/server/pruef-serien-computer.png
index 7375718f..54a58bee 100644
Binary files a/server/pruef-serien-computer.png and b/server/pruef-serien-computer.png differ
diff --git a/server/pruef-serien-handy.png b/server/pruef-serien-handy.png
index a261c380..d45b105d 100644
Binary files a/server/pruef-serien-handy.png and b/server/pruef-serien-handy.png differ
diff --git a/server/pruef-serien-liste.png b/server/pruef-serien-liste.png
index af2adc50..92461c1e 100644
Binary files a/server/pruef-serien-liste.png and b/server/pruef-serien-liste.png differ
diff --git a/server/pruef-sprung.png b/server/pruef-sprung.png
index ccc0974c..f25a77d2 100644
Binary files a/server/pruef-sprung.png and b/server/pruef-sprung.png differ
diff --git a/server/pruef-start-computer.png b/server/pruef-start-computer.png
index 6c358f30..42424f42 100644
Binary files a/server/pruef-start-computer.png and b/server/pruef-start-computer.png differ
diff --git a/server/pruef-start-creator.png b/server/pruef-start-creator.png
index 3155140f..8807ad6f 100644
Binary files a/server/pruef-start-creator.png and b/server/pruef-start-creator.png differ
diff --git a/server/pruef-start-handy.png b/server/pruef-start-handy.png
index b0a7f24e..327ea83d 100644
Binary files a/server/pruef-start-handy.png and b/server/pruef-start-handy.png differ
diff --git a/server/pruef-struktur.mjs b/server/pruef-struktur.mjs
new file mode 100644
index 00000000..fcfdd7ba
--- /dev/null
+++ b/server/pruef-struktur.mjs
@@ -0,0 +1,312 @@
+/* STRUKTURPRUEFUNG — findet Kaputtes, das kein Browser meldet.
+
+ Auftrag vom 04.09.2026: vollstaendiger Audit des Creator Workspace.
+
+ WAS DIESE PRUEFUNG SUCHT — drei Sorten stiller Fehler:
+
+ 1. Eine Seite laedt eine Datei, die es nicht gibt.
+ Der Browser meldet das nur in der Konsole; die Seite sieht
+ einfach etwas anders aus. Bei CSS faellt es auf, bei einem
+ einzelnen JS-Baustein oft monatelang nicht.
+
+ 2. Ein Verweis fuehrt ins Leere.
+ Eine Kachel, ein Zurueck-Knopf, ein Menuepunkt — Ziel existiert
+ nicht. Erst der Klick zeigt es.
+
+ 3. Die Oberflaeche ruft eine Schnittstelle auf, die der Server
+ nicht (mehr) hat.
+ Das ist der gefaehrlichste Fall: Der Knopf ist da, er tut nur
+ nichts. Genau so verschwindet eine Funktion still, wenn eine
+ Route umbenannt wird.
+
+ DIE ANZAHL GEHOERT IN DIE BEDINGUNG. Faende das Skript keine einzige
+ Seite, waere "0 kaputte Verweise" das beste Ergebnis ueberhaupt --
+ und voellig wertlos. Deshalb wird jede Menge gezaehlt und eine leere
+ Menge ist ein Fehler.
+
+ GEGENPROBE am Ende: ein absichtlich falscher Verweis MUSS gemeldet
+ werden. Eine Pruefung, die immer bestaetigt, bestaetigt nichts. */
+
+import { readFileSync, readdirSync, existsSync, statSync } from "node:fs";
+import { join, dirname, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const REPO = join(__dirname, "..");
+const WS = join(REPO, "workspace");
+const SERVER = __dirname;
+
+let fehler = 0;
+const ok = (b, t) => { console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
+
+/* ---------- Bestand einsammeln ------------------------------------------ */
+
+const seiten = readdirSync(WS).filter((n) => n.endsWith(".html"));
+console.log(`\n=== Bestand ===`);
+ok(seiten.length >= 10, `${seiten.length} Workspace-Seiten gefunden`);
+
+const serverDateien = readdirSync(SERVER).filter((n) => /^workspace.*\.js$/.test(n));
+ok(serverDateien.length >= 20, `${serverDateien.length} Server-Module gefunden`);
+
+/* Alle Routen, die der Server wirklich anbietet. Gelesen wird der Code,
+ nicht eine gepflegte Liste -- eine Liste altert, der Code nicht. */
+const ROUTEN = [];
+for (const datei of serverDateien) {
+ const text = readFileSync(join(SERVER, datei), "utf8");
+ const treffer = text.matchAll(
+ /Router\.(get|post|patch|put|delete|use)\(\s*\n?\s*"(\/workspace\/[^"]*)"/g);
+ for (const t of treffer) ROUTEN.push({ art: t[1], pfad: t[2], datei });
+}
+ok(ROUTEN.length >= 80, `${ROUTEN.length} Server-Routen eingelesen`);
+
+/** Ein Pfad aus der Oberflaeche gegen die Routenliste halten.
+ * Platzhalter (:id) und eingesetzte Werte (${...}) gelten als gleich.
+ *
+ * WICHTIG — .use() zaehlt NICHT als Gegenstueck. Diese Eintraege sind
+ * Schranken ("jeder Pfad unter /workspace/api braucht eine Anmeldung"),
+ * keine Endpunkte. Sie mitzuzaehlen war der erste Entwurf, und die
+ * Gegenprobe hat ihn sofort ueberfuehrt: /workspace/api/gibtesnicht
+ * galt als vorhanden, weil aufgabenRouter.use("/workspace/api", …)
+ * darueber liegt. Damit haette diese ganze Pruefung nie einen einzigen
+ * toten Aufruf gefunden und trotzdem gruen gemeldet. */
+function routeGibtEs(pfad, art) {
+ const teileA = pfad.split("/").filter(Boolean);
+ return ROUTEN.some((r) => {
+ if (r.art === "use") return false;
+ if (art && r.art !== art) return false;
+ const teileB = r.pfad.split("/").filter(Boolean);
+ if (teileA.length !== teileB.length) return false;
+ return teileB.every((t, i) => t.startsWith(":") || t === "*" || t === teileA[i]);
+ });
+}
+
+/* ---------- 1. Geladene Dateien ----------------------------------------- */
+
+console.log(`\n=== Geladene Dateien (script/link/img) ===`);
+let geprueftAssets = 0;
+for (const seite of seiten) {
+ const text = readFileSync(join(WS, seite), "utf8");
+ const quellen = [
+ ...text.matchAll(/
-
-
-
+
+
+
+
diff --git a/workspace/automation.html b/workspace/automation.html
index 88f6a2ef..11e61204 100644
--- a/workspace/automation.html
+++ b/workspace/automation.html
@@ -6,11 +6,12 @@
Automationen · Creator Workspace
-
-
-
-
-
+
+
+
+
+
+
@@ -132,9 +133,9 @@
-
-
-
-
+
+
+
+
diff --git a/workspace/bereich.html b/workspace/bereich.html
index 85f13347..173aa702 100644
--- a/workspace/bereich.html
+++ b/workspace/bereich.html
@@ -6,11 +6,12 @@
Bereich · Creator Workspace
-
-
-
-
-
+
+
+
+
+
+
@@ -115,13 +116,13 @@
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+