diff --git a/.claude/agents/.claude.agent.md b/.claude/agents/.claude.agent.md
new file mode 100644
index 0000000..0a7e1ce
--- /dev/null
+++ b/.claude/agents/.claude.agent.md
@@ -0,0 +1,9 @@
+---
+name: .claude
+description: Describe what this custom agent does and when to use it.
+tools: Read, Grep, Glob, Bash # specify the tools this agent can use. If not set, all enabled tools are allowed.
+---
+
+
+
+Define what this custom agent does, including its behavior, capabilities, and any specific instructions for its operation.
\ No newline at end of file
diff --git a/.claude/agents/claude.agent.md b/.claude/agents/claude.agent.md
new file mode 100644
index 0000000..dcb3831
--- /dev/null
+++ b/.claude/agents/claude.agent.md
@@ -0,0 +1,9 @@
+---
+name: claude
+description: Describe what this custom agent does and when to use it.
+tools: Read, Grep, Glob, Bash # specify the tools this agent can use. If not set, all enabled tools are allowed.
+---
+
+
+
+Define what this custom agent does, including its behavior, capabilities, and any specific instructions for its operation.
\ No newline at end of file
diff --git a/proxy/src/index.ts b/proxy/src/index.ts
index 1f5d181..edb863f 100644
--- a/proxy/src/index.ts
+++ b/proxy/src/index.ts
@@ -16,12 +16,18 @@
interface Env {
/** Geheimnis: `wrangler secret put ANTHROPIC_API_KEY` */
ANTHROPIC_API_KEY: string;
+ /** Geheimnis: `wrangler secret put TURNSTILE_SECRET_KEY` (Web-Demo) */
+ TURNSTILE_SECRET_KEY: string;
/** KV-Namespace für das Rate-Limit (Binding in wrangler.toml) */
RATE_LIMIT: KVNamespace;
/** Erlaubte Anfragen pro Gerät und Tag (in wrangler.toml unter [vars]) */
TAGES_LIMIT: string;
/** Erlaubte Anfragen pro IP-Adresse und Tag (in wrangler.toml unter [vars]) */
TAGES_LIMIT_IP: string;
+ /** Web-Demo: hartes Tagesbudget über alle Nutzer (Kosten-Deckel) */
+ DEMO_TAGES_BUDGET: string;
+ /** Web-Demo: max. Analysen pro IP innerhalb von 30 Tagen */
+ DEMO_LIMIT_IP: string;
}
const ANTHROPIC_URL = 'https://api.anthropic.com/v1/messages';
@@ -44,8 +50,290 @@ function fehler(status: number, typ: string, meldung: string): Response {
);
}
+// ============================================================
+// Web-Demo (/demo): 2 Gratis-Analysen direkt auf der Webseite
+// ============================================================
+//
+// Schutzschichten (jede allein wäre umgehbar, zusammen dicht genug):
+// 1. Turnstile-Token (Bot-Mauer, wird serverseitig verifiziert)
+// 2. Analyse 1 anonym: max. 1 pro IP / 30 Tage
+// 3. Analyse 2 nur mit E-Mail: pro E-Mail (Hash) genau 1 — dauerhaft
+// 4. IP-Gesamtlimit über 30 Tage (bremst Verlauf-Löscher)
+// 5. Globales Tagesbudget (harter Kosten-Deckel für den Betreiber)
+
+/** Nur die eigene Webseite darf den Demo-Endpunkt aufrufen (CORS). */
+const DEMO_ORIGIN_MUSTER = /^https:\/\/([a-z0-9-]+\.)?behoerdenklar\.pages\.dev$/;
+
+/** ~5 MB Datei entsprechen ~6,7 Mio. Base64-Zeichen. */
+const DEMO_MAX_BASE64 = 7_000_000;
+
+/** Bekannte Wegwerf-E-Mail-Domains (kleine, pragmatische Liste). */
+const WEGWERF_DOMAINS = new Set([
+ 'mailinator.com', 'guerrillamail.com', '10minutemail.com', 'temp-mail.org',
+ 'tempmail.com', 'trashmail.com', 'yopmail.com', 'sharklasers.com',
+ 'getnada.com', 'dispostable.com', 'maildrop.cc', 'throwawaymail.com',
+]);
+
+/** Identisch zur App (src/services/analyse.ts) — gleiche Qualität in der Demo. */
+const DEMO_SYSTEM_PROMPT = `Du bist ein Assistent, der deutschen Behördenbriefe für Privatpersonen verständlich macht. Die Nutzer sind Deutsche, die Amtsdeutsch schwer verstehen, oder Menschen mit Deutsch als Fremdsprache.
+
+Deine Aufgabe:
+1. Lies den fotografierten/hochgeladenen Brief vollständig.
+2. Erkläre ihn in einfacher Alltagssprache (Sprachniveau A2/B1): kurze Sätze, keine Schachtelsätze, keine unerklärten Fachbegriffe.
+3. Extrahiere Fristen und Termine exakt. Datumsangaben immer als ISO-Format (JJJJ-MM-TT). Wenn du ein Datum nicht sicher lesen kannst, lass das Feld null — erfinde niemals Daten.
+4. Erstelle eine konkrete Checkliste, was der Nutzer tun muss.
+5. Sei sachlich und beruhigend, nicht alarmierend.
+
+Wichtig: Wenn das Bild kein Behördenbrief ist oder unlesbar ist, schreibe das klar in kernaussage und erklaerung_einfach und lasse frist/termin null.`;
+
+/** Schlanke Demo-Variante des Analyse-Schemas (ohne Antwort-Optionen —
+ * der Antwort-Generator bleibt der App vorbehalten). */
+const DEMO_SCHEMA = {
+ type: 'object',
+ additionalProperties: false,
+ required: ['brieftyp', 'absender', 'kernaussage', 'erklaerung_einfach', 'fachbegriffe', 'frist', 'termin', 'checkliste'],
+ properties: {
+ brieftyp: { type: 'string', description: 'Kurze Kategorie des Briefs, z. B. "Einladung Jobcenter".' },
+ absender: { type: 'string', description: 'Die Behörde, die den Brief geschickt hat.' },
+ kernaussage: { type: 'string', description: 'Antwort auf "Was will das Amt von mir?" in 2-3 kurzen Sätzen, Sprachniveau A2.' },
+ erklaerung_einfach: { type: 'string', description: 'Erklärung des gesamten Briefs in einfacher Alltagssprache (A2/B1). Kurze Sätze.' },
+ fachbegriffe: {
+ type: 'array',
+ description: 'Fachbegriffe aus dem Brief mit einfacher Erklärung.',
+ items: {
+ type: 'object', additionalProperties: false, required: ['begriff', 'erklaerung'],
+ properties: { begriff: { type: 'string' }, erklaerung: { type: 'string' } },
+ },
+ },
+ frist: {
+ description: 'Frist, bis wann reagiert werden muss. null wenn keine Frist im Brief steht.',
+ anyOf: [
+ {
+ type: 'object', additionalProperties: false, required: ['datum', 'aktion'],
+ properties: { datum: { type: 'string', description: 'ISO JJJJ-MM-TT' }, aktion: { type: 'string' } },
+ },
+ { type: 'null' },
+ ],
+ },
+ termin: {
+ description: 'Persönlicher Termin. null wenn keiner im Brief steht.',
+ anyOf: [
+ {
+ type: 'object', additionalProperties: false, required: ['datum', 'uhrzeit', 'ort'],
+ properties: {
+ datum: { type: 'string', description: 'ISO JJJJ-MM-TT' },
+ uhrzeit: { anyOf: [{ type: 'string' }, { type: 'null' }] },
+ ort: { anyOf: [{ type: 'string' }, { type: 'null' }] },
+ },
+ },
+ { type: 'null' },
+ ],
+ },
+ checkliste: { type: 'array', description: 'To-do-Liste. Leer wenn nichts zu tun ist.', items: { type: 'string' } },
+ },
+};
+
+function demoCorsHeaders(origin: string | null): Record {
+ const erlaubt = origin && DEMO_ORIGIN_MUSTER.test(origin) ? origin : 'https://behoerdenklar.pages.dev';
+ return {
+ 'access-control-allow-origin': erlaubt,
+ 'access-control-allow-methods': 'POST, OPTIONS',
+ 'access-control-allow-headers': 'content-type',
+ 'access-control-max-age': '86400',
+ };
+}
+
+/** Fehler für die Demo-Seite: { code, meldung } + CORS. */
+function demoFehler(status: number, code: string, meldung: string, origin: string | null): Response {
+ return Response.json({ code, meldung }, { status, headers: demoCorsHeaders(origin) });
+}
+
+/** SHA-256-Hash der E-Mail (mit Pepper) — es wird nie Klartext gespeichert. */
+async function emailHash(email: string, pepper: string): Promise {
+ const daten = new TextEncoder().encode(`bk-demo|${pepper}|${email}`);
+ const digest = await crypto.subtle.digest('SHA-256', daten);
+ return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
+}
+
+async function demoHandler(request: Request, env: Env): Promise {
+ const origin = request.headers.get('origin');
+ if (request.method === 'OPTIONS') {
+ return new Response(null, { status: 204, headers: demoCorsHeaders(origin) });
+ }
+ if (request.method !== 'POST') {
+ return demoFehler(405, 'methode', 'Nur POST erlaubt.', origin);
+ }
+ if (!origin || !DEMO_ORIGIN_MUSTER.test(origin)) {
+ return demoFehler(403, 'origin', 'Aufruf nur von der BehördenKlar-Webseite erlaubt.', origin);
+ }
+
+ let body: { bild?: string; mimeType?: string; turnstileToken?: string; email?: string };
+ try {
+ body = await request.json();
+ } catch {
+ return demoFehler(400, 'json', 'Ungültige Anfrage.', origin);
+ }
+
+ const { bild, mimeType, turnstileToken } = body;
+ const email = typeof body.email === 'string' ? body.email.trim().toLowerCase() : null;
+
+ if (typeof bild !== 'string' || bild.length === 0 || bild.length > DEMO_MAX_BASE64) {
+ return demoFehler(400, 'datei', 'Die Datei fehlt oder ist zu groß (max. 5 MB).', origin);
+ }
+ if (mimeType !== 'image/jpeg' && mimeType !== 'image/png' && mimeType !== 'application/pdf') {
+ return demoFehler(400, 'datei', 'Nur Fotos (JPG/PNG) oder PDF sind möglich.', origin);
+ }
+ if (typeof turnstileToken !== 'string' || !turnstileToken) {
+ return demoFehler(403, 'turnstile', 'Sicherheitsprüfung fehlt. Bitte Seite neu laden.', origin);
+ }
+
+ // Schicht 1: Turnstile serverseitig verifizieren (niemals nur im Browser!)
+ const ip = request.headers.get('cf-connecting-ip') ?? 'unbekannt';
+ const pruefung = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ secret: env.TURNSTILE_SECRET_KEY, response: turnstileToken, remoteip: ip }),
+ });
+ const pruefungJson = (await pruefung.json()) as { success?: boolean };
+ if (!pruefungJson.success) {
+ return demoFehler(403, 'turnstile', 'Sicherheitsprüfung fehlgeschlagen. Bitte Seite neu laden und erneut versuchen.', origin);
+ }
+
+ // E-Mail prüfen (nur für die 2. Analyse nötig)
+ let mailKey: string | null = null;
+ if (email) {
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(email)) {
+ return demoFehler(400, 'email', 'Bitte geben Sie eine gültige E-Mail-Adresse ein.', origin);
+ }
+ const domain = email.split('@')[1];
+ if (WEGWERF_DOMAINS.has(domain)) {
+ return demoFehler(400, 'email', 'Wegwerf-E-Mail-Adressen sind nicht möglich. Bitte nutzen Sie Ihre normale Adresse.', origin);
+ }
+ mailKey = `demo:mail:${await emailHash(email, env.TURNSTILE_SECRET_KEY)}`;
+ }
+
+ // Schichten 2-5: Limits lesen
+ const heute = new Date().toISOString().slice(0, 10);
+ const tagKey = `demo:tag:${heute}`;
+ const ipKey = `demo:ip:${ip}`;
+ const anonKey = `demo:anon:${ip}`;
+ const budget = parseInt(env.DEMO_TAGES_BUDGET || '100', 10);
+ const ipLimit = parseInt(env.DEMO_LIMIT_IP || '3', 10);
+
+ const [tagWert, ipWert, anonWert, mailWert] = await Promise.all([
+ env.RATE_LIMIT.get(tagKey),
+ env.RATE_LIMIT.get(ipKey),
+ env.RATE_LIMIT.get(anonKey),
+ mailKey ? env.RATE_LIMIT.get(mailKey) : Promise.resolve(null),
+ ]);
+ const tagZahl = parseInt(tagWert ?? '0', 10);
+ const ipZahl = parseInt(ipWert ?? '0', 10);
+ const anonZahl = parseInt(anonWert ?? '0', 10);
+
+ // Schicht 5: globales Tagesbudget (Kosten-Deckel)
+ if (tagZahl >= budget) {
+ return demoFehler(429, 'budget', 'Die Gratis-Demo ist für heute ausgebucht. Kommen Sie morgen wieder — oder tragen Sie sich in die Warteliste ein.', origin);
+ }
+ // Schicht 4: IP-Gesamtlimit (30 Tage)
+ if (ipZahl >= ipLimit) {
+ return demoFehler(429, 'aufgebraucht', 'Die Gratis-Analysen für diesen Anschluss sind aufgebraucht. In der App gibt es 3 weitere gratis.', origin);
+ }
+ // Schicht 2/3: ohne E-Mail nur 1x, pro E-Mail nur 1x (dauerhaft)
+ if (!email && anonZahl >= 1) {
+ return demoFehler(403, 'email_noetig', 'Ihre erste Gratis-Analyse ist verbraucht. Für die zweite geben Sie bitte Ihre E-Mail-Adresse ein.', origin);
+ }
+ if (mailKey && mailWert !== null) {
+ return demoFehler(429, 'aufgebraucht', 'Mit dieser E-Mail-Adresse wurde die Gratis-Analyse schon genutzt. In der App gibt es 3 weitere gratis.', origin);
+ }
+
+ // KI-Analyse — identischer Aufbau wie in der App
+ const dateiBlock =
+ mimeType === 'application/pdf'
+ ? { type: 'document', source: { type: 'base64', media_type: 'application/pdf', data: bild } }
+ : { type: 'image', source: { type: 'base64', media_type: mimeType, data: bild } };
+
+ const antwort = await fetch(ANTHROPIC_URL, {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/json',
+ 'x-api-key': env.ANTHROPIC_API_KEY,
+ 'anthropic-version': ANTHROPIC_VERSION,
+ },
+ body: JSON.stringify({
+ model: 'claude-sonnet-5',
+ max_tokens: MAX_TOKENS_OBERGRENZE,
+ system: DEMO_SYSTEM_PROMPT,
+ output_config: { format: { type: 'json_schema', schema: DEMO_SCHEMA } },
+ messages: [
+ {
+ role: 'user',
+ content: [
+ dateiBlock,
+ { type: 'text', text: `Analysiere diesen Behördenbrief. Heute ist der ${heute} (wichtig für relative Datumsangaben wie "innerhalb von 14 Tagen").` },
+ ],
+ },
+ ],
+ }),
+ });
+
+ if (!antwort.ok) {
+ const status = antwort.status;
+ // Fehlerdetails ins Worker-Log (wrangler tail) — hilft bei der Diagnose
+ console.log('Demo-KI-Fehler', status, (await antwort.text()).slice(0, 500));
+ if (status === 429 || status >= 500) {
+ return demoFehler(503, 'ki', 'Der KI-Dienst ist gerade ausgelastet. Bitte versuchen Sie es in einer Minute erneut.', origin);
+ }
+ return demoFehler(502, 'ki', 'Die Analyse ist fehlgeschlagen. Bitte versuchen Sie es mit einem neuen, gut beleuchteten Foto.', origin);
+ }
+
+ const daten = (await antwort.json()) as {
+ stop_reason?: string;
+ content?: { type: string; text?: string }[];
+ };
+
+ // Erst NACH erfolgreichem KI-Aufruf zählen (Fehler kosten kein Kontingent)
+ const MONAT_TTL = 60 * 60 * 24 * 30;
+ const schreiben: Promise[] = [
+ env.RATE_LIMIT.put(tagKey, String(tagZahl + 1), { expirationTtl: 60 * 60 * 48 }),
+ env.RATE_LIMIT.put(ipKey, String(ipZahl + 1), { expirationTtl: MONAT_TTL }),
+ ];
+ if (!email) {
+ schreiben.push(env.RATE_LIMIT.put(anonKey, String(anonZahl + 1), { expirationTtl: MONAT_TTL }));
+ }
+ if (mailKey) {
+ // bewusst OHNE TTL: „pro E-Mail für immer nur 1" — es liegt nur der Hash
+ schreiben.push(env.RATE_LIMIT.put(mailKey, heute));
+ }
+ await Promise.all(schreiben);
+
+ if (daten.stop_reason === 'refusal') {
+ return demoFehler(422, 'inhalt', 'Die KI konnte diesen Inhalt nicht verarbeiten. Bitte prüfen Sie, ob das Foto wirklich einen Behördenbrief zeigt.', origin);
+ }
+ const textBlock = [...(daten.content ?? [])].reverse().find((b) => b.type === 'text');
+ if (!textBlock?.text) {
+ return demoFehler(502, 'ki', 'Die KI hat keine verwertbare Antwort geliefert. Bitte erneut versuchen.', origin);
+ }
+
+ let analyse: unknown;
+ try {
+ analyse = JSON.parse(textBlock.text);
+ } catch {
+ return demoFehler(502, 'ki', 'Die Antwort war unlesbar. Bitte erneut versuchen.', origin);
+ }
+
+ return Response.json(
+ { analyse, versuch: email ? 2 : 1 },
+ { headers: demoCorsHeaders(origin) }
+ );
+}
+
export default {
async fetch(request: Request, env: Env): Promise {
+ // Web-Demo hat einen eigenen Pfad mit eigenen Regeln
+ if (new URL(request.url).pathname === '/demo') {
+ return demoHandler(request, env);
+ }
+
if (request.method !== 'POST') {
return fehler(405, 'invalid_request_error', 'Nur POST erlaubt.');
}
diff --git a/proxy/wrangler.toml b/proxy/wrangler.toml
index 5b9a7f1..7df1ae1 100644
--- a/proxy/wrangler.toml
+++ b/proxy/wrangler.toml
@@ -8,6 +8,10 @@ compatibility_date = "2026-07-01"
[vars]
TAGES_LIMIT = "20"
TAGES_LIMIT_IP = "100"
+# Web-Demo (/demo): hartes Tagesbudget über alle Nutzer (Kosten-Deckel,
+# 100 Analysen ≈ 2,50 €/Tag Maximalschaden) + IP-Limit über 30 Tage
+DEMO_TAGES_BUDGET = "100"
+DEMO_LIMIT_IP = "3"
[[kv_namespaces]]
binding = "RATE_LIMIT"
diff --git a/webseite/datenschutz.html b/webseite/datenschutz.html
index 7fb5bc3..47ddc17 100644
--- a/webseite/datenschutz.html
+++ b/webseite/datenschutz.html
@@ -145,7 +145,38 @@
Ihre Adresse wird dann gelöscht.
-
8. Reichweitenmessung (Webseite)
+
8. Gratis-Demo (Webseite)
+
+ Auf unserer Webseite können Sie zwei Brief-Analysen kostenlos
+ ausprobieren, ohne die App zu installieren. Dabei gilt:
+
+
+
+ Ihr Brief-Foto wird — nur mit Ihrer Einwilligung
+ (Häkchen vor dem Hochladen, Art. 6 Abs. 1 lit. a, Art. 9 Abs. 2
+ lit. a DSGVO) — über unseren Server (Cloudflare) an Anthropic (USA)
+ zur Analyse geschickt. Es wird auf unserem Server nicht gespeichert;
+ bei Anthropic wird es nach spätestens 30 Tagen gelöscht (wie in
+ Abschnitt 3a beschrieben). Das Ergebnis sehen nur Sie in Ihrem
+ Browser; wir speichern es nicht.
+
+
+ Zum Schutz vor Missbrauch (Art. 6 Abs. 1 lit. f
+ DSGVO) prüfen wir Anfragen mit Cloudflare Turnstile (Bot-Erkennung
+ durch Cloudflare, Inc., USA), zählen Analysen pro IP-Adresse
+ (Speicherung max. 30 Tage) und führen ein tägliches Gesamtlimit.
+
+
+ Für die zweite Gratis-Analyse geben Sie Ihre
+ E-Mail-Adresse an. Wir speichern sie ausschließlich als
+ nicht umkehrbare Prüfsumme (Hash), um das Kontingent von zwei
+ Analysen durchzusetzen — die Adresse selbst wird nicht gespeichert
+ und nicht für E-Mails verwendet. In die Warteliste (Abschnitt 7)
+ kommen Sie nur, wenn Sie sich dort separat eintragen.
+
+
+
+
9. Reichweitenmessung (Webseite)
Auf dieser Webseite nutzen wir Cloudflare Web Analytics (Cloudflare,
Inc., USA) — eine datenschutzfreundliche Besucherstatistik
@@ -157,14 +188,14 @@
DSGVO).
-
9. Keine Rechtsberatung
+
10. Keine Rechtsberatung
BehördenKlar erklärt Briefe verständlich, ersetzt aber keine
Rechtsberatung. Bei rechtlich wichtigen Entscheidungen wenden Sie sich
an eine Beratungsstelle oder eine Anwältin/einen Anwalt.
-
10. Änderungen
+
11. Änderungen
Wir passen diese Datenschutzerklärung an, wenn sich die App oder die
Rechtslage ändert. Die aktuelle Fassung finden Sie immer auf dieser
diff --git a/webseite/demo.html b/webseite/demo.html
new file mode 100644
index 0000000..6b94143
--- /dev/null
+++ b/webseite/demo.html
@@ -0,0 +1,550 @@
+
+
+
+ Kein Konto. Keine App-Installation. Laden Sie ein Foto Ihres
+ Behördenbriefs hoch — in etwa einer Minute lesen Sie ihn in einfacher
+ Sprache. 2 Analysen gratis.
+
+
+
+
+
§ 1 Brief hochladen
+
+
Antrag auf Klartext · 2 Gratis-Analysen
+
+
+
+
+
+
+ 🎉 Ihre erste Analyse ist verbraucht. Für die
+ zweite geben Sie bitte Ihre E-Mail-Adresse ein — so stellen wir
+ sicher, dass jeder genau 2 Gratis-Analysen bekommt.
+
+
+
+ Die Adresse wird nur verschlüsselt (als Prüfsumme) gespeichert,
+ um mitzuzählen — Sie bekommen dadurch keine E-Mails von uns.
+
+
+
+
+
+
+
+
+
+ Geht mit Foto (JPG/PNG) oder PDF, max. 5 MB. Tipp: gutes Licht,
+ Brief von oben, ganzer Text im Bild.
+
+
+
+
+ ⏳
+
Ihr Brief wird analysiert…
+
Das dauert bis zu einer Minute. Bitte lassen Sie die Seite geöffnet.
+
+
+
+
+
+
+
+
§ 2 Ihr Brief in Klartext
+
+
+ Brief
+
+
+
+
+
+ Einfach erklärt
+
+
+
+
+ Das müssen Sie tun
+
+
+
+
+ Schwere Wörter erklärt
+
+
+
+
+ ⚖️ BehördenKlar erklärt Briefe, ersetzt aber keine Rechtsberatung. Bei
+ wichtigen Entscheidungen hilft eine Beratungsstelle oder ein Anwalt.
+
+
+
+
+
§ 3 Wie geht es weiter?
+
+
Warteliste
+
Das hat Ihnen geholfen?
+
+ Die App kann noch mehr: Briefe übersetzen in 9 Sprachen,
+ Fristen mit Erinnerung, fertige
+ Antwort-Entwürfe — und Ihre Briefe bleiben
+ verschlüsselt auf Ihrem Handy gespeichert.
+
+
+ 🎁 Warteliste = 5 Gratis-Analysen in der App statt 3.
+