Vier Titel vom Hub genommen, Allowlist mit Mail-Pattern.

Claude/Copilot/Codex und EIT: listed:false; Projektseiten und
Fulfill-Slugs bleiben. Sichtbar: Menschen, Vorhang, Praxis.

Allowlist: Exact, *@domain und *.domain (mind. zwei Labels),
z. B. *.edeka.de für Apex und Subdomains.
This commit is contained in:
grok-bot committed 2026-10-30 12:00:19 +01:00
1 parent c6471c9a5a
commit 3ee30db4a6
12 files changed
+134 -63

No files matched your search

+10 -4
View File
@@ -1,29 +1,35 @@
# HANDOFF — KI-Buch-Archive
## Stand
**Datum:** 2026-10-30 · **HEAD:** `main` · Ampel-stale + Fulfill Design A · **Working tree clean**
**Datum:** 2026-10-30 · **HEAD:** `main` · listed:false + Allowlist-Pattern · **Working tree clean**
**Domain:** https://books.maatini.space · **Origin:** Gitea `http://10.27.27.139:3000/admin/ki-buch-archive.git`
**Modell:** YAML in `content/<slug>/status.yaml` ist SSOT; HTML in `projects/` und Hub-Tags sind Spiegel. Kein CMS, kein Shop. Begleit-ZIPs unter `/downloads/` bleiben öffentlich.
## Letzte Arbeit (committed)
### 3. Vier Titel vom Hub genommen (`listed: false`)
- Ausgeblendet: Claude-Code-Harness, Copilot-CLI-Harness, Codex-Harness, Agents mit Angular/Spring/Quarkus.
- Hub und `/beziehen.html` listen sie nicht. `projects/`, `downloads/`, Fulfill-Slugs bleiben.
- Allowlist: Exact plus Pattern `*@domain` und `*.domain` (mind. zwei Labels). Beispiel enthält `*.edeka.de`.
- Sichtbar: Menschen, Vorhang, Coding Agents in der Praxis.
### 1. Ampel + YAML→HTML-Spiegel
- Claude / Copilot / Codex: Ampel **stale** (90-Tage-Regel; `last_reviewed` unverändert 2026-07-29/30/31). Keine erfundene CLI-Nachprüfung.
- Companion: Claude `0.1.2`, Copilot `0.1.1`, Codex `0.1.1`. Praxis `0.1.3` und EIT `0.1.0` bleiben **current** (45-Tage-Review überfällig, 90 Tage noch nicht).
- `scripts/sync-companion-status.py` (apply + `--check`). Hub-Tags, Prüfstatus-Karten, reviewed-line, Lesevertrag-Fuß. Changelog-Historie, SHA256, Paketstand-Karten unberührt.
### 2. Buch-PDF per E-Mail (Design A)
- `/beziehen.html` → `POST /api/beziehen` → Allowlist `private/allowlist.txt` (`email slug`, eine Zeile pro Titel) → HMAC-Link 24 h an die Adresse → `GET /d/<token>`.
- `/beziehen.html` → `POST /api/beziehen` → Allowlist `private/allowlist.txt` (Exact oder Pattern `*@domain` / `*.domain`, dann Slug) → HMAC-Link 24 h an die Adresse → `GET /d/<token>`.
- Unbekannte Adresse: dieselbe Bestätigung, keine Mail. Rate-Limit 5/15 min pro IP. Python 3 stdlib, kein Pip.
- PDFs: `private/books/<slug>.pdf` (gitignored). Nicht unter `/downloads/`.
- Tests: `python3 -m unittest fulfill.test_server` (19, grün).
- Tests: `python3 -m unittest fulfill.test_server` (23, grün).
- Deploy-Vorlagen: `fulfill/fulfill.env.example`, `fulfill/ki-buch-fulfill.service`, `fulfill/nginx.snippet.conf` (`/api/` und `/d/` → `127.0.0.1:8787`). SMTP `books@maatini.space`.
- Impressum: kurzer DSGVO-Satz (Art. 6 Abs. 1 lit. b).
## Einstieg (neue Session)
```
Repo /Volumes/SSD2TB/books/ki-buch-archive, Branch main.
Lies HANDOFF.md. Ampel-stale + Fulfill Design A sind committed.
Lies HANDOFF.md. listed:false plus Allowlist-Pattern (*@domain, *.domain) sind committed.
Nicht SMTP/Allowlist/PDFs erfinden.
Nächster Schritt nur mit Auftrag: Host-Deploy (Allowlist, PDFs, Env, nginx, systemd).
```
+14 -1
View File
@@ -92,6 +92,8 @@ python3 scripts/sync-companion-status.py --check
Das Script schreibt Hub-Tags, Statuskarten (Prüfstatus), reviewed-line und Lesevertrag-Fuß. Changelog-Historie, SHA256 und Paketstand-Karten bleiben unberührt.
`listed: false` in `status.yaml`: keine Hub-Karte, keine Option unter `/beziehen.html`. Projektseiten, Downloads und Fulfill-Slug bleiben. Default ist gelistet.
Autor-Checklisten:
- `content/was-bleibt-vom-menschen/status.yaml` · `MAINTENANCE.md`
@@ -227,7 +229,18 @@ Kein Framework-Umbau nötig. Begleit-Service-Unterseiten (Stand/Errata/…) nur,
Kein öffentlicher PDF-Pfad. Ablauf:
1. Formular `/beziehen.html` (E-Mail + Titel) → `POST /api/beziehen`
2. Treffer in `private/allowlist.txt` (`email slug`, eine Zeile pro Titel)
2. Treffer in `private/allowlist.txt` (eine Zeile pro Titel: Exact-Adresse
oder Pattern, dann Slug). Sichtbare Optionen: `was-bleibt-vom-menschen`,
`ki-hinterm-vorhang`, `coding-agents-praxis`. Die übrigen Slugs in
`fulfill/server.py` (`BOOKS`) bleiben gültig, auch wenn `listed: false`.
- `ada@example.com slug` — eine Adresse
- `*@edeka.de slug` — alle Postfächer an genau `@edeka.de`
- `*.edeka.de slug` — `@edeka.de` und Subdomains (`@markt.edeka.de`)
Matching über DNS-Labels, nicht String-Ende (`not-edeka.de` trifft
`*.edeka.de` nicht). Muster mit nur einer Label-Stufe (`*.de`, `*@de`)
und Catch-alls (`*`, `*@*`) matchen nicht.
3. HMAC-Link 24 Stunden an diese Adresse (`GET /d/<token>`)
4. Unbekannte Adressen: dieselbe Bestätigung, keine Mail
-4
View File
@@ -46,11 +46,7 @@
<option value="">Bitte wählen</option>
<option value="was-bleibt-vom-menschen">Was bleibt vom Menschen?</option>
<option value="ki-hinterm-vorhang">Die KI hinter dem Vorhang</option>
<option value="claude-code-harness">Der Claude-Code-Harness</option>
<option value="copilot-cli-harness">Der Copilot-CLI-Harness</option>
<option value="codex-harness">Der Codex-Harness</option>
<option value="coding-agents-praxis">Coding Agents in der Praxis</option>
<option value="agents-angular-spring-quarkus">Agents mit Angular, Spring Boot &amp; Quarkus</option>
</select>
<div class="form-actions">
@@ -9,6 +9,7 @@ book_fact_cutoff: "2026-08" # August 2026
companion_version: "0.1.0"
material_version: "material-2026-08-08"
status: current # current | drift | stale
listed: false # Hub + /beziehen.html; Projektseiten bleiben
next_review_due: "2026-09-08"
contact_email: "agents-angular-spring-quarkus@maatini.space"
# Companion zu Coding Agents in der Praxis; Golden Path: GitHub Copilot
+1
View File
@@ -10,6 +10,7 @@ claude_code_tested: "2.1.220"
companion_version: "0.1.2"
material_version: "material-2026-07-29"
status: stale # current | drift | stale
listed: false # Hub + /beziehen.html; Projektseiten bleiben
# stale: letzte CLI-Prüfung 2026-07-29, >90 Tage (Markierung 2026-10-30), keine Nachprüfung.
next_review_due: "2026-08-29"
docs_url: "https://code.claude.com/docs"
+1
View File
@@ -10,6 +10,7 @@ codex_cli_tested: "0.146.0"
companion_version: "0.1.1"
material_version: "material-2026-07-31"
status: stale # current | drift | stale
listed: false # Hub + /beziehen.html; Projektseiten bleiben
# stale: letzte CLI-Prüfung 2026-07-31, >90 Tage (Markierung 2026-10-30), keine Nachprüfung.
next_review_due: "2026-08-31"
docs_url: "https://learn.chatgpt.com/codex"
+1
View File
@@ -10,6 +10,7 @@ copilot_cli_tested: "1.0.76"
companion_version: "0.1.1"
material_version: "material-2026-07-30"
status: stale # current | drift | stale
listed: false # Hub + /beziehen.html; Projektseiten bleiben
# stale: letzte CLI-Prüfung 2026-07-30, >90 Tage (Markierung 2026-10-30), keine Nachprüfung.
next_review_due: "2026-08-30"
docs_url: "https://docs.github.com/en/copilot/how-tos/copilot-cli"
+45 -1
View File
@@ -64,8 +64,52 @@ def load_allowlist(path: Path) -> set[tuple[str, str]]:
return pairs
def _labels(host: str) -> list[str] | None:
parts = host.split(".")
if len(parts) < 2:
return None
if any(not p or "*" in p for p in parts):
return None
return parts
def _domain_suffix_match(domain: str, suffix: str, *, extra_labels: bool) -> bool:
want = _labels(suffix)
got = _labels(domain)
if want is None or got is None:
return False
if extra_labels:
if len(got) < len(want):
return False
return got[-len(want) :] == want
return got == want
def pattern_matches(pattern: str, email: str) -> bool:
pattern = normalize_email(pattern)
email = normalize_email(email)
if "*" not in pattern:
return pattern == email
if "@" not in email or "*" in email:
return False
local, domain = email.rsplit("@", 1)
if not local or not domain:
return False
if pattern.startswith("*@"):
return _domain_suffix_match(domain, pattern[2:], extra_labels=False)
if pattern.startswith("*."):
return _domain_suffix_match(domain, pattern[2:], extra_labels=True)
return False
def is_allowed(pairs: set[tuple[str, str]], email: str, slug: str) -> bool:
return (normalize_email(email), slug) in pairs
email_n = normalize_email(email)
if (email_n, slug) in pairs:
return True
for pattern, pslug in pairs:
if pslug == slug and pattern_matches(pattern, email_n):
return True
return False
def _b64url(data: bytes) -> str:
+33
View File
@@ -50,6 +50,29 @@ class AllowlistTests(unittest.TestCase):
self.assertFalse(server.is_allowed(pairs, "ada@example.com", "codex-harness"))
self.assertFalse(server.is_allowed(pairs, "other@example.com", "claude-code-harness"))
def test_star_at_domain_matches_local_part_on_exact_domain(self):
pairs = {("*@edeka.de", "was-bleibt-vom-menschen")}
self.assertTrue(server.is_allowed(pairs, "max@edeka.de", "was-bleibt-vom-menschen"))
self.assertTrue(server.is_allowed(pairs, "Max@Edeka.DE", "was-bleibt-vom-menschen"))
self.assertFalse(server.is_allowed(pairs, "max@markt.edeka.de", "was-bleibt-vom-menschen"))
self.assertFalse(server.is_allowed(pairs, "max@edeka.de", "coding-agents-praxis"))
def test_star_dot_domain_matches_apex_and_subdomains(self):
pairs = {("*.edeka.de", "was-bleibt-vom-menschen")}
self.assertTrue(server.is_allowed(pairs, "max@edeka.de", "was-bleibt-vom-menschen"))
self.assertTrue(server.is_allowed(pairs, "max@markt.edeka.de", "was-bleibt-vom-menschen"))
self.assertFalse(server.is_allowed(pairs, "max@not-edeka.de", "was-bleibt-vom-menschen"))
self.assertFalse(server.is_allowed(pairs, "max@edeka.de.evil.com", "was-bleibt-vom-menschen"))
self.assertFalse(server.is_allowed(pairs, "max@example.com", "was-bleibt-vom-menschen"))
def test_broad_patterns_never_match(self):
for pattern in ("*", "*.de", "*@de", "*@*", "*.*", "*@"):
pairs = {(pattern, "was-bleibt-vom-menschen")}
self.assertFalse(
server.is_allowed(pairs, "max@edeka.de", "was-bleibt-vom-menschen"),
msg=pattern,
)
class TokenTests(unittest.TestCase):
def test_roundtrip(self):
@@ -112,6 +135,16 @@ class AppTests(unittest.TestCase):
self.assertTrue(url.startswith("https://books.maatini.space/d/"))
self.assertIn("Claude", title)
def test_domain_pattern_sends_mail_to_matching_address(self):
self.allowlist.write_text("*.edeka.de was-bleibt-vom-menschen\n", encoding="utf-8")
(self.books / "was-bleibt-vom-menschen.pdf").write_bytes(b"%PDF-edeka")
result = self.app.request_download("max@markt.edeka.de", "was-bleibt-vom-menschen", "203.0.113.9")
self.assertEqual(result.status, 200)
self.assertEqual(result.message, MESSAGE)
self.assertEqual(len(self.sent), 1)
self.assertEqual(self.sent[0][0], "max@markt.edeka.de")
self.assertEqual(self.sent[0][1], "was-bleibt-vom-menschen")
def test_unknown_email_same_message_no_mail(self):
result = self.app.request_download("nobody@example.com", "claude-code-harness", "203.0.113.9")
self.assertEqual(result.status, 200)
-49
View File
@@ -62,42 +62,6 @@
<a class="btn btn--primary" href="/projects/ki-hinterm-vorhang/">Zum Begleit-Service →</a>
</div>
</li>
<li class="card">
<h2><a href="/projects/claude-code-harness/">Der Claude-Code-Harness</a></h2>
<p>
Begleit-Service: Stand &amp; Drift, Errata, Vorlagen/Beispiele (ZIP),
schnelle Hilfe und Feedback zur 1.&nbsp;Auflage 2026.
</p>
<div class="card__meta">
<span class="tag">claude-code-harness</span>
<span class="tag tag--stale">companion-0.1.2</span>
<a class="btn btn--primary" href="/projects/claude-code-harness/">Zum Begleit-Service →</a>
</div>
</li>
<li class="card">
<h2><a href="/projects/copilot-cli-harness/">Der Copilot-CLI-Harness</a></h2>
<p>
Begleit-Service: Stand &amp; Drift, Errata, Vorlagen/Beispiele (ZIP),
schnelle Hilfe und Feedback zur 1.&nbsp;Auflage 2026.
</p>
<div class="card__meta">
<span class="tag">copilot-cli-harness</span>
<span class="tag tag--stale">companion-0.1.1</span>
<a class="btn btn--primary" href="/projects/copilot-cli-harness/">Zum Begleit-Service →</a>
</div>
</li>
<li class="card">
<h2><a href="/projects/codex-harness/">Der Codex-Harness</a></h2>
<p>
Begleit-Service: Stand &amp; Drift, Errata, Vorlagen/Beispiele (ZIP),
schnelle Hilfe und Feedback zur 1.&nbsp;Auflage 2026.
</p>
<div class="card__meta">
<span class="tag">codex-harness</span>
<span class="tag tag--stale">companion-0.1.1</span>
<a class="btn btn--primary" href="/projects/codex-harness/">Zum Begleit-Service →</a>
</div>
</li>
<li class="card">
<h2><a href="/projects/coding-agents-praxis/">Coding Agents in der Praxis</a></h2>
<p>
@@ -111,19 +75,6 @@
<a class="btn btn--primary" href="/projects/coding-agents-praxis/">Zum Begleit-Service →</a>
</div>
</li>
<li class="card">
<h2><a href="/projects/agents-angular-spring-quarkus/">Agents mit Angular, Spring Boot &amp; Quarkus</a></h2>
<p>
Begleit-Service: Stand &amp; Drift, Errata, Stack-Vorlagen/Checklisten (ZIP),
Command-Referenz &amp; Agent-Mapping, schnelle Hilfe und Feedback zur 1.&nbsp;Auflage 2026
(Companion; Golden Path Copilot).
</p>
<div class="card__meta">
<span class="tag">agents-angular-spring-quarkus</span>
<span class="tag tag--ok">companion-0.1.0</span>
<a class="btn btn--primary" href="/projects/agents-angular-spring-quarkus/">Zum Begleit-Service →</a>
</div>
</li>
</ul>
</main>
+17 -3
View File
@@ -1,5 +1,19 @@
# Eine Zeile: E-Mail Leerzeichen Buch-Slug
# Eine Zeile: E-Mail oder Pattern, Leerzeichen, Buch-Slug
# Kopieren nach private/allowlist.txt (liegt nicht im Git).
# Sichtbar unter /beziehen.html:
#
# Exact: ada@example.com slug
# Domain: *@edeka.de slug (nur @edeka.de)
# Suffix: *.edeka.de slug (@edeka.de und Subdomains, z. B. @markt.edeka.de)
# Zu breit und wirkungslos: * *.de *@de *@*
ada@example.com claude-code-harness
ada@example.com copilot-cli-harness
ada@example.com was-bleibt-vom-menschen
ada@example.com ki-hinterm-vorhang
ada@example.com coding-agents-praxis
*.edeka.de was-bleibt-vom-menschen
# Nicht auf dem Hub, Fulfill akzeptiert die Slugs weiter:
# ada@example.com claude-code-harness
# ada@example.com copilot-cli-harness
# ada@example.com codex-harness
# ada@example.com agents-angular-spring-quarkus
+11 -1
View File
@@ -104,11 +104,21 @@ def hub_pattern(slug: str) -> re.Pattern[str]:
)
def is_listed(st: dict[str, str]) -> bool:
return st.get("listed", "true").lower() not in ("false", "no", "0")
def sync_hub(html: str, projects: dict[str, dict[str, str]]) -> str:
for slug, st in projects.items():
pat = hub_pattern(slug)
if not is_listed(st):
if pat.search(html):
raise SystemExit(
f"index.html: Karte für ungelistetes {slug} – entfernen oder listed: true"
)
continue
tag_mod, _label = STATUS_UI[st["status"]]
companion = st["companion_version"]
pat = hub_pattern(slug)
if not pat.search(html):
raise SystemExit(f"index.html: keine Karte für {slug}")
html = pat.sub(rf"\g<1>{tag_mod}\g<3>{companion}\g<4>", html)