generated from knso/webklar-preview-template
Release-Vorbereitung: Proxy, Kostenoptimierung, Freemium, Store-Assets
- Backend-Proxy (Cloudflare Worker in proxy/): API-Key serverseitig, Modell-Whitelist, max_tokens-Deckel, Tageslimits pro Gerät und IP - Modell-Split: Analyse auf Sonnet 5, Übersetzung/Antwort auf Haiku 4.5 - Bild-Downscaling auf 2000px vor dem Upload (ScanScreen) - Expo SDK 56 -> 57 Upgrade - Freemium: 3 Gratis-Analysen mit lokalem Zähler und Paywall-Hinweis - Einstellungen: API-Key-Feld nur noch im Dev-Modus (NUTZT_PROXY) - Build-Setup: Bundle-IDs, eas.json, Splash-Screen-Plugin - Tests: jest-expo + 17 Tests für die Ampel-/Fristlogik - Rechtliches: Datenschutzerklärung (Entwurf), Webseite (webseite/, live auf behoerdenklar.pages.dev), Store-Texte (store/) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
61
proxy/README.md
Normal file
61
proxy/README.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# BehördenKlar Backend-Proxy
|
||||
|
||||
Cloudflare Worker, der den Anthropic-API-Key serverseitig hält. Die App schickt
|
||||
ihre Claude-Anfragen an diesen Worker statt direkt an Anthropic — kein API-Key
|
||||
in der App, Missbrauchsschutz inklusive.
|
||||
|
||||
## Was der Worker macht
|
||||
|
||||
1. Prüft die anonyme Geräte-ID (`x-geraete-id`-Header) und setzt ein
|
||||
**Tageslimit pro Gerät** durch (Standard: 20 Anfragen, siehe `wrangler.toml`)
|
||||
2. Erlaubt nur die **Modelle, die die App nutzt**, und deckelt `max_tokens`
|
||||
3. Leitet den Request-Body unverändert an `api.anthropic.com/v1/messages`
|
||||
weiter und setzt dabei serverseitig `x-api-key` + `anthropic-version`
|
||||
4. Gibt die Anthropic-Antwort unverändert zurück
|
||||
|
||||
## Deployment (einmalig)
|
||||
|
||||
```bash
|
||||
cd proxy
|
||||
npm install
|
||||
|
||||
# Bei Cloudflare anmelden (kostenloser Account reicht für den Start)
|
||||
npx wrangler login
|
||||
|
||||
# KV-Namespace für das Rate-Limit anlegen …
|
||||
npx wrangler kv namespace create RATE_LIMIT
|
||||
# … und die ausgegebene ID in wrangler.toml bei [[kv_namespaces]] eintragen
|
||||
|
||||
# Anthropic-API-Key als Secret hinterlegen (von console.anthropic.com)
|
||||
npx wrangler secret put ANTHROPIC_API_KEY
|
||||
|
||||
# Deployen
|
||||
npm run deploy
|
||||
```
|
||||
|
||||
Der Deploy gibt eine URL aus, z. B. `https://behoerdenklar-proxy.<account>.workers.dev`.
|
||||
|
||||
## App umstellen
|
||||
|
||||
In [`src/services/claudeClient.ts`](../src/services/claudeClient.ts) die URL eintragen:
|
||||
|
||||
```ts
|
||||
const PROXY_URL: string | null = 'https://behoerdenklar-proxy.<account>.workers.dev';
|
||||
```
|
||||
|
||||
Fertig — die App braucht dann keinen API-Key mehr in den Einstellungen.
|
||||
|
||||
## Lokal testen
|
||||
|
||||
```bash
|
||||
npm run dev # startet den Worker auf http://localhost:8787
|
||||
```
|
||||
|
||||
Dann in `claudeClient.ts` vorübergehend `PROXY_URL = 'http://localhost:8787'`
|
||||
setzen (im Simulator; auf echtem Gerät die LAN-IP des Rechners verwenden).
|
||||
|
||||
## Später ergänzen (wenn Abo/IAP kommt)
|
||||
|
||||
- Abo-Prüfung: RevenueCat-Webhook oder Receipt-Validierung vor dem Weiterleiten
|
||||
- Tageslimit je nach Abo-Stufe (Free: 3, Abo: 20+)
|
||||
- Die Geräte-ID durch eine echte Nutzer-ID ersetzen
|
||||
1576
proxy/package-lock.json
generated
Normal file
1576
proxy/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
15
proxy/package.json
Normal file
15
proxy/package.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "behoerdenklar-proxy",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "wrangler dev",
|
||||
"deploy": "wrangler deploy",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^5.20260708.1",
|
||||
"typescript": "~5.9.0",
|
||||
"wrangler": "^4.0.0"
|
||||
}
|
||||
}
|
||||
128
proxy/src/index.ts
Normal file
128
proxy/src/index.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* BehördenKlar Backend-Proxy — Cloudflare Worker
|
||||
*
|
||||
* Hält den Anthropic-API-Key serverseitig, damit kein Key in der App steckt.
|
||||
* Die App schickt denselben Request-Body wie an die Anthropic Messages API;
|
||||
* der Worker prüft, limitiert und leitet weiter — die Antwort geht
|
||||
* unverändert zurück (die App parst sie wie eine direkte Anthropic-Antwort).
|
||||
*
|
||||
* Schutzmaßnahmen gegen Missbrauch des Endpunkts:
|
||||
* - Nur POST, nur erlaubte Modelle, max_tokens gedeckelt
|
||||
* - Tageslimit pro Geräte-ID (KV-basiert, weiches Limit)
|
||||
*
|
||||
* Deployment: siehe proxy/README.md
|
||||
*/
|
||||
|
||||
interface Env {
|
||||
/** Geheimnis: `wrangler secret put ANTHROPIC_API_KEY` */
|
||||
ANTHROPIC_API_KEY: string;
|
||||
/** KV-Namespace für das Rate-Limit (Binding in wrangler.toml) */
|
||||
RATE_LIMIT: KVNamespace;
|
||||
/** Erlaubte Anfragen pro Gerät und Tag (in wrangler.toml unter [vars]) */
|
||||
TAGES_LIMIT: string;
|
||||
/** Erlaubte Anfragen pro IP-Adresse und Tag (in wrangler.toml unter [vars]) */
|
||||
TAGES_LIMIT_IP: string;
|
||||
}
|
||||
|
||||
const ANTHROPIC_URL = 'https://api.anthropic.com/v1/messages';
|
||||
const ANTHROPIC_VERSION = '2023-06-01';
|
||||
|
||||
/** Nur Modelle, die die App tatsächlich nutzt — verhindert, dass ein
|
||||
* extrahierter Endpunkt als Gratis-Zugang für teure Modelle dient. */
|
||||
const ERLAUBTE_MODELLE = new Set([
|
||||
'claude-sonnet-5', // Brief-Analyse (Vision)
|
||||
'claude-haiku-4-5', // Übersetzung & Antwort-Entwürfe
|
||||
]);
|
||||
|
||||
const MAX_TOKENS_OBERGRENZE = 16000;
|
||||
|
||||
/** Fehlerantwort im Anthropic-Format, damit die App sie normal verarbeitet. */
|
||||
function fehler(status: number, typ: string, meldung: string): Response {
|
||||
return Response.json(
|
||||
{ type: 'error', error: { type: typ, message: meldung } },
|
||||
{ status }
|
||||
);
|
||||
}
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: Env): Promise<Response> {
|
||||
if (request.method !== 'POST') {
|
||||
return fehler(405, 'invalid_request_error', 'Nur POST erlaubt.');
|
||||
}
|
||||
|
||||
// Geräte-ID der App (anonym, dient nur dem Tageslimit)
|
||||
const geraeteId = request.headers.get('x-geraete-id');
|
||||
if (!geraeteId || !/^g_[a-z0-9]{24}$/.test(geraeteId)) {
|
||||
return fehler(400, 'invalid_request_error', 'Fehlende oder ungültige Geräte-ID.');
|
||||
}
|
||||
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return fehler(400, 'invalid_request_error', 'Request-Body ist kein gültiges JSON.');
|
||||
}
|
||||
|
||||
if (typeof body.model !== 'string' || !ERLAUBTE_MODELLE.has(body.model)) {
|
||||
return fehler(400, 'invalid_request_error', 'Dieses Modell ist nicht erlaubt.');
|
||||
}
|
||||
// max_tokens deckeln statt ablehnen — schützt vor Kosten-Missbrauch
|
||||
if (typeof body.max_tokens !== 'number' || body.max_tokens > MAX_TOKENS_OBERGRENZE) {
|
||||
body.max_tokens = MAX_TOKENS_OBERGRENZE;
|
||||
}
|
||||
// Die App streamt nicht; Streaming würde das Durchreichen verkomplizieren
|
||||
if (body.stream) {
|
||||
return fehler(400, 'invalid_request_error', 'Streaming wird nicht unterstützt.');
|
||||
}
|
||||
|
||||
// Tageslimits prüfen (weiche Limits: KV ist eventually consistent,
|
||||
// parallele Anfragen können das Limit minimal überschreiten — okay).
|
||||
// Zwei Ebenen: pro Gerät (normale Nutzung) und pro IP-Adresse (dämmt
|
||||
// Angreifer ein, die sich beliebig neue Geräte-IDs ausdenken). Das
|
||||
// IP-Limit ist bewusst höher, weil sich viele Nutzer eine IP teilen
|
||||
// können (Familien-WLAN, Mobilfunk/CGNAT).
|
||||
const heute = new Date().toISOString().slice(0, 10);
|
||||
const ip = request.headers.get('cf-connecting-ip') ?? 'unbekannt';
|
||||
const kvKey = `rl:${geraeteId}:${heute}`;
|
||||
const kvKeyIp = `rlip:${ip}:${heute}`;
|
||||
const limit = parseInt(env.TAGES_LIMIT || '20', 10);
|
||||
const limitIp = parseInt(env.TAGES_LIMIT_IP || '100', 10);
|
||||
const [bisher, bisherIp] = (
|
||||
await Promise.all([env.RATE_LIMIT.get(kvKey), env.RATE_LIMIT.get(kvKeyIp)])
|
||||
).map((wert) => parseInt(wert ?? '0', 10));
|
||||
if (bisher >= limit || bisherIp >= limitIp) {
|
||||
return fehler(
|
||||
429,
|
||||
'rate_limit_error',
|
||||
'Tageslimit erreicht. Bitte versuchen Sie es morgen erneut.'
|
||||
);
|
||||
}
|
||||
|
||||
// An Anthropic weiterleiten — Header werden frisch gebaut, nichts vom
|
||||
// Client wird durchgereicht (außer dem geprüften Body)
|
||||
const antwort = await fetch(ANTHROPIC_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-api-key': env.ANTHROPIC_API_KEY,
|
||||
'anthropic-version': ANTHROPIC_VERSION,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
// Nur zählen, wenn die Anfrage Anthropic erreicht hat (5xx kostet kein Kontingent)
|
||||
if (antwort.status < 500) {
|
||||
await Promise.all([
|
||||
env.RATE_LIMIT.put(kvKey, String(bisher + 1), { expirationTtl: 60 * 60 * 48 }),
|
||||
env.RATE_LIMIT.put(kvKeyIp, String(bisherIp + 1), { expirationTtl: 60 * 60 * 48 }),
|
||||
]);
|
||||
}
|
||||
|
||||
return new Response(antwort.body, {
|
||||
status: antwort.status,
|
||||
headers: {
|
||||
'content-type': antwort.headers.get('content-type') ?? 'application/json',
|
||||
},
|
||||
});
|
||||
},
|
||||
} satisfies ExportedHandler<Env>;
|
||||
12
proxy/tsconfig.json
Normal file
12
proxy/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022"],
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"types": ["@cloudflare/workers-types"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
14
proxy/wrangler.toml
Normal file
14
proxy/wrangler.toml
Normal file
@@ -0,0 +1,14 @@
|
||||
name = "behoerdenklar-proxy"
|
||||
main = "src/index.ts"
|
||||
compatibility_date = "2026-07-01"
|
||||
|
||||
# Erlaubte Anfragen pro Tag: pro Gerät (normale Nutzung) und pro IP-Adresse
|
||||
# (dämmt Angreifer ein, die Geräte-IDs fälschen; höher, weil sich viele
|
||||
# Nutzer eine IP teilen können)
|
||||
[vars]
|
||||
TAGES_LIMIT = "20"
|
||||
TAGES_LIMIT_IP = "100"
|
||||
|
||||
[[kv_namespaces]]
|
||||
binding = "RATE_LIMIT"
|
||||
id = "7577adef8ceb4ed6bfaeef81a1d496af"
|
||||
Reference in New Issue
Block a user