Compare commits

..
5 Commits
Author SHA1 Message Date
JUSNandClaude Opus 5.5 6a7f35eb19 Praesentation MBE 29.10.: Handzettel, Musterbrief, Doku
- marketing/praesentation-mbe/handzettel: A4 zum Dalassen (Testzugang
  1 Monat, bis 100 Briefe), QR-Code wird nach Anlegen des Codes eingetragen
- musterbrief-auslaenderbehoerde: erfundene Behoerde/Person, zum Ausdrucken
  und Live-Fotografieren (Frist + Termin + Telefon + Fachbegriffe)
- Testphase im Preisblatt auf 1 Monat + Auswertungsgespraech
- FEATURES.md: Handy<->PC, Einrichtungs-Codes, lokal testen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 18:46:29 +02:00
JUSNandClaude Opus 5.5 cf188af080 App: QR-Uebergabe mit dem Beratungs-PC
- Brief -> "Am Computer zeigen": schickt den Brief verschluesselt auf den
  Bildschirm der Beratung (zweisprachig, wenn eine Uebersetzung da ist)
- Archiv -> "Vom Computer uebernehmen": holt das Ergebnis vom PC ins
  verschluesselte Archiv, mit Erinnerungen
- Gleiches Paeckchen-Format wie die Webseite (expo-crypto combined =
  IV||Chiffrat||Tag); Test prueft beide Richtungen gegen WebCrypto
- Link-Einfuegen als Ersatz, wenn keine Kamera da ist

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 18:46:29 +02:00
JUSNandClaude Opus 5.5 014737bd95 Demo-Seite: Einrichtungs-Code, Handy als Kamera fuer den PC, Mitnehmen per QR
- ?code=... oder Eingabefeld: kostenlos ueber die Beratungsstelle
- PC: "Lieber mit dem Handy fotografieren" - QR scannen, am Handy
  fotografieren, Ergebnis erscheint am PC auf Deutsch und am Handy in der
  Muttersprache (Umschalter zwischen beiden Sprachen)
- PC: "Ergebnis aufs Handy mitnehmen" und "Guthaben auch auf dem Handy"
- uebergabe.js: AES-256-GCM per WebCrypto, Schluessel nur hinter dem #
- qrcode.js (MIT, Kazuhiko Arase) selbst gehostet, keine Drittanfragen
- Datenschutzerklaerung: Abschnitte 9a (Einrichtungs-Code) und 9b (Uebergabe)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 18:46:29 +02:00
JUSNandClaude Opus 5.5 0eab2a0ee9 Worker: Einrichtungs-Codes, verschluesselte Uebergabe Handy<->PC, zweisprachige Analyse
- /code loest Einrichtungs-Codes fuer Beratungsstellen ein (Guthaben mit
  Name + Ablaufdatum, max. 10 Fehlversuche pro IP und Tag). Leeres oder
  abgelaufenes Kontingent sagt "fragen Sie bei <Stelle> nach" statt "kaufen".
- /uebergabe/<id>: Durable Object haelt nur Chiffrat (AES-GCM, Schluessel
  nur im QR-Code), loescht nach 15 Minuten, 50 Schreibvorgaenge, Rate-Limit.
  Durable Object statt KV, weil KV fehlende Schluessel zwischenspeichert.
- /demo mit auchDeutsch: deutsch analysieren (Sonnet), dann mit Haiku in die
  Zielsprache uebersetzen - PC zeigt Deutsch, Handy die Muttersprache.
- LOKAL_TEST=1 (.dev.vars) erlaubt localhost fuer Tests mit wrangler dev.
- tools/einrichtungs-code.js: neu | stand | aufladen | verlaengern.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 18:46:29 +02:00
JUSNandClaude Opus 5.5 7621dd054a Outreach: Stand der Beratungsstellen-Mails aktualisiert
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 18:13:47 +02:00
24 changed files with 4350 additions and 69 deletions

No files matched your search

+2
View File
@@ -27,6 +27,7 @@ import { AnalyseScreen } from './src/screens/AnalyseScreen';
import { AntwortScreen } from './src/screens/AntwortScreen';
import { GlossarScreen } from './src/screens/GlossarScreen';
import { EinstellungenScreen } from './src/screens/EinstellungenScreen';
import { VerbindenScreen } from './src/screens/VerbindenScreen';
const Stack = createNativeStackNavigator<RootStackParamList>();
const Tab = createBottomTabNavigator<HauptTabParamList>();
@@ -131,6 +132,7 @@ export default function App() {
<Stack.Screen name="Antwort" component={AntwortScreen} options={{ title: 'Antwort erstellen' }} />
<Stack.Screen name="Glossar" component={GlossarScreen} options={{ title: 'Behörden-Glossar' }} />
<Stack.Screen name="Einstellungen" component={EinstellungenScreen} options={{ title: 'Einstellungen' }} />
<Stack.Screen name="Verbinden" component={VerbindenScreen} options={{ title: 'Verbinden' }} />
</Stack.Navigator>
</NavigationContainer>
</AppSchutz>
+33 -2
View File
@@ -1,6 +1,6 @@
# BehördenKlar — Überblick für Claude
> Stand: 23.09.2026. Diese Datei beschreibt, **was es gibt**.
> Stand: 22.10.2026. Diese Datei beschreibt, **was es gibt**.
> Für Release-Stand und offene Aufgaben siehe die Projekt-Erinnerung.
> Für Arbeitsregeln siehe `AGENTS.md` (Expo-Docs vor Code lesen!).
@@ -92,6 +92,30 @@ Sprechzeiten — und schreibt einen **Spickzettel** mit 3–5 Sätzen für das T
**Regel im Prompt:** Kontaktdaten nur, wenn sie **wörtlich** im Brief stehen.
Getestet mit einem Brief ohne Telefonnummer → `telefon: null`, nichts erfunden.
Eine falsche Nummer wäre schlimmer als keine.
### 3c. Handy ↔ PC — für die Beratung (seit 22.10.)
Ein QR-Code verbindet zwei Geräte, **Ende-zu-Ende verschlüsselt** (AES-256-GCM).
Schlüssel und Übergabe-ID stehen hinter dem `#` der Adresse, gehen also nie an
einen Server. Der Worker hält nur Chiffrat, höchstens 15 Minuten
(Durable Object `Uebergabe`, Route `/uebergabe/<id>[/<fach>]`).
| Art (`a=`) | Wer zeigt den QR | Was passiert |
|---|---|---|
| `k` Kamera | PC: „Lieber mit dem Handy fotografieren" | Handy fotografiert, Ergebnis erscheint am PC **auf Deutsch** und am Handy **in der Muttersprache** (`auchDeutsch` → Worker analysiert deutsch und übersetzt mit Haiku, kostet 1 Analyse). Guthaben des PCs gilt am Handy nur für diese Sitzung. |
| `m` Mitnehmen | PC: „Ergebnis aufs Handy mitnehmen" | Ergebnis landet auf dem Handy (Browser) bzw. im App-Archiv, einmal abholbar |
| `g` Guthaben | PC: „Auch auf dem Handy nutzen" | Guthaben/Einrichtungs-Zugang dauerhaft aufs Handy (Browser) |
Gleiches Päckchen-Format in `webseite/uebergabe.js` und `src/services/uebergabe.ts`
(IV‖Chiffrat‖Tag, base64url) — Test: `src/utils/__tests__/uebergabe.test.ts`.
**App:** Brief → „Am Computer zeigen" (`a=k`), Archiv → „Vom Computer übernehmen"
(`a=m`), Bildschirm `VerbindenScreen` mit QR-Scanner und Link-Einfügen als Ersatz.
### 3d. Einrichtungs-Codes für Beratungsstellen (seit 22.10.)
Die Einrichtung zahlt, Ratsuchende nicht. Ein Code (z. B. `MBE-K7QX`) zeigt auf
ein Guthaben wie beim Kauf, mit Name und Ablaufdatum. Einlösen per Link
`behoerdenklar.de/demo?code=…` oder Eingabefeld; Banner „Kostenlos über …".
Verwalten mit `node tools/einrichtungs-code.js neu|stand|aufladen|verlaengern`.
Falsche Codes: max. 10 pro IP und Tag.
### 4. Ampel statt Datum
Rot ≤ 7 Tage, gelb ≤ 21, sonst grün. Die Startseite sortiert nach
**Dringlichkeit**, nicht nach Eingang — man sieht sofort, was brennt.
@@ -207,6 +231,8 @@ und Sprachwähler in der handgemachten `index.html` nach und schreibt die
| `/kaufen` | Stripe-Bezahlseite erzeugen |
| `/kauf-status` | Nach Rückkehr: Zahlung prüfen, Guthaben freischalten |
| `/stripe-webhook` | Sicherungsnetz + Wiederherstellungs-Mail |
| `/code` | Einrichtungs-Code einlösen → signiertes Guthaben-Token |
| `/uebergabe/<id>` | Handy ↔ PC: verschlüsselte Päckchen (Durable Object, 15 Min.) |
### Bezahlung (LIVE, echtes Geld)
- **Guthaben statt Abo:** 5 Analysen 2,99 € · 15 Analysen 6,99 €
@@ -247,11 +273,16 @@ rotiert werden.
```bash
npm run pruefung # Gesundheits-Check — nach JEDEM Deploy
npm test # 35 Tests
npm test # 43 Tests
npx tsc --noEmit # Typen
node tools/sprachseiten/build.js # Sprachseiten neu bauen
```
**Lokal testen** (ohne live zu gehen): in `/Users/homefolder/Behörden_App/.claude/launch.json`
`proxy-worker` (Port 8799, `.dev.vars` mit `LOKAL_TEST=1` und Turnstile-Testschlüssel)
+ `webseite` (Port 4173) starten, dann `http://localhost:4173/demo.html`. Auf
localhost nimmt die Seite automatisch den lokalen Worker und den Test-Sitekey.
**Deploy:**
```bash
cd proxy && npx wrangler deploy # Worker
+183 -40
View File
@@ -20,39 +20,59 @@ selbst ausprobieren. Das überzeugt mehr als jedes PDF.
| **MBE Donnersberg** (Migrationsberatung, Kirchheimbolanden) | mbe@fid-donnersberg.de | 06352 7897955 | Erstmail 17.07. · Nachfass: ____ | ____ | |
| **Diakonie Donnersbergkreis** (Soziale Lebensberatung, Kirchheimbolanden) | slb.kibo@diakonie-pfalz.de | *auf diakonie-pfalz.de nachsehen* | ____ | ____ | |
| **Caritas-Zentrum Kaiserslautern** (Migrationsberatung, Engelsgasse 1) | Migration.Kaiserslautern@caritas-speyer.de | 0631 36120-222 | ____ | ____ | |
| **DRK Kaiserslautern-Stadt** (Migrationsberatung, Barbarossastraße 27) — Birgit Steinmann, Leitung · nutzt schon mbeon | b.steinmann@kv-kls.drk.de | 0631 80093-140 | ____ | ____ | |
---
## Mail 1 — MBE Donnersberg *(Nachfass-Mail — die haben im Juli schon eine bekommen)*
**Von:** justin@webklar.com *(direkt aus dem Webklar-Postfach senden)*
**An:** mbe@fid-donnersberg.de
**Betreff:** Nachtrag zu meiner Mail vom Juli: BehördenKlar jetzt im Browser nutzbar
**Betreff:** Nachtrag zu meiner Mail vom Juli: BehördenKlar jetzt ausprobierbar
> Bewusst kürzer als die Erstmail. Die Juli-Mail nannte noch „iPhone-App", ein Abo
> (2,99 €/Monat) und Anhänge — beides stimmt so nicht mehr, deshalb Preis ehrlich
> korrigieren (Pakete statt Abo). Nach Absenden: ANRUFEN (06352 7897955), die Mail
> ist schon 2½ Monate her.
Sehr geehrtes Team der Migrationsberatung,
im Juli hatte ich Ihnen von BehördenKlar geschrieben, einem Werkzeug, das
Behördenbriefe in einfacher Sprache erklärt. Seitdem hat sich viel getan, deshalb
melde ich mich noch einmal kurz.
im Juli hatte ich Ihnen von BehördenKlar geschrieben. Seitdem hat sich viel getan,
deshalb melde ich mich noch einmal kurz.
Man kann es jetzt direkt im Browser nutzen — auf jedem Handy, ohne App und ohne Konto:
**https://behoerdenklar.de/demo**
Schwierige Briefe vom Amt kenne ich von klein auf: Meine Mutter ist selbst nach
Deutschland gekommen, und solche Briefe gehörten bei uns zum Alltag. Deshalb arbeite
ich an einer App, die Behördenbriefe in einfacher Sprache erklärt, auf Wunsch in
einer von 27 Sprachen, darunter Arabisch, Farsi, Paschtu, Kurdisch, Tigrinya, Türkisch
und Ukrainisch.
- Brief fotografieren → Erklärung in einfachem Deutsch **oder in einer von 27
Sprachen**, u. a. Arabisch, Farsi, Kurdisch, Tigrinya und Ukrainisch. Die deutschen
Fachbegriffe bleiben stehen, damit man sie im Brief wiederfindet.
- **Frist und Termin mit einem Tipp in den Handy-Kalender**, mit Erinnerung vorher.
- **Anruf-Hilfe:** Telefonnummer und Aktenzeichen aus dem Brief, dazu ein kurzer
Spickzettel, was man am Telefon sagen kann.
Die App ist noch in Arbeit, aber die Browser-Version läuft schon, auf jedem Handy,
ohne Konto und ohne Installation: https://behoerdenklar.de/demo
Es soll Ihre Beratung nicht ersetzen — eher dafür sorgen, dass Menschen vorbereitet
zu Ihnen kommen und keine Frist verpassen.
- Die deutschen Fachbegriffe bleiben sichtbar, damit man sie im Brief wiederfindet.
- Fristen und Termine kommen mit einem Tipp in den Handy-Kalender, damit keine Frist
untergeht.
- Für Anrufe beim Amt gibt es einen Spickzettel mit der Bedeutung in der eigenen
Sprache.
Dürfte ich es Ihnen einmal 10 Minuten zeigen? Gern bei Ihnen in Kirchheimbolanden
oder am Telefon — ich richte mich nach Ihrer Zeit.
Es soll Ihre Beratung nicht ersetzen, sondern dafür sorgen, dass Menschen vorbereitet
zu Ihnen kommen.
Eine Korrektur zu meiner Juli-Mail: Es wird kein Abo geben. Zwei Briefe sind gratis,
danach gibt es kleine Pakete ab 2,99 €, weil die KI pro Brief Geld kostet. Für
Beratungsstellen möchte ich eine eigene, deutlich günstigere Lösung anbieten, bei der
die Ratsuchenden selbst nichts bezahlen. Ums große Geld geht es mir dabei nicht. Wie
das für Sie aussehen könnte, würde ich gern gemeinsam mit Ihnen überlegen.
Dürfte ich es Ihnen 10 Minuten zeigen, gern bei Ihnen in Kirchheimbolanden oder am
Telefon? Mich interessiert vor allem Ihre ehrliche Einschätzung, solange die App noch
entsteht: Was würde Ihren Ratsuchenden wirklich helfen, und was fehlt noch?
Mit freundlichen Grüßen
Justin Klein
Webklar GbR · Winnweiler · behoerdenbriefhelfer@gmail.com
Webklar GbR · Winnweiler
Tel. 0170 4969375
justin@webklar.com
---
@@ -92,35 +112,136 @@ Webklar GbR · Winnweiler · behoerdenbriefhelfer@gmail.com
## Mail 3 — Caritas Kaiserslautern *(Erstmail)*
**Von:** justin@webklar.com *(direkt aus dem Webklar-Postfach senden)*
**An:** Migration.Kaiserslautern@caritas-speyer.de
**Betreff:** Behördenbriefe in 27 Sprachen erklärt — 10 Minuten für Ihre Einschätzung?
**Betreff:** Behördenbriefe in 27 Sprachen erklärt – als Ergänzung zu Ihrer Beratung
> Aufbau wie Mail 4 (DRK). Kein mbeon-Absatz, weil unbekannt ist, ob Caritas es nutzt.
> Steht auf caritas.de oder der Zentrum-Seite ein Name der Leitung: Anrede
> persönlich machen („Sehr geehrte Frau …") — wirkt deutlich besser als „Team".
Sehr geehrtes Team der Migrationsberatung,
mein Name ist Justin Klein, ich komme aus Winnweiler. Ich habe BehördenKlar
entwickelt: eine Webseite, die Behördenbriefe in einfacher Sprache erklärt — auf
Wunsch in einer von 27 Sprachen, darunter Arabisch, Farsi/Dari, Paschtu, Kurdisch,
Tigrinya, Somali und Ukrainisch.
mein Name ist Justin Klein, ich komme aus Winnweiler. Schwierige Briefe vom Amt kenne
ich von klein auf: Meine Mutter ist selbst nach Deutschland gekommen, und solche
Briefe gehörten bei uns zum Alltag. Ich weiß, wie viel Sorge ein Brief machen kann,
den man nicht versteht.
Sie funktioniert direkt im Browser, auf jedem Handy, ohne App und ohne Konto:
**https://behoerdenklar.de/demo**
Deshalb arbeite ich an BehördenKlar: einer App, die Behördenbriefe in einfacher
Sprache erklärt, auf Wunsch in einer von 27 Sprachen, darunter Arabisch,
Farsi, Paschtu, Kurdisch, Tigrinya, Türkisch und Ukrainisch.
- Brief fotografieren → Erklärung in der eigenen Sprache. Die **deutschen
Fachbegriffe bleiben sichtbar**, damit man sie im Brief wiederfindet.
- **Frist und Termin mit einem Tipp in den Handy-Kalender**, mit Erinnerung vorher.
- **Anruf-Hilfe:** Telefonnummer und Aktenzeichen aus dem Brief und ein Spickzettel
für das Telefonat — jeder Satz auf Deutsch, darunter die Bedeutung in der eigenen
Sprache.
Ich habe mir dabei viele Gedanken gemacht, was den Menschen, die zu Ihnen kommen,
wirklich hilft:
Es ersetzt Ihre Beratung nicht. Es soll helfen, dass Ihre Klientinnen und Klienten
vorbereitet zu Ihnen kommen und zwischen den Terminen keine Frist verpassen.
- Es braucht kein Konto, damit niemand schon an einer Anmeldung scheitert, und die
Browser-Version auch keine Installation.
- Die deutschen Fachbegriffe bleiben sichtbar, damit man sie im Brief wiederfindet
und gezielt nachfragen kann.
- Fristen und Termine kommen mit einem Tipp in den Handy-Kalender, damit keine Frist
untergeht.
- Für Anrufe beim Amt gibt es einen Spickzettel: jeder Satz auf Deutsch, darunter die
Bedeutung in der eigenen Sprache.
- Unter jeder Erklärung steht der Hinweis, dass bei wichtigen Entscheidungen eine
Beratungsstelle hilft.
- Die Briefe werden nicht dauerhaft gespeichert.
Die App ist noch in Arbeit, aber die Browser-Version können Sie schon jetzt direkt
selbst ausprobieren: https://behoerdenklar.de/demo
BehördenKlar soll Ihre Beratung nicht ersetzen, sondern davor ansetzen: Wer einen
Brief bekommt, versteht sofort, worum es geht und ob es eilt, auch abends oder am
Wochenende. Mit den wichtigen Fragen kommt man dann vorbereitet zu Ihnen und
verpasst zwischen den Terminen keine Frist.
Ums große Geld geht es mir dabei nicht, aber die KI, die die Briefe erklärt, kostet
mich pro Brief Geld. Deshalb ist die Browser-Version für Privatleute nicht ganz kostenlos: Zwei
Briefe sind gratis, danach gibt es kleine Pakete ab 2,99 €. Für Beratungsstellen
möchte ich eine eigene, deutlich günstigere Lösung anbieten, bei der die Ratsuchenden
selbst nichts bezahlen. Wie das für Sie aussehen könnte, würde ich gern gemeinsam mit
Ihnen überlegen.
Dürfte ich es Ihnen 10 Minuten zeigen, gern bei Ihnen in der Engelsgasse oder am
Telefon? Ich richte mich nach Ihrer Zeit.
Telefon? Mich interessiert vor allem Ihre ehrliche Einschätzung, solange die App noch
entsteht: Was würde Ihren Ratsuchenden wirklich helfen, und was fehlt noch?
Mit freundlichen Grüßen
Justin Klein
Webklar GbR · Winnweiler · behoerdenbriefhelfer@gmail.com
Webklar GbR · Winnweiler
Tel. 0170 4969375
justin@webklar.com
---
## Mail 4 — DRK Kaiserslautern-Stadt *(Erstmail, persönlich an die Leitung)*
**Von:** justin@webklar.com *(direkt aus dem Webklar-Postfach senden, nicht über
Gmail „Senden als" — webklar.com hat DMARC `p=quarantine`, sonst droht Spam-Ordner)*
**An:** b.steinmann@kv-kls.drk.de
**Betreff:** Behördenbriefe in 27 Sprachen erklärt – als Ergänzung zu mbeon
> Hintergrund: Das DRK KL berät schon über **mbeon** (Online-Beratung der MBE, für
> Ratsuchende kostenlos). Deshalb als *Ergänzung* positionieren, nie als Konkurrenz.
> Ton: helfend, persönlich (Mutter), Gedanken zeigen statt behaupten (jeder Punkt
> mit „damit …"). „Ums große Geld geht es mir nicht" nur EINMAL — öfter wirkt es wie
> das Gegenteil. Preise: Webseite = Privatleute, Beratungsstellen deutlich günstiger,
> Ratsuchende zahlen nichts, KEINE Zahlen (siehe „Preise für Beratungsstellen").
> Alle Funktions-Aussagen am 30.09. gegen demo.html + Demo-Prompt geprüft.
> KI + Datenschutz MÜSSEN in der Mail stehen (kurz, Details erst im Gespräch): Die Demo-Seite nennt vor dem Hochladen
> „KI-Dienst Anthropic (USA)" — fehlt das in der Mail, wirkt es verschwiegen.
> Fragt sie „Was kostet Sie ein Brief?": ehrlich antworten (ein paar Cent KI +
> Zahlungsgebühren + Server + Arbeitszeit), nicht ausweichen.
Sehr geehrte Frau Steinmann,
mein Name ist Justin Klein, ich komme aus Winnweiler. Schwierige Briefe vom Amt kenne
ich von klein auf: Meine Mutter ist selbst nach Deutschland gekommen, und solche
Briefe gehörten bei uns zum Alltag. Ich weiß, wie viel Sorge ein Brief machen kann,
den man nicht versteht.
Deshalb arbeite ich an BehördenKlar: einer App, die Behördenbriefe in einfacher
Sprache erklärt, auf Wunsch in einer von 27 Sprachen, darunter Arabisch,
Farsi, Paschtu, Kurdisch, Tigrinya, Türkisch und Ukrainisch.
Ich habe mir dabei viele Gedanken gemacht, was den Menschen, die zu Ihnen kommen,
wirklich hilft:
- Es braucht kein Konto, damit niemand schon an einer Anmeldung scheitert, und die
Browser-Version auch keine Installation.
- Die deutschen Fachbegriffe bleiben sichtbar, damit man sie im Brief wiederfindet
und gezielt nachfragen kann.
- Fristen und Termine kommen mit einem Tipp in den Handy-Kalender, damit keine Frist
untergeht.
- Für Anrufe beim Amt gibt es einen Spickzettel: jeder Satz auf Deutsch, darunter die
Bedeutung in der eigenen Sprache.
- Unter jeder Erklärung steht der Hinweis, dass bei wichtigen Entscheidungen eine
Beratungsstelle hilft.
- Die Briefe werden nicht dauerhaft gespeichert.
Die App ist noch in Arbeit, aber die Browser-Version können Sie schon jetzt direkt
selbst ausprobieren: https://behoerdenklar.de/demo
Ich habe gesehen, dass Sie auch über mbeon beraten. BehördenKlar soll das nicht
ersetzen, sondern davor ansetzen: Wer einen Brief bekommt, versteht sofort, worum es
geht und ob es eilt, auch abends oder am Wochenende. Mit den wirklich wichtigen
Fragen kommt man dann vorbereitet zu Ihnen, persönlich oder über mbeon.
Ums große Geld geht es mir dabei nicht, aber die KI, die die Briefe erklärt, kostet
mich pro Brief Geld.
Deshalb ist die Browser-Version für Privatleute nicht ganz kostenlos: Zwei Briefe sind gratis,
danach gibt es kleine Pakete ab 2,99 €. Für Beratungsstellen möchte ich eine eigene,
deutlich günstigere Lösung anbieten, bei der die Ratsuchenden selbst nichts bezahlen.
Wie das für Sie aussehen könnte, würde ich gern gemeinsam mit Ihnen überlegen.
Dürfte ich es Ihnen 10 Minuten zeigen, gern bei Ihnen in der Barbarossastraße oder am
Telefon? Mich interessiert vor allem Ihre ehrliche Einschätzung, solange die App noch
entsteht: Was würde Ihren Ratsuchenden wirklich helfen, und was fehlt noch?
Mit freundlichen Grüßen
Justin Klein
Webklar GbR · Winnweiler
Tel. 0170 4969375
justin@webklar.com
---
@@ -164,9 +285,10 @@ fehlt noch" ist für dich schon ein Erfolg.
> erfinden wir nicht: Steht nichts im Brief, sagen wir das.
**„Was kostet das für unsere Klienten?"**
> Zwei Briefe sind kostenlos. Danach 2,99 € für fünf Briefe oder 6,99 € für
> fünfzehn — kein Abo. Wenn Sie eine Idee haben, wie das für Ihre Einrichtung
> einfacher geht, bin ich offen dafür.
> Für Privatleute: zwei Briefe kostenlos, danach 2,99 € für fünf oder 6,99 € für
> fünfzehn — kein Abo. Für Beratungsstellen soll es anders laufen: Die Einrichtung
> bekommt ein Briefkontingent zu einem deutlich günstigeren Preis, und die
> Ratsuchenden zahlen gar nichts. *(Zahlen: siehe „Preise für Beratungsstellen".)*
**„Ersetzt das unsere Beratung?"**
> Nein. Bei rechtlich wichtigen Entscheidungen verweisen wir ausdrücklich auf
@@ -187,6 +309,27 @@ fehlt noch" ist für dich schon ein Erfolg.
4. Darf ich Ihre Stelle auf meiner Seite als Beratungsstelle nennen?
*(Und umgekehrt: Würden Sie auf BehördenKlar verlinken? — Das hilft enorm bei Google.)*
**Nicht in die Mail, erst im Gespräch:** die Idee eines Guthabens, das die
Einrichtung für ihre Klienten bereitstellt. Das gibt es technisch noch nicht —
also nichts versprechen, nur fragen, ob das interessant wäre.
---
## Preise für Beratungsstellen — nur im Gespräch, nie in die Mail *(Vorschlag 30.09.)*
**Grundidee:** Die Einrichtung bezahlt, die Ratsuchenden zahlen nichts. Die
Einrichtung kauft vorher ein **Briefkontingent** — jeder Brief ist bezahlt, bevor er
genutzt wird. Mehr Nutzer heißt mehr Einnahmen, nie mehr Verlust.
| | Preis | pro Brief |
|---|---|---|
| Deine Kosten (eigene Rechnung ~2,5 ct, mit Puffer für fremde Sprachen) | — | ~5 ct |
| Privatleute (Webseite) | 2,99 € für 5 · 6,99 € für 15 | 47–60 ct |
| **Test** für eine Beratungsstelle, 1 Monat (verlängerbar) + Auswertungsgespräch | bis 100 Briefe gratis | kostet dich max. ~5 € |
| **Kleines Paket** | 100 Briefe · 20 € | 20 ct |
| **Großes Paket** | 500 Briefe · 75 € | 15 ct |
| **Untergrenze beim Verhandeln** | — | 10 ct |
- **Nie „unbegrenzt"** anbieten — immer eine feste Briefzahl. Wenn sie „Lizenz"
sagen: „Jahreslizenz mit X Briefen" ist dasselbe.
- **Keine Mehrwertsteuer** (Kleinunternehmer §19) — der Preis ist der Endpreis.
- Echte Kosten einmal in der Anthropic-Konsole unter „Usage" nachsehen, dann ist
die Untergrenze sicher.
- Den **Einrichtungs-Code gibt es technisch noch nicht** — nichts mit Datum
versprechen. Bauen, sobald eine Stelle ernsthaft Ja sagt.
+202
View File
@@ -0,0 +1,202 @@
<!DOCTYPE html>
<!--
Handzettel für Beratungsstellen (DIN A4, zum Dalassen nach der Präsentation).
CODE unten eintragen, sobald der Einrichtungs-Code angelegt ist
(node tools/einrichtungs-code.js neu "MBE Donnersberg" MBE --briefe 100 --monate 1),
dann neu als PDF ausgeben:
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --headless=new \
--no-pdf-header-footer --print-to-pdf=handzettel.pdf "file://$PWD/handzettel.html"
-->
<html lang="de">
<head>
<meta charset="UTF-8">
<title>BehördenKlar für Beratungsstellen</title>
<script src="../../webseite/qrcode.js"></script>
<style>
@page { size: A4; margin: 0; }
:root {
--papier: #faf7f0; --tinte: #21211d; --grau: #6b675e;
--amtsblau: #1d4e89; --stempelrot: #b3372b; --marker: #ffe66b; --gruen: #2e7d4f;
}
* { margin: 0; padding: 0; box-sizing: border-box; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
body { font-family: Georgia, 'Times New Roman', serif; color: var(--tinte); background: #fff; }
.seite {
width: 210mm; height: 297mm; overflow: hidden; position: relative;
padding: 11mm 14mm 0;
background: repeating-linear-gradient(0deg, transparent 0 6.5mm, rgba(33,33,29,0.03) 6.5mm 6.7mm), var(--papier);
font-size: 9.6pt; line-height: 1.42;
}
.mono { font-family: 'Courier New', ui-monospace, monospace; }
.briefkopf { display: flex; justify-content: space-between; align-items: baseline; padding-bottom: 2mm; border-bottom: 0.6mm solid var(--tinte); }
.absender { font-weight: 700; font-size: 12.5pt; }
.absender small { font-weight: 400; font-size: 8pt; opacity: .6; letter-spacing: .04em; }
.az { color: var(--grau); font-size: 8pt; }
.betreff { margin-top: 5mm; font-size: 8pt; color: var(--grau); text-transform: uppercase; letter-spacing: .14em; }
h1 { font-size: 23pt; line-height: 1.14; margin-top: 1.5mm; letter-spacing: -0.01em; }
.markiert { background: linear-gradient(104deg, transparent 2%, var(--marker) 4%, var(--marker) 96%, transparent 98%); padding: 0 1.5mm; }
.anrede { margin-top: 3mm; font-size: 10.5pt; max-width: 165mm; }
.schritte { display: flex; gap: 3mm; margin-top: 4.5mm; }
.schritt { flex: 1; border: 0.4mm solid var(--tinte); background: #fff; padding: 2.2mm 3mm; font-size: 9pt; line-height: 1.3; }
.schritt b { font-family: 'Courier New', monospace; color: var(--amtsblau); margin-right: 1mm; }
h2 { font-size: 8pt; text-transform: uppercase; letter-spacing: .14em; color: var(--grau); font-weight: 400;
margin: 5mm 0 2.5mm; display: flex; align-items: center; gap: 3mm; }
h2::after { content: ''; flex: 1; border-top: 0.25mm solid rgba(33,33,29,.25); }
.raster { display: grid; grid-template-columns: 1fr 1fr; gap: 2.4mm 5mm; }
.punkt { display: flex; gap: 2.5mm; align-items: flex-start; }
.punkt .ik { font-size: 12pt; line-height: 1.2; width: 6mm; flex-shrink: 0; text-align: center; }
.punkt h3 { font-size: 10pt; line-height: 1.25; }
.punkt p { color: #3a3833; font-size: 8.8pt; line-height: 1.36; }
.punkt.neu h3::after { content: 'NEU'; font-family: 'Courier New', monospace; font-size: 6.5pt; letter-spacing: .1em;
color: var(--stempelrot); border: 0.3mm solid var(--stempelrot); padding: 0 1mm; margin-left: 1.5mm; vertical-align: 1px; }
.unten { display: grid; grid-template-columns: 1fr 72mm; gap: 5mm; margin-top: 5mm; }
.kasten { background: #fff; border: 0.5mm solid var(--tinte); padding: 3.5mm 4mm; position: relative; }
.kasten .label { position: absolute; top: -2.6mm; left: 3.5mm; font-family: 'Courier New', monospace; font-size: 7pt;
text-transform: uppercase; letter-spacing: .1em; background: var(--tinte); color: var(--papier); padding: .4mm 2mm; }
.kasten ul { list-style: none; font-size: 8.7pt; line-height: 1.38; }
.kasten li { padding-left: 4mm; position: relative; margin-bottom: 1mm; }
.kasten li::before { content: '✓'; position: absolute; left: 0; color: var(--gruen); font-weight: 700; }
.kasten li.ehrlich::before { content: '!'; color: var(--stempelrot); left: .8mm; }
.angebot { border: 0.7mm solid var(--tinte); box-shadow: 1.6mm 1.8mm 0 rgba(33,33,29,.9); background: #fff; padding: 4mm; text-align: center; }
.angebot .kopf { font-family: 'Courier New', monospace; font-size: 7.5pt; text-transform: uppercase; letter-spacing: .1em; color: var(--grau); }
.angebot .gross { font-size: 13pt; font-weight: 700; line-height: 1.25; margin: 1.5mm 0; }
.angebot .gross .markiert { padding: 0 1mm; }
.angebot .klein { font-size: 8pt; color: var(--grau); line-height: 1.35; }
#qr { width: 34mm; height: 34mm; margin: 2.5mm auto 1.5mm; }
#qr svg { width: 100%; height: 100%; display: block; }
.code { font-family: 'Courier New', monospace; font-weight: 700; font-size: 13pt; letter-spacing: .08em; background: var(--marker); display: inline-block; padding: .3mm 2.5mm; }
.link { font-family: 'Courier New', monospace; font-size: 7.5pt; color: var(--amtsblau); word-break: break-all; margin-top: 1mm; }
.chatgpt { margin-top: 4mm; font-size: 8.7pt; line-height: 1.4; color: #3a3833; }
.chatgpt b { color: var(--tinte); }
.fuss { position: absolute; left: 14mm; right: 14mm; bottom: 8mm; border-top: 0.4mm solid var(--tinte); padding-top: 2mm;
display: flex; justify-content: space-between; align-items: flex-end; font-size: 8pt; line-height: 1.4; }
.fuss .wer b { font-size: 9.5pt; }
.stempel { display: inline-block; padding: 1mm 2.5mm; border: 0.5mm solid var(--stempelrot); color: var(--stempelrot);
font-family: 'Courier New', monospace; font-weight: 700; font-size: 7.5pt; letter-spacing: .1em;
text-transform: uppercase; transform: rotate(-4deg); }
</style>
</head>
<body>
<div class="seite">
<div class="briefkopf">
<span class="absender">📬 BehördenKlar <small>by Webklar</small></span>
<span class="az mono">Az: KLAR-BERATUNG/2026 · Anlage: 1 Testzugang</span>
</div>
<p class="betreff">Für Beratungsstellen · Migrationsberatung · Soziale Beratung</p>
<h1>Ihre Ratsuchenden verstehen den Brief, <span class="markiert">bevor sie zu Ihnen kommen.</span></h1>
<p class="anrede">
BehördenKlar erklärt schwierige Post in einfacher Sprache, auf Wunsch in 27 Sprachen:
von Ausländerbehörde, Jobcenter, Finanzamt, Krankenkasse, Vermieter oder Inkasso.
Ein Foto genügt. Ihre Beratungszeit geht in die Lösung, nicht ins Vorlesen.
</p>
<div class="schritte">
<div class="schritt"><b>1</b> Brief mit dem Handy fotografieren, im Browser, ohne App und ohne Konto.</div>
<div class="schritt"><b>2</b> Nach etwa einer Minute: Was will das Amt, was ist zu tun, bis wann?</div>
<div class="schritt"><b>3</b> Frist in den Kalender, Spickzettel fürs Telefonat, mit den wichtigen Fragen zu Ihnen.</div>
</div>
<h2>Was BehördenKlar kann</h2>
<div class="raster">
<div class="punkt"><span class="ik">🌐</span><div>
<h3>27 Sprachen, Fachbegriffe bleiben deutsch</h3>
<p>Arabisch, Farsi, Paschtu, Kurdisch, Tigrinya, Türkisch, Ukrainisch u. v. m. Das deutsche Wort bleibt stehen, damit man es im Brief wiederfindet.</p>
</div></div>
<div class="punkt"><span class="ik">🚦</span><div>
<h3>Frist erkannt, Dringlichkeit sichtbar</h3>
<p>Ampel: Rot bei 7 Tagen oder weniger. Mit konkreter To-do-Liste: welche Unterlagen, wohin, bis wann.</p>
</div></div>
<div class="punkt"><span class="ik">📅</span><div>
<h3>Frist und Termin in den Handy-Kalender</h3>
<p>Ein Tipp genügt, das Handy erinnert 3 Tage und 1 Tag vorher. Keine Frist geht zwischen zwei Beratungsterminen unter.</p>
</div></div>
<div class="punkt"><span class="ik">📞</span><div>
<h3>Anruf-Hilfe fürs Amt</h3>
<p>Telefonnummer und Aktenzeichen aus dem Brief, dazu Sätze zum Sagen: auf Deutsch, darunter die Bedeutung in der eigenen Sprache.</p>
</div></div>
<div class="punkt neu"><span class="ik">💻</span><div>
<h3>Beratung am PC, Foto mit dem Handy</h3>
<p>QR-Code am Bildschirm scannen, Brief fotografieren: Sie lesen die Erklärung auf Deutsch, Ihr Gegenüber gleichzeitig in seiner Sprache.</p>
</div></div>
<div class="punkt neu"><span class="ik">📱</span><div>
<h3>Ergebnis zum Mitnehmen</h3>
<p>Per QR-Code wandert die Erklärung vom Beratungs-PC aufs Handy der Ratsuchenden, mit Kalender und Anruf-Hilfe.</p>
</div></div>
<div class="punkt"><span class="ik">🔎</span><div>
<h3>Erfindet nichts</h3>
<p>Steht keine Telefonnummer oder kein Datum im Brief, sagt BehördenKlar das, statt zu raten.</p>
</div></div>
<div class="punkt"><span class="ik">⚖️</span><div>
<h3>Ergänzung, kein Ersatz</h3>
<p>Jede Erklärung verweist bei wichtigen Entscheidungen ausdrücklich auf eine Beratungsstelle.</p>
</div></div>
</div>
<div class="unten">
<div>
<div class="kasten">
<span class="label">Datenschutz, ehrlich erklärt</span>
<ul>
<li>Kein Konto, keine Anmeldung, keine Installation nötig.</li>
<li>Briefe werden bei uns nicht dauerhaft gespeichert.</li>
<li>Übergabe zwischen PC und Handy Ende-zu-Ende verschlüsselt: Der Schlüssel steckt nur im QR-Code, unser Server sieht nichts.</li>
<li>Keine Werbung, kein Verkauf von Daten.</li>
<li class="ehrlich">Die Erklärung erstellt der KI-Dienst Anthropic (USA). Er löscht Anfragen nach spätestens 30 Tagen und nutzt sie nicht zum Training. Das sagen wir offen, auch auf der Seite vor dem Hochladen.</li>
</ul>
</div>
<p class="chatgpt">
<b>„Ist das nicht einfach ChatGPT?“</b> Dort braucht man ein Konto und eine gut
formulierte Frage, und danach ist der Brief vergessen. Bei BehördenKlar macht man
ein Foto. Frist, Termin und Telefonnummer werden zu Kalender-Eintrag und
Anruf-Hilfe, und deutsche Fachbegriffe bleiben erkennbar.
</p>
<p class="chatgpt">
<b>Und nach dem Test?</b> Ihre Einrichtung bekommt ein festes Briefkontingent zu
einem Preis für Beratungsstellen. Die Ratsuchenden zahlen weiterhin nichts. Ohne
Abo-Falle: Nach vier Wochen schauen wir gemeinsam, was es gebracht hat.
</p>
</div>
<div class="angebot">
<p class="kopf">Ihr Testzugang</p>
<p class="gross">1 Monat · bis 100 Briefe<br><span class="markiert">kostenlos</span></p>
<p class="klein">Ratsuchende zahlen nichts. Kein Vertrag, endet von selbst, verlängerbar.</p>
<div id="qr"></div>
<p class="klein">Code für Ihre Ratsuchenden:</p>
<p><span class="code" id="codeText"></span></p>
<p class="link" id="linkText"></p>
</div>
</div>
<div class="fuss">
<div class="wer">
<b>Justin Klein</b> · Webklar GbR · Am Schwimmbad 10, 67722 Winnweiler<br>
Tel. 0170 4969375 · justin@webklar.com · <b>behoerdenklar.de</b>
</div>
<span class="stempel">Aus dem Donnersbergkreis</span>
</div>
</div>
<script>
// Einrichtungs-Code hier eintragen, sobald er angelegt ist (z. B. 'MBE-K7QX').
var CODE = '';
var url = CODE ? 'https://behoerdenklar.de/demo?code=' + CODE : 'https://behoerdenklar.de/demo';
var qr = qrcode(0, 'M');
qr.addData(url);
qr.make();
document.getElementById('qr').innerHTML = qr.createSvgTag({ cellSize: 4, margin: 0, scalable: true });
var codeEl = document.getElementById('codeText');
codeEl.textContent = CODE || 'wird beim Termin eingetragen';
if (!CODE) { codeEl.style.fontSize = '8.5pt'; codeEl.style.letterSpacing = '0'; }
document.getElementById('linkText').textContent = url.replace('https://', '');
</script>
</body>
</html>
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 615 KiB

@@ -0,0 +1,141 @@
<!DOCTYPE html>
<!--
Musterbrief für Vorführungen (Präsentation MBE Kirchheimbolanden, 29.10.2026).
Behörde, Person, Aktenzeichen und Telefonnummer sind frei erfunden.
Zum Ausdrucken: Chrome → Drucken → A4, Ränder "Keine". PDF/PNG daneben.
Daten bei Bedarf anpassen: Frist und Termin sollen bei der Vorführung
in 1–3 Wochen liegen, damit die Ampel Gelb/Rot zeigt.
-->
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Musterbrief Ausländerbehörde</title>
<style>
@page { size: A4; margin: 0; }
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { background: #fff; }
body {
font-family: Arial, Helvetica, sans-serif; color: #111;
font-size: 10pt; line-height: 1.32;
}
.seite { width: 210mm; height: 297mm; position: relative; padding: 0 20mm 0 25mm; overflow: hidden; }
.kopf { position: absolute; top: 12mm; left: 25mm; right: 20mm; display: flex; justify-content: space-between; align-items: flex-start; }
.wappen { display: flex; gap: 4mm; align-items: center; }
.wappen svg { width: 13mm; height: 15mm; }
.behoerde { font-size: 13pt; font-weight: bold; line-height: 1.2; }
.behoerde small { display: block; font-size: 10pt; font-weight: normal; }
.absenderzeile { position: absolute; top: 45mm; left: 25mm; font-size: 7pt; text-decoration: underline; }
.anschrift { position: absolute; top: 51mm; left: 25mm; width: 85mm; font-size: 10.5pt; line-height: 1.35; }
.info { position: absolute; top: 45mm; left: 125mm; width: 65mm; font-size: 8.5pt; line-height: 1.45; }
.info td { vertical-align: top; padding: 0 2mm 0.6mm 0; }
.info td:first-child { color: #444; white-space: nowrap; }
.text { position: absolute; top: 92mm; left: 25mm; right: 20mm; }
.betreff { font-weight: bold; margin-bottom: 4mm; }
p { margin-bottom: 2.4mm; text-align: justify; hyphens: auto; }
ol { margin: 0 0 3mm 6mm; }
ol li { margin-bottom: 0.8mm; padding-left: 1mm; }
.fett { font-weight: bold; }
.gruss { margin-top: 3mm; margin-bottom: 0; }
.unterschrift { font-family: 'Brush Script MT', 'Segoe Script', cursive; font-size: 16pt; color: #1b3a7a; margin: 0 0 0 2mm; line-height: 1.2; }
.fuss {
position: absolute; bottom: 9mm; left: 25mm; right: 20mm;
border-top: 0.3mm solid #999; padding-top: 2mm;
display: flex; justify-content: space-between; font-size: 7pt; color: #444; line-height: 1.35;
}
.muster { position: absolute; bottom: 3.5mm; left: 25mm; right: 20mm; font-size: 6.5pt; color: #999; text-align: center; }
.falz { position: absolute; left: 4mm; width: 4mm; border-top: 0.2mm solid #bbb; }
</style>
</head>
<body>
<div class="seite">
<span class="falz" style="top: 105mm;"></span>
<span class="falz" style="top: 148.5mm;"></span>
<span class="falz" style="top: 210mm;"></span>
<div class="kopf">
<div class="wappen">
<svg viewBox="0 0 26 30" aria-hidden="true">
<path d="M1 1h24v13c0 8-6 13-12 15C7 27 1 22 1 14z" fill="#f4f4f4" stroke="#333" stroke-width="1.2"/>
<path d="M1 10h24M13 1v28" stroke="#333" stroke-width="0.8"/>
<circle cx="7" cy="5.5" r="2" fill="#2c5aa0"/><circle cx="19" cy="18" r="2.5" fill="#b3372b"/>
</svg>
<div class="behoerde">Kreisverwaltung Musterhausen<small>Fachbereich 32 – Ausländerbehörde</small></div>
</div>
<div style="font-size: 8pt; text-align: right; color: #333;">Musterplatz 1<br>67999 Musterhausen</div>
</div>
<div class="absenderzeile">Kreisverwaltung Musterhausen · Musterplatz 1 · 67999 Musterhausen</div>
<div class="anschrift">
Herrn<br>
Ahmad Beispiel<br>
Lindenweg 5<br>
67999 Musterhausen
</div>
<table class="info">
<tr><td>Aktenzeichen</td><td>32.1-AE-2026-04711</td></tr>
<tr><td>Auskunft erteilt</td><td>Frau Muster</td></tr>
<tr><td>Zimmer</td><td>2.14</td></tr>
<tr><td>Telefon</td><td>069 90009-314</td></tr>
<tr><td>E-Mail</td><td>abh@musterhausen.example</td></tr>
<tr><td>Datum</td><td>20.10.2026</td></tr>
</table>
<div class="text">
<p class="betreff">Ihr Antrag auf Verlängerung der Aufenthaltserlaubnis vom 01.10.2026<br>
hier: Aufforderung zur Vorlage fehlender Unterlagen gemäß § 82 Abs. 1 AufenthG<br>
und Einladung zur persönlichen Vorsprache</p>
<p>Sehr geehrter Herr Beispiel,</p>
<p>in Ihrer o. g. aufenthaltsrechtlichen Angelegenheit nehmen wir Bezug auf Ihren Antrag auf
Verlängerung der Aufenthaltserlaubnis. Ihr derzeitiger Aufenthaltstitel ist bis zum
15.11.2026 befristet. Zur abschließenden Prüfung der Erteilungsvoraussetzungen gemäß
§§ 5, 8 AufenthG sind die nachfolgend aufgeführten Unterlagen bislang nicht bzw. nicht
vollständig vorgelegt worden:</p>
<ol>
<li>gültiger Nationalpass im Original bzw. Nachweise über Ihre Bemühungen zur Passbeschaffung,</li>
<li>aktuelle Meldebescheinigung (nicht älter als drei Monate),</li>
<li>Nachweise über die Sicherung des Lebensunterhalts (Arbeitsvertrag sowie Lohn- und
Gehaltsabrechnungen der letzten drei Monate bzw. aktueller Bewilligungsbescheid des Jobcenters),</li>
<li>Nachweis über einen ausreichenden Krankenversicherungsschutz,</li>
<li>ein aktuelles biometrisches Lichtbild.</li>
</ol>
<p>Gemäß § 82 Abs. 1 AufenthG obliegt es Ihnen, die für Ihr Anliegen erforderlichen Nachweise
unverzüglich beizubringen. Wir fordern Sie daher auf, die vorgenannten Unterlagen
<span class="fett">bis spätestens 12.11.2026</span> bei der Ausländerbehörde einzureichen.</p>
<p>Zur Aufnahme Ihrer biometrischen Merkmale (Fingerabdrücke, Unterschrift) werden Sie
gebeten, am <span class="fett">Donnerstag, den 05.11.2026, um 10:30 Uhr</span> in Zimmer 2.14
persönlich vorzusprechen. Bitte bringen Sie dieses Schreiben sowie Ihren derzeitigen
Aufenthaltstitel mit. Sollten Sie den Termin nicht wahrnehmen können, ist dieser rechtzeitig
vorher telefonisch abzusagen.</p>
<p>Werden die angeforderten Unterlagen nicht fristgerecht vorgelegt, kann über Ihren Antrag
nach Aktenlage entschieden werden. Dies kann zur Ablehnung des Antrags führen.</p>
<p>Hinweis: Da Sie den Antrag vor Ablauf Ihres Aufenthaltstitels gestellt haben, gilt dieser
gemäß § 81 Abs. 4 AufenthG bis zur Entscheidung der Ausländerbehörde als fortbestehend
(Fiktionswirkung). Eine entsprechende Fiktionsbescheinigung wird Ihnen bei Ihrer Vorsprache
ausgehändigt.</p>
<p>Sprechzeiten: Mo, Di und Do 08:00–12:00 Uhr, Do zusätzlich 14:00–17:30 Uhr.
Vorsprachen nur nach vorheriger Terminvereinbarung.</p>
<p class="gruss">Mit freundlichen Grüßen<br>Im Auftrag</p>
<div class="unterschrift">Muster</div>
<p style="margin-top: 0;">Muster</p>
</div>
<div class="fuss">
<div>Kreisverwaltung Musterhausen<br>Musterplatz 1 · 67999 Musterhausen</div>
<div>Telefon 069 90009-0<br>www.musterhausen.example</div>
<div>Bankverbindung: Sparkasse Musterhausen<br>IBAN DE00 0000 0000 0000 0000 00</div>
</div>
<div class="muster">Beispielbrief für Vorführzwecke · Behörde, Person und alle Angaben sind frei erfunden</div>
</div>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 420 KiB

+266 -10
View File
@@ -1,3 +1,5 @@
import { DurableObject } from 'cloudflare:workers';
/**
* BehördenKlar Backend-Proxy — Cloudflare Worker
*
@@ -36,6 +38,12 @@ interface Env {
TOKEN_SIGNING_SECRET: string;
/** Bezahlung: `wrangler secret put BREVO_API_KEY` (für die Guthaben-Link-Mail) */
BREVO_API_KEY: string;
/** Nur lokal (.dev.vars): "1" erlaubt Aufrufe von http://localhost */
LOKAL_TEST?: string;
/** Handy ↔ PC: verschlüsselte Übergabe (Durable Object, siehe unten) */
UEBERGABE: DurableObjectNamespace<Uebergabe>;
/** Handy ↔ PC: Schreib-Bremse pro IP (Rate-Limit-Binding in wrangler.toml) */
UEBERGABE_LIMIT: RateLimit;
}
const ANTHROPIC_URL = 'https://api.anthropic.com/v1/messages';
@@ -71,6 +79,12 @@ function fehler(status: number, typ: string, meldung: string): Response {
/** Nur die eigene Webseite darf den Demo-Endpunkt aufrufen (CORS). */
const DEMO_ORIGIN_MUSTER = /^https:\/\/([a-z0-9-]+\.)?behoerdenklar\.(de|pages\.dev)$/;
/** Nur für `wrangler dev` mit LOKAL_TEST=1 in .dev.vars — live nie gesetzt. */
const LOKAL_MUSTER = /^http:\/\/localhost:\d+$/;
let lokalTest = false;
function originOk(origin: string | null): origin is string {
return !!origin && (DEMO_ORIGIN_MUSTER.test(origin) || (lokalTest && LOKAL_MUSTER.test(origin)));
}
/** ~5 MB Datei entsprechen ~6,7 Mio. Base64-Zeichen. */
const DEMO_MAX_BASE64 = 7_000_000;
@@ -82,6 +96,10 @@ const WEGWERF_DOMAINS = new Set([
'getnada.com', 'dispostable.com', 'maildrop.cc', 'throwawaymail.com',
]);
/** Was in jeder Sprache deutsch bzw. unverändert bleiben muss. */
const SPRACH_AUSNAHMEN = (sprachName: string) =>
`Ausnahme: das Feld "begriff" bei fachbegriffe bleibt der deutsche Originalbegriff aus dem Brief (der Nutzer muss ihn im Brief wiederfinden) — nur dessen "erklaerung" wird auf ${sprachName} geschrieben. Datumsangaben bleiben im ISO-Format. Ebenfalls NICHT übersetzen: das Feld "deutsch" bei anruf (das wird am Telefon auf Deutsch gesagt) und alle kontakt-Felder (sie stehen so im Brief).`;
/** Identisch zur App (src/services/analyse.ts) — gleiche Qualität in der Demo. */
/** Zielsprachen der Web-Demo — identisch zur Sprachauswahl der Webseite.
* Der Wert wird NUR als Name in den Prompt gesetzt, nie als freier Text,
@@ -178,7 +196,7 @@ const DEMO_SCHEMA = {
};
function demoCorsHeaders(origin: string | null): Record<string, string> {
const erlaubt = origin && DEMO_ORIGIN_MUSTER.test(origin) ? origin : 'https://behoerdenklar.de';
const erlaubt = originOk(origin) ? origin : 'https://behoerdenklar.de';
return {
'access-control-allow-origin': erlaubt,
'access-control-allow-methods': 'POST, OPTIONS',
@@ -199,6 +217,39 @@ async function emailHash(email: string, pepper: string): Promise<string> {
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
}
/** Fertige deutsche Analyse in eine andere Sprache übersetzen (Haiku, gleiches Schema).
* Gibt null zurück, wenn etwas schiefgeht — der Aufrufer zeigt dann Deutsch. */
async function uebersetzeAnalyse(env: Env, analyse: unknown, sprachName: string): Promise<unknown | null> {
try {
const r = 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-haiku-4-5',
max_tokens: MAX_TOKENS_OBERGRENZE,
system: `Du übersetzt die Erklärung eines deutschen Behördenbriefs für Menschen mit wenig Deutschkenntnissen. Du bekommst ein JSON-Objekt. Gib dasselbe Objekt mit derselben Struktur zurück, aber schreibe alle Textfelder auf ${sprachName}, in einfacher, klarer Sprache. Erfinde nichts dazu und lass nichts weg; null bleibt null. ${SPRACH_AUSNAHMEN(sprachName)}`,
output_config: { format: { type: 'json_schema', schema: DEMO_SCHEMA } },
messages: [{ role: 'user', content: JSON.stringify(analyse) }],
}),
});
if (!r.ok) {
console.log('Übersetzung fehlgeschlagen', r.status, (await r.text()).slice(0, 300));
return null;
}
const d = (await r.json()) as { stop_reason?: string; content?: { type: string; text?: string }[] };
const text = [...(d.content ?? [])].reverse().find((b) => b.type === 'text')?.text;
if (d.stop_reason !== 'end_turn' || !text) return null;
return JSON.parse(text);
} catch (e) {
console.log('Übersetzung fehlgeschlagen', String(e));
return null;
}
}
async function demoHandler(request: Request, env: Env): Promise<Response> {
const origin = request.headers.get('origin');
if (request.method === 'OPTIONS') {
@@ -207,11 +258,11 @@ async function demoHandler(request: Request, env: Env): Promise<Response> {
if (request.method !== 'POST') {
return demoFehler(405, 'methode', 'Nur POST erlaubt.', origin);
}
if (!origin || !DEMO_ORIGIN_MUSTER.test(origin)) {
if (!originOk(origin)) {
return demoFehler(403, 'origin', 'Aufruf nur von der BehördenKlar-Webseite erlaubt.', origin);
}
let body: { bild?: string; mimeType?: string; turnstileToken?: string; email?: string; walletToken?: string; zielsprache?: string };
let body: { bild?: string; mimeType?: string; turnstileToken?: string; email?: string; walletToken?: string; zielsprache?: string; auchDeutsch?: boolean };
try {
body = await request.json();
} catch {
@@ -224,6 +275,10 @@ async function demoHandler(request: Request, env: Env): Promise<Response> {
const sprachCode = typeof body.zielsprache === 'string' && DEMO_SPRACHEN[body.zielsprache]
? body.zielsprache : 'de';
const sprachName = DEMO_SPRACHEN[sprachCode];
// Zweisprachig (Beratung am PC, Ratsuchende am Handy): erst auf Deutsch
// analysieren, dann mit Haiku übersetzen — wie in der App. Kostet den
// Nutzer weiterhin nur EINE Analyse.
const zweisprachig = body.auchDeutsch === true && sprachCode !== 'de';
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);
@@ -254,14 +309,25 @@ async function demoHandler(request: Request, env: Env): Promise<Response> {
// (die sind nur ein Kosten-Deckel für die kostenlose Stufe).
let walletId: string | null = null;
let walletRest = 0;
let einrichtungsName: string | null = null;
if (walletToken) {
walletId = await verifyWallet(walletToken, env.TOKEN_SIGNING_SECRET);
if (!walletId) {
return demoFehler(403, 'wallet_ungueltig', 'Ihr Zugangs-Token ist ungültig. Bitte laden Sie die Seite neu.', origin);
}
walletRest = parseInt((await env.RATE_LIMIT.get(`wallet:${walletId}`)) ?? '0', 10);
const [restWert, einrichtung] = await Promise.all([
env.RATE_LIMIT.get(`wallet:${walletId}`),
ladeEinrichtung(env, walletId),
]);
walletRest = parseInt(restWert ?? '0', 10);
einrichtungsName = einrichtung?.name ?? null;
if (einrichtung && einrichtung.ablauf < heute) {
return demoFehler(402, 'kontingent_abgelaufen', `Der Zugang über ${einrichtung.name} ist abgelaufen. Bitte fragen Sie dort nach.`, origin);
}
if (walletRest <= 0) {
return demoFehler(402, 'kein_guthaben', 'Ihr Guthaben ist aufgebraucht. Bitte kaufen Sie neues Guthaben.', origin);
return einrichtung
? demoFehler(402, 'kontingent_leer', `Die Analysen von ${einrichtung.name} sind aufgebraucht. Bitte fragen Sie dort nach.`, origin)
: demoFehler(402, 'kein_guthaben', 'Ihr Guthaben ist aufgebraucht. Bitte kaufen Sie neues Guthaben.', origin);
}
}
const bezahlt = walletId !== null;
@@ -336,9 +402,9 @@ async function demoHandler(request: Request, env: Env): Promise<Response> {
body: JSON.stringify({
model: 'claude-sonnet-5',
max_tokens: MAX_TOKENS_OBERGRENZE,
system: sprachCode === 'de'
system: sprachCode === 'de' || zweisprachig
? DEMO_SYSTEM_PROMPT
: `${DEMO_SYSTEM_PROMPT}\n\nWICHTIG — ZIELSPRACHE: Schreibe ALLE Textfelder deiner Antwort auf ${sprachName}, nicht auf Deutsch. Ausnahme: das Feld "begriff" bei fachbegriffe bleibt der deutsche Originalbegriff aus dem Brief (der Nutzer muss ihn im Brief wiederfinden) — nur dessen "erklaerung" wird auf ${sprachName} geschrieben. Datumsangaben bleiben im ISO-Format. Ebenfalls NICHT übersetzen: das Feld "deutsch" bei anruf (das wird am Telefon auf Deutsch gesagt) und alle kontakt-Felder (sie stehen so im Brief).`,
: `${DEMO_SYSTEM_PROMPT}\n\nWICHTIG — ZIELSPRACHE: Schreibe ALLE Textfelder deiner Antwort auf ${sprachName}, nicht auf Deutsch. ${SPRACH_AUSNAHMEN(sprachName)}`,
output_config: { format: { type: 'json_schema', schema: DEMO_SCHEMA } },
messages: [
{
@@ -408,12 +474,23 @@ async function demoHandler(request: Request, env: Env): Promise<Response> {
return demoFehler(502, 'ki', 'Die Antwort war unlesbar. Bitte erneut versuchen.', origin);
}
let analyseDe: unknown = undefined;
let ausgabeSprache = sprachCode;
if (zweisprachig) {
analyseDe = analyse;
const uebersetzt = await uebersetzeAnalyse(env, analyse, sprachName);
if (uebersetzt) analyse = uebersetzt;
else ausgabeSprache = 'de'; // lieber Deutsch als gar nichts
}
return Response.json(
{
analyse,
analyse_de: analyseDe,
versuch: bezahlt ? 'bezahlt' : email ? 2 : 1,
guthaben: bezahlt ? walletRest - 1 : undefined,
sprache: sprachCode,
einrichtung: einrichtungsName ?? undefined,
sprache: ausgabeSprache,
},
{ headers: demoCorsHeaders(origin) }
);
@@ -518,7 +595,7 @@ async function kaufenHandler(request: Request, env: Env): Promise<Response> {
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);
if (!originOk(origin)) return demoFehler(403, 'origin', 'Aufruf nur von der BehördenKlar-Webseite erlaubt.', origin);
let body: { priceId?: string; sprache?: string };
try {
@@ -571,7 +648,7 @@ async function kaufenHandler(request: Request, env: Env): Promise<Response> {
async function kaufStatusHandler(request: Request, env: Env): Promise<Response> {
const origin = request.headers.get('origin');
if (request.method === 'OPTIONS') return new Response(null, { status: 204, headers: demoCorsHeaders(origin) });
if (!origin || !DEMO_ORIGIN_MUSTER.test(origin)) return demoFehler(403, 'origin', 'Nicht erlaubt.', origin);
if (!originOk(origin)) return demoFehler(403, 'origin', 'Nicht erlaubt.', origin);
const sessionId = new URL(request.url).searchParams.get('session_id') ?? '';
if (!/^cs_[A-Za-z0-9_]+$/.test(sessionId)) return demoFehler(400, 'session', 'Ungültige Sitzung.', origin);
@@ -593,6 +670,182 @@ async function kaufStatusHandler(request: Request, env: Env): Promise<Response>
return Response.json({ token, guthaben: rest }, { headers: demoCorsHeaders(origin) });
}
// ============================================================
// Einrichtungs-Codes — Beratungsstellen zahlen, Ratsuchende nicht
// ============================================================
//
// Ein Code (z. B. `MBE-K7QX`) zeigt auf ein Guthaben wie beim Kauf, nur dass
// es allen gehört, die den Code kennen. Angelegt wird er von Hand mit
// tools/einrichtungs-code.js (kein Secret nötig, nur wrangler-Login).
//
// KV: code:<CODE> -> walletId
// wallet:<walletId> -> Restguthaben (wie beim Kauf)
// einrichtung:<walletId> -> {"name":"MBE Donnersberg","ablauf":"2027-01-31"}
//
// Der zufällige Teil im Code macht Raten aussichtslos; zusätzlich sind
// Fehlversuche pro IP und Tag begrenzt.
const CODE_FEHLVERSUCHE_PRO_TAG = 10;
interface Einrichtung {
name: string;
/** letzter gültiger Tag, ISO-Datum */
ablauf: string;
}
async function ladeEinrichtung(env: Env, walletId: string): Promise<Einrichtung | null> {
const roh = await env.RATE_LIMIT.get(`einrichtung:${walletId}`);
if (!roh) return null;
try {
return JSON.parse(roh) as Einrichtung;
} catch {
return null;
}
}
/** POST /code — Einrichtungs-Code einlösen, signiertes Wallet-Token liefern. */
async function codeHandler(request: Request, env: Env): Promise<Response> {
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 (!originOk(origin)) return demoFehler(403, 'origin', 'Nicht erlaubt.', origin);
let body: { code?: string };
try {
body = await request.json();
} catch {
return demoFehler(400, 'json', 'Ungültige Anfrage.', origin);
}
const code = typeof body.code === 'string' ? body.code.trim().toUpperCase().replace(/\s+/g, '') : '';
const ip = request.headers.get('cf-connecting-ip') ?? 'unbekannt';
const heute = new Date().toISOString().slice(0, 10);
const failKey = `codefail:${ip}:${heute}`;
const fehlversuche = parseInt((await env.RATE_LIMIT.get(failKey)) ?? '0', 10);
if (fehlversuche >= CODE_FEHLVERSUCHE_PRO_TAG) {
return demoFehler(429, 'code_gesperrt', 'Zu viele falsche Codes. Bitte versuchen Sie es morgen wieder.', origin);
}
const walletId = /^[A-Z0-9-]{4,40}$/.test(code) ? await env.RATE_LIMIT.get(`code:${code}`) : null;
const einrichtung = walletId ? await ladeEinrichtung(env, walletId) : null;
if (!walletId || !einrichtung) {
await env.RATE_LIMIT.put(failKey, String(fehlversuche + 1), { expirationTtl: 60 * 60 * 48 });
return demoFehler(404, 'code_unbekannt', 'Diesen Code kennen wir nicht. Bitte prüfen Sie die Schreibweise.', origin);
}
if (einrichtung.ablauf < heute) {
return demoFehler(410, 'kontingent_abgelaufen', `Der Zugang über ${einrichtung.name} ist abgelaufen. Bitte fragen Sie dort nach.`, origin);
}
const rest = parseInt((await env.RATE_LIMIT.get(`wallet:${walletId}`)) ?? '0', 10);
const token = await signWallet(walletId, env.TOKEN_SIGNING_SECRET);
return Response.json(
{ token, guthaben: rest, einrichtung: einrichtung.name, ablauf: einrichtung.ablauf },
{ headers: demoCorsHeaders(origin) }
);
}
// ============================================================
// Übergabe Handy ↔ PC — Ende-zu-Ende verschlüsselt
// ============================================================
//
// Ein Gerät zeigt einen QR-Code, das andere scannt ihn. Im QR-Code stehen
// eine zufällige Übergabe-ID und ein AES-Schlüssel — der Schlüssel steht
// hinter dem `#` der Adresse und wird deshalb NIE an einen Server geschickt.
// Hier liegen nur verschlüsselte Päckchen, die wir selbst nicht lesen
// können. Jede Übergabe löscht sich nach 15 Minuten von selbst.
//
// PUT /uebergabe/<id>/<fach> -> Päckchen ablegen (Text, verschlüsselt)
// GET /uebergabe/<id> -> alle Päckchen dieser Übergabe
// DELETE /uebergabe/<id> -> sofort löschen
//
// Durable Object statt KV, weil KV Abfragen auf noch nicht vorhandene
// Schlüssel zwischenspeichert — das Warten am PC würde bis zu einer Minute
// hängen. Ein Durable Object ist sofort konsistent.
//
// Ein Fach (z. B. „status", „ergebnis") wird überschrieben, nicht angehängt.
const UEBERGABE_LEBENSDAUER_MS = 15 * 60 * 1000;
const UEBERGABE_MAX_ZEICHEN = 400_000; // Analyse-JSON in zwei Sprachen, verschlüsselt
const UEBERGABE_MAX_SCHREIBVORGAENGE = 50;
const UEBERGABE_ID_MUSTER = /^[A-Za-z0-9_-]{22}$/; // 128 Bit, base64url
const UEBERGABE_FACH_MUSTER = /^[a-z]{2,16}$/;
export class Uebergabe extends DurableObject<Env> {
async lesen(): Promise<Record<string, string>> {
const eintraege = await this.ctx.storage.list<string>({ prefix: 'fach:' });
const faecher: Record<string, string> = {};
for (const [k, v] of eintraege) faecher[k.slice(5)] = v;
return faecher;
}
async schreiben(fach: string, wert: string): Promise<boolean> {
const zahl = (await this.ctx.storage.get<number>('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);
}
return true;
}
async loeschen(): Promise<void> {
await this.ctx.storage.deleteAlarm();
await this.ctx.storage.deleteAll();
}
async alarm(): Promise<void> {
await this.ctx.storage.deleteAll();
}
}
function uebergabeCors(origin: string | null): Record<string, string> {
return {
...demoCorsHeaders(origin),
'access-control-allow-methods': 'GET, PUT, DELETE, OPTIONS',
'cache-control': 'no-store',
};
}
async function uebergabeHandler(request: Request, env: Env, pfad: string): Promise<Response> {
const origin = request.headers.get('origin');
const cors = uebergabeCors(origin);
if (request.method === 'OPTIONS') return new Response(null, { status: 204, headers: cors });
// Webseite: nur unsere eigene. Die App schickt keinen Origin-Header.
if (origin && !originOk(origin)) {
return Response.json({ code: 'origin', meldung: 'Nicht erlaubt.' }, { status: 403, headers: cors });
}
const [, , id, fach] = pfad.split('/'); // "", "uebergabe", id, fach?
if (!id || !UEBERGABE_ID_MUSTER.test(id)) {
return Response.json({ code: 'id', meldung: 'Ungültige Übergabe.' }, { status: 400, headers: cors });
}
const stub = env.UEBERGABE.get(env.UEBERGABE.idFromName(id));
if (request.method === 'GET' && !fach) {
return Response.json({ faecher: await stub.lesen() }, { headers: cors });
}
if (request.method === 'DELETE' && !fach) {
await stub.loeschen();
return new Response(null, { status: 204, headers: cors });
}
if (request.method === 'PUT' && fach && UEBERGABE_FACH_MUSTER.test(fach)) {
const ip = request.headers.get('cf-connecting-ip') ?? 'unbekannt';
const { success } = await env.UEBERGABE_LIMIT.limit({ key: ip });
if (!success) {
return Response.json({ code: 'zu_viele', meldung: 'Zu viele Anfragen. Bitte kurz warten.' }, { status: 429, headers: cors });
}
const wert = await request.text();
if (!wert || wert.length > UEBERGABE_MAX_ZEICHEN || !/^[A-Za-z0-9_-]+$/.test(wert)) {
return Response.json({ code: 'inhalt', meldung: 'Ungültiger Inhalt.' }, { status: 400, headers: cors });
}
if (!(await stub.schreiben(fach, wert))) {
return Response.json({ code: 'voll', meldung: 'Diese Übergabe ist abgeschlossen.' }, { status: 409, headers: cors });
}
return new Response(null, { status: 204, headers: cors });
}
return Response.json({ code: 'methode', meldung: 'Nicht erlaubt.' }, { status: 405, headers: cors });
}
/** POST /stripe-webhook — Sicherungsnetz: Signatur prüfen, Guthaben aktivieren, Link mailen. */
async function webhookHandler(request: Request, env: Env): Promise<Response> {
if (request.method !== 'POST') return new Response('nur POST', { status: 405 });
@@ -641,10 +894,13 @@ async function webhookHandler(request: Request, env: Env): Promise<Response> {
export default {
async fetch(request: Request, env: Env): Promise<Response> {
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 === '/kaufen') return kaufenHandler(request, env);
if (pfad === '/kauf-status') return kaufStatusHandler(request, env);
if (pfad === '/code') return codeHandler(request, env);
if (pfad.startsWith('/uebergabe/')) return uebergabeHandler(request, env, pfad);
if (pfad === '/stripe-webhook') return webhookHandler(request, env);
if (request.method !== 'POST') {
+18
View File
@@ -18,3 +18,21 @@ DEMO_LIMIT_IP = "8"
[[kv_namespaces]]
binding = "RATE_LIMIT"
id = "7577adef8ceb4ed6bfaeef81a1d496af"
# Handy <-> PC: verschluesselte Uebergabe per QR-Code (siehe src/index.ts).
# SQLite-Durable-Objects laufen auch im kostenlosen Workers-Tarif.
[[durable_objects.bindings]]
name = "UEBERGABE"
class_name = "Uebergabe"
[[migrations]]
tag = "v1-uebergabe"
new_sqlite_classes = ["Uebergabe"]
# Schreib-Bremse fuer die Uebergabe: 60 Paeckchen pro Minute und IP
[[ratelimits]]
name = "UEBERGABE_LIMIT"
namespace_id = "1001"
[ratelimits.simple]
limit = 60
period = 60
+5 -1
View File
@@ -32,7 +32,9 @@ export type IkonenName =
| 'archiv'
| 'schloss'
| 'lupe'
| 'pfeilLinks';
| 'pfeilLinks'
| 'computer'
| 'qrScannen';
// `as const` hält die Namen als Literal-Typen — so prüft TypeScript gegen
// die offizielle SF-Symbol-/Material-Namensliste von expo-symbols
@@ -62,6 +64,8 @@ const NAMEN = {
schloss: { ios: 'lock.fill', android: 'lock' },
lupe: { ios: 'magnifyingglass', android: 'search' },
pfeilLinks: { ios: 'chevron.left', android: 'chevron_left' },
computer: { ios: 'desktopcomputer', android: 'computer' },
qrScannen: { ios: 'qrcode.viewfinder', android: 'qr_code_scanner' },
} as const;
interface Props {
+10
View File
@@ -287,6 +287,16 @@ export function AnalyseScreen({ navigation, route }: Props) {
</View>
)}
{/* ── In der Beratung: Brief auf dem Bildschirm zeigen ── */}
<View style={{ marginTop: abstand.l }}>
<GrossButton
titel="Am Computer zeigen"
ikone="computer"
variante="sekundaer"
onPress={() => navigation.navigate('Verbinden', { modus: 'senden', briefId: brief.id })}
/>
</View>
{/* ── Datenschutz-Fuß + Löschen ── */}
<View style={styles.schutzZeile}>
<Ikone name="schloss" groesse={13} farbe={farben.textTertiaer} />
+11
View File
@@ -12,6 +12,7 @@ import { useAppStore } from '../store/useAppStore';
import { berechneAmpel } from '../utils/ampel';
import { formatiereDatum } from '../services/erinnerungen';
import { Ikone } from '../components/Ikone';
import { GrossButton } from '../components/GrossButton';
import { farben, schrift, abstand, TOUCH_TARGET } from '../theme';
type Nav = NativeStackNavigationProp<RootStackParamList>;
@@ -144,6 +145,16 @@ export function ArchivScreen() {
})}
</View>
)}
{/* Ergebnis vom Beratungs-PC per QR-Code übernehmen */}
<View style={{ marginTop: abstand.l }}>
<GrossButton
titel="Vom Computer übernehmen"
ikone="qrScannen"
variante="sekundaer"
onPress={() => navigation.navigate('Verbinden', { modus: 'empfangen' })}
/>
</View>
</ScrollView>
);
}
+191
View File
@@ -0,0 +1,191 @@
/**
* QR-Code vom Beratungs-PC scannen (Übergabe Handy ↔ PC).
*
* Zwei Wege, beide Ende-zu-Ende verschlüsselt:
* - „senden": ein Brief aus dem Archiv erscheint am PC (der PC zeigt dafür
* auf behoerdenklar.de/demo „Lieber mit dem Handy fotografieren")
* - „empfangen": das Ergebnis vom PC landet im Archiv, mit Erinnerungen
* (der PC zeigt dafür „Ergebnis aufs Handy mitnehmen")
*
* Ohne Kamera (Simulator, Erlaubnis verweigert) kann man den Link einfügen.
*/
import React, { useRef, useState } from 'react';
import { ActivityIndicator, StyleSheet, Text, TextInput, View, ScrollView } from 'react-native';
import { CameraView, useCameraPermissions } from 'expo-camera';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { RootStackParamList } from '../types';
import { useAppStore, neueId } from '../store/useAppStore';
import { planeErinnerungen } from '../services/erinnerungen';
import { holeAusUebergabe, loescheUebergabe, oeffneUebergabe, sendeAnUebergabe } from '../services/uebergabe';
import { briefZuWebPaket, leseUebergabeLink, webPaketZuBrief, WebPaket } from '../utils/uebergabeFormat';
import { GrossButton } from '../components/GrossButton';
import { Ikone } from '../components/Ikone';
import { farben, schrift, abstand } from '../theme';
type Props = NativeStackScreenProps<RootStackParamList, 'Verbinden'>;
type Zustand =
| { art: 'scannen' }
| { art: 'arbeitet'; text: string }
| { art: 'fertig'; text: string }
| { art: 'fehler'; text: string };
export function VerbindenScreen({ navigation, route }: Props) {
const modus = route.params.modus;
const briefId = route.params.modus === 'senden' ? route.params.briefId : null;
const brief = useAppStore((s) => s.briefe.find((b) => b.id === briefId));
const addBrief = useAppStore((s) => s.addBrief);
const [erlaubnis, frageErlaubnis] = useCameraPermissions();
const [zustand, setZustand] = useState<Zustand>({ art: 'scannen' });
const [eingefuegt, setEingefuegt] = useState('');
const beschaeftigt = useRef(false); // Kamera meldet denselben Code mehrfach
async function verarbeite(text: string) {
if (beschaeftigt.current) return;
beschaeftigt.current = true;
try {
const link = leseUebergabeLink(text);
if (!link) {
setZustand({ art: 'fehler', text: 'Das ist kein QR-Code von BehördenKlar. Bitte den Code auf dem Bildschirm der Beratung scannen.' });
return;
}
if (link.art === 'g') {
setZustand({ art: 'fehler', text: 'Dieser Code ist für die Webseite gedacht. Öffnen Sie ihn mit der normalen Handy-Kamera.' });
return;
}
if (modus === 'senden') {
if (link.art !== 'k') {
setZustand({ art: 'fehler', text: 'Dieser Code ist zum Mitnehmen. Gehen Sie dafür ins Archiv auf „Vom Computer übernehmen".' });
return;
}
if (!brief) {
setZustand({ art: 'fehler', text: 'Der Brief wurde nicht gefunden.' });
return;
}
setZustand({ art: 'arbeitet', text: 'Der Brief wird verschlüsselt geschickt …' });
const u = await oeffneUebergabe(link);
await sendeAnUebergabe(u, 'status', { schritt: 'verbunden' });
await sendeAnUebergabe(u, 'ergebnis', briefZuWebPaket(brief));
setZustand({ art: 'fertig', text: 'Fertig. Der Brief ist jetzt auf dem Bildschirm der Beratung zu sehen.' });
return;
}
// empfangen
if (link.art !== 'm') {
setZustand({ art: 'fehler', text: 'Dieser Code wartet auf einen Brief. Öffnen Sie dafür den Brief und tippen Sie auf „Am Computer zeigen".' });
return;
}
setZustand({ art: 'arbeitet', text: 'Das Ergebnis wird geholt …' });
const u = await oeffneUebergabe(link);
const faecher = await holeAusUebergabe(u);
const paket = faecher.ergebnis as WebPaket | undefined;
if (!paket?.analyse) {
setZustand({ art: 'fehler', text: 'Dieser Code ist abgelaufen oder wurde schon benutzt (er gilt 15 Minuten und nur einmal). Bitte am Computer einen neuen erzeugen.' });
return;
}
loescheUebergabe(u);
const neu = webPaketZuBrief(paket, neueId(), new Date());
await addBrief(neu);
planeErinnerungen(neu).catch(() => {});
navigation.replace('Analyse', { briefId: neu.id });
} catch {
setZustand({ art: 'fehler', text: 'Keine Verbindung. Bitte prüfen Sie das Internet und versuchen Sie es noch einmal.' });
} finally {
beschaeftigt.current = false;
}
}
const titel = modus === 'senden' ? 'Am Computer zeigen' : 'Vom Computer übernehmen';
const anleitung =
modus === 'senden'
? 'Die Beratung öffnet behoerdenklar.de/demo und tippt auf „Lieber mit dem Handy fotografieren". Scannen Sie den QR-Code, dann erscheint Ihr Brief auf dem Bildschirm.'
: 'Die Beratung tippt am Computer auf „Ergebnis aufs Handy mitnehmen". Scannen Sie den QR-Code, dann landet der Brief verschlüsselt in Ihrem Archiv.';
return (
<ScrollView style={styles.container} contentContainerStyle={{ padding: abstand.m, paddingBottom: abstand.xl }} keyboardShouldPersistTaps="handled">
<Text style={styles.titel}>{titel}</Text>
<Text style={styles.text}>{anleitung}</Text>
{zustand.art === 'scannen' && (
<View style={styles.kameraRahmen}>
{erlaubnis?.granted ? (
<CameraView
style={StyleSheet.absoluteFill}
facing="back"
barcodeScannerSettings={{ barcodeTypes: ['qr'] }}
onBarcodeScanned={({ data }) => verarbeite(data)}
/>
) : (
<View style={styles.kameraLeer}>
<Text style={styles.text}>Für den QR-Code braucht die App kurz die Kamera.</Text>
<GrossButton titel="Kamera erlauben" ikone="kamera" onPress={frageErlaubnis} />
</View>
)}
</View>
)}
{zustand.art === 'arbeitet' && (
<View style={styles.meldung}>
<ActivityIndicator color={farben.primaer} />
<Text style={styles.text}>{zustand.text}</Text>
</View>
)}
{zustand.art === 'fertig' && (
<View style={styles.meldung}>
<Ikone name="checkAn" groesse={28} farbe={farben.ampelGruen} />
<Text style={styles.text}>{zustand.text}</Text>
<GrossButton titel="Zurück zum Brief" onPress={() => navigation.goBack()} />
</View>
)}
{zustand.art === 'fehler' && (
<View style={styles.meldung}>
<Ikone name="warnung" groesse={26} farbe={farben.fehler} />
<Text style={styles.text}>{zustand.text}</Text>
<GrossButton titel="Noch einmal scannen" variante="sekundaer" onPress={() => setZustand({ art: 'scannen' })} />
</View>
)}
<View style={styles.schutzZeile}>
<Ikone name="schloss" groesse={13} farbe={farben.textTertiaer} />
<Text style={styles.schutzText}>Ende-zu-Ende verschlüsselt · nach 15 Minuten gelöscht</Text>
</View>
{zustand.art === 'scannen' && (
<View style={{ marginTop: abstand.l }}>
<Text style={styles.klein}>Kein Scannen möglich? Link hier einfügen:</Text>
<TextInput
style={styles.feld}
value={eingefuegt}
onChangeText={setEingefuegt}
placeholder="https://behoerdenklar.de/demo#…"
placeholderTextColor={farben.textTertiaer}
autoCapitalize="none"
autoCorrect={false}
accessibilityLabel="Link vom Computer einfügen"
/>
<GrossButton titel="Link verwenden" variante="sekundaer" deaktiviert={!eingefuegt.trim()} onPress={() => verarbeite(eingefuegt)} />
</View>
)}
</ScrollView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: farben.hintergrund },
titel: { fontSize: schrift.titel, fontWeight: '700', color: farben.text, marginBottom: abstand.s },
text: { fontSize: schrift.basis, color: farben.text, lineHeight: 26 },
klein: { fontSize: schrift.klein, color: farben.textSekundaer, marginBottom: abstand.s },
kameraRahmen: {
marginTop: abstand.m, height: 300, borderRadius: 16, overflow: 'hidden',
backgroundColor: farben.flaecheSekundaer,
},
kameraLeer: { flex: 1, justifyContent: 'center', padding: abstand.m, gap: abstand.m },
meldung: { marginTop: abstand.m, gap: abstand.m, padding: abstand.m, borderRadius: 16, backgroundColor: farben.flaeche },
schutzZeile: { flexDirection: 'row', alignItems: 'center', gap: 6, marginTop: abstand.m },
schutzText: { fontSize: 13, color: farben.textTertiaer },
feld: {
backgroundColor: farben.flaeche, borderRadius: 12, padding: 14, fontSize: schrift.klein,
color: farben.text, borderWidth: 1, borderColor: farben.rand, marginBottom: abstand.s,
},
});
+59
View File
@@ -0,0 +1,59 @@
/**
* Übergabe Handy ↔ PC — Ende-zu-Ende verschlüsselt (AES-256-GCM).
*
* Päckchen-Format wie auf der Webseite: base64url(IV[12] || Chiffrat || Tag[16]).
* expo-crypto liefert mit `combined()` genau diese Reihenfolge, deshalb können
* App und Webseite die Päckchen gegenseitig lesen. Der Worker sieht nur
* Chiffrat und löscht alles nach 15 Minuten.
*/
import { AESEncryptionKey, AESSealedData, aesDecryptAsync, aesEncryptAsync } from 'expo-crypto';
import { utf8Dekodieren, utf8Kodieren } from '../utils/utf8';
import { UebergabeLink, base64UrlZuBase64, base64ZuBase64Url } from '../utils/uebergabeFormat';
export interface OffeneUebergabe {
link: UebergabeLink;
schluessel: AESEncryptionKey;
}
export async function oeffneUebergabe(link: UebergabeLink): Promise<OffeneUebergabe> {
const schluessel = await AESEncryptionKey.import(base64UrlZuBase64(link.schluessel), 'base64');
return { link, schluessel };
}
async function verschluesseln(u: OffeneUebergabe, daten: unknown): Promise<string> {
const versiegelt = await aesEncryptAsync(utf8Kodieren(JSON.stringify(daten)), u.schluessel);
return base64ZuBase64Url((await versiegelt.combined('base64')) as string);
}
async function entschluesseln(u: OffeneUebergabe, text: string): Promise<unknown | null> {
try {
const versiegelt = AESSealedData.fromCombined(base64UrlZuBase64(text));
const bytes = (await aesDecryptAsync(versiegelt, u.schluessel)) as Uint8Array;
return JSON.parse(utf8Dekodieren(bytes));
} catch {
return null; // nicht von dieser Übergabe oder beschädigt
}
}
const adresse = (u: OffeneUebergabe) => `${u.link.basis}/uebergabe/${u.link.id}`;
export async function sendeAnUebergabe(u: OffeneUebergabe, fach: string, daten: unknown): Promise<void> {
const r = await fetch(`${adresse(u)}/${fach}`, { method: 'PUT', body: await verschluesseln(u, daten) });
if (!r.ok) throw new Error(`Übergabe fehlgeschlagen (${r.status})`);
}
export async function holeAusUebergabe(u: OffeneUebergabe): Promise<Record<string, unknown>> {
const r = await fetch(adresse(u), { headers: { 'cache-control': 'no-store' } });
if (!r.ok) throw new Error(`Übergabe nicht erreichbar (${r.status})`);
const faecher = ((await r.json()) as { faecher?: Record<string, string> }).faecher ?? {};
const aus: Record<string, unknown> = {};
for (const [fach, wert] of Object.entries(faecher)) {
const d = await entschluesseln(u, wert);
if (d) aus[fach] = d;
}
return aus;
}
export function loescheUebergabe(u: OffeneUebergabe): void {
fetch(adresse(u), { method: 'DELETE' }).catch(() => {});
}
+2
View File
@@ -145,4 +145,6 @@ export type RootStackParamList = {
Antwort: { briefId: string };
Glossar: undefined;
Einstellungen: undefined;
/** QR-Code vom Beratungs-PC: Brief hinschicken oder Ergebnis übernehmen. */
Verbinden: { modus: 'senden'; briefId: string } | { modus: 'empfangen' };
};
+112
View File
@@ -0,0 +1,112 @@
/// <reference types="node" />
/**
* Übergabe Handy ↔ PC: App und Webseite müssen die Päckchen gegenseitig lesen.
*
* Die Web-Seite wird hier mit WebCrypto nachgebaut — exakt wie in
* webseite/uebergabe.js. Für die App läuft die echte Web-Implementierung von
* expo-crypto (gleiche Kombi-Reihenfolge IV‖Chiffrat‖Tag wie nativ).
*/
jest.mock('expo-crypto/build/aes/ExpoCryptoAES', () => jest.requireActual('expo-crypto/build/aes/ExpoCryptoAES.web'));
import { webcrypto } from 'crypto';
import { holeAusUebergabe, oeffneUebergabe, sendeAnUebergabe } from '../../services/uebergabe';
import { briefZuWebPaket, leseUebergabeLink, webPaketZuBrief } from '../uebergabeFormat';
import { BriefEintrag } from '../../types';
const subtle = webcrypto.subtle;
const b64u = (b: Uint8Array) => Buffer.from(b).toString('base64url');
// --- Web-Seite (wie webseite/uebergabe.js) ---
async function webSchluessel(k: string) {
return subtle.importKey('raw', Buffer.from(k, 'base64url'), { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']);
}
async function webVerschluesseln(k: string, daten: unknown) {
const iv = webcrypto.getRandomValues(new Uint8Array(12));
const ct = new Uint8Array(await subtle.encrypt({ name: 'AES-GCM', iv }, await webSchluessel(k), new TextEncoder().encode(JSON.stringify(daten))));
return b64u(Buffer.concat([iv, ct]));
}
async function webEntschluesseln(k: string, text: string) {
const b = Buffer.from(text, 'base64url');
const klar = await subtle.decrypt({ name: 'AES-GCM', iv: b.subarray(0, 12) }, await webSchluessel(k), b.subarray(12));
return JSON.parse(new TextDecoder().decode(klar));
}
const ID = 'v1wRNjDlmRZeGSS_YRiRVA';
const KEY = b64u(webcrypto.getRandomValues(new Uint8Array(32)));
const link = (a: string) => `https://behoerdenklar.de/demo#a=${a}&s=${ID}&k=${KEY}&l=ar&d=1`;
const ANALYSE_DE = {
brieftyp: 'Aufforderung', absender: 'Ausländerbehörde', kernaussage: 'Unterlagen fehlen.',
erklaerung_einfach: 'Bitte bis 12.11. einreichen.', fachbegriffe: [{ begriff: 'Fiktionsbescheinigung', erklaerung: 'Papier' }],
frist: { datum: '2026-11-12', aktion: 'Unterlagen einreichen' }, termin: null, checkliste: ['Pass'],
kontakt: { telefon: '069 90009-314' }, anruf: [],
};
const ANALYSE_AR = { ...ANALYSE_DE, kernaussage: 'المستندات ناقصة.', checkliste: ['جواز السفر'] };
describe('leseUebergabeLink', () => {
it('liest Kamera- und Mitnehmen-Codes', () => {
expect(leseUebergabeLink(link('k'))).toMatchObject({ art: 'k', id: ID, schluessel: KEY, basis: 'https://behoerdenklar-proxy.behoerdenbrief.workers.dev' });
expect(leseUebergabeLink(link('m'))?.art).toBe('m');
});
it('nimmt beim lokalen Test den lokalen Worker', () => {
expect(leseUebergabeLink(`http://localhost:4173/demo.html#a=m&s=${ID}&k=${KEY}`)?.basis).toBe('http://localhost:8799');
});
it('lehnt fremde Seiten und kaputte Codes ab', () => {
expect(leseUebergabeLink(`https://boese.example/demo#a=m&s=${ID}&k=${KEY}`)).toBeNull();
expect(leseUebergabeLink('https://behoerdenklar.de/demo#a=m&s=kurz&k=kurz')).toBeNull();
expect(leseUebergabeLink('Hallo')).toBeNull();
});
});
describe('Verschlüsselung App ↔ Webseite', () => {
const fetchOriginal = global.fetch;
afterEach(() => { global.fetch = fetchOriginal; });
it('App liest, was die Webseite verschlüsselt hat', async () => {
const chiffrat = await webVerschluesseln(KEY, { analyse: ANALYSE_AR, analyse_de: ANALYSE_DE, sprache: 'ar' });
global.fetch = jest.fn(async () => new Response(JSON.stringify({ faecher: { ergebnis: chiffrat, fremd: 'QUJD' } }))) as typeof fetch;
const u = await oeffneUebergabe(leseUebergabeLink(link('m'))!);
const faecher = await holeAusUebergabe(u);
expect(faecher.ergebnis).toEqual({ analyse: ANALYSE_AR, analyse_de: ANALYSE_DE, sprache: 'ar' });
expect(faecher.fremd).toBeUndefined(); // nicht entschlüsselbar → ignoriert
});
it('Webseite liest, was die App verschlüsselt hat', async () => {
let gesendet = '';
let adresse = '';
global.fetch = jest.fn(async (url: string, opt: RequestInit) => {
adresse = url;
gesendet = String(opt.body);
return new Response(null, { status: 204 });
}) as unknown as typeof fetch;
const u = await oeffneUebergabe(leseUebergabeLink(link('k'))!);
await sendeAnUebergabe(u, 'ergebnis', { analyse: ANALYSE_DE, sprache: 'de' });
expect(adresse).toBe(`https://behoerdenklar-proxy.behoerdenbrief.workers.dev/uebergabe/${ID}/ergebnis`);
expect(gesendet).toMatch(/^[A-Za-z0-9_-]+$/); // base64url, wie der Worker verlangt
expect(await webEntschluesseln(KEY, gesendet)).toEqual({ analyse: ANALYSE_DE, sprache: 'de' });
});
});
describe('Umwandeln Web ↔ App', () => {
it('Ergebnis vom PC wird ein Brief: Deutsch als Grundlage, Arabisch als Übersetzung', () => {
const b = webPaketZuBrief({ analyse: ANALYSE_AR, analyse_de: ANALYSE_DE, sprache: 'ar' }, 'id1', new Date('2026-10-29T10:00:00Z'));
expect(b.analyse.kernaussage).toBe('Unterlagen fehlen.');
expect(b.analyse.frist).toEqual({ datum: '2026-11-12', aktion: 'Unterlagen einreichen' });
expect(b.analyse.antwort_noetig).toBe(false);
expect(b.uebersetzungen.ar.kernaussage).toBe('المستندات ناقصة.');
expect(b.erstelltAm).toBe('2026-10-29T10:00:00.000Z');
});
it('rein deutsches Ergebnis hat keine Übersetzung', () => {
const b = webPaketZuBrief({ analyse: ANALYSE_DE, sprache: 'de' }, 'id2', new Date());
expect(b.analyse.kernaussage).toBe('Unterlagen fehlen.');
expect(b.uebersetzungen).toEqual({});
});
it('Brief aus dem Archiv geht zweisprachig an den PC', () => {
const brief = webPaketZuBrief({ analyse: ANALYSE_AR, analyse_de: ANALYSE_DE, sprache: 'ar' }, 'id3', new Date());
const p = briefZuWebPaket(brief as BriefEintrag);
expect(p.sprache).toBe('ar');
expect(p.analyse.kernaussage).toBe('المستندات ناقصة.');
expect(p.analyse.frist).toEqual(ANALYSE_DE.frist);
expect(p.analyse_de?.kernaussage).toBe('Unterlagen fehlen.');
});
});
+117
View File
@@ -0,0 +1,117 @@
/**
* Übergabe Handy ↔ PC — reine Hilfsfunktionen ohne native Module (testbar).
*
* Gleiches Protokoll wie die Webseite (webseite/uebergabe.js): Der QR-Code
* enthält hinter dem `#` eine Übergabe-ID (s), einen AES-256-Schlüssel (k)
* und die Art der Übergabe (a):
* a=k „Kamera": der PC wartet auf einen Brief → App schickt ihn hin
* a=m „Mitnehmen": der PC hat ein Ergebnis bereitgelegt → App holt es
* a=g Guthaben der Webseite (gilt nur im Browser, nicht in der App)
*/
import { BriefAnalyse, BriefEintrag, Fachbegriff, Uebersetzung } from '../types';
export type UebergabeArt = 'k' | 'm' | 'g';
export interface UebergabeLink {
art: UebergabeArt;
id: string;
schluessel: string; // base64url, 32 Byte
/** Worker, der die Päckchen hält (lokal beim Testen, sonst live). */
basis: string;
}
const LIVE_BASIS = 'https://behoerdenklar-proxy.behoerdenbrief.workers.dev';
const LOKAL_BASIS = 'http://localhost:8799';
const ERLAUBTE_SEITEN = /^https:\/\/([a-z0-9-]+\.)?behoerdenklar\.(de|pages\.dev)\//;
const LOKALE_SEITE = /^http:\/\/(localhost|127\.0\.0\.1):\d+\//;
/** Liest einen gescannten QR-Code. null = kein BehördenKlar-Code. */
export function leseUebergabeLink(text: string): UebergabeLink | null {
const t = (text || '').trim();
const lokal = LOKALE_SEITE.test(t);
if (!ERLAUBTE_SEITEN.test(t) && !lokal) return null;
const raute = t.indexOf('#');
if (raute < 0) return null;
const werte: Record<string, string> = {};
for (const teil of t.slice(raute + 1).split('&')) {
const i = teil.indexOf('=');
if (i > 0) werte[teil.slice(0, i)] = decodeURIComponent(teil.slice(i + 1));
}
const art = werte.a as UebergabeArt;
if (art === 'g') return { art, id: '', schluessel: '', basis: '' };
if ((art !== 'k' && art !== 'm') || !/^[A-Za-z0-9_-]{22}$/.test(werte.s ?? '') || !/^[A-Za-z0-9_-]{43}$/.test(werte.k ?? '')) {
return null;
}
return { art, id: werte.s, schluessel: werte.k, basis: lokal ? LOKAL_BASIS : LIVE_BASIS };
}
export function base64UrlZuBase64(s: string): string {
const b = s.replace(/-/g, '+').replace(/_/g, '/');
return b + '='.repeat((4 - (b.length % 4)) % 4);
}
export function base64ZuBase64Url(s: string): string {
return s.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
/** Was die Webseite als Ergebnis schickt bzw. erwartet. */
export interface WebPaket {
analyse: WebAnalyse;
analyse_de?: WebAnalyse;
sprache?: string;
}
/** Die Analyse der Webseite: wie BriefAnalyse, dafür mit Kontakt + Anruf-Hilfe. */
type WebAnalyse = Partial<BriefAnalyse> & {
kontakt?: unknown;
anruf?: unknown;
};
function textOder(s: unknown, ersatz = ''): string {
return typeof s === 'string' ? s : ersatz;
}
function alsBriefAnalyse(w: WebAnalyse): BriefAnalyse {
return {
brieftyp: textOder(w.brieftyp, 'Brief'),
absender: textOder(w.absender),
kernaussage: textOder(w.kernaussage),
erklaerung_einfach: textOder(w.erklaerung_einfach),
fachbegriffe: Array.isArray(w.fachbegriffe) ? (w.fachbegriffe as Fachbegriff[]) : [],
frist: w.frist ?? null,
termin: w.termin ?? null,
checkliste: Array.isArray(w.checkliste) ? w.checkliste.filter((x) => typeof x === 'string') : [],
// Die Web-Analyse kennt keine Antwort-Vorschläge — die App kann sie nicht erfinden
antwort_noetig: false,
antwort_optionen: [],
};
}
/** Ergebnis vom PC → Brief fürs Archiv. Deutsch ist die Grundlage, die
* andere Sprache landet im Übersetzungs-Cache (wie bei App-Übersetzungen). */
export function webPaketZuBrief(p: WebPaket, id: string, jetzt: Date): BriefEintrag {
const sprache = p.sprache && p.sprache !== 'de' ? p.sprache : null;
const deutsch = p.analyse_de ?? (sprache ? null : p.analyse);
const basis = alsBriefAnalyse(deutsch ?? p.analyse);
const uebersetzungen: Record<string, Uebersetzung> = {};
if (sprache) {
const f = alsBriefAnalyse(p.analyse);
uebersetzungen[sprache] = {
kernaussage: f.kernaussage,
erklaerung_einfach: f.erklaerung_einfach,
checkliste: f.checkliste,
fachbegriffe: f.fachbegriffe,
};
}
return { id, erstelltAm: jetzt.toISOString(), analyse: basis, uebersetzungen };
}
/** Brief aus dem Archiv → Ergebnis für den PC. Gibt es eine Übersetzung,
* bekommt der PC beide Sprachen (Deutsch zuerst für die Beratung). */
export function briefZuWebPaket(b: BriefEintrag): WebPaket {
const deutsch: WebAnalyse = { ...b.analyse };
const [sprache] = Object.keys(b.uebersetzungen ?? {});
if (!sprache) return { analyse: deutsch, sprache: 'de' };
return {
analyse: { ...b.analyse, ...b.uebersetzungen[sprache] },
analyse_de: deutsch,
sprache,
};
}
+108
View File
@@ -0,0 +1,108 @@
/**
* Einrichtungs-Codes für Beratungsstellen anlegen und verwalten.
*
* 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 aufladen MBE-K7QX 100
* node tools/einrichtungs-code.js verlaengern MBE-K7QX 2027-06-30
*
* Ein Code ist ein Guthaben wie beim Kauf, das allen gehört, die den Code
* kennen. Die Ratsuchenden zahlen nichts, die Einrichtung zahlt (oder testet
* gratis). Gilt bis zum Ablaufdatum oder bis die Briefe aufgebraucht sind.
*
* Braucht nur den wrangler-Login (npx wrangler login), kein Secret.
* Link für Handzettel/QR-Code: https://behoerdenklar.de/demo?code=<CODE>
*/
const { execFileSync } = require('child_process');
const crypto = require('crypto');
const path = require('path');
const PROXY = path.join(__dirname, '..', 'proxy');
const KV_ID = '7577adef8ceb4ed6bfaeef81a1d496af'; // RATE_LIMIT aus proxy/wrangler.toml
// ohne 0/O, 1/I/L: am Telefon und auf Papier nicht zu verwechseln
const ZEICHEN = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
function kv(...args) {
return execFileSync('npx', ['wrangler', 'kv', 'key', ...args, '--namespace-id', KV_ID, '--remote'],
{ cwd: PROXY, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
}
function lies(schluessel) {
try { return kv('get', schluessel, '--text'); } catch { return null; }
}
function schreib(schluessel, wert) { kv('put', schluessel, wert); }
function option(name, standard) {
const i = process.argv.indexOf('--' + name);
return i > 0 ? process.argv[i + 1] : standard;
}
function abbruch(text) { console.error('\x1b[31m' + text + '\x1b[0m'); process.exit(1); }
function zufall(laenge) {
let s = '';
for (const b of crypto.randomBytes(laenge)) s += ZEICHEN[b % ZEICHEN.length];
return s;
}
function plusMonate(monate) {
const d = new Date();
d.setMonth(d.getMonth() + monate);
return d.toISOString().slice(0, 10);
}
function ladeCode(code) {
code = String(code || '').toUpperCase();
const walletId = lies(`code:${code}`);
if (!walletId) abbruch(`Code ${code} gibt es nicht.`);
const einrichtung = JSON.parse(lies(`einrichtung:${walletId}`) || '{}');
const rest = parseInt(lies(`wallet:${walletId}`) || '0', 10);
return { code, walletId, einrichtung, rest };
}
function zeige({ code, einrichtung, rest }) {
const heute = new Date().toISOString().slice(0, 10);
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}
`);
}
const [befehl, a, b] = process.argv.slice(2);
if (befehl === 'neu') {
const name = a;
const praefix = String(b || '').toUpperCase();
if (!name || !/^[A-Z]{2,12}$/.test(praefix)) {
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 code = `${praefix}-${zufall(4)}`;
if (lies(`code:${code}`)) abbruch('Zufällig schon vergeben — einfach nochmal ausführen.');
const walletId = crypto.randomUUID();
schreib(`wallet:${walletId}`, String(briefe));
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 });
} else if (befehl === 'stand') {
zeige(ladeCode(a));
} else if (befehl === 'aufladen') {
const c = ladeCode(a);
const dazu = parseInt(b, 10);
if (!(dazu > 0)) abbruch('So geht es: node tools/einrichtungs-code.js aufladen MBE-K7QX 100');
schreib(`wallet:${c.walletId}`, String(c.rest + dazu));
console.log(`\n \x1b[32m✓ ${dazu} Briefe dazu\x1b[0m`);
zeige({ ...c, rest: c.rest + dazu });
} else if (befehl === 'verlaengern') {
const c = ladeCode(a);
if (!/^\d{4}-\d{2}-\d{2}$/.test(b || '')) abbruch('So geht es: node tools/einrichtungs-code.js verlaengern MBE-K7QX 2027-06-30');
const einrichtung = { ...c.einrichtung, ablauf: b };
schreib(`einrichtung:${c.walletId}`, JSON.stringify(einrichtung));
console.log('\n \x1b[32m✓ verlängert\x1b[0m');
zeige({ ...c, einrichtung });
} else {
console.log(require('fs').readFileSync(__filename, 'utf8').split('*/')[0].replace(/^\/\*\*?|^ \* ?/gm, ''));
}
+42 -1
View File
@@ -29,7 +29,7 @@
<body>
<div class="wrap">
<h1>Datenschutzerklärung</h1>
<p class="stand">für die App und Webseite „BehördenKlar" — Stand: 18.08.2026</p>
<p class="stand">für die App und Webseite „BehördenKlar" — Stand: 22.10.2026</p>
<div class="karte">
<h2>1. Verantwortlicher</h2>
@@ -215,6 +215,47 @@
für die gesetzlichen Fristen auf (Art. 6 Abs. 1 lit. c DSGVO).
</p>
<h2>9a. Zugang über eine Beratungsstelle (Einrichtungs-Code)</h2>
<p>
Beratungsstellen können ihren Ratsuchenden einen Code geben, mit dem
BehördenKlar für sie kostenlos ist. Der Code verweist auf ein
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).
</p>
<h2>9b. Übergabe zwischen Handy und Computer</h2>
<p>
Sie können ein Ergebnis per QR-Code von einem Gerät auf ein anderes
bringen, zum Beispiel vom Computer der Beratungsstelle auf Ihr Handy,
oder mit dem Handy fotografieren und das Ergebnis am Computer anzeigen.
Dabei gilt:
</p>
<ul>
<li>
<strong>Ende-zu-Ende-Verschlüsselung.</strong> Das Ergebnis wird auf
dem einen Gerät mit AES-256 verschlüsselt und erst auf dem anderen
Gerät wieder entschlüsselt. Den Schlüssel enthält nur der QR-Code
(im Teil der Adresse nach dem „#", den Browser nicht an Server
senden). <strong>Wir können den Inhalt nicht lesen.</strong>
</li>
<li>
<strong>Kurze Zwischenspeicherung.</strong> Das verschlüsselte
Ergebnis liegt bis zur Abholung auf unserem Server (Cloudflare),
höchstens 15 Minuten, und wird beim Abholen bzw. danach automatisch
gelöscht. Zum Schutz vor Missbrauch begrenzen wir die Zahl der
Übertragungen pro IP-Adresse (Art. 6 Abs. 1 lit. f DSGVO).
</li>
<li>
Wer den QR-Code fotografiert oder abfotografiert, kann das Ergebnis
innerhalb dieser 15 Minuten öffnen. Zeigen Sie den Code deshalb nur
der Person, für die er bestimmt ist.
</li>
</ul>
<h2>10. Reichweitenmessung (Webseite)</h2>
<p>
Auf dieser Webseite nutzen wir Cloudflare Web Analytics (Cloudflare,
+430 -15
View File
@@ -208,6 +208,25 @@
background: linear-gradient(104deg, transparent 1%, var(--marker) 3%, var(--marker) 97%, transparent 99%);
font-size: 16px;
}
/* Handy <-> PC */
.cta-zwei { background: #fff; color: var(--amtsblau); font-size: 16.5px; padding: 13px 20px; margin-top: 12px; }
.verbunden {
display: none; margin-bottom: 16px; padding: 12px 16px;
background: #eaf1fa; border: 2px solid var(--amtsblau); font-size: 16px;
}
.qr-box { display: none; margin-top: 16px; padding: 18px; background: #fff; border: 2px dashed var(--amtsblau); text-align: center; }
.qr-box .qr { width: 220px; height: 220px; margin: 10px auto; }
.qr-box .qr svg { width: 100%; height: 100%; display: block; }
.qr-box ol { text-align: start; margin: 8px auto 0; max-width: 420px; padding-inline-start: 22px; font-size: 15.5px; }
.qr-box .zustand { margin-top: 12px; font-weight: 700; font-size: 16.5px; }
.qr-box .abbrechen { margin-top: 10px; background: none; border: none; color: var(--grau); text-decoration: underline; font-family: inherit; font-size: 14.5px; cursor: pointer; }
.umschalter { display: none; gap: 8px; flex-wrap: wrap; margin-bottom: 6px; }
.umschalter button {
font-family: inherit; font-size: 15px; padding: 6px 14px; cursor: pointer;
border: 1.5px solid var(--tinte); background: #fff; color: var(--tinte);
}
.umschalter button[aria-pressed="true"] { background: var(--tinte); color: var(--papier); }
.link-knopf { background: none; border: none; color: var(--amtsblau); font-family: inherit; font-size: inherit; text-decoration: underline; cursor: pointer; padding: 0; }
footer { margin-top: 48px; font-size: 14.5px; color: var(--grau); }
footer a { color: var(--grau); }
</style>
@@ -264,13 +283,21 @@
<div class="antrag">
<p class="formular-kopf">Antrag auf Klartext · 2 Gratis-Analysen</p>
<p class="verbunden" id="verbundenBanner"></p>
<div id="eingabe">
<p id="kaufOk" style="display: none; background: #e7f4ea; border: 1px solid #b7dfc1; color: #1a7f37; padding: 12px 14px; border-radius: 8px; margin-bottom: 14px; font-weight: 600;">
✅ Kauf erfolgreich! Ihr Guthaben wurde aktiviert.
</p>
<p id="guthabenBanner" class="bonus-zeile" style="display: none;">
💳 Ihr Guthaben: <strong id="guthabenZahl">0</strong> Analysen — laden Sie den nächsten Brief hoch.
<span id="guthabenText">💳 Ihr Guthaben:</span> <strong id="guthabenZahl">0</strong> Analysen — laden Sie den nächsten Brief hoch.
<br><button type="button" class="link-knopf" id="guthabenHandyKnopf" style="font-size: 14.5px;">📱 Auch auf dem Handy nutzen</button>
</p>
<div class="qr-box" id="guthabenQrBox">
<strong>Guthaben aufs Handy holen</strong>
<div class="qr" id="guthabenQr"></div>
<p class="klein" style="margin-top: 0;">Mit der Handy-Kamera scannen. Danach können Sie Briefe auch direkt am Handy erklären lassen.</p>
</div>
<p style="margin-bottom: 14px; font-size: 15.5px;">
🌐 <label for="sprachWahl">Erklärung in dieser Sprache:</label>
<select id="sprachWahl" style="font-family: inherit; font-size: 15.5px; padding: 5px 8px; border: 1.5px solid var(--tinte); background: #fff; margin-inline-start: 6px;"></select>
@@ -303,8 +330,31 @@
<input type="file" id="datei" accept="image/jpeg,image/png,application/pdf" style="display: none;">
<button class="cta" id="startKnopf" disabled>📷 Brief auswählen &amp; erklären lassen</button>
<button class="cta cta-zwei" id="handyKnopf" type="button" style="display: none;">📱 Lieber mit dem Handy fotografieren</button>
<div class="qr-box" id="handyBox">
<strong>Mit dem Handy fotografieren</strong>
<div class="qr" id="handyQr"></div>
<ol>
<li>Handy-Kamera auf den Code richten und den Link öffnen.</li>
<li>Am Handy den Brief fotografieren.</li>
<li>Die Erklärung erscheint hier <em>und</em> auf dem Handy.</li>
</ol>
<label class="konsens" id="handyDeutschZeile" style="justify-content: center; margin: 14px 0 0;">
<input type="checkbox" id="handyDeutsch" checked>
<span>Hier am Bildschirm auf Deutsch zeigen, am Handy in der gewählten Sprache</span>
</label>
<p class="zustand" id="handyZustand">⏳ Warte auf das Handy …</p>
<p class="klein" style="margin-top: 6px;">Verschlüsselt: Den Schlüssel kennt nur dieser QR-Code. Der Code gilt 15 Minuten.</p>
<button type="button" class="abbrechen" id="handyAbbrechen">Abbrechen</button>
</div>
<div class="cf-turnstile" data-sitekey="0x4AAAAAAESi2p41B915R4hW" data-action="turnstile-spin-v1" style="margin-top: 14px;"></div>
<script>
// Lokaler Test (wrangler dev): offizieller Turnstile-Testschlüssel, der immer durchlässt
if (/^(localhost|127\.0\.0\.1)$/.test(location.hostname)) {
document.querySelector('.cf-turnstile').setAttribute('data-sitekey', '1x00000000000000000000AA');
}
</script>
<p class="klein">
Geht mit Foto (JPG/PNG) oder PDF, max. 5 MB. Tipp: gutes Licht,
@@ -318,6 +368,13 @@
Ihrem Handy, nicht von uns:</strong> Diese Seite greift weder auf
Kamera noch auf Mikrofon zu.
</p>
<details id="codeTeil" style="margin-top: 14px; border-top: 1px solid rgba(33, 33, 29, 0.2);">
<summary>🤝 Sie haben einen Code von einer Beratungsstelle?</summary>
<p>Dann ist BehördenKlar für Sie kostenlos. Geben Sie den Code hier ein:</p>
<input class="wl-input" id="codeFeld" placeholder="z. B. MBE-K7QX" autocapitalize="characters" autocomplete="off" spellcheck="false" style="margin-top: 10px; text-transform: uppercase;">
<button class="cta" id="codeKnopf" type="button">Code einlösen</button>
</details>
</div>
<div class="status" id="status">
@@ -377,6 +434,7 @@
<section id="ergebnis">
<h2><span class="nr">§ 3</span> Ihr Brief in Klartext</h2>
<div class="umschalter" id="sprachUmschalter" role="group" aria-label="Sprache der Erklärung"></div>
<div class="brief-karte">
<span class="karten-label neutral" id="ergBrieftyp">Brief</span>
@@ -415,6 +473,15 @@
<div id="ergBegriffe"></div>
</div>
<div id="mitnehmenTeil" style="display: none; margin-top: 22px;">
<button class="cta cta-zwei" id="mitnehmenKnopf" type="button">📱 Ergebnis aufs Handy mitnehmen</button>
<div class="qr-box" id="mitnehmenBox">
<strong>Ergebnis aufs Handy</strong>
<div class="qr" id="mitnehmenQr"></div>
<p class="klein" style="margin-top: 0;">Mit der Handy-Kamera scannen. Dort gibt es den Kalender-Eintrag und die Anruf-Hilfe. Verschlüsselt, der Code gilt 15 Minuten.</p>
</div>
</div>
<p class="hinweis-recht">
⚖️ BehördenKlar erklärt Briefe, ersetzt aber keine Rechtsberatung. Bei
wichtigen Entscheidungen hilft eine Beratungsstelle oder ein Anwalt.
@@ -465,10 +532,14 @@
</div>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<script src="qrcode.js"></script>
<script src="uebergabe.js"></script>
<script>
(function () {
'use strict';
var BASIS = 'https://behoerdenklar-proxy.behoerdenbrief.workers.dev';
var BASIS = /^(localhost|127\.0\.0\.1)$/.test(location.hostname)
? 'http://localhost:8799' // lokaler Test mit `wrangler dev`
: 'https://behoerdenklar-proxy.behoerdenbrief.workers.dev';
var ENDPUNKT = BASIS + '/demo';
var KAUFEN_ENDPUNKT = BASIS + '/kaufen';
var STATUS_ENDPUNKT = BASIS + '/kauf-status';
@@ -508,6 +579,10 @@
var werbung = document.getElementById('werbung');
var guthabenBanner = document.getElementById('guthabenBanner');
var guthabenZahl = document.getElementById('guthabenZahl');
var guthabenText = document.getElementById('guthabenText');
var codeTeil = document.getElementById('codeTeil');
var codeFeld = document.getElementById('codeFeld');
var codeKnopf = document.getElementById('codeKnopf');
var kaufOk = document.getElementById('kaufOk');
var paketListe = document.getElementById('paketListe');
var kaufKonsens = document.getElementById('kaufKonsens');
@@ -521,6 +596,32 @@
function guthaben() { return parseInt(localStorage.getItem(GUTHABEN_SCHLUESSEL) || '0', 10); }
function setzeGuthaben(n) { localStorage.setItem(GUTHABEN_SCHLUESSEL, String(Math.max(0, n | 0))); }
function hatGuthaben() { return !!walletToken() && guthaben() > 0; }
// Einrichtungs-Code: Name der Beratungsstelle, über die das Guthaben läuft
var EINRICHTUNG_SCHLUESSEL = 'bk_einrichtung';
function einrichtung() { return localStorage.getItem(EINRICHTUNG_SCHLUESSEL) || ''; }
function setzeEinrichtung(name) {
if (name) localStorage.setItem(EINRICHTUNG_SCHLUESSEL, name);
else localStorage.removeItem(EINRICHTUNG_SCHLUESSEL);
}
// Handy ↔ PC: Ist dieses Gerät gerade das Handy, das für einen PC
// fotografiert? Ein Guthaben aus dem QR-Code (w) gilt nur für diese
// Sitzung und wird hier NICHT gespeichert.
var handyModus = null;
var leseModus = false; // Ergebnis vom PC übernommen, nur anzeigen
var istPc = window.matchMedia && window.matchMedia('(hover: hover) and (pointer: fine)').matches;
function walletFuerAnalyse() {
if (handyModus && handyModus.w) return handyModus.w;
return hatGuthaben() ? walletToken() : '';
}
function meldeAnPc(daten, fach) {
if (!handyModus) return Promise.resolve();
return BKUebergabe.senden(handyModus.ue, fach || 'status', daten).catch(function () {});
}
function zeigeGuthabenBanner(n) {
guthabenText.textContent = einrichtung() ? '🤝 Kostenlos über ' + einrichtung() + ':' : '💳 Ihr Guthaben:';
guthabenZahl.textContent = n;
guthabenBanner.style.display = 'block';
}
// Schlüsselname bewusst 'v2': setzt evtl. falsch gezählte Stände (aus der
// Testphase) bei bestehenden Besuchern automatisch einmalig zurück.
@@ -529,17 +630,27 @@
function setzeBenutzt(n) { localStorage.setItem(SPEICHER_SCHLUESSEL, String(n)); }
function zeigeStand() {
// Bezahlter Modus: Eingabe offen, Guthaben-Stand zeigen, Gratis-Sperre egal
if (hatGuthaben()) {
if (handyModus && handyModus.w) {
eingabe.style.display = 'block';
guthabenBanner.style.display = 'block';
guthabenZahl.textContent = guthaben();
guthabenBanner.style.display = 'none';
emailTeil.style.display = 'none';
fehlerBox.style.display = 'none';
kaufBlock.style.display = 'none';
codeTeil.style.display = 'none';
return;
}
// Bezahlter Modus: Eingabe offen, Guthaben-Stand zeigen, Gratis-Sperre egal
if (hatGuthaben()) {
eingabe.style.display = 'block';
zeigeGuthabenBanner(guthaben());
emailTeil.style.display = 'none';
fehlerBox.style.display = 'none';
kaufBlock.style.display = 'none';
codeTeil.style.display = 'none';
return;
}
guthabenBanner.style.display = 'none';
codeTeil.style.display = 'block';
// Kauf ist IMMER möglich — auch sofort, ohne die Gratis-Analysen zu nutzen.
kaufBlock.style.display = 'block';
// 2. Versuch: E-Mail-Feld einblenden; alles verbraucht: Gratis-Weg sperren
@@ -556,7 +667,7 @@
konsens.addEventListener('change', function () { startKnopf.disabled = !konsens.checked; });
startKnopf.addEventListener('click', function () {
fehlerBox.style.display = 'none';
if (!hatGuthaben() && benutzt() >= 1 && !emailFeld.value.trim()) {
if (!walletFuerAnalyse() && benutzt() >= 1 && !emailFeld.value.trim()) {
emailTeil.style.display = 'block';
emailFeld.focus();
zeigeFehler('Bitte geben Sie zuerst Ihre E-Mail-Adresse ein — dann wählen Sie den Brief aus.');
@@ -625,7 +736,8 @@
try {
var istPdf = datei.type === 'application/pdf';
var base64 = istPdf ? await pdfAlsBase64(datei) : await bildVerkleinern(datei);
var bezahlt = hatGuthaben();
var wallet = walletFuerAnalyse();
var bezahlt = !!wallet;
var body = {
bild: base64,
mimeType: istPdf ? 'application/pdf' : 'image/jpeg',
@@ -633,8 +745,10 @@
};
var email = emailFeld.value.trim();
body.zielsprache = zielsprache();
if (bezahlt) body.walletToken = walletToken();
if (bezahlt) body.walletToken = wallet;
else if (benutzt() >= 1 && email) body.email = email;
if (handyModus && handyModus.d) body.auchDeutsch = true;
meldeAnPc({ schritt: 'analyse' });
var antwort = await fetch(ENDPUNKT, {
method: 'POST',
@@ -647,6 +761,12 @@
status.style.display = 'none';
eingabe.style.display = 'block';
if (window.turnstile) window.turnstile.reset();
meldeAnPc({ schritt: 'fehler', meldung: daten.meldung || '' });
// Guthaben vom PC: Fehler nur anzeigen, am Handy nichts umstellen
if (handyModus && handyModus.w) {
zeigeFehler(daten.meldung || 'Etwas ist schiefgelaufen. Bitte erneut versuchen.');
return;
}
if (daten.code === 'email_noetig') {
// Nur das E-Mail-Feld zeigen — NICHT als verbraucht markieren.
// (Sonst würde ein serverseitiges "E-Mail nötig", z. B. bei
@@ -660,6 +780,14 @@
zeigeStand();
return;
}
if (daten.code === 'kontingent_leer' || daten.code === 'kontingent_abgelaufen') {
setzeWallet('');
setzeGuthaben(0);
setzeEinrichtung(null);
zeigeStand();
zeigeFehler(daten.meldung);
return;
}
if (daten.code === 'kein_guthaben') {
setzeGuthaben(0);
guthabenBanner.style.display = 'none';
@@ -674,15 +802,29 @@
// Token erneuern — sonst würde die nächste Analyse ein "Seite neu laden" verlangen
if (window.turnstile) window.turnstile.reset();
// Handy fotografiert für den PC: Ergebnis verschlüsselt hinüberschicken
await meldeAnPc({
analyse: daten.analyse, analyse_de: daten.analyse_de, sprache: daten.sprache,
guthaben: daten.guthaben, einrichtung: daten.einrichtung,
}, 'ergebnis');
var analyse = bereiteVersionen(daten, false);
if (handyModus && handyModus.w) {
zeigeAnalyse(analyse);
eingabe.style.display = 'block';
document.getElementById('ergebnis').scrollIntoView({ behavior: 'smooth' });
return;
}
if (daten.versuch === 'bezahlt' || typeof daten.guthaben === 'number') {
setzeGuthaben(daten.guthaben);
zeigeErgebnis(daten.analyse, null, daten.guthaben);
zeigeErgebnis(analyse, null, daten.guthaben);
return;
}
setzeBenutzt(Math.max(benutzt(), daten.versuch));
if (email) document.getElementById('wlEmail').value = email;
zeigeErgebnis(daten.analyse, daten.versuch);
zeigeErgebnis(analyse, daten.versuch);
} catch (e) {
status.style.display = 'none';
eingabe.style.display = 'block';
@@ -708,7 +850,33 @@
return { farbe: farbe, text: punkt + ' ' + text };
}
function zeigeErgebnis(analyse, versuch, guthabenRest) {
// Eine Analyse kann in zwei Sprachen vorliegen (PC Deutsch, Handy Muttersprache).
var versionen = [];
var aktiveVersion = 0;
var letzteDaten = null; // { analyse, analyse_de, sprache } — für „Mitnehmen"
function bereiteVersionen(d, deutschZuerst) {
letzteDaten = { analyse: d.analyse, analyse_de: d.analyse_de, sprache: d.sprache || 'de' };
versionen = [{ code: letzteDaten.sprache, analyse: d.analyse }];
if (d.analyse_de) versionen[deutschZuerst ? 'unshift' : 'push']({ code: 'de', analyse: d.analyse_de });
aktiveVersion = 0;
return versionen[0].analyse;
}
function zeichneUmschalter() {
var box = document.getElementById('sprachUmschalter');
box.innerHTML = '';
box.style.display = versionen.length > 1 ? 'flex' : 'none';
versionen.forEach(function (v, i) {
var b = document.createElement('button');
b.type = 'button';
b.textContent = SPRACH_NAMEN[v.code] || v.code;
b.setAttribute('aria-pressed', i === aktiveVersion ? 'true' : 'false');
b.addEventListener('click', function () { aktiveVersion = i; zeigeAnalyse(v.analyse); });
box.appendChild(b);
});
}
/** Nur die Anzeige einer Analyse — ohne Guthaben/Gratis-Ablauf. */
function zeigeAnalyse(analyse) {
status.style.display = 'none';
// Optionale Karten zuruecksetzen. Sonst bleibt beim zweiten Brief z. B.
// die Checkliste des ERSTEN Briefs stehen — zwei Briefe vermischt.
@@ -751,8 +919,15 @@
zeigeKalender(analyse);
zeigeAnruf(analyse);
zeichneUmschalter();
document.getElementById('mitnehmenTeil').style.display = istPc && !handyModus && !leseModus ? 'block' : 'none';
document.getElementById('mitnehmenBox').style.display = 'none';
document.getElementById('ergebnis').style.display = 'block';
}
function zeigeErgebnis(analyse, versuch, guthabenRest) {
zeigeAnalyse(analyse);
// Bezahlter Modus: Guthaben-Stand zeigen, Eingabe für den nächsten Brief öffnen
if (typeof guthabenRest === 'number') {
@@ -760,8 +935,7 @@
kaufOk.style.display = 'none';
if (guthabenRest > 0) {
eingabe.style.display = 'block';
guthabenBanner.style.display = 'block';
guthabenZahl.textContent = guthabenRest;
zeigeGuthabenBanner(guthabenRest);
emailTeil.style.display = 'none';
kaufBlock.style.display = 'none';
} else {
@@ -1039,6 +1213,7 @@
if (res.ok && res.d && res.d.token) {
setzeWallet(res.d.token);
setzeGuthaben(res.d.guthaben);
setzeEinrichtung(null);
status.style.display = 'none';
kaufOk.style.display = 'block';
zeigeStand();
@@ -1055,10 +1230,241 @@
});
}
// ---- Einrichtungs-Code einlösen ------------------------------------
function loeseCodeEin(code) {
code = String(code || '').trim().toUpperCase();
if (!code) { codeFeld.focus(); return; }
if (walletToken() && guthaben() > 0 && !einrichtung() &&
!window.confirm('Sie haben noch gekauftes Guthaben. Wenn Sie den Code einlösen, nutzt diese Seite den Code. Ihr gekauftes Guthaben bleibt über den Link in Ihrer Kauf-E-Mail erhalten. Weiter?')) {
return;
}
codeKnopf.disabled = true;
fehlerBox.style.display = 'none';
fetch(BASIS + '/code', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ code: code }),
})
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })
.then(function (res) {
codeKnopf.disabled = false;
if (!res.ok || !res.d.token) {
codeTeil.open = true;
codeFeld.value = code;
zeigeFehler(res.d.meldung || 'Der Code konnte nicht eingelöst werden.');
return;
}
setzeWallet(res.d.token);
setzeGuthaben(res.d.guthaben);
setzeEinrichtung(res.d.einrichtung);
zeigeStand();
if (res.d.guthaben <= 0) {
zeigeFehler('Die Analysen von ' + res.d.einrichtung + ' sind aufgebraucht. Bitte fragen Sie dort nach.');
return;
}
kaufOk.textContent = '✅ Code eingelöst. BehördenKlar ist für Sie kostenlos, über ' + res.d.einrichtung + '.';
kaufOk.style.display = 'block';
})
.catch(function () {
codeKnopf.disabled = false;
zeigeFehler('Verbindung fehlgeschlagen. Bitte prüfen Sie Ihr Internet und versuchen Sie es erneut.');
});
}
codeKnopf.addEventListener('click', function () { loeseCodeEin(codeFeld.value); });
codeFeld.addEventListener('keydown', function (e) { if (e.key === 'Enter') loeseCodeEin(codeFeld.value); });
// ---- Handy ↔ PC ------------------------------------------------------
var verbundenBanner = document.getElementById('verbundenBanner');
var handyKnopf = document.getElementById('handyKnopf');
var handyBox = document.getElementById('handyBox');
var handyQr = document.getElementById('handyQr');
var handyZustand = document.getElementById('handyZustand');
var handyDeutsch = document.getElementById('handyDeutsch');
var handyDeutschZeile = document.getElementById('handyDeutschZeile');
var UEBERGABE_MS = BKUebergabe.LEBENSDAUER_MIN * 60 * 1000;
function zeigeQr(el, adresse) {
el.innerHTML = BKUebergabe.qrSvg(adresse);
el.setAttribute('data-link', adresse); // zum Testen ohne Kamera
}
// PC: QR-Code zeigen und warten, bis das Handy fotografiert hat
var pcWarten = null; // { ue, bis, timer }
var pruefeLaeuft = false;
function zeichneHandyQr() {
if (!pcWarten) return;
var sprache = zielsprache();
handyDeutschZeile.style.display = sprache === 'de' ? 'none' : 'flex';
zeigeQr(handyQr, BKUebergabe.adresse({
a: 'k', s: pcWarten.ue.id, k: pcWarten.ue.k, l: sprache,
d: sprache !== 'de' && handyDeutsch.checked ? '1' : '',
w: hatGuthaben() ? walletToken() : '',
}));
}
function stoppeWarten() {
if (pcWarten && pcWarten.timer) clearInterval(pcWarten.timer);
pcWarten = null;
}
function schliesseHandyBox() {
handyBox.style.display = 'none';
handyKnopf.style.display = istPc ? 'block' : 'none';
}
handyKnopf.addEventListener('click', async function () {
stoppeWarten();
fehlerBox.style.display = 'none';
try {
pcWarten = { ue: await BKUebergabe.neu(), bis: Date.now() + UEBERGABE_MS };
} catch (e) {
zeigeFehler('Dieser Browser kann die verschlüsselte Übergabe nicht. Bitte einen aktuellen Browser verwenden.');
return;
}
zeichneHandyQr();
handyZustand.textContent = '⏳ Warte auf das Handy …';
handyKnopf.style.display = 'none';
handyBox.style.display = 'block';
handyBox.scrollIntoView({ behavior: 'smooth', block: 'center' });
pcWarten.timer = setInterval(pruefeHandy, 1500);
});
handyDeutsch.addEventListener('change', zeichneHandyQr);
document.getElementById('handyAbbrechen').addEventListener('click', function () {
if (pcWarten) BKUebergabe.loeschen(pcWarten.ue);
stoppeWarten();
schliesseHandyBox();
});
async function pruefeHandy() {
if (!pcWarten || pruefeLaeuft) return;
if (Date.now() > pcWarten.bis) {
stoppeWarten();
schliesseHandyBox();
zeigeFehler('Der QR-Code ist abgelaufen. Tippen Sie noch einmal auf „Lieber mit dem Handy fotografieren".');
return;
}
pruefeLaeuft = true;
try {
var ue = pcWarten.ue;
var f = await BKUebergabe.holen(ue);
if (!pcWarten || pcWarten.ue !== ue) return; // inzwischen abgebrochen
if (f.ergebnis) {
stoppeWarten();
BKUebergabe.loeschen(ue);
schliesseHandyBox();
zeigeVomHandy(f.ergebnis);
} else if (f.status) {
handyZustand.textContent =
f.status.schritt === 'analyse' ? '⏳ Das Handy hat fotografiert. Der Brief wird erklärt …' :
f.status.schritt === 'fehler' ? '⚠️ Am Handy hat es nicht geklappt: ' + (f.status.meldung || '') + ' Bitte dort noch einmal versuchen.' :
'✅ Handy verbunden. Jetzt am Handy den Brief fotografieren.';
}
} catch (e) {
// kurz keine Verbindung — beim nächsten Durchlauf erneut versuchen
} finally {
pruefeLaeuft = false;
}
}
function zeigeVomHandy(e) {
// Hat das Handy das Guthaben dieses PCs benutzt, den neuen Stand übernehmen
if (typeof e.guthaben === 'number' && hatGuthaben()) setzeGuthaben(e.guthaben);
zeigeAnalyse(bereiteVersionen(e, true));
werbung.style.display = 'none';
zeigeStand();
document.getElementById('ergebnis').scrollIntoView({ behavior: 'smooth' });
}
// PC: Ergebnis per QR-Code aufs Handy mitgeben
document.getElementById('mitnehmenKnopf').addEventListener('click', async function () {
var knopf = this;
if (!letzteDaten) return;
knopf.disabled = true;
try {
var ue = await BKUebergabe.neu();
await BKUebergabe.senden(ue, 'ergebnis', letzteDaten);
zeigeQr(document.getElementById('mitnehmenQr'), BKUebergabe.adresse({ a: 'm', s: ue.id, k: ue.k }));
document.getElementById('mitnehmenBox').style.display = 'block';
} catch (e) {
zeigeFehler('Das Ergebnis konnte nicht vorbereitet werden. Bitte prüfen Sie die Internetverbindung.');
}
knopf.disabled = false;
});
// PC: Guthaben bzw. Zugang der Beratungsstelle aufs Handy holen
document.getElementById('guthabenHandyKnopf').addEventListener('click', function () {
var box = document.getElementById('guthabenQrBox');
if (box.style.display === 'block') { box.style.display = 'none'; return; }
zeigeQr(document.getElementById('guthabenQr'), BKUebergabe.adresse({
a: 'g', w: walletToken(), n: guthaben(), e: einrichtung(),
}));
box.style.display = 'block';
});
/** Handy: Seite wurde über einen QR-Code vom PC geöffnet. */
async function pruefeUebergabe(h) {
if (!h || !h.a) return;
if (h.a === 'g') { uebernehmeGuthaben(h); return; }
var ue = await BKUebergabe.oeffne(h.s, h.k).catch(function () { return null; });
if (!ue) {
zeigeFehler('Dieser QR-Code ist ungültig. Bitte am Computer einen neuen erzeugen.');
return;
}
if (h.a === 'k') {
handyModus = { ue: ue, w: h.w || '', d: h.d === '1' };
if (h.l && SPRACH_NAMEN[h.l]) {
try { localStorage.setItem(SPRACH_SCHLUESSEL, h.l); } catch (e) {}
document.getElementById('sprachWahl').value = h.l;
}
verbundenBanner.textContent = '🔗 Mit dem Computer verbunden. Fotografieren Sie jetzt Ihren Brief. Die Erklärung erscheint hier und auf dem Bildschirm.';
verbundenBanner.style.display = 'block';
handyKnopf.style.display = 'none';
zeigeStand();
meldeAnPc({ schritt: 'verbunden' });
return;
}
if (h.a === 'm') {
leseModus = true;
eingabe.style.display = 'none';
kaufBlock.style.display = 'none';
status.querySelector('.gross').textContent = 'Das Ergebnis wird geladen …';
status.style.display = 'block';
var f = null;
try { f = await BKUebergabe.holen(ue); } catch (e) {}
status.style.display = 'none';
if (!f || !f.ergebnis) {
leseModus = false;
zeigeStand();
zeigeFehler('Dieser QR-Code ist abgelaufen oder wurde schon benutzt (er gilt ' + BKUebergabe.LEBENSDAUER_MIN + ' Minuten und nur einmal). Bitte am Computer einen neuen erzeugen.');
return;
}
BKUebergabe.loeschen(ue);
verbundenBanner.textContent = '📱 Vom Computer übernommen. Tragen Sie die Frist gleich in Ihren Kalender ein, dann vergessen Sie nichts.';
verbundenBanner.style.display = 'block';
zeigeAnalyse(bereiteVersionen(f.ergebnis, false));
document.getElementById('ergebnis').scrollIntoView({ behavior: 'smooth' });
}
}
function uebernehmeGuthaben(h) {
if (!h.w) return;
if (walletToken() && walletToken() !== h.w && guthaben() > 0 &&
!window.confirm('Auf diesem Handy ist schon Guthaben gespeichert. Durch das vom Computer ersetzen? Ihr bisheriges Guthaben bleibt über den Link in Ihrer Kauf-E-Mail erhalten.')) {
return;
}
setzeWallet(h.w);
setzeGuthaben(parseInt(h.n, 10) || 1);
setzeEinrichtung(h.e || null);
kaufOk.textContent = h.e ? '✅ Übernommen. BehördenKlar ist für Sie kostenlos, über ' + h.e + '.' : '✅ Guthaben übernommen.';
kaufOk.style.display = 'block';
zeigeStand();
}
function pruefeRueckkehr() {
var p = new URLSearchParams(window.location.search);
var w = p.get('wallet');
if (w) { setzeWallet(w); setzeGuthaben(Math.max(guthaben(), 1)); reinigeUrl(); return; }
if (w) { setzeWallet(w); setzeGuthaben(Math.max(guthaben(), 1)); setzeEinrichtung(null); reinigeUrl(); return; }
var c = p.get('code');
if (c) { reinigeUrl(); loeseCodeEin(c); return; }
var kauf = p.get('kauf');
if (kauf === 'abbruch') {
reinigeUrl();
@@ -1093,12 +1499,21 @@
});
feld.addEventListener('change', function () {
try { localStorage.setItem(SPRACH_SCHLUESSEL, feld.value); } catch (e) {}
zeichneHandyQr();
});
})();
rendrePakete();
var startHash = BKUebergabe.ausAdresse();
if (startHash && window.history.replaceState) {
// Schlüssel nicht im Browser-Verlauf stehen lassen
window.history.replaceState(null, document.title, window.location.pathname + window.location.search);
}
pruefeRueckkehr();
zeigeStand();
handyKnopf.style.display = istPc ? 'block' : 'none';
document.getElementById('guthabenHandyKnopf').style.display = istPc ? '' : 'none';
pruefeUebergabe(startHash);
})();
</script>
<!-- Cloudflare Web Analytics: cookielose Reichweitenmessung, keine Nutzer-Profile -->
+2297
View File
File diff suppressed because it is too large. Load diff
+121
View File
@@ -0,0 +1,121 @@
/**
* Übergabe Handy ↔ PC — Ende-zu-Ende verschlüsselt.
*
* Ein Gerät erzeugt eine Übergabe (zufällige ID + AES-256-Schlüssel) und
* zeigt sie als QR-Code. Beides steht hinter dem `#` der Adresse; dieser
* Teil wird von Browsern NIE an einen Server geschickt. Der Worker sieht nur
* verschlüsselte Päckchen und löscht sie nach 15 Minuten.
*
* Päckchen-Format: base64url( IV[12 Byte] || AES-GCM-Chiffrat ) eines JSON.
* Die App (src/utils/uebergabe.ts) benutzt exakt dasselbe Format.
*/
(function (w) {
'use strict';
var LOKAL = /^(localhost|127\.0\.0\.1)$/.test(w.location.hostname);
var BASIS = (LOKAL ? 'http://localhost:8799' : 'https://behoerdenklar-proxy.behoerdenbrief.workers.dev') + '/uebergabe/';
function b64u(bytes) {
var s = '';
for (var i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
function ausB64u(text) {
var s = atob(text.replace(/-/g, '+').replace(/_/g, '/'));
var b = new Uint8Array(s.length);
for (var i = 0; i < s.length; i++) b[i] = s.charCodeAt(i);
return b;
}
function zufall(n) { return w.crypto.getRandomValues(new Uint8Array(n)); }
function schluessel(roh) {
return w.crypto.subtle.importKey('raw', roh, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']);
}
/** Neue Übergabe: { id, k, key } — k ist der Schlüssel als Text für den QR-Code. */
async function neu() {
var roh = zufall(32);
return { id: b64u(zufall(16)), k: b64u(roh), key: await schluessel(roh) };
}
/** Übergabe aus ID + Schlüsseltext (aus dem QR-Code) öffnen. */
async function oeffne(id, k) {
if (!/^[A-Za-z0-9_-]{22}$/.test(id || '') || !/^[A-Za-z0-9_-]{43}$/.test(k || '')) return null;
return { id: id, k: k, key: await schluessel(ausB64u(k)) };
}
async function verschluesseln(ue, daten) {
var iv = zufall(12);
var klar = new TextEncoder().encode(JSON.stringify(daten));
var ct = new Uint8Array(await w.crypto.subtle.encrypt({ name: 'AES-GCM', iv: iv }, ue.key, klar));
var alles = new Uint8Array(iv.length + ct.length);
alles.set(iv, 0); alles.set(ct, iv.length);
return b64u(alles);
}
/** Entschlüsseln; null, wenn das Päckchen nicht von dieser Übergabe stammt. */
async function entschluesseln(ue, text) {
try {
var b = ausB64u(text);
var klar = await w.crypto.subtle.decrypt({ name: 'AES-GCM', iv: b.slice(0, 12) }, ue.key, b.slice(12));
return JSON.parse(new TextDecoder().decode(klar));
} catch (e) { return null; }
}
/** Päckchen in ein Fach legen (z. B. „status", „ergebnis"). */
async function senden(ue, fach, daten) {
var r = await fetch(BASIS + ue.id + '/' + fach, { method: 'PUT', body: await verschluesseln(ue, daten) });
if (!r.ok) throw new Error('Übergabe fehlgeschlagen (' + r.status + ')');
}
/** Alle Fächer holen und entschlüsseln: { status: {...}, ergebnis: {...} }. */
async function holen(ue) {
var r = await fetch(BASIS + ue.id, { cache: 'no-store' });
if (!r.ok) throw new Error('Übergabe nicht erreichbar (' + r.status + ')');
var faecher = (await r.json()).faecher || {};
var aus = {};
for (var f in faecher) {
var d = await entschluesseln(ue, faecher[f]);
if (d) aus[f] = d;
}
return aus;
}
function loeschen(ue) {
return fetch(BASIS + ue.id, { method: 'DELETE' }).catch(function () {});
}
/** Adresse für den QR-Code. `werte` landet komplett hinter dem `#`. */
function adresse(werte) {
var teile = [];
for (var k in werte) if (werte[k] !== undefined && werte[k] !== null && werte[k] !== '') {
teile.push(k + '=' + encodeURIComponent(werte[k]));
}
return w.location.origin + w.location.pathname + '#' + teile.join('&');
}
/** Werte hinter dem `#` lesen (oder null, wenn keine da sind). */
function ausAdresse() {
var h = w.location.hash.replace(/^#/, '');
if (!h) return null;
var werte = {};
h.split('&').forEach(function (t) {
var i = t.indexOf('=');
if (i > 0) werte[t.slice(0, i)] = decodeURIComponent(t.slice(i + 1));
});
return werte;
}
/** QR-Code als SVG-Text (braucht qrcode.js). */
function qrSvg(text) {
var qr = w.qrcode(0, 'M');
qr.addData(text);
qr.make();
return qr.createSvgTag({ cellSize: 4, margin: 2, scalable: true });
}
w.BKUebergabe = {
neu: neu, oeffne: oeffne, senden: senden, holen: holen, loeschen: loeschen,
adresse: adresse, ausAdresse: ausAdresse, qrSvg: qrSvg,
LEBENSDAUER_MIN: 15,
};
})(window);