Compare commits

...
12 Commits
Author SHA1 Message Date
ClaudeandClaude Opus 5.5 594aa9196a Datenschutz Weg B: PDF aus der freigegebenen Fassung (Stand 22.10.2026) gedruckt
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 16:01:19 +00:00
ClaudeandClaude Sonnet 5.5 3b5e1d559a Datenschutz Weg B (E4): Forensatz sagt "sind angewiesen" statt "nehmen auf"
Der Hinweis in der E-Mail zu einem Forenbeitrag ist eine Arbeitsanweisung. Der Dienst legt den Baustein nicht in den Pult-Eintrag und
erfasst keine von Hand geschriebene Mail; der Text sagt es darum wie im Absatz davor: Die Mitarbeiter sind angewiesen, den Hinweis
aufzunehmen. Der Test verlangt den neuen Satz und verbietet den alten. Kommentar zur Forenerkennung des Dienstes nachgezogen.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 15:41:20 +00:00
ClaudeandClaude Sonnet 5.5 c6e3c47be7 Datenschutz Weg B (E4): Beiträge aus Frage-Foren beantworten wir nicht im Forum
Abschnitt 11 sagt, was der Dienst seit E4 tut: Für Beiträge in den Frage-Foren (gutefrage.net,
forum-hilfe.de, wer-weiss-was.de, wpde.org) entwirft die KI keine Antwort; ein Mitarbeiter sieht
den Beitrag an und meldet sich höchstens per E-Mail, wenn der Beitrag ausdrücklich um Angebote
per E-Mail bittet (mit dem Hinweis am Ende), sonst nicht. Der Satz über öffentlich sichtbare
Antworten in Frage-Foren entfällt, Abschnitt 14 sagt "höchstens eine Antwort". Der Test pinnt
die Sätze, die Forenliste (gleich in Quelle und Regel) und dass nirgends steht, wir antworteten
im Forum.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 15:26:18 +00:00
ClaudeandClaude Sonnet 5.5 b73cd340bc Datenschutz Weg B (E2): aussortierte Anfragen spätestens am Tag nach dem Fund kürzen
Abschnitt 11 sagt jetzt, was der Dienst seit Regel C tut: Anfragen, die die KI als nicht
passend aussortiert, werden spätestens am Tag nach dem Fund gekürzt (erster täglicher
Kürzlauf nach dem Berliner Kalendertag des Fundes). 30 Tage gelten nur noch nach einer
Absage, gerechnet ab dem Lesen. Der Zweck "Mitarbeiter sehen nach, ob die KI zu Unrecht
aussortiert hat" und der Satz "Bis dahin speichern wir sie" entfallen. Die Fristenliste und
der Test legal_datenschutz_anfragen sind angepasst (Liste wörtlich, nur noch ein Eintrag mit
30 Tagen, entfallene Sätze verboten).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 15:24:28 +00:00
ClaudeandClaude Sonnet 5.5 53ae593860 Datenschutz Weg B: zwei Sätze lesbarer (Plattform-Hinweise, Firmenadresse im Widerspruchskasten)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 14:47:06 +00:00
ClaudeandClaude Sonnet 5.5 fd5994c243 Datenschutz Weg B nach zweiter Prüfung: Firmen-Domain, Postfach, Zwecke, verständlichere Fristen
- Prüfwerte nennen die Firmen-Domain (bei Firmenadressen, nicht bei Freemail); der Widerspruchskasten sagt, dass die Sperre dann auch die Domain trifft
- Postfach: der Dienst lädt jede neue Mail zur Prüfung des Absenders, gespeichert werden nur die Mails der Plattformen
- Name: "Wir lesen Ihren Namen nicht gesondert aus"; Zweck der 30 Tage (Fehlurteile der KI prüfen) und der Reste (Auswertung) genannt
- Hinweis: Mitarbeiter nehmen ihn in jede Antwort auf, auch in selbst geschriebene; Frage-Foren: Antwort öffentlich sichtbar
- Widerspruchskasten nennt die Plattform-Mail im Postfach (bis 90 Tage nach Eingang)
- Verständlich: "Fund" erklärt, Fristen als Liste, robots.txt umschrieben, Absätze höchstens 130 Wörter; Hinweis im Dienst folgt (02a0b3c, Nachfolger)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 14:45:32 +00:00
ClaudeandClaude Sonnet 5.5 5c49f63616 Datenschutz Weg B: Alarm-Postfach bei Hetzner, Sicherungen bis zu sieben Tage, keine [OFFEN]-Markierung mehr
Abschnitt 11: Das Alarm-Postfach ist support@webklar.com bei der Hetzner Online GmbH (Abschnitt 8); der Dienst liest dort nur den Posteingang
und übernimmt nur die E-Mails der Plattformen; diese löschen wir im Postfach spätestens 90 Tage nach dem Eingang (keine "Postfach-Regel"
versprochen). Gekürzte Daten bleiben in den Datensicherungen bis zu sieben Tage, wie im übrigen Text. Der Hinweis in der Antwort heißt jetzt
"Am Ende unserer Antwort steht ein Hinweis" und nennt, dass die Mitarbeiter angewiesen sind, ihn beim Bearbeiten nicht zu entfernen; er ist
wörtlich auf Claras Hinweis im Dienst abgestimmt. Abschnitt 12: Hetzner nennt das Postfach, der eigene Listenpunkt entfällt.

Tests: jede [OFFEN]-Markierung (auch im Kommentar) und jeder Span class="offen" macht legalTexts rot; Pins für die neuen Sätze. Das Upload-Skript
bricht weiter ab, solange eine Markierung steht. pdf_pruefsumme bleibt rot bis zum Neudruck des PDF am Pushtag.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 14:18:18 +00:00
ClaudeandClaude Sonnet 5.5 23ab43f9fe Datenschutz Weg B nach Prüfung: Zusagen auf den Dienst begrenzt, [OFFEN]-Markierungen, Upload-Sperren
Abschnitt 11: Sperre und Kürzung ab dem Lesen unserer Antwort, nur je Beitrag und E-Mail-Adresse (neues Gesuch = neuer Beitrag);
aussortierte Anfragen 30 Tage; feste Ausschlussliste der Websuche samt robots.txt; Kriterium für die Dauer der Reste; Kopie der
Standardvertragsklauseln; Hinweis in der ersten Nachricht nennt den Widerspruch; Abschnitt 11 und 14: die KI sortiert aus, ein
Mitarbeiter entscheidet über jede Antwort. Alarm-Postfach-Anbieter und Aufbewahrung der Server-Sicherungen stehen als sichtbare
[OFFEN]-Markierung im Text (auch in Abschnitt 12).

Tests: Pins angepasst, Markierungen nur die zwei erlaubten, neuer Test für Zusagen in Reichweite des Dienstes. Upload-Skript bricht ab,
solange eine [OFFEN]-Markierung steht oder die Prüfsumme neben dem PDF nicht zur HTML passt.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 13:57:18 +00:00
ClaudeandClaude Sonnet 5.5 b3ca84a8e5 Datenschutz-Upload: Vermerk "Abschnitt 11 Interessenten (Antworten auf öffentliche Anfragen)", Test prüft den vollen Wortlaut
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 13:10:22 +00:00
ClaudeandClaude Sonnet 5.5 6a25df22fb Tests: legal_datenschutz_anfragen (Abschnitt 11 Weg B, Stand 22.10.2026), Titelliste und Stand-Pin angepasst
Drei neue Tests: tragende Wendungen von Abschnitt 11, keine Stufe-2-Reste und keine offenen Markierungen, Abschnitt 2, 12 und 14 passend zu 11.
Der alte Pin "Stand 17.10.2026" prüft nur noch "nicht vor der Reichweitenmessung". Die Titelliste kennt die neuen Überschriften von 11 und 14.
pdf_pruefsumme bleibt rot, bis das PDF neu gedruckt ist.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 13:10:22 +00:00
ClaudeandClaude Sonnet 5.5 e554071739 Datenschutz Stand 22.10.2026: Abschnitt 11 Interessenten nur für den Anfrage-Weg (Weg B), Anker #interessenten
Abschnitt 11 beschreibt nur, was heute läuft: öffentliche Gesuche (Alarm-E-Mails der Plattformen, Websuche), KI bewertet und entwirft, ein
Mitarbeiter sendet einmal von Hand. Daten, Rechtsgrundlage, Anthropic, Fristen (90/30 Tage), Hash-Werte, Kopien und Widerspruch.
Abschnitt 2 (auch Privatpersonen aus Foren), 12 (Anthropic-Zweck, Plattformen) und 14 (keine Einzelentscheidung nach Art. 22) angepasst.
Kein Resend, Cloudflare, Overpass und kein Kaltmail-Weg: die volle Fassung bleibt auf feature/workers-datenschutz für Stufe 2.
PDF und Prüfsumme sind noch nicht neu gedruckt.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 13:10:22 +00:00
Claude 0fbeadd0c1 Rechtliches: Skript zum Hochladen der Datenschutzerklärung als neue Version
Der Ablauf des Vorbilds (hochladen) ist jetzt eine exportierte, parametrisierte Funktion; datenschutz-hochladen.mjs ruft sie mit Karte datenschutz, PDF und HTML auf und bricht ab, wenn der Stand in der HTML nicht der Freigabe entspricht.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
(cherry picked from commit 06676c459d0ac4f534aed7d616e2c0d40ac97b97)
2026-10-22 13:03:30 +00:00
7 changed files with 493 additions and 29 deletions

No files matched your search

Binary file not shown.
@@ -1 +1 @@
d9e43fa83fa9cb6fd40937df034068891409dbdaa98061b5dd3b0c1e51cca1ea 21d406e6836a7d0c1afdb99dc259e4174fb3fe499afcbdac30c8a45a1aa8d605
+37 -7
View File
@@ -44,9 +44,9 @@
<div class="brand">WEBKLAR</div> <div class="brand">WEBKLAR</div>
<h1>Datenschutzerklärung</h1> <h1>Datenschutzerklärung</h1>
<p class="meta">Stand 17.10.2026</p> <p class="meta">Stand 22.10.2026</p>
<p>Hier erfahren Sie, welche personenbezogenen Daten wir verarbeiten, wenn Sie unsere Websites besuchen, unser Kundenportal nutzen oder mit uns einen Vertrag schließen, und welche Rechte Sie haben.</p> <p>Hier erfahren Sie, welche personenbezogenen Daten wir verarbeiten, wenn Sie unsere Websites besuchen, unser Kundenportal nutzen, mit uns einen Vertrag schließen oder wenn wir auf Ihre öffentliche Anfrage nach einer Website antworten, und welche Rechte Sie haben.</p>
<h2>1. Verantwortlicher</h2> <h2>1. Verantwortlicher</h2>
<p>WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, vertreten durch die Gesellschafter Kenso Grimm und Justin Klein<br> <p>WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, vertreten durch die Gesellschafter Kenso Grimm und Justin Klein<br>
@@ -55,6 +55,7 @@
<h2>2. Geltungsbereich</h2> <h2>2. Geltungsbereich</h2>
<p>Diese Erklärung gilt für die Website webklar.com, für das Kundenportal project.webklar.com einschließlich der Vorschauseiten unter *.project.webklar.com und für unsere Leistungen, insbesondere unsere Abos (Hosting-Abo und Wartungs-Abo).</p> <p>Diese Erklärung gilt für die Website webklar.com, für das Kundenportal project.webklar.com einschließlich der Vorschauseiten unter *.project.webklar.com und für unsere Leistungen, insbesondere unsere Abos (Hosting-Abo und Wartungs-Abo).</p>
<p>Die Erklärung gilt auch für Menschen und Betriebe, die im Netz öffentlich nach einer Website suchen und auf deren Anfrage wir antworten (Abschnitt 11). Das können auch Privatpersonen sein, zum Beispiel in Frage-Foren.</p>
<p>Websites, die wir im Auftrag unserer Kunden betreiben, verantwortet der jeweilige Kunde. Dort verarbeiten wir Daten als Auftragsverarbeiter nach den Weisungen des Kunden. Informationen dazu finden Sie in der Datenschutzerklärung der jeweiligen Website.</p> <p>Websites, die wir im Auftrag unserer Kunden betreiben, verantwortet der jeweilige Kunde. Dort verarbeiten wir Daten als Auftragsverarbeiter nach den Weisungen des Kunden. Informationen dazu finden Sie in der Datenschutzerklärung der jeweiligen Website.</p>
<h2>3. Hosting, Server-Protokolle und Überwachung der Erreichbarkeit</h2> <h2>3. Hosting, Server-Protokolle und Überwachung der Erreichbarkeit</h2>
@@ -141,14 +142,43 @@
<h2>10. Domains unserer Kunden</h2> <h2>10. Domains unserer Kunden</h2>
<p>Registrieren wir für Sie eine Domain, geben wir die Daten des Domaininhabers (Name, Anschrift, E-Mail-Adresse, Telefonnummer) an den Domain-Registrar, die Hetzner Online GmbH, und an die zuständige Vergabestelle weiter, bei .de-Domains an die DENIC eG in Frankfurt am Main. Sie verarbeiten die Daten als eigene Verantwortliche. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO.</p> <p>Registrieren wir für Sie eine Domain, geben wir die Daten des Domaininhabers (Name, Anschrift, E-Mail-Adresse, Telefonnummer) an den Domain-Registrar, die Hetzner Online GmbH, und an die zuständige Vergabestelle weiter, bei .de-Domains an die DENIC eG in Frankfurt am Main. Sie verarbeiten die Daten als eigene Verantwortliche. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO.</p>
<h2>11. Interessenten</h2> <a id="interessenten"></a>
<p>Sprechen wir Unternehmen als mögliche Kunden an, verarbeiten wir öffentlich verfügbare Geschäftsdaten: Firmenname, Ansprechpartner, geschäftliche Kontaktdaten und Angaben zur bestehenden Website. Quellen sind etwa die Website des Unternehmens und öffentliche Branchenverzeichnisse. Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO. Unser berechtigtes Interesse liegt in der Anbahnung von Geschäftsbeziehungen. Besteht kein Interesse oder widersprechen Sie, löschen wir die Daten. Ohne Kontakt löschen wir sie spätestens nach zwölf Monaten.</p> <h2>11. Interessenten: Antworten auf öffentliche Anfragen</h2>
<p>Manche Menschen und Betriebe suchen öffentlich im Netz jemanden, der ihnen eine Website baut, zum Beispiel in einer Kleinanzeige, auf einem Portal für Freelancer oder in einem Frage-Forum. Auf solche Gesuche melden wir uns von uns aus nur einmal und bieten unsere Leistungen an. Eine Ausnahme sind Frage-Foren: Dort antworten wir nicht im Forum (siehe unten). Weil wir Ihre Daten dabei nicht bei Ihnen erheben, sondern aus Ihrem öffentlichen Beitrag, informieren wir Sie hier und mit einem Hinweis am Ende unserer Antwort darüber (Art. 14 DSGVO).</p>
<p>So läuft es ab: Ein KI-Assistent (das Sprachmodell Claude von Anthropic) bewertet, ob die Anfrage zu unseren Leistungen passt, und entwirft eine Antwort (für Beiträge aus Frage-Foren nicht, siehe unten). Ein Mitarbeiter von WEBklar liest den Entwurf, ändert ihn bei Bedarf und sendet ihn einmal von Hand: über die Plattform, auf der Sie gefragt haben, oder per E-Mail, wenn Ihr Beitrag darum bittet. Automatische E-Mails verschicken wir nicht. Für E-Mails gilt Abschnitt 8. Passt Ihre Anfrage nach der Bewertung der KI nicht zu uns, schreiben wir Ihnen nicht und kürzen die Daten spätestens am Tag nach dem Fund (das ist der Tag, an dem wir Ihre Anfrage gefunden haben; siehe unten).</p>
<p>Am Ende unserer Antwort steht ein Hinweis. Er sagt, dass eine KI-Assistentin von WEBklar antwortet, dass wir die Angaben aus Ihrer Anfrage spätestens 90 Tage nach dem Tag, an dem wir Ihre Anfrage gefunden haben, in unserer Datenbank kürzen, das heißt, Titel, Text, Link und E-Mail-Adresse löschen, und er verweist auf diesen Abschnitt. Außerdem nennt er Ihr Widerspruchsrecht: Wenn Sie keine weitere Nachricht von uns möchten oder der Speicherung widersprechen, antworten Sie uns bitte kurz; sobald wir Ihre Antwort lesen, schreiben wir Ihnen zu dieser Anfrage nicht mehr und kürzen Ihre Daten (bei einem Widerspruch sofort, sonst innerhalb von 30 Tagen). Unsere Mitarbeiter sind angewiesen, diesen Hinweis in jede Antwort aufzunehmen, auch in eine selbst geschriebene, und beim Bearbeiten eines Entwurfs nicht zu entfernen.</p>
<p>Beiträge in Frage-Foren (derzeit gutefrage.net, forum-hilfe.de, wer-weiss-was.de und wpde.org) beantworten wir nicht im Forum. Die KI entwirft dafür keine Antwort. Ein Mitarbeiter sieht den Beitrag an und meldet sich höchstens per E-Mail, wenn der Beitrag ausdrücklich um Angebote per E-Mail bittet; unsere Mitarbeiter sind angewiesen, in diese E-Mail am Ende den Hinweis aufzunehmen, der oben beschrieben ist. Bittet der Beitrag nicht darum, melden wir uns nicht.</p>
<p>Die Gesuche finden wir auf zwei Wegen. Erstens schicken uns Plattformen auf unseren Suchauftrag Benachrichtigungs-E-Mails (derzeit Kleinanzeigen, freelancermap und freelance.de); diese Plattformen rufen wir nicht selbst ab. Zweitens findet ein KI-Assistent mit einer Websuche öffentliche Beiträge im Netz und in Frage-Foren, derzeit in den Foren gutefrage.net, forum-hilfe.de, wer-weiss-was.de und wpde.org sowie auf anderen öffentlichen Webseiten. Die Suche und den Abruf der Seiten führt Anthropic aus. Von der Websuche und vom Abruf ausgenommen sind die Plattformen Kleinanzeigen, freelancermap, freelance.de, Facebook, Instagram, LinkedIn, Xing, Reddit, Upwork, Fiverr und MyHammer sowie Seiten, deren Betreiber automatische Abrufe in der Datei robots.txt untersagen. Wir suchen nach Gesuchen, nicht nach bestimmten Personen.</p>
<p>Wir speichern den Titel des Beitrags, den Beitragstext (höchstens 4000 Zeichen), den Link, das Datum, Ort und Postleitzahl, wenn sie genannt sind, die Plattform, eine Punktzahl mit kurzer Begründung und, wenn der Beitrag um eine Antwort per E-Mail bittet, die E-Mail-Adresse aus dem Beitrag. Wir lesen Ihren Namen nicht gesondert aus; steht er im Titel oder im Text, speichern wir ihn damit. Telefonnummern entfernen wir beim Speichern automatisch aus dem Text; das folgt einer Faustregel und erkennt nicht jede Schreibweise. Dazu kommen der Entwurf unserer Antwort, die gesendete Nachricht mit Text und Zeitpunkt und ein Protokoll der einzelnen Arbeitsschritte, zum Beispiel „Neue Anfrage: Titel des Beitrags“.</p>
<p>Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO. Unser berechtigtes Interesse ist, auf öffentliche Gesuche nach Website-Leistungen zu antworten. Wir melden uns je Anfrage von uns aus nur einmal, und Sie können der Verarbeitung jederzeit widersprechen.</p>
<p>Wir speichern die Daten auf unserem Server bei der Hetzner Online GmbH in Deutschland (Abschnitt 3). Für die Bewertung, den Entwurf und die Websuche setzen wir das Sprachmodell Claude der Anthropic Ireland, Limited, Dublin, Irland, als Auftragsverarbeiter ein. Anthropic erhält dafür den Titel, den Text und den Ort des Beitrags sowie bei der Websuche unsere Suchaufträge und die gefundenen Seiten. Dabei können Daten in die USA übermittelt werden, abgesichert durch Standardvertragsklauseln (Art. 46 Abs. 2 lit. c DSGVO); eine Kopie erhalten Sie bei uns (support@webklar.com).</p>
<p>Die KI sortiert Anfragen aus, die nicht zu uns passen; auf diese schreiben wir nicht. Ob und was wir einer passenden Anfrage antworten, entscheidet ein Mitarbeiter (Abschnitt 14).</p>
<p>Wie lange wir die Daten speichern: Wir kürzen Ihre Daten in unserer Datenbank</p>
<ul>
<li>spätestens 90 Tage nach dem Fund;</li>
<li>spätestens am Tag nach dem Fund, wenn Ihre Anfrage nicht zu uns passt;</li>
<li>innerhalb von 30 Tagen, nachdem wir Ihre Antwort gelesen haben, wenn Sie uns sagen, dass Sie kein Interesse haben;</li>
<li>sofort, wenn Sie widersprechen: Wir sperren und kürzen sie, sobald wir Ihren Widerspruch lesen.</li>
</ul>
<p>Kürzen heißt: Wir löschen Titel, Beitragstext, Link, E-Mail-Adresse, Entwurf und unsere Nachricht an Sie und bereinigen die zugehörigen Einträge im Protokoll. Übrig bleiben nur pseudonymisierte Prüfwerte (Hash-Werte) des Beitrags, Ihrer E-Mail-Adresse und, wenn es eine Firmenadresse ist (also nicht bei Anbietern wie gmail.com oder web.de), der Domain dieser Adresse, das ist der Teil hinter dem @, zum Beispiel firma.de. Dazu bleiben Ort, Plattform, Punktzahl, Status und die Zeitpunkte von Beitrag und Fund, jedoch ohne Titel, Text, Link und Kontaktdaten.</p>
<p>Ein Hash-Wert ist ein Prüfwert, aus dem sich der ursprüngliche Inhalt nicht ablesen lässt. Wir erkennen damit nur, wenn derselbe Beitrag, dieselbe E-Mail-Adresse oder, bei einer Firmenadresse, eine andere Adresse derselben Firma noch einmal auftaucht; wir schreiben dann nicht noch einmal und nehmen den Beitrag nicht noch einmal auf. Diese Prüfwerte bewahren wir so lange auf, wie wir verhindern müssen, dass wir Sie wegen desselben Beitrags oder derselben E-Mail-Adresse (bei einer Firmenadresse: derselben Firma) erneut ansprechen. Ort, Plattform, Punktzahl, Status und die Zeitpunkte behalten wir für unsere Auswertung, damit unsere Zähler und der zeitliche Verlauf stimmen.</p>
<p>Haben Sie uns geantwortet und übernehmen wir Ihre Angaben als Kontakt oder Kunde in unsere Kundenverwaltung, gelten für diese Daten stattdessen Abschnitt 5 und, wenn ein Vertrag zustande kommt, die Abschnitte 7 und 9.</p>
<p>Für Kopien außerhalb der Datenbank gilt Folgendes. Die Benachrichtigungs-E-Mails der Plattformen enthalten die Anzeigentexte und gehen in unserem Postfach support@webklar.com bei der Hetzner Online GmbH ein (Abschnitt 8). Unser Dienst liest dort nur den Posteingang. Er lädt dazu jede neue E-Mail dieses Postfachs kurz, um den Absender zu prüfen; übernommen und gespeichert werden nur die Benachrichtigungen der Plattformen, alle anderen E-Mails verwirft er sofort. Die Benachrichtigungs-E-Mails der Plattformen löschen wir im Postfach spätestens 90 Tage nach dem Eingang, auch wenn wir die Anfrage in der Datenbank schon früher gekürzt haben.</p>
<p>Eine E-Mail, die ein Mitarbeiter Ihnen sendet, liegt außerdem in unserem Postfach; dafür gilt Abschnitt 8 und, soweit die E-Mail ein Geschäftsbrief ist, die Frist aus Abschnitt 9. Kürzen wir Daten, bleiben sie in unseren Datensicherungen noch bis zu sieben Tage erhalten und werden dann überschrieben. Die Antwort, die wir auf einer Plattform abschicken, sehen auch deren Betreiber. Dafür gelten die Datenschutzhinweise der Plattform.</p>
<div class="box widerspruch">
<strong>Ihr Widerspruchsrecht gegen diese Verarbeitung (Art. 21 DSGVO)</strong>
<p>Sie können der Verarbeitung Ihrer Daten jederzeit widersprechen, ohne Gründe anzugeben. Antworten Sie dazu auf unsere Nachricht oder schreiben Sie an support@webklar.com.</p>
<p>Sobald wir Ihre Nachricht lesen, sperren wir den Beitrag und, soweit wir sie gespeichert haben, Ihre E-Mail-Adresse, schreiben Ihnen dazu nicht mehr und kürzen Ihre Daten wie oben beschrieben. Ist es eine Firmenadresse und nicht die eines Anbieters wie gmail.com oder web.de, sperren wir auch deren Domain, also auch andere Adressen derselben Firma. Wir erkennen Sie dabei nur am Beitrag, an Ihrer E-Mail-Adresse und, bei einer Firmenadresse, an deren Domain: Ein neues Gesuch, das Sie später einstellen, ist für uns ein neuer Beitrag, wenn es keine dieser Angaben enthält; sagen Sie uns dann bitte noch einmal Bescheid.</p>
<p>Die Benachrichtigung der Plattform in unserem Postfach erfasst das Kürzen in der Datenbank nicht; wir löschen sie dort spätestens 90 Tage nach dem Eingang (siehe oben). Weitere Rechte (Art. 15 bis 20 DSGVO) und das Beschwerderecht beschreibt Abschnitt 13.</p>
</div>
<h2>12. Empfänger im Überblick</h2> <h2>12. Empfänger im Überblick</h2>
<ul> <ul>
<li>Hetzner Online GmbH: Hosting, E-Mail und Datensicherung (Auftragsverarbeiter)</li> <li>Hetzner Online GmbH: Hosting, E-Mail (auch das Postfach für die Benachrichtigungen der Plattformen, Abschnitt 11) und Datensicherung (Auftragsverarbeiter)</li>
<li>UptimeRobot s. r. o.: Überwachung der Erreichbarkeit, Daten auch außerhalb der EU (insbesondere USA), Auftragsverarbeiter (Abschnitt 3)</li> <li>UptimeRobot s. r. o.: Überwachung der Erreichbarkeit, Daten auch außerhalb der EU (insbesondere USA), Auftragsverarbeiter (Abschnitt 3)</li>
<li>Stripe: Zahlungsabwicklung und Eröffnung des Stripe-Kontos für den Webshop und den Buchungskalender unserer Kunden (Abschnitte 6d, 6f und 7)</li> <li>Stripe: Zahlungsabwicklung und Eröffnung des Stripe-Kontos für den Webshop und den Buchungskalender unserer Kunden (Abschnitte 6d, 6f und 7)</li>
<li>Anthropic Ireland, Limited: Bewertung öffentlicher Anfragen, Entwurf von Antworten sowie Websuche und Seitenabruf durch ein Sprachmodell (Auftragsverarbeiter, Daten auch in den USA, Abschnitt 11)</li>
<li>Plattformen, auf denen wir Ihre Anfrage beantworten, zum Beispiel Kleinanzeigen: Unsere Antwort läuft über deren Dienst (Abschnitt 11)</li>
<li>Porkbun LLC: bisheriger E-Mail-Dienst, nur noch bis zur Löschung älterer E-Mails (Abschnitt 8)</li> <li>Porkbun LLC: bisheriger E-Mail-Dienst, nur noch bis zur Löschung älterer E-Mails (Abschnitt 8)</li>
<li>Domain-Registrar und Vergabestellen, sofern wir eine Domain für Sie registrieren (Abschnitt 10)</li> <li>Domain-Registrar und Vergabestellen, sofern wir eine Domain für Sie registrieren (Abschnitt 10)</li>
<li>Steuerberatung und Behörden, soweit gesetzlich erforderlich</li> <li>Steuerberatung und Behörden, soweit gesetzlich erforderlich</li>
@@ -163,8 +193,8 @@
</div> </div>
<p>Sie können sich außerdem bei einer Datenschutz-Aufsichtsbehörde beschweren. Für uns zuständig ist der Landesbeauftragte für den Datenschutz und die Informationsfreiheit Rheinland-Pfalz, Hintere Bleiche 34, 55116 Mainz, poststelle@datenschutz.rlp.de.</p> <p>Sie können sich außerdem bei einer Datenschutz-Aufsichtsbehörde beschweren. Für uns zuständig ist der Landesbeauftragte für den Datenschutz und die Informationsfreiheit Rheinland-Pfalz, Hintere Bleiche 34, 55116 Mainz, poststelle@datenschutz.rlp.de.</p>
<h2>14. Pflicht zur Bereitstellung, keine automatisierten Entscheidungen</h2> <h2>14. Pflicht zur Bereitstellung, keine automatisierten Einzelentscheidungen nach Art. 22 DSGVO</h2>
<p>Für einen Vertrag mit uns brauchen wir die dafür nötigen Angaben. Ohne sie können wir den Vertrag nicht schließen. Darüber hinaus sind Sie nicht verpflichtet, uns Daten bereitzustellen. Automatisierte Entscheidungen einschließlich Profiling im Sinne von Art. 22 DSGVO finden nicht statt.</p> <p>Für einen Vertrag mit uns brauchen wir die dafür nötigen Angaben. Ohne sie können wir den Vertrag nicht schließen. Darüber hinaus sind Sie nicht verpflichtet, uns Daten bereitzustellen. Entscheidungen, die Ihnen gegenüber rechtliche Wirkung entfalten oder Sie in ähnlicher Weise erheblich beeinträchtigen (Art. 22 DSGVO), treffen wir nicht automatisiert. Die Bewertung öffentlicher Anfragen durch einen KI-Assistenten (Abschnitt 11) sortiert Anfragen aus, die nicht zu uns passen; auf diese schreiben wir nicht. Für alle anderen bereitet sie höchstens eine Antwort vor; ob und was wir antworten, entscheidet ein Mitarbeiter, der die Antwort von Hand abschickt.</p>
<h2>15. Änderungen</h2> <h2>15. Änderungen</h2>
<p>Wir passen diese Erklärung an, wenn sich unsere Verarbeitung oder die Rechtslage ändert. Es gilt die jeweils hier veröffentlichte Fassung.</p> <p>Wir passen diese Erklärung an, wenn sich unsere Verarbeitung oder die Rechtslage ändert. Es gilt die jeweils hier veröffentlichte Fassung.</p>
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env node
// Lädt die Datenschutzerklärung (PDF und HTML) nach der Freigabe als neue Version in WOMS „Rechtliches“ hoch (Karte datenschutz, Bucket
// legal-files, Collection legalVersions): neue Fassung "current", die bisherige gültige wird "superseded" mit validTo. Nur mit
// --freigabe=JJJJ-MM-TT; dieser Tag muss der „Stand TT.MM.JJJJ“ in public/datenschutz.html entsprechen. Schon hochgeladene Stände (gleiche source,
// also gleicher Commit) werden übersprungen. Den Ablauf teilt sich das Skript mit rechtliches-hochladen.mjs (Funktion hochladen).
// Zwei Sperren gegen eine unfertige Fassung (vor dem Schreiben): (1) steht im Text noch eine „[OFFEN: …]“-Markierung (offene Tatsache im
// Faktenblatt), (2) passt die Prüfsumme neben dem PDF (docs/WEBklar-Datenschutzerklaerung.pdf.sha256) nicht zur HTML, ist das PDF also nicht aus
// dieser HTML gedruckt (scripts/build-legal-pdfs.sh datenschutz), dann Abbruch.
//
// node scripts/datenschutz-hochladen.mjs --probe nur lesen: Schlüssel, Bucket, Karte datenschutz und ihre Versionen
// node scripts/datenschutz-hochladen.mjs --freigabe=JJJJ-MM-TT lädt die Datenschutzerklärung hoch (validFrom = Freigabedatum); ohne Karte,
// ohne beide Dateien, mit nicht committetem Stand oder mit abweichendem
// „Stand“ Abbruch vor dem Schreiben
//
// Schlüssel: kurzlebiger Appwrite-Schlüssel mit files.read/files.write/documents.read/documents.write in /root/.appwrite-rechtliches.key (chmod 600).
// Der Import dieser Datei liest weder den Schlüssel noch nutzt er das Netz; beides geschieht nur in main (deps.schluessel, deps.fetch).
// Dieses Skript wird nur geschrieben und mit Nachbau-deps getestet, nie von einem Agenten ausgeführt (auch nicht mit --probe).
import { createHash } from 'node:crypto'
import { readFileSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { BUCKET, KEY_DATEI, alleDokumente, client, ddmmjjjj, hochladen, istKalendertag, versionen } from './rechtliches-hochladen.mjs'
const DOKUMENT_KEY = 'datenschutz'
/** [PDF, HTML]: in dieser Reihenfolge hochgeladen. */
const DATEIEN = Object.freeze(['docs/WEBklar-Datenschutzerklaerung.pdf', 'public/datenschutz.html'])
const QUELLE = 'public/datenschutz.html'
/** Neben dem PDF: SHA-256 der HTML, aus der es gedruckt wurde (scripts/build-legal-pdfs.sh datenschutz). */
const PRUEFSUMME = 'docs/WEBklar-Datenschutzerklaerung.pdf.sha256'
/** Für Kunden sichtbar (WOMS „Rechtliches“): darum weder „Workers“ noch „Hustler“. */
const AENDERUNG = 'Abschnitt 11 Interessenten (Antworten auf öffentliche Anfragen)'
const USAGE = 'Aufruf: node scripts/datenschutz-hochladen.mjs --probe | --freigabe=JJJJ-MM-TT'
/**
* Kommandozeile prüfen: --probe allein oder genau eine --freigabe=JJJJ-MM-TT (ein echter Kalendertag).
* @returns {{ modus: 'probe'|'hochladen', freigabe: string|null }}
*/
export function parseArgs(argv) {
const fremde = argv.filter((a) => a !== '--probe' && !a.startsWith('--freigabe='))
if (fremde.length > 0) throw new Error(`${USAGE}\nUnbekanntes Argument: ${fremde[0].slice(0, 40)}`)
const probe = argv.filter((a) => a === '--probe')
const freigaben = argv.filter((a) => a.startsWith('--freigabe='))
if (probe.length > 0) {
if (probe.length > 1 || freigaben.length > 0) throw new Error(`${USAGE}\n--probe steht allein.`)
return { modus: 'probe', freigabe: null }
}
if (freigaben.length !== 1) throw new Error(`${USAGE}\nFreigabe fehlt: genau eine --freigabe=JJJJ-MM-TT ist beim Hochladen Pflicht.`)
const freigabe = freigaben[0].slice('--freigabe='.length)
if (!istKalendertag(freigabe)) throw new Error(`Freigabe ungültig (erwartet JJJJ-MM-TT, ein echter Kalendertag): ${freigabe.slice(0, 20)}`)
return { modus: 'hochladen', freigabe }
}
/** Der Tag hinter „Stand“ in der Kopfzeile der HTML (TT.MM.JJJJ), sonst null. */
const standIn = (html) => /<p class="meta">\s*Stand (\d{2}\.\d{2}\.\d{4})\s*<\/p>/.exec(html)?.[1] ?? null
/** Einstieg: deps = { fetch, schluessel, log, git, dateien } wie bei rechtliches-hochladen.mjs. @returns {Promise<number>} Exit-Code (0 oder 1) */
export async function main(argv, deps = {}) {
const log = deps.log ?? console.log
let args
try {
args = parseArgs(argv)
} catch (err) {
log(err.message)
return 1
}
try {
const fetchFn = deps.fetch ?? globalThis.fetch
const api = client(fetchFn, (deps.schluessel ?? (() => readFileSync(KEY_DATEI, 'utf8').trim()))())
if (args.modus === 'probe') return await probe(api, log)
const { freigabe } = args
return await hochladen(api, log, {
documentKey: DOKUMENT_KEY,
dateien: DATEIEN,
quelle: QUELLE,
changeNote: `Fassung vom ${ddmmjjjj(freigabe)}: ${AENDERUNG}`,
freigabe,
kartenHinweis: '(die Karte gehört zu WOMS „Rechtliches“; dieses Skript legt keine Karten an)',
pruefeInhalt: (lies) => {
const html = lies(QUELLE)
const stand = standIn(html.toString('utf8'))
if (!stand) return `Abbruch: „Stand TT.MM.JJJJ“ fehlt in ${QUELLE}`
if (stand !== ddmmjjjj(freigabe)) return `Abbruch: Stand ${stand} in ${QUELLE} passt nicht zur Freigabe ${ddmmjjjj(freigabe)}`
// offene Tatsachen sind kein veröffentlichungsfähiger Text: die Markierung zuerst durch die geklärte Angabe ersetzen
const offen = [...new Set(html.toString('utf8').replace(/<!--[\s\S]*?-->/g, '').match(/\[OFFEN[^\]]*\]/g) ?? [])]
if (offen.length > 0) return `Abbruch: offene Markierung im Text von ${QUELLE}: ${offen.join(', ')} (erst klären und ersetzen, dann Stand/Pin setzen, PDF drucken, pushen)`
// das PDF muss aus genau dieser HTML gedruckt sein
let summe = ''
try {
summe = lies(PRUEFSUMME).toString('utf8').trim()
} catch {
return `Abbruch: ${PRUEFSUMME} fehlt oder ist nicht lesbar (PDF mit scripts/build-legal-pdfs.sh datenschutz drucken)`
}
if (summe !== createHash('sha256').update(html).digest('hex')) return `Abbruch: das PDF ist nicht aus dieser HTML gedruckt (Prüfsumme in ${PRUEFSUMME} passt nicht zu ${QUELLE}); scripts/build-legal-pdfs.sh datenschutz`
return null
},
}, deps)
} catch (err) {
log(`Fehler: ${err.message}`)
return 1
}
}
/** --probe: nur lesen. Eine fehlende Karte ist ein Stopp. */
async function probe(api, log) {
await api('GET', `/storage/buckets/${BUCKET}/files`)
const karten = await alleDokumente(api, 'legalDocuments')
log(`${DOKUMENT_KEY}: ${(await versionen(api, DOKUMENT_KEY)).map((v) => `${v.version}:${v.status}`).join(' ') || '—'}`)
if (!karten.some((k) => k.key === DOKUMENT_KEY)) {
log(`STOPP: Karte fehlt: ${DOKUMENT_KEY}`)
return 1
}
log(`Schlüssel ok (files.read + documents.read), Karte ${DOKUMENT_KEY} vorhanden.`)
return 0
}
if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) {
main(process.argv.slice(2)).then((code) => {
process.exitCode = code
})
}
+39 -18
View File
@@ -23,8 +23,8 @@ import { FUNKTIONEN_UNTERLAGEN_V6 } from '../server/services/products.js'
const ENDPOINT = 'https://ticket.webklar.com/v1' const ENDPOINT = 'https://ticket.webklar.com/v1'
const PROJECT = '6a1058610003c5a13a05' const PROJECT = '6a1058610003c5a13a05'
const DB = 'woms-database' const DB = 'woms-database'
const BUCKET = 'legal-files' export const BUCKET = 'legal-files'
const KEY_DATEI = '/root/.appwrite-rechtliches.key' export const KEY_DATEI = '/root/.appwrite-rechtliches.key'
const ROOT = fileURLToPath(new URL('..', import.meta.url)) const ROOT = fileURLToPath(new URL('..', import.meta.url))
const LIVE = 'https://project.webklar.com/' const LIVE = 'https://project.webklar.com/'
@@ -81,7 +81,7 @@ export function dokumentFuer(documentKey) {
const MODI = { '--probe': 'probe', '--karten-anlegen': 'karten', '--pruefen': 'pruefen' } const MODI = { '--probe': 'probe', '--karten-anlegen': 'karten', '--pruefen': 'pruefen' }
const istKalendertag = (iso) => { export const istKalendertag = (iso) => {
if (!/^\d{4}-\d{2}-\d{2}$/.test(iso)) return false if (!/^\d{4}-\d{2}-\d{2}$/.test(iso)) return false
const d = new Date(`${iso}T12:00:00Z`) const d = new Date(`${iso}T12:00:00Z`)
return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === iso return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === iso
@@ -165,7 +165,7 @@ const q = (o) => encodeURIComponent(JSON.stringify(o))
const neueId = () => Array.from(crypto.getRandomValues(new Uint8Array(10)), (b) => b.toString(16).padStart(2, '0')).join('').slice(0, 20) const neueId = () => Array.from(crypto.getRandomValues(new Uint8Array(10)), (b) => b.toString(16).padStart(2, '0')).join('').slice(0, 20)
/** Kleiner Appwrite-Client über deps.fetch; Fehler werden zu Error mit Methode, Pfad und Status. */ /** Kleiner Appwrite-Client über deps.fetch; Fehler werden zu Error mit Methode, Pfad und Status. */
function client(fetchFn, schluessel) { export function client(fetchFn, schluessel) {
const kopf = { 'X-Appwrite-Project': PROJECT, 'X-Appwrite-Key': schluessel } const kopf = { 'X-Appwrite-Project': PROJECT, 'X-Appwrite-Key': schluessel }
return async function api(method, pfad, body) { return async function api(method, pfad, body) {
const formular = typeof FormData !== 'undefined' && body instanceof FormData const formular = typeof FormData !== 'undefined' && body instanceof FormData
@@ -180,7 +180,7 @@ function client(fetchFn, schluessel) {
} }
} }
async function alleDokumente(api, sammlung) { export async function alleDokumente(api, sammlung) {
const aus = [] const aus = []
for (let offset = 0; ; offset += 100) { for (let offset = 0; ; offset += 100) {
const r = await api('GET', `/databases/${DB}/collections/${sammlung}/documents?queries[]=${q({ method: 'limit', values: [100] })}&queries[]=${q({ method: 'offset', values: [offset] })}`) const r = await api('GET', `/databases/${DB}/collections/${sammlung}/documents?queries[]=${q({ method: 'limit', values: [100] })}&queries[]=${q({ method: 'offset', values: [offset] })}`)
@@ -189,12 +189,12 @@ async function alleDokumente(api, sammlung) {
} }
} }
async function versionen(api, documentKey) { export async function versionen(api, documentKey) {
const r = await api('GET', `/databases/${DB}/collections/legalVersions/documents?queries[]=${q({ method: 'equal', attribute: 'documentKey', values: [documentKey] })}&queries[]=${q({ method: 'limit', values: [100] })}`) const r = await api('GET', `/databases/${DB}/collections/legalVersions/documents?queries[]=${q({ method: 'equal', attribute: 'documentKey', values: [documentKey] })}&queries[]=${q({ method: 'limit', values: [100] })}`)
return r.documents return r.documents
} }
const ddmmjjjj = (iso) => iso.split('-').reverse().join('.') export const ddmmjjjj = (iso) => iso.split('-').reverse().join('.')
// ---------------------------------------------------------------------------------------------------------- Aufruf // ---------------------------------------------------------------------------------------------------------- Aufruf
@@ -218,7 +218,14 @@ export async function main(argv, deps = {}) {
if (args.modus === 'probe') return await probe(api, log) if (args.modus === 'probe') return await probe(api, log)
if (args.modus === 'karten') return await kartenAnlegen(api, log) if (args.modus === 'karten') return await kartenAnlegen(api, log)
if (args.modus === 'pruefen') return await pruefen(api, log) if (args.modus === 'pruefen') return await pruefen(api, log)
return await hochladen(api, log, args, deps) const d = dokumentFuer(args.documentKey)
return await hochladen(api, log, {
documentKey: args.documentKey,
dateien: d.dateien,
quelle: d.quelle,
changeNote: `Fassung vom ${ddmmjjjj(args.freigabe)}: Kundenansicht neu (Fassung 6)`,
freigabe: args.freigabe,
}, deps)
} catch (err) { } catch (err) {
log(`Fehler: ${err.message}`) log(`Fehler: ${err.message}`)
return 1 return 1
@@ -263,28 +270,42 @@ async function pruefen(api, log) {
return fehlt.length > 0 ? 1 : 0 return fehlt.length > 0 ? 1 : 0
} }
/** <documentKey> --freigabe=…: genau ein Dokument hochladen. Als Erstes die Kartenprüfung, dann Git- und Dateiprüfung, erst dann schreiben. */ /**
async function hochladen(api, log, { documentKey, freigabe }, deps) { * Genau ein Dokument als neue Version hochladen (auch vom Datenschutz-Skript genutzt). Als Erstes die Kartenprüfung, dann Datei- und Git-Prüfung
const d = dokumentFuer(documentKey) * und die Inhaltsprüfung des Aufrufers, erst dann schreiben: erst alle Dateien in den Bucket, dann das Versionsdokument, zuletzt die bisherige
* gültige Version ablösen. deps = { git, dateien } wie bei main.
* @param {object} api Client aus client()
* @param {Function} log
* @param {object} dok documentKey; dateien = [PDF, HTML] (Pfade im Projekt, in dieser Reihenfolge hochgeladen); quelle = Pfad in source
* (kundenbereich@<Commit>:<quelle>); changeNote und freigabe (JJJJ-MM-TT = validFrom) fertig vom Aufrufer; kartenHinweis hängt an der Meldung
* „Karte fehlt“; pruefeInhalt(lies) darf nach der Git-Prüfung mit lies(pfad) → Buffer den Inhalt prüfen und gibt eine Abbruchmeldung oder null zurück
* @returns {Promise<number>} Exit-Code (0 oder 1)
*/
export async function hochladen(api, log, { documentKey, dateien, quelle, changeNote, freigabe, kartenHinweis = '(zuerst --karten-anlegen)', pruefeInhalt }, deps) {
const karten = await alleDokumente(api, 'legalDocuments') const karten = await alleDokumente(api, 'legalDocuments')
if (!karten.some((k) => k.key === documentKey)) { if (!karten.some((k) => k.key === documentKey)) {
log(`Karte fehlt: ${documentKey} (zuerst --karten-anlegen)`) log(`Karte fehlt: ${documentKey} ${kartenHinweis}`)
return 1 return 1
} }
const git = deps.git ?? ((args) => execFileSync('git', args, { cwd: ROOT, encoding: 'utf8' })) const git = deps.git ?? ((args) => execFileSync('git', args, { cwd: ROOT, encoding: 'utf8' }))
const dateisystem = deps.dateien ?? { existsSync: (rel) => existsSync(path.join(ROOT, rel)), readFileSync: (rel) => readFileSync(path.join(ROOT, rel)) } const dateisystem = deps.dateien ?? { existsSync: (rel) => existsSync(path.join(ROOT, rel)), readFileSync: (rel) => readFileSync(path.join(ROOT, rel)) }
for (const rel of d.dateien) { for (const rel of dateien) {
if (!dateisystem.existsSync(rel)) { if (!dateisystem.existsSync(rel)) {
log(`Datei fehlt: ${rel}`) log(`Datei fehlt: ${rel}`)
return 1 return 1
} }
} }
if (git(['status', '--porcelain', '--', ...d.dateien]).trim() !== '') { if (git(['status', '--porcelain', '--', ...dateien]).trim() !== '') {
log(`Abbruch: nicht committeter Stand in ${d.dateien.join(' und ')} (git status --porcelain ist nicht leer)`) log(`Abbruch: nicht committeter Stand in ${dateien.join(' und ')} (git status --porcelain ist nicht leer)`)
return 1
}
const inhaltsfehler = pruefeInhalt?.((rel) => dateisystem.readFileSync(rel))
if (inhaltsfehler) {
log(inhaltsfehler)
return 1 return 1
} }
const commit = git(['rev-parse', '--short', 'HEAD']).trim() const commit = git(['rev-parse', '--short', 'HEAD']).trim()
const source = `kundenbereich@${commit}:${d.quelle}` const source = `kundenbereich@${commit}:${quelle}`
const alle = await versionen(api, documentKey) const alle = await versionen(api, documentKey)
const plan = planVersion({ alle, source, validFrom: freigabe }) const plan = planVersion({ alle, source, validFrom: freigabe })
if (plan.schon) { if (plan.schon) {
@@ -292,7 +313,7 @@ async function hochladen(api, log, { documentKey, freigabe }, deps) {
return 0 return 0
} }
const fileIds = [] const fileIds = []
for (const rel of d.dateien) { for (const rel of dateien) {
const form = new FormData() const form = new FormData()
form.append('fileId', neueId()) form.append('fileId', neueId())
form.append('file', new Blob([dateisystem.readFileSync(rel)], { type: rel.endsWith('.pdf') ? 'application/pdf' : 'text/html' }), path.basename(rel)) form.append('file', new Blob([dateisystem.readFileSync(rel)], { type: rel.endsWith('.pdf') ? 'application/pdf' : 'text/html' }), path.basename(rel))
@@ -306,7 +327,7 @@ async function hochladen(api, log, { documentKey, freigabe }, deps) {
validFrom: freigabe, validFrom: freigabe,
validTo: null, validTo: null,
status: 'current', status: 'current',
changeNote: `Fassung vom ${ddmmjjjj(freigabe)}: Kundenansicht neu (Fassung 6)`, changeNote,
fileIds, fileIds,
source, source,
}, },
+141
View File
@@ -0,0 +1,141 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { createHash } from 'node:crypto'
// Hochladen der Datenschutzerklärung nach WOMS "Rechtliches" (scripts/datenschutz-hochladen.mjs): die Tests rufen main nur mit Nachbau-deps
// auf, nie mit dem echten Schlüssel oder dem Netz. Das globale fetch wirft und zählt, damit ein Weg am Nachbau vorbei auffällt.
let netzzugriffe = 0
globalThis.fetch = () => {
netzzugriffe += 1
throw new Error('Netzzugriff im Test')
}
const m = await import('../scripts/datenschutz-hochladen.mjs') // der Import liest weder Schlüssel noch Netz
const frueher = ['v1', 'v2', 'v3', 'v4', 'v5'].map((v, i) => ({ $id: `s${i}`, version: v, validFrom: `2026-09-0${i + 1}`, status: 'superseded', source: `x${i}` }))
const AKTUELL = { $id: 'alt', version: 'v6', validFrom: '2026-10-05', status: 'current', source: 'kundenbereich@aaaaaaa:public/datenschutz.html' }
// stand = das Datum, das im Nachbau der HTML-Datei hinter "Stand" steht (die PDF-Datei ist beliebiger Inhalt)
// zusatz = zusätzlicher Inhalt der HTML (zum Beispiel eine offene Markierung); summe = Inhalt der .sha256 (null = passend zur HTML, 'fehlt' = nicht lesbar)
function backend({ versionen = [...frueher, AKTUELL], karten = [{ $id: 'datenschutz', key: 'datenschutz' }], gitStatus = '', stand = '20.10.2026', vorhanden = () => true, zusatz = '', summe = null } = {}) {
const aufrufe = [], log = []
const antwort = (obj) => ({ ok: true, status: 200, text: async () => JSON.stringify(obj) })
const fetch = async (url, opt = {}) => {
const methode = opt.method ?? 'GET', pfad = new URL(url).pathname.replace(/^\/v1/, '')
aufrufe.push({ methode, pfad, body: opt.body })
if (methode === 'GET' && pfad.endsWith('/collections/legalDocuments/documents')) return antwort({ total: karten.length, documents: karten })
if (methode === 'GET' && pfad.endsWith('/collections/legalVersions/documents')) return antwort({ total: versionen.length, documents: versionen })
if (methode === 'GET' && pfad.startsWith('/storage/buckets/legal-files/files')) return antwort({ total: 0, files: [] })
if (methode === 'POST' && pfad === '/storage/buckets/legal-files/files') return antwort({ $id: `f${aufrufe.length}` })
if (methode === 'POST' && pfad.endsWith('/collections/legalVersions/documents')) return antwort({ $id: 'v-neu' })
if (methode === 'PATCH' && pfad.includes('/collections/legalVersions/documents/')) return antwort({})
throw new Error(`unerwartet: ${methode} ${pfad}`)
}
const html = () => Buffer.from((stand === null ? '<p class="meta">ohne Datum</p>' : `<p class="meta">Stand ${stand}</p>`) + zusatz)
const pruefsumme = () => {
if (summe === 'fehlt') throw new Error('ENOENT')
return Buffer.from(`${summe ?? createHash('sha256').update(html()).digest('hex')}\n`)
}
return { aufrufe, log, deps: { fetch, schluessel: () => 'test-schluessel', log: (z) => log.push(z), git: (args) => (args[0] === 'status' ? gitStatus : 'abc1234\n'), dateien: { existsSync: vorhanden, readFileSync: (rel) => (rel.endsWith('.html') ? html() : rel.endsWith('.sha256') ? pruefsumme() : Buffer.from('x')) } } }
}
const schreibend = (b) => b.aufrufe.filter((a) => a.methode !== 'GET')
test('parseArgs: --probe allein oder --freigabe=Tag, sonst Aufruf-Fehler', () => {
assert.deepEqual(m.parseArgs(['--probe']), { modus: 'probe', freigabe: null })
assert.deepEqual(m.parseArgs(['--freigabe=2026-10-20']), { modus: 'hochladen', freigabe: '2026-10-20' })
for (const bad of [[], ['--freigabe=20.10.2026'], ['--freigabe=2026-13-40'], ['--probe', '--freigabe=2026-10-20'], ['datenschutz']]) assert.throws(() => m.parseArgs(bad), /Aufruf|Freigabe/)
for (const bad of [['--freigabe=2026-10-20', '--freigabe=2026-10-21'], ['--probe', '--probe'], ['--freigabe=2026-10-20', '--karten-anlegen'], ['datenschutz', '--freigabe=2026-10-20']]) assert.throws(() => m.parseArgs(bad), /Aufruf|Freigabe/, JSON.stringify(bad))
})
test('Hochladen: Dateien zuerst (PDF, dann HTML), dann Version v7, dann die bisherige ablösen', async () => {
const b = backend()
assert.equal(await m.main(['--freigabe=2026-10-20'], b.deps), 0)
assert.deepEqual(schreibend(b).map((a) => `${a.methode} ${a.pfad.split('/').slice(-1)[0]}`), ['POST files', 'POST files', 'POST documents', 'PATCH alt'])
const neu = JSON.parse(schreibend(b)[2].body).data
assert.deepEqual([neu.documentKey, neu.version, neu.validFrom, neu.status, neu.source, neu.fileIds.length], ['datenschutz', 'v7', '2026-10-20', 'current', 'kundenbereich@abc1234:public/datenschutz.html', 2])
assert.equal(neu.changeNote, 'Fassung vom 20.10.2026: Abschnitt 11 Interessenten (Antworten auf öffentliche Anfragen)')
assert.deepEqual(JSON.parse(schreibend(b)[3].body).data, { status: 'superseded', validTo: '2026-10-19' })
})
test('Hochladen: die beiden Dateien sind die PDF und die HTML der Datenschutzerklärung, mit Dateiname und Typ', async () => {
const b = backend()
assert.equal(await m.main(['--freigabe=2026-10-20'], b.deps), 0)
const dateien = schreibend(b).slice(0, 2).map((a) => a.body.get('file'))
assert.deepEqual(dateien.map((f) => [f.name, f.type]), [['WEBklar-Datenschutzerklaerung.pdf', 'application/pdf'], ['datenschutz.html', 'text/html']])
assert.equal(await dateien[1].text(), '<p class="meta">Stand 20.10.2026</p>')
})
test('Changenote nennt weder Workers noch Hustler (sie kann für Kunden sichtbar werden)', async () => {
const b = backend()
assert.equal(await m.main(['--freigabe=2026-10-20'], b.deps), 0)
assert.doesNotMatch(JSON.parse(schreibend(b)[2].body).data.changeNote, /workers|hustler/i)
})
test('schon hochgeladen, fehlende Karte, nicht eingecheckter Stand und --probe schreiben nichts', async () => {
const schon = backend({ versionen: [{ ...AKTUELL, source: 'kundenbereich@abc1234:public/datenschutz.html' }] })
assert.equal(await m.main(['--freigabe=2026-10-20'], schon.deps), 0)
const ohneKarte = backend({ karten: [] })
assert.equal(await m.main(['--freigabe=2026-10-20'], ohneKarte.deps), 1)
const schmutzig = backend({ gitStatus: ' M public/datenschutz.html\n' })
assert.equal(await m.main(['--freigabe=2026-10-20'], schmutzig.deps), 1)
const probe = backend()
assert.equal(await m.main(['--probe'], probe.deps), 0)
for (const b of [schon, ohneKarte, schmutzig, probe]) assert.equal(schreibend(b).length, 0)
})
test('Fehlende Karte und fehlende Datei brechen mit Meldung ab, bevor etwas geschrieben wird', async () => {
const ohneKarte = backend({ karten: [] })
assert.equal(await m.main(['--freigabe=2026-10-20'], ohneKarte.deps), 1)
assert.ok(ohneKarte.log.some((z) => z.includes('Karte fehlt: datenschutz')))
assert.ok(!ohneKarte.log.some((z) => z.includes('--karten-anlegen')), 'dieses Skript legt keine Karten an')
const ohnePdf = backend({ vorhanden: (rel) => !rel.endsWith('.pdf') })
assert.equal(await m.main(['--freigabe=2026-10-20'], ohnePdf.deps), 1)
assert.ok(ohnePdf.log.some((z) => z.includes('Datei fehlt: docs/WEBklar-Datenschutzerklaerung.pdf')))
assert.equal(schreibend(ohneKarte).length + schreibend(ohnePdf).length, 0)
})
test('Stand in der HTML muss der Freigabe entsprechen, sonst Abbruch vor jedem Schreiben', async () => {
const falsch = backend({ stand: '17.10.2026' })
assert.equal(await m.main(['--freigabe=2026-10-20'], falsch.deps), 1)
assert.ok(falsch.log.some((z) => z.includes('Stand 17.10.2026') && z.includes('20.10.2026')), falsch.log.join(' | '))
const ohneStand = backend({ stand: null })
assert.equal(await m.main(['--freigabe=2026-10-20'], ohneStand.deps), 1)
assert.ok(ohneStand.log.some((z) => z.includes('Stand')), ohneStand.log.join(' | '))
for (const b of [falsch, ohneStand]) assert.equal(schreibend(b).length, 0)
const richtig = backend({ stand: '21.10.2026' })
assert.equal(await m.main(['--freigabe=2026-10-21'], richtig.deps), 0, 'mit passender Freigabe geht derselbe Stand durch')
})
test('offene Markierung im Text: Abbruch vor jedem Schreiben, mit Nennung der Markierung; Kommentare zählen nicht', async () => {
const offen = backend({ zusatz: '<p>bei <span class="offen">[OFFEN: Anbieter Alarm-Postfach]</span></p>' })
assert.equal(await m.main(['--freigabe=2026-10-20'], offen.deps), 1)
assert.ok(offen.log.some((z) => z.includes('offene Markierung') && z.includes('[OFFEN: Anbieter Alarm-Postfach]')), offen.log.join(' | '))
const zweiMal = backend({ zusatz: '<p>[OFFEN: Aufbewahrung der Server-Sicherungen] und noch einmal [OFFEN: Aufbewahrung der Server-Sicherungen]</p>' })
assert.equal(await m.main(['--freigabe=2026-10-20'], zweiMal.deps), 1)
assert.equal(zweiMal.log.filter((z) => z.includes('offene Markierung'))[0].match(/Aufbewahrung der Server-Sicherungen/g).length, 1, 'jede Markierung nur einmal genannt')
for (const b of [offen, zweiMal]) assert.equal(schreibend(b).length, 0)
const kommentar = backend({ zusatz: '<!-- [OFFEN: nur eine Notiz im Quelltext] -->' })
assert.equal(await m.main(['--freigabe=2026-10-20'], kommentar.deps), 0, 'eine Markierung im HTML-Kommentar ist nicht sichtbar')
})
test('Prüfsumme neben dem PDF muss zur HTML passen, sonst Abbruch vor jedem Schreiben (veraltetes PDF)', async () => {
const veraltet = backend({ summe: 'a'.repeat(64) })
assert.equal(await m.main(['--freigabe=2026-10-20'], veraltet.deps), 1)
assert.ok(veraltet.log.some((z) => z.includes('das PDF ist nicht aus dieser HTML gedruckt') && z.includes('build-legal-pdfs.sh datenschutz')), veraltet.log.join(' | '))
const fehlt = backend({ summe: 'fehlt' })
assert.equal(await m.main(['--freigabe=2026-10-20'], fehlt.deps), 1)
assert.ok(fehlt.log.some((z) => z.includes('WEBklar-Datenschutzerklaerung.pdf.sha256') && z.includes('fehlt')), fehlt.log.join(' | '))
for (const b of [veraltet, fehlt]) assert.equal(schreibend(b).length, 0)
// passende Prüfsumme (Standard im Nachbau): der Upload geht durch
const passt = backend()
assert.equal(await m.main(['--freigabe=2026-10-20'], passt.deps), 0)
assert.equal(schreibend(passt).length, 4)
})
test('--probe liest nur: Bucket, Karte und Versionen, meldet eine fehlende Karte als Stopp', async () => {
const b = backend()
assert.equal(await m.main(['--probe'], b.deps), 0)
assert.ok(b.log.includes('datenschutz: v1:superseded v2:superseded v3:superseded v4:superseded v5:superseded v6:current'), b.log.join(' | '))
assert.deepEqual(b.aufrufe.map((a) => a.pfad.replace(/^\/databases\/woms-database/, '')), ['/storage/buckets/legal-files/files', '/collections/legalDocuments/documents', '/collections/legalVersions/documents'])
const ohneKarte = backend({ karten: [] })
assert.equal(await m.main(['--probe'], ohneKarte.deps), 1)
assert.ok(ohneKarte.log.includes('STOPP: Karte fehlt: datenschutz'))
})
test('ungültige Aufrufe enden mit Exit 1 und Hinweis, ohne ein einziges Netz-Zeichen', async () => {
const b = backend()
assert.equal(await m.main([], b.deps), 1)
assert.match(b.log[0], /Aufruf/)
assert.equal(b.aufrufe.length, 0)
})
test('kein Test hat das echte fetch erreicht', () => {
assert.equal(netzzugriffe, 0)
})
+154 -3
View File
@@ -558,7 +558,8 @@ test('legal_datenschutz_kundenansicht: Abschnitt 6 nennt Kundenansicht, Sperren,
const terms = /^(\d{4})-(\d{2})-(\d{2})-v\d+$/.exec(config.abo.termsVersion) const terms = /^(\d{4})-(\d{2})-(\d{2})-v\d+$/.exec(config.abo.termsVersion)
assert.ok(terms) assert.ok(terms)
assert.ok(standTag >= `${terms[1]}-${terms[2]}-${terms[3]}`, `Stand ${stand[0]} liegt vor termsVersion ${config.abo.termsVersion}`) assert.ok(standTag >= `${terms[1]}-${terms[2]}-${terms[3]}`, `Stand ${stand[0]} liegt vor termsVersion ${config.abo.termsVersion}`)
assert.ok(html.includes('<p class="meta">Stand 17.10.2026</p>'), 'Stand = Tag der Änderung (Abschnitt 4a Reichweitenmessung auf webklar.com)') // Stand = Tag der letzten Änderung der Erklärung; seit dem 17.10.2026 (Abschnitt 4a Reichweitenmessung auf webklar.com) nicht davor (der genaue Tag ist in legal_datenschutz_anfragen gepinnt)
assert.ok(html.includes(`<p class="meta">${stand[0]}</p>`) && standTag >= '2026-10-17', `Stand ${stand[0]} liegt vor der Reichweitenmessung (17.10.2026)`)
}) })
// ---------------------------------------------------------------- Abschnitt 6c und Funktionsbestellung (TP2 F12a) // ---------------------------------------------------------------- Abschnitt 6c und Funktionsbestellung (TP2 F12a)
@@ -603,8 +604,8 @@ test('legal_datenschutz_termine: Abschnitt 6c steht zwischen 6b und 7 und nennt
const titel = [...html.matchAll(/<h2>(\d+)\. ([^<]*)<\/h2>/g)].map((m) => `${m[1]}. ${m[2]}`) const titel = [...html.matchAll(/<h2>(\d+)\. ([^<]*)<\/h2>/g)].map((m) => `${m[1]}. ${m[2]}`)
assert.deepEqual(titel, [ assert.deepEqual(titel, [
'1. Verantwortlicher', '2. Geltungsbereich', '3. Hosting, Server-Protokolle und Überwachung der Erreichbarkeit', '4. Cookies und Speicher im Browser', '5. Kontaktaufnahme', '6. Kundenportal', '1. Verantwortlicher', '2. Geltungsbereich', '3. Hosting, Server-Protokolle und Überwachung der Erreichbarkeit', '4. Cookies und Speicher im Browser', '5. Kontaktaufnahme', '6. Kundenportal',
'7. Abos und Zahlung über Stripe', '8. E-Mail', '9. Buchhaltung und Rechnungen', '10. Domains unserer Kunden', '11. Interessenten', '12. Empfänger im Überblick', '13. Ihre Rechte', '7. Abos und Zahlung über Stripe', '8. E-Mail', '9. Buchhaltung und Rechnungen', '10. Domains unserer Kunden', '11. Interessenten: Antworten auf öffentliche Anfragen', '12. Empfänger im Überblick', '13. Ihre Rechte',
'14. Pflicht zur Bereitstellung, keine automatisierten Entscheidungen', '15. Änderungen', '14. Pflicht zur Bereitstellung, keine automatisierten Einzelentscheidungen nach Art. 22 DSGVO', '15. Änderungen',
]) ])
}) })
@@ -732,3 +733,153 @@ test('legal_datenschutz_4a: eigene Website-Analyse auf webklar.com mit Rolle, Re
// Kontaktformular: seit 17.10.2026 auch per Mail an das eigene Postfach // Kontaktformular: seit 17.10.2026 auch per Mail an das eigene Postfach
assert.ok(ds.includes('und schicken sie zugleich per E-Mail an unser Postfach (Abschnitt 8)')) assert.ok(ds.includes('und schicken sie zugleich per E-Mail an unser Postfach (Abschnitt 8)'))
}) })
// ---------------------------------------------------------------- Abschnitt 11 Interessenten, Weg B (nur der heute laufende Weg: öffentliche Anfragen)
test('legal_datenschutz_anfragen: Abschnitt 11 beschreibt nur den Anfrage-Weg (KI bewertet, Mitarbeiter sendet von Hand), mit Anker, Quelle, Daten, Rechtsgrundlage, Fristen und Widerspruch', () => {
const html = read(DS)
// Stand-Pin: der Tag der Fassung (das endgültige Datum setzt der Push, dann auch hier)
assert.ok(html.includes('<p class="meta">Stand 22.10.2026</p>'), 'Stand 22.10.2026')
assert.match(html, /<a id="interessenten"><\/a>\s*<h2>11\. Interessenten: Antworten auf öffentliche Anfragen<\/h2>/)
assert.equal(html.split('id="interessenten"').length - 1, 1, 'der Anker steht genau einmal da')
const s = between(strip(html), '11. Interessenten: Antworten auf öffentliche Anfragen', '12. Empfänger im Überblick')
for (const t of ['Art. 14 DSGVO', 'KI-Assistent', 'Claude von Anthropic', 'von Hand', 'Automatische E-Mails verschicken wir nicht', 'Für E-Mails gilt Abschnitt 8',
// Quellen: Alarm-E-Mails und Websuche; Plattformen mit Bot-Verbot ausgenommen
'Benachrichtigungs-E-Mails', 'Kleinanzeigen, freelancermap und freelance.de', 'Websuche', 'Frage-Foren', 'Die Suche und den Abruf der Seiten führt Anthropic aus',
// feste Ausschlussliste (so wie BOT_VERBOT_DOMAINS im Dienst) statt eines Kriteriums
'Von der Websuche und vom Abruf ausgenommen sind die Plattformen Kleinanzeigen, freelancermap, freelance.de, Facebook, Instagram, LinkedIn, Xing, Reddit, Upwork, Fiverr und MyHammer sowie Seiten, deren Betreiber automatische Abrufe in der Datei robots.txt untersagen',
'nicht nach bestimmten Personen',
// Daten
'Titel des Beitrags', 'Beitragstext (höchstens 4000 Zeichen)', 'den Link', 'Ort und Postleitzahl', 'die Plattform', 'Punktzahl mit kurzer Begründung', 'E-Mail-Adresse aus dem Beitrag',
'Wir lesen Ihren Namen nicht gesondert aus; steht er im Titel oder im Text, speichern wir ihn damit', 'Telefonnummern entfernen wir beim Speichern automatisch', 'Faustregel',
// Rechtsgrundlage, Empfänger
'Art. 6 Abs. 1 lit. f DSGVO', 'Hetzner Online GmbH', 'Anthropic Ireland, Limited', 'als Auftragsverarbeiter', 'USA', 'Standardvertragsklauseln (Art. 46 Abs. 2 lit. c DSGVO); eine Kopie erhalten Sie bei uns (support@webklar.com)',
// keine automatisierte Einzelentscheidung: die KI sortiert aus, ein Mitarbeiter entscheidet über jede Antwort
'Die KI sortiert Anfragen aus, die nicht zu uns passen; auf diese schreiben wir nicht', 'Ob und was wir einer passenden Anfrage antworten, entscheidet ein Mitarbeiter (Abschnitt 14)',
// Hinweis am Ende unserer Antwort (der Dienst setzt ihn in Claras Entwurf, src/anfragen/clara.js; die Sätze hier stehen dort wörtlich, test/clara.test.js).
// Ein Mitarbeiter kann den Entwurf bearbeiten: zugesagt ist deshalb nur die Arbeitsanweisung, den Hinweis stehen zu lassen.
'Am Ende unserer Antwort steht ein Hinweis', 'dass eine KI-Assistentin von WEBklar antwortet', 'dass wir die Angaben aus Ihrer Anfrage spätestens 90 Tage nach dem Tag, an dem wir Ihre Anfrage gefunden haben, in unserer Datenbank kürzen, das heißt, Titel, Text, Link und E-Mail-Adresse löschen',
'Wenn Sie keine weitere Nachricht von uns möchten oder der Speicherung widersprechen, antworten Sie uns bitte kurz',
'schreiben wir Ihnen zu dieser Anfrage nicht mehr und kürzen Ihre Daten (bei einem Widerspruch sofort, sonst innerhalb von 30 Tagen)',
'Unsere Mitarbeiter sind angewiesen, diesen Hinweis in jede Antwort aufzunehmen, auch in eine selbst geschriebene, und beim Bearbeiten eines Entwurfs nicht zu entfernen',
// Fristen und Reste: aussortierte Anfragen spätestens am Tag nach dem Fund (Entscheidung E2 vom 22.10.2026, Regel C im Dienst: erster täglicher Kürzlauf nach dem Berliner Kalendertag des Fundes),
// 30 Tage nur noch nach einer Absage, Absage und Widerspruch erst ab dem Lesen unserer Seite
'spätestens 90 Tage nach dem Fund', 'Passt Ihre Anfrage nach der Bewertung der KI nicht zu uns, schreiben wir Ihnen nicht und kürzen die Daten spätestens am Tag nach dem Fund',
'innerhalb von 30 Tagen, nachdem wir Ihre Antwort gelesen haben', 'Wir sperren und kürzen sie, sobald wir Ihren Widerspruch lesen', 'pseudonymisierte Prüfwerte (Hash-Werte)', 'Ort, Plattform, Punktzahl, Status',
'jedoch ohne Titel, Text, Link und Kontaktdaten', 'bereinigen die zugehörigen Einträge im Protokoll', 'Abschnitt 5 und, wenn ein Vertrag zustande kommt, die Abschnitte 7 und 9',
// Dauer der Reste (Art. 14 Abs. 2 lit. a: Kriterium)
'so lange auf, wie wir verhindern müssen, dass wir Sie wegen desselben Beitrags oder derselben E-Mail-Adresse (bei einer Firmenadresse: derselben Firma) erneut ansprechen',
// Kopien: Alarm-Postfach = support@webklar.com bei Hetzner (Abschnitt 8), Löschung der Plattform-Mails 90 Tage nach Eingang, Datensicherungen wie im übrigen Text
'gehen in unserem Postfach support@webklar.com bei der Hetzner Online GmbH ein (Abschnitt 8)', 'Unser Dienst liest dort nur den Posteingang',
// ehrlich: er lädt jede neue Mail des gemeinsamen Postfachs kurz (zur Prüfung des Absenders), gespeichert wird nur, was von einer Plattform kommt
'Er lädt dazu jede neue E-Mail dieses Postfachs kurz, um den Absender zu prüfen; übernommen und gespeichert werden nur die Benachrichtigungen der Plattformen, alle anderen E-Mails verwirft er sofort',
'Die Benachrichtigungs-E-Mails der Plattformen löschen wir im Postfach spätestens 90 Tage nach dem Eingang',
'Kürzen wir Daten, bleiben sie in unseren Datensicherungen noch bis zu sieben Tage erhalten und werden dann überschrieben', 'Geschäftsbrief',
// Widerspruch
'Ihr Widerspruchsrecht gegen diese Verarbeitung (Art. 21 DSGVO)', 'Antworten Sie dazu auf unsere Nachricht oder schreiben Sie an support@webklar.com',
'Sobald wir Ihre Nachricht lesen, sperren wir den Beitrag und, soweit wir sie gespeichert haben, Ihre E-Mail-Adresse',
'Wir erkennen Sie dabei nur am Beitrag, an Ihrer E-Mail-Adresse und, bei einer Firmenadresse, an deren Domain', 'Ein neues Gesuch, das Sie später einstellen, ist für uns ein neuer Beitrag, wenn es keine dieser Angaben enthält; sagen Sie uns dann bitte noch einmal Bescheid',
// Prüfwerte auch der Firmen-Domain, Sperre trifft bei einer Firmenadresse die ganze Firma (Register und widerspruch.mjs im Dienst: keys.js, suppress)
'der Domain dieser Adresse, das ist der Teil hinter dem @, zum Beispiel firma.de', 'also nicht bei Anbietern wie gmail.com oder web.de',
'eine andere Adresse derselben Firma noch einmal auftaucht; wir schreiben dann nicht noch einmal und nehmen den Beitrag nicht noch einmal auf',
'Ist es eine Firmenadresse und nicht die eines Anbieters wie gmail.com oder web.de, sperren wir auch deren Domain, also auch andere Adressen derselben Firma',
// Zweck: warum Ort, Plattform, Punktzahl, Status und Zeitpunkte bleiben (der frühere Zweck "Mitarbeiter sehen nach, ob die KI zu Unrecht aussortiert hat" entfällt mit E2)
'Ort, Plattform, Punktzahl, Status und die Zeitpunkte behalten wir für unsere Auswertung, damit unsere Zähler und der zeitliche Verlauf stimmen',
// Foren (Entscheidung E4 vom 22.10.2026): im Forum antworten wir nicht; kein Entwurf; ein Mitarbeiter meldet sich höchstens per E-Mail, wenn der Beitrag ausdrücklich darum bittet (mit demselben Hinweis).
// Im Dienst: src/anfragen/foren.js, FOREN_ANWEISUNG "Forenbeitrag: nicht im Forum antworten. Nur per E-Mail melden, wenn der Beitrag ausdrücklich um Angebote per E-Mail bittet; sonst Erledigt."
'Eine Ausnahme sind Frage-Foren: Dort antworten wir nicht im Forum (siehe unten)',
'und entwirft eine Antwort (für Beiträge aus Frage-Foren nicht, siehe unten)',
'Beiträge in Frage-Foren (derzeit gutefrage.net, forum-hilfe.de, wer-weiss-was.de und wpde.org) beantworten wir nicht im Forum. Die KI entwirft dafür keine Antwort. Ein Mitarbeiter sieht den Beitrag an und meldet sich höchstens per E-Mail, wenn der Beitrag ausdrücklich um Angebote per E-Mail bittet; unsere Mitarbeiter sind angewiesen, in diese E-Mail am Ende den Hinweis aufzunehmen, der oben beschrieben ist. Bittet der Beitrag nicht darum, melden wir uns nicht',
// die Antwort bei der Plattform: ohne den Satz über öffentlich sichtbare Antworten in Foren (dort antworten wir nicht)
'Die Antwort, die wir auf einer Plattform abschicken, sehen auch deren Betreiber. Dafür gelten die Datenschutzhinweise der Plattform',
// Widerspruch und Absage wirken in der Datenbank; die Plattform-Mail im Postfach bleibt bis zur Löschung nach 90 Tagen (und das steht im Kasten)
'auch wenn wir die Anfrage in der Datenbank schon früher gekürzt haben',
'Die Benachrichtigung der Plattform in unserem Postfach erfasst das Kürzen in der Datenbank nicht; wir löschen sie dort spätestens 90 Tage nach dem Eingang (siehe oben)']) assert.ok(s.includes(t), t)
// der Widerspruch steht im Kasten (box widerspruch) im Abschnitt 11
const kasten = html.slice(html.indexOf('<a id="interessenten">'), html.indexOf('<h2>12. Empfänger im Überblick</h2>'))
assert.ok(kasten.includes('<div class="box widerspruch">'))
// Verständlichkeit: "Fund" ist erklärt, bevor es zum zweiten Mal vorkommt; die Fristen stehen als Liste; kein Absatz in Abschnitt 11 ist länger als 130 Wörter; "robots.txt" ist erklärt
const fund1 = s.indexOf('nach dem Fund')
const erklaert = s.indexOf('das ist der Tag, an dem wir Ihre Anfrage gefunden haben')
assert.ok(fund1 > 0 && erklaert > fund1 && erklaert < s.indexOf('spätestens 90 Tage nach dem Fund;'), '"Fund" wird beim ersten Gebrauch erklärt')
assert.ok(/<p>Wie lange wir die Daten speichern:[^<]*<\/p>\s*<ul>(?:\s*<li>[^<]+<\/li>){4}\s*<\/ul>/.test(kasten), 'die vier Fristen stehen als Liste')
// E2: die Liste nennt in dieser Reihenfolge 90 Tage, den Tag nach dem Fund (aussortiert), 30 Tage nach dem Lesen einer Absage (nur dort 30 Tage) und den Widerspruch
const fristen = [...kasten.matchAll(/<li>([^<]+)<\/li>/g)].map((m) => m[1])
assert.deepEqual(fristen, [
'spätestens 90 Tage nach dem Fund;',
'spätestens am Tag nach dem Fund, wenn Ihre Anfrage nicht zu uns passt;',
'innerhalb von 30 Tagen, nachdem wir Ihre Antwort gelesen haben, wenn Sie uns sagen, dass Sie kein Interesse haben;',
'sofort, wenn Sie widersprechen: Wir sperren und kürzen sie, sobald wir Ihren Widerspruch lesen.'])
assert.equal(fristen.filter((f) => f.includes('30 Tagen')).length, 1, '30 Tage nur noch für die Absage')
const absaetze = [...kasten.matchAll(/<p>([\s\S]*?)<\/p>/g)].map((m) => strip(m[1]).split(' ').length)
assert.ok(absaetze.length >= 15 && Math.max(...absaetze) <= 130, `längster Absatz in Abschnitt 11: ${Math.max(...absaetze)} Wörter`)
// die Zusagen und Auskünfte, die der Dienst nicht hält oder die unerklärt Fachsprache waren
for (const t of ['verarbeitet nur diese E-Mails', 'andere E-Mails in diesem Postfach übernimmt er nicht in seine Datenbank', 'Ein eigenes Feld für Ihren Namen gibt es nicht', 'deren robots.txt den Abruf sperrt',
'damit wir den Beitrag nicht noch einmal bewerten', 'Wir erkennen Sie dabei nur am Beitrag und an der E-Mail-Adresse', 'beim Bearbeiten des Entwurfs nicht zu entfernen', 'in unserer Datenbank spätestens 90 Tage nach dem Fund kürzen',
// E2: aussortierte Anfragen bleiben nicht mehr 30 Tage, und der Zweck "nachsehen, ob die KI zu Unrecht aussortiert hat" entfällt
'kürzen die Daten innerhalb von 30 Tagen nach dem Fund', 'innerhalb von 30 Tagen nach dem Fund, wenn Ihre Anfrage nicht zu uns passt', 'Bis dahin speichern wir sie', 'nachsehen können', 'zu Unrecht aussortiert',
// E4: kein Satz über öffentlich sichtbare Antworten in Frage-Foren mehr, denn dort antworten wir nicht
'in einem Frage-Forum ist sie in der Regel für alle Besucher der Seite sichtbar', 'für alle Besucher der Seite sichtbar',
// der Hinweis in der Forum-E-Mail ist eine Anweisung an die Mitarbeiter, keine Tatsache, die der Dienst erzwingt (sie ist im Pult-Eintrag nicht enthalten)
'in diese E-Mail nehmen unsere Mitarbeiter am Ende den Hinweis auf'])
assert.ok(!s.includes(t), `nicht mehr im Text: ${t}`)
// E4: die Forenliste in der Regel für Antworten und in der Quelle ist dieselbe (der Dienst erkennt diese vier Foren, platforms.js FINN_ALLOWED_DOMAINS, jeden Fund von Finn und zusätzlich jedes Forum, dessen Host oder erster Pfad-Abschnitt „forum“ oder „foren“ nennt, istForenfund; der Text verspricht nur „derzeit“ die vier)
assert.equal(s.split('gutefrage.net, forum-hilfe.de, wer-weiss-was.de und wpde.org').length - 1, 2, 'die vier Foren stehen in der Quelle und in der Regel für Antworten, beide Male gleich')
// die Regel für Foren steht nach dem Hinweis und vor den Quellen; es gibt keinen Hinweis auf eine Antwort im Forum
assert.ok(s.indexOf('Am Ende unserer Antwort steht ein Hinweis') < s.indexOf('beantworten wir nicht im Forum') && s.indexOf('beantworten wir nicht im Forum') < s.indexOf('Die Gesuche finden wir auf zwei Wegen'))
const imForum = [...s.matchAll(/im Forum/g)]
assert.ok(imForum.length >= 2 && imForum.every((m) => s.slice(m.index - 6, m.index) === 'nicht '), 'jedes "im Forum" in Abschnitt 11 steht hinter "nicht": wir antworten nirgends im Forum')
})
test('legal_datenschutz_anfragen: nichts aus dem Kaltmail-Weg (Stufe 2); keine [OFFEN]-Markierung und kein Platzhalter mehr im Text', () => {
const html = read(DS)
const ds = strip(html)
const s = between(ds, '11. Interessenten: Antworten auf öffentliche Anfragen', '12. Empfänger im Überblick')
for (const t of ['Gratis-Vorschau', 'höchstens zwei E-Mails', 'höchstens drei Betriebe', 'schreibt und versendet', 'Antworten sortiert', 'Place-ID', 'Impressum', 'OpenStreetMap', 'Overpass', 'Resend', 'Cloudflare',
'Abweichend von Abschnitt 8', 'Abmelde-Link', 'bei Heilberufen', 'Geschäftsdaten in geringem Umfang', 'sechs Monate nach der letzten Nachricht', 'zwölf Monaten', 'Branchenverzeichnisse', 'nur für diese Antwort',
'Postfach bei der Hetzner', 'In allen Fällen bleiben nur']) assert.ok(!s.includes(t), `noch da (Stufe 2 oder zu viel versprochen): ${t}`)
// Resend, Cloudflare und Overpass stehen nirgends in der Erklärung
for (const t of ['Resend', 'Cloudflare', 'Overpass']) assert.ok(!ds.includes(t), `${t} gehört nicht in die Weg-B-Fassung`)
// Beide früher offenen Tatsachen sind geklärt (Alarm-Postfach = support@webklar.com bei Hetzner; Datensicherungen bis zu sieben Tage). Jede [OFFEN]-Markierung macht diesen
// Test rot, auch im Quelltext-Kommentar und auch ohne Span. Zusätzlich bricht scripts/datenschutz-hochladen.mjs vor dem Schreiben ab, solange eine Markierung steht.
assert.ok(!/\[OFFEN/i.test(html), `[OFFEN]-Markierung im Text: ${(html.match(/\[OFFEN[^\]]*\]/gi) ?? []).join(', ')}`)
assert.ok(!html.includes('class="offen"'), 'kein gelber Platzhalter-Span mehr')
// Zusagen, die es nicht gibt: keine "Postfach-Regel" (die Löschung der Plattform-Mails ist eine eigene Technik des Dienstes), keine unbestimmten Sicherungen
for (const t of ['Postfach-Regel', 'eigenes E-Mail-Postfach', 'Datensicherungen unseres Servers', 'noch kurze Zeit']) assert.ok(!ds.includes(t), `nicht mehr im Text: ${t}`)
// keine anderen Platzhalter
assert.ok(!/\[Prüfen|TODO|XXX|\{[a-z]+\}/i.test(strip(html.replace(/<!--[\s\S]*?-->/g, ''))), 'keine anderen Platzhalter im sichtbaren Text')
})
test('legal_datenschutz_anfragen: Zusagen nur in der Reichweite des Dienstes (Sperre nur je Beitrag, E-Mail-Adresse und bei Firmenadressen Domain, Fristen ab dem Lesen), keine zu weiten Aussagen', () => {
const ds = strip(read(DS))
const s = between(ds, '11. Interessenten: Antworten auf öffentliche Anfragen', '12. Empfänger im Überblick')
// der Dienst sperrt nur denselben Beitrag, dieselbe Adresse (bei Firmenadressen auch die Domain) und erreicht keine Antwort auf einer Plattform: keine Zusage "nicht mehr schreiben" ohne Einschränkung
for (const t of ['schreiben Ihnen nicht mehr', 'melden uns nicht wieder', 'sperren Ihre Daten dann sofort', 'derzeit unter anderem Facebook', 'Plattformen, die automatisierte Zugriffe untersagen', 'entscheidet nicht allein', 'kurze Zeit enthalten',
// der Mitarbeiter kann den Entwurf bearbeiten: keine feste Zusage über den Inhalt der Nachricht
'In unserer ersten Nachricht steht'])
assert.ok(!ds.includes(t), `zu weit: ${t}`)
assert.ok(s.includes('schreiben Ihnen dazu nicht mehr'), 'die Sperre gilt "dazu", also für den Beitrag und die Adresse')
})
test('legal_datenschutz_anfragen: Abschnitt 2, 12 und 14 passen zu Abschnitt 11 und es gibt keinen Widerspruch zu 8', () => {
const ds = strip(read(DS))
const s2 = between(ds, '2. Geltungsbereich', '3. Hosting')
assert.ok(s2.includes('Menschen und Betriebe, die im Netz öffentlich nach einer Website suchen') && s2.includes('Privatpersonen') && s2.includes('Frage-Foren') && s2.includes('(Abschnitt 11)'))
const emp = between(ds, '12. Empfänger im Überblick', '13. Ihre Rechte')
assert.ok(emp.includes('Anthropic Ireland, Limited: Bewertung öffentlicher Anfragen, Entwurf von Antworten sowie Websuche und Seitenabruf durch ein Sprachmodell (Auftragsverarbeiter, Daten auch in den USA, Abschnitt 11)'))
assert.ok(emp.includes('Plattformen, auf denen wir Ihre Anfrage beantworten'))
// das Alarm-Postfach liegt bei Hetzner: kein eigener Anbieter in der Liste, Hetzner nennt es
assert.ok(emp.includes('Hetzner Online GmbH: Hosting, E-Mail (auch das Postfach für die Benachrichtigungen der Plattformen, Abschnitt 11) und Datensicherung (Auftragsverarbeiter)'))
assert.ok(!emp.includes('E-Mail-Postfach für die Benachrichtigungen der Plattformen'), 'kein eigener Listenpunkt für das Alarm-Postfach')
assert.ok(!emp.includes('Betrieben') && !emp.includes('Website-Texte'), 'der Anthropic-Zweck nennt keine Website-Texte von Betrieben mehr')
const s14 = between(ds, '14. Pflicht zur Bereitstellung', '15. Änderungen')
assert.ok(s14.includes('keine automatisierten Einzelentscheidungen nach Art. 22 DSGVO'))
// E4: bei Beiträgen aus Frage-Foren entwirft die KI gar keine Antwort, daher "höchstens"
assert.ok(s14.includes('Die Bewertung öffentlicher Anfragen durch einen KI-Assistenten (Abschnitt 11) sortiert Anfragen aus, die nicht zu uns passen; auf diese schreiben wir nicht. Für alle anderen bereitet sie höchstens eine Antwort vor; ob und was wir antworten, entscheidet ein Mitarbeiter, der die Antwort von Hand abschickt.'))
assert.ok(!s14.includes('bereitet sie nur eine Antwort vor'))
assert.ok(!ds.includes('keine automatisierten Entscheidungen'), 'die zu weite alte Überschrift ist weg')
// Einleitung nennt die Antwort auf öffentliche Anfragen
assert.ok(ds.includes('oder wenn wir auf Ihre öffentliche Anfrage nach einer Website antworten'))
// Abschnitt 8 (E-Mail über Hetzner) bleibt unverändert gültig, Abschnitt 11 verweist darauf, statt davon abzuweichen
assert.ok(between(ds, '8. E-Mail', '9. Buchhaltung').includes('versenden und empfangen wir über den E-Mail-Dienst der Hetzner Online GmbH'))
})