diff --git a/proxy/src/index.ts b/proxy/src/index.ts
index b303c53..2dc9fc1 100644
--- a/proxy/src/index.ts
+++ b/proxy/src/index.ts
@@ -245,7 +245,8 @@ async function uebersetzeAnalyse(env: Env, analyse: unknown, sprachName: string)
if (d.stop_reason !== 'end_turn' || !text) return null;
return JSON.parse(text);
} catch (e) {
- console.log('Übersetzung fehlgeschlagen', String(e));
+ // Nur den Fehlertyp loggen: JSON.parse-Meldungen zitieren Teile der Eingabe (= Briefinhalt)
+ console.log('Übersetzung fehlgeschlagen', e instanceof Error ? e.name : 'unbekannt');
return null;
}
}
@@ -392,7 +393,9 @@ async function demoHandler(request: Request, env: Env): Promise {
? { 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, {
+ let antwort: Response;
+ try {
+ antwort = await fetch(ANTHROPIC_URL, {
method: 'POST',
headers: {
'content-type': 'application/json',
@@ -417,6 +420,10 @@ async function demoHandler(request: Request, env: Env): Promise {
],
}),
});
+ } catch (e) {
+ console.log('Demo-KI nicht erreichbar', String(e));
+ return demoFehler(503, 'ki', 'Der KI-Dienst ist gerade nicht erreichbar. Bitte versuchen Sie es in einer Minute erneut.', origin);
+ }
if (!antwort.ok) {
const status = antwort.status;
@@ -433,7 +440,13 @@ async function demoHandler(request: Request, env: Env): Promise {
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 {
+ let datenRoh: unknown;
+ try {
+ datenRoh = await antwort.json();
+ } catch {
+ return demoFehler(502, 'ki', 'Die KI hat keine verwertbare Antwort geliefert. Bitte erneut versuchen.', origin);
+ }
+ const daten = datenRoh as {
stop_reason?: string;
content?: { type: string; text?: string }[];
};
@@ -444,6 +457,17 @@ async function demoHandler(request: Request, env: Env): Promise {
if (bezahlt) {
// Bezahlte Analyse: Guthaben um 1 verringern (kein TTL — bleibt bestehen)
await env.RATE_LIMIT.put(`wallet:${walletId}`, String(walletRest - 1));
+ if (einrichtungsName) {
+ // Für die Auswertung mit der Beratungsstelle: nur Datum und Sprache,
+ // kein Briefinhalt, kein Bezug zu einer Person. Ein Schlüssel pro
+ // Analyse, damit gleichzeitige Analysen sich nicht überschreiben.
+ // Lesen: node tools/einrichtungs-code.js stand
+ await env.RATE_LIMIT.put(
+ `nutzung:${walletId}:${heute}:${sprachCode}:${crypto.randomUUID().slice(0, 8)}`,
+ '1',
+ { expirationTtl: 60 * 60 * 24 * 390 }, // < 13 Monate (siehe Datenschutzerklärung 9a)
+ );
+ }
} else {
const schreiben: Promise[] = [
env.RATE_LIMIT.put(tagKey, String(tagZahl + 1), { expirationTtl: TAG_TTL }),
@@ -476,11 +500,18 @@ async function demoHandler(request: Request, env: Env): Promise {
let analyseDe: unknown = undefined;
let ausgabeSprache = sprachCode;
+ let uebersetzungFehlgeschlagen = false;
if (zweisprachig) {
- analyseDe = analyse;
const uebersetzt = await uebersetzeAnalyse(env, analyse, sprachName);
- if (uebersetzt) analyse = uebersetzt;
- else ausgabeSprache = 'de'; // lieber Deutsch als gar nichts
+ if (uebersetzt) {
+ analyseDe = analyse;
+ analyse = uebersetzt;
+ } else {
+ // lieber Deutsch als gar nichts — aber nur EINE Fassung schicken,
+ // sonst zeigt die Seite zwei Knöpfe „Deutsch | Deutsch"
+ ausgabeSprache = 'de';
+ uebersetzungFehlgeschlagen = true;
+ }
}
return Response.json(
@@ -491,6 +522,7 @@ async function demoHandler(request: Request, env: Env): Promise {
guthaben: bezahlt ? walletRest - 1 : undefined,
einrichtung: einrichtungsName ?? undefined,
sprache: ausgabeSprache,
+ uebersetzung_fehlgeschlagen: uebersetzungFehlgeschlagen || undefined,
},
{ headers: demoCorsHeaders(origin) }
);
@@ -685,7 +717,15 @@ async function kaufStatusHandler(request: Request, env: Env): Promise
// Der zufällige Teil im Code macht Raten aussichtslos; zusätzlich sind
// Fehlversuche pro IP und Tag begrenzt.
-const CODE_FEHLVERSUCHE_PRO_TAG = 10;
+// Großzügig, weil sich eine ganze Beratungsstelle eine IP teilt. Raten bleibt
+// trotzdem aussichtslos: 31^4 ≈ 920.000 Codes je Präfix.
+const CODE_FEHLVERSUCHE_PRO_TAG = 30;
+
+/** „mbe k7qx", „MBEK7QX", „MBE-K7QX" → „MBE-K7QX" (Bindestrich vor den letzten 4 Zeichen). */
+function normCode(eingabe: string): string {
+ const roh = eingabe.toUpperCase().replace(/[^A-Z0-9]/g, '');
+ return roh.length > 4 ? `${roh.slice(0, -4)}-${roh.slice(-4)}` : roh;
+}
interface Einrichtung {
name: string;
@@ -716,7 +756,7 @@ async function codeHandler(request: Request, env: Env): Promise {
} catch {
return demoFehler(400, 'json', 'Ungültige Anfrage.', origin);
}
- const code = typeof body.code === 'string' ? body.code.trim().toUpperCase().replace(/\s+/g, '') : '';
+ const code = typeof body.code === 'string' ? normCode(body.code) : '';
const ip = request.headers.get('cf-connecting-ip') ?? 'unbekannt';
const heute = new Date().toISOString().slice(0, 10);
@@ -782,9 +822,8 @@ export class Uebergabe extends DurableObject {
const zahl = (await this.ctx.storage.get('zahl')) ?? 0;
if (zahl >= UEBERGABE_MAX_SCHREIBVORGAENGE) return false;
await this.ctx.storage.put({ [`fach:${fach}`]: wert, zahl: zahl + 1 });
- if ((await this.ctx.storage.getAlarm()) === null) {
- await this.ctx.storage.setAlarm(Date.now() + UEBERGABE_LEBENSDAUER_MS);
- }
+ // gleitend: 15 Minuten nach dem LETZTEN Päckchen löschen, nicht nach dem ersten
+ await this.ctx.storage.setAlarm(Date.now() + UEBERGABE_LEBENSDAUER_MS);
return true;
}
@@ -896,7 +935,12 @@ export default {
const pfad = new URL(request.url).pathname;
lokalTest = env.LOKAL_TEST === '1';
// Web-Demo + Bezahl-Routen haben eigene Regeln
- if (pfad === '/demo') return demoHandler(request, env);
+ if (pfad === '/demo') {
+ return demoHandler(request, env).catch((e) => {
+ console.log('Demo-Fehler', e instanceof Error ? e.name : 'unbekannt');
+ return demoFehler(503, 'dienst', 'Der Dienst ist gerade nicht erreichbar. Bitte versuchen Sie es in einer Minute erneut.', request.headers.get('origin'));
+ });
+ }
if (pfad === '/kaufen') return kaufenHandler(request, env);
if (pfad === '/kauf-status') return kaufStatusHandler(request, env);
if (pfad === '/code') return codeHandler(request, env);
diff --git a/proxy/wrangler.toml b/proxy/wrangler.toml
index fe121de..6116d8d 100644
--- a/proxy/wrangler.toml
+++ b/proxy/wrangler.toml
@@ -2,6 +2,11 @@ name = "behoerdenklar-proxy"
main = "src/index.ts"
compatibility_date = "2026-07-01"
+# Worker-Logs im Cloudflare-Dashboard (und `npx wrangler tail`) — hilft,
+# wenn live etwas schiefgeht (z. B. die Übersetzung)
+[observability]
+enabled = true
+
# Erlaubte Anfragen pro Tag: pro Gerät (normale Nutzung) und pro IP-Adresse
# (dämmt Angreifer ein, die Geräte-IDs fälschen; höher, weil sich viele
# Nutzer eine IP teilen können)
diff --git a/tools/einrichtungs-code.js b/tools/einrichtungs-code.js
index 7e93784..6306b87 100644
--- a/tools/einrichtungs-code.js
+++ b/tools/einrichtungs-code.js
@@ -1,8 +1,9 @@
/**
* Einrichtungs-Codes für Beratungsstellen anlegen und verwalten.
*
+ * node tools/einrichtungs-code.js neu "MBE Donnersberg" MBE --briefe 100 --bis 2026-12-18
* node tools/einrichtungs-code.js neu "MBE Donnersberg" MBE --briefe 100 --monate 1
- * node tools/einrichtungs-code.js stand MBE-K7QX
+ * node tools/einrichtungs-code.js stand MBE-K7QX (mit Nutzung nach Woche und Sprache)
* node tools/einrichtungs-code.js aufladen MBE-K7QX 100
* node tools/einrichtungs-code.js verlaengern MBE-K7QX 2027-06-30
*
@@ -49,8 +50,26 @@ function plusMonate(monate) {
return d.toISOString().slice(0, 10);
}
+/** Wie im Worker: „mbe k7qx" → „MBE-K7QX". */
+function normCode(c) {
+ const roh = String(c || '').toUpperCase().replace(/[^A-Z0-9]/g, '');
+ return roh.length > 4 ? `${roh.slice(0, -4)}-${roh.slice(-4)}` : roh;
+}
+
+/** Nutzung je Analyse (vom Worker: nutzung::::). */
+function nutzung(walletId) {
+ let liste = [];
+ try {
+ liste = JSON.parse(kv('list', '--prefix', `nutzung:${walletId}:`));
+ } catch { return null; }
+ return liste.map((e) => {
+ const [, , datum, sprache] = e.name.split(':');
+ return { datum, sprache };
+ });
+}
+
function ladeCode(code) {
- code = String(code || '').toUpperCase();
+ code = normCode(code);
const walletId = lies(`code:${code}`);
if (!walletId) abbruch(`Code ${code} gibt es nicht.`);
const einrichtung = JSON.parse(lies(`einrichtung:${walletId}`) || '{}');
@@ -58,14 +77,31 @@ function ladeCode(code) {
return { code, walletId, einrichtung, rest };
}
-function zeige({ code, einrichtung, rest }) {
+function zeige({ code, einrichtung, rest, walletId }) {
const heute = new Date().toISOString().slice(0, 10);
+ const n = walletId ? nutzung(walletId) : null;
+ let auswertung = '';
+ if (n && n.length) {
+ const proWoche = {}, proSprache = {};
+ for (const { datum, sprache } of n) {
+ const d = new Date(`${datum}T00:00:00Z`);
+ const montag = new Date(d.getTime() - ((d.getUTCDay() + 6) % 7) * 86400000).toISOString().slice(0, 10);
+ proWoche[montag] = (proWoche[montag] || 0) + 1;
+ proSprache[sprache] = (proSprache[sprache] || 0) + 1;
+ }
+ auswertung = `
+ Genutzt: ${n.length} Briefe
+ Pro Woche: ${Object.keys(proWoche).sort().map((w) => `ab ${w}: ${proWoche[w]}`).join(' · ')}
+ Sprachen: ${Object.entries(proSprache).sort((a, b) => b[1] - a[1]).map(([s, z]) => `${s} ${z}`).join(' · ')}`;
+ } else if (n) {
+ auswertung = '\n Genutzt: noch keine Briefe';
+ }
console.log(`
Einrichtung: ${einrichtung.name}
Code: \x1b[1m${code}\x1b[0m
Übrig: ${rest} Briefe
Gültig bis: ${einrichtung.ablauf}${einrichtung.ablauf < heute ? ' \x1b[31m(abgelaufen)\x1b[0m' : ''}
- Link: https://behoerdenklar.de/demo?code=${code}
+ Link: https://behoerdenklar.de/demo?code=${code}${auswertung}
`);
}
@@ -78,7 +114,10 @@ if (befehl === 'neu') {
abbruch('So geht es: node tools/einrichtungs-code.js neu "MBE Donnersberg" MBE --briefe 100 --monate 1');
}
const briefe = parseInt(option('briefe', '100'), 10);
- const ablauf = option('bis', plusMonate(parseInt(option('monate', '1'), 10)));
+ const monate = parseInt(option('monate', '1'), 10);
+ const ablauf = option('bis', Number.isFinite(monate) && monate > 0 ? plusMonate(monate) : '');
+ if (!(briefe > 0)) abbruch('--briefe braucht eine Zahl größer 0, z. B. --briefe 100');
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(ablauf)) abbruch('--bis braucht ein Datum wie 2026-12-18 (oder --monate 1)');
const code = `${praefix}-${zufall(4)}`;
if (lies(`code:${code}`)) abbruch('Zufällig schon vergeben — einfach nochmal ausführen.');
const walletId = crypto.randomUUID();
@@ -86,7 +125,7 @@ if (befehl === 'neu') {
schreib(`einrichtung:${walletId}`, JSON.stringify({ name, ablauf }));
schreib(`code:${code}`, walletId);
console.log('\n \x1b[32m✓ Code angelegt\x1b[0m');
- zeige({ code, einrichtung: { name, ablauf }, rest: briefe });
+ zeige({ code, einrichtung: { name, ablauf }, rest: briefe, walletId });
} else if (befehl === 'stand') {
zeige(ladeCode(a));
} else if (befehl === 'aufladen') {
diff --git a/webseite/datenschutz.html b/webseite/datenschutz.html
index dc4b7db..b013508 100644
--- a/webseite/datenschutz.html
+++ b/webseite/datenschutz.html
@@ -61,8 +61,9 @@
Anthropic PBC (USA) — den Betreiber des KI-Modells
„Claude". Anthropic verarbeitet die Daten ausschließlich zur
- Erstellung der Analyse, speichert sie maximal 30 Tage (zur
- Missbrauchserkennung) und verwendet sie nicht zum Training der KI.
+ Erstellung der Analyse, löscht sie in der Regel nach 30 Tagen
+ (länger nur, wenn ein Verdacht auf Missbrauch geprüft werden muss)
+ und verwendet sie nicht zum Training der KI.
@@ -105,15 +106,16 @@
4. Übermittlung in die USA
Anthropic PBC und Cloudflare, Inc. haben ihren Sitz in den USA. Die
- Übermittlung stützt sich auf den Angemessenheitsbeschluss der
- EU-Kommission zum EU-U.S. Data Privacy Framework bzw. auf
- EU-Standardvertragsklauseln (Art. 46 DSGVO).
+ Übermittlung stützt sich auf EU-Standardvertragsklauseln (Art. 46
+ Abs. 2 lit. c DSGVO), die Bestandteil der
+ Auftragsverarbeitungsverträge beider Anbieter sind.
5. Speicherdauer
-
Brieffotos bei Anthropic: maximal 30 Tage, danach automatische Löschung
+
Brieffotos bei Anthropic: in der Regel 30 Tage, danach automatische Löschung (länger nur bei Prüfung eines Missbrauchsverdachts)
Anonyme Geräte-Kennung + Tageszähler auf unserem Server: maximal 48 Stunden
+
Technische Server-Protokolle (Cloudflare Workers Logs: Zeitpunkt, aufgerufene Adresse, Fehlerart, technische Metadaten, kein Briefinhalt) zur Fehlersuche: maximal 7 Tage (Art. 6 Abs. 1 lit. f DSGVO)
Daten auf Ihrem Gerät: bis Sie sie löschen („Alle Daten löschen" in den Einstellungen oder Deinstallation der App)
@@ -157,7 +159,7 @@
(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
+ bei Anthropic wird es in der Regel nach 30 Tagen gelöscht (wie in
Abschnitt 3a beschrieben). Das Ergebnis sehen nur Sie in Ihrem
Browser; wir speichern es nicht.
@@ -222,9 +224,13 @@
Guthaben der Einrichtung, das wie in Abschnitt 9 beschrieben unter
einer zufälligen Kennung verwaltet wird. Wir erfahren dabei nicht, wer
den Code nutzt, und die Beratungsstelle sieht keine Briefe oder
- Ergebnisse, nur wie viele Analysen noch übrig sind. Gegen das Erraten
- von Codes zählen wir falsche Eingaben pro IP-Adresse (Speicherung
- max. 48 Stunden, Art. 6 Abs. 1 lit. f DSGVO).
+ Ergebnisse, nur Zahlen: wie viele Analysen noch übrig sind und wie
+ viele pro Woche und Sprache genutzt wurden. Für die Auswertung
+ mit der Beratungsstelle zählen wir pro Analyse über einen Code nur das
+ Datum und die gewählte Sprache, ohne Briefinhalt und ohne Bezug zu
+ einer Person (Speicherung max. 13 Monate). Gegen das Erraten von
+ Codes zählen wir falsche Eingaben pro IP-Adresse (Speicherung max.
+ 48 Stunden). Rechtsgrundlage ist jeweils Art. 6 Abs. 1 lit. f DSGVO.