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:
1 parent
c6471c9a5a
commit
3ee30db4a6
12 files changed
+134
-63
No files matched your search
+10
-4
@@ -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).
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 & 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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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 & Drift, Errata, Vorlagen/Beispiele (ZIP),
|
||||
schnelle Hilfe und Feedback zur 1. 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 & Drift, Errata, Vorlagen/Beispiele (ZIP),
|
||||
schnelle Hilfe und Feedback zur 1. 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 & Drift, Errata, Vorlagen/Beispiele (ZIP),
|
||||
schnelle Hilfe und Feedback zur 1. 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 & Quarkus</a></h2>
|
||||
<p>
|
||||
Begleit-Service: Stand & Drift, Errata, Stack-Vorlagen/Checklisten (ZIP),
|
||||
Command-Referenz & Agent-Mapping, schnelle Hilfe und Feedback zur 1. 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>
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user