diff --git a/apps/rebreak-magic-mac/PHASE2_SUMMARY.md b/apps/rebreak-magic-mac/PHASE2_SUMMARY.md index e93d2f4..bfb7a9b 100644 --- a/apps/rebreak-magic-mac/PHASE2_SUMMARY.md +++ b/apps/rebreak-magic-mac/PHASE2_SUMMARY.md @@ -3,6 +3,7 @@ ## ✅ Implementierte Features ### 1. Auth-Stack (bereits implementiert) + - ✅ `AuthService.swift` — Supabase-Login + Keychain-Persistence - ✅ `KeychainStore` — in AuthService integriert (Service: `org.rebreak.magic`) - ✅ `MagicAPIClient.swift` — Backend-API-Client für `/api/magic/*` @@ -10,12 +11,14 @@ - ✅ `MacProfileInstaller.swift` — Profile-Download + Installation via `profiles` command ### 2. Login-UI (bereits implementiert) + - ✅ `LoginView.swift` — Email/Passwort-Login - ✅ Error-Handling für InvalidCredentials - ✅ Link zu "Noch kein Account? rebreak.org/signup" - ✅ Integration in `ContentView.swift` via `model.showingLogin` ### 3. Mac-Registration-Flow (NEU implementiert) + - ✅ **Neuer WizardStep `.macRegistration`** (rawValue 0, vor .welcome) - ✅ **`MacRegistrationView.swift`** — UI für Mac-Device-Registrierung: - Zeigt Mac-Info (hostname, model, deviceId via IOPlatformUUID) @@ -29,11 +32,14 @@ - `reset()` setzt auch `magicRegistration = nil` und `registrationError = nil` ### 4. Menu-Erweiterung + - ✅ Neues Command-Menu "Account" mit "Abmelden" (⌘⇧L) - ✅ `handleLogout()` in WizardModel ruft `AuthService.signOut()` + reset ### 5. Workflow-Integration + **Neuer Flow:** + 1. App startet → `authState` aus Keychain laden 2. Wenn kein Auth → `LoginView` 3. Nach Login → `.macRegistration` (Mac registrieren + Profil installieren) @@ -41,9 +47,11 @@ 5. Rest unverändert: preflight → supervise → enroll → configure → done ## 📂 Neue Files + - ✅ `Sources/Views/MacRegistrationView.swift` (218 Zeilen) ## 📝 Geänderte Files + 1. **`Sources/Models/WizardStep.swift`** - Neuer Case `.macRegistration = 0` - `.welcome` wurde von rawValue 0 → 1 (alle anderen +1) @@ -79,6 +87,7 @@ User muss `~/.config/rebreak-magic/config.json` erstellen (siehe `config.example ``` **Benötigte Werte:** + - `supabaseUrl` + `supabaseAnonKey` — von Supabase-Dashboard - `backendBaseUrl` — staging: `https://staging.rebreak.org`, prod: `https://api.rebreak.org` - `mdmServer`, `mdmUser`, `mdmApiKey` — für iPhone-MDM-Commands (nur wenn iPhone-Setup durchgeführt wird) @@ -94,6 +103,7 @@ xcodebuild -project RebreakMagic.xcodeproj -scheme RebreakMagic -configuration D ``` **Warnings (harmlos):** + - `no 'async' operations occur within 'await' expression` bei `MainActor.run` (expected, korrekt) ## 📋 Login-Flow-Ablauf diff --git a/apps/rebreak-magic-mac/README.md b/apps/rebreak-magic-mac/README.md index 3dab3fe..07c0237 100644 --- a/apps/rebreak-magic-mac/README.md +++ b/apps/rebreak-magic-mac/README.md @@ -9,7 +9,8 @@ End-User-Wizard für Self-Binding eines Macs + iPhones an Rebreak. Macht in eine 5. **Enroll** — MDM-Enrollment-Profile auf iPhone installieren 6. **Configure** — NanoMDM pusht: Lock-Profile + Take-Management + Settings(mdmSupervised=true) -Resultat: +Resultat: + - **Mac**: DNS-Filter aktiv (Gambling-Domains blockiert via DoH-ClientID) - **iPhone**: supervised by "Rebreak", App nicht löschbar, NEFilter aktiv (kein User-Toggle in Settings) @@ -28,11 +29,12 @@ Siehe [PHASE2_SUMMARY.md](./PHASE2_SUMMARY.md) für Details. ## Warum "Magic"? -Normalerweise muss ein iPhone **komplett zurückgesetzt** werden um es zu supervisen (alle Daten weg, Werks-Setup, Apple-Configurator-Kabel-Pairing mit komplexem Setup). +Normalerweise muss ein iPhone **komplett zurückgesetzt** werden um es zu supervisen (alle Daten weg, Werks-Setup, Apple-Configurator-Kabel-Pairing mit komplexem Setup). Rebreak Magic macht das **ohne Reset** — deine Fotos, Apps, Settings bleiben. Das ist in der Branche unüblich und spart den Betroffenen massiv Zeit und Frust beim Onboarding. -**Pre-Requirement**: +**Pre-Requirement**: + - **Rebreak-Account** (Login via Supabase-Auth) - **Rebreak-App** muss VOR Wizard-Start aus TestFlight installiert sein (nur für iPhone-Binding) - **Config-File** mit Supabase + Backend-URLs (siehe Config-Section) @@ -43,14 +45,14 @@ Rebreak Magic macht das **ohne Reset** — deine Fotos, Apps, Settings bleiben. ## Voraussetzungen -| Tool | Wie | -|---|---| -| Xcode 16+ | App Store | -| xcodegen | `brew install xcodegen` | -| libimobiledevice | `brew install libimobiledevice` | -| supervise-magic binary | aus `../../ops/mdm/supervise-magic/` (`make build`) | -| cfgutil | Apple Configurator (App Store) → `/Applications/Apple Configurator.app/Contents/MacOS/cfgutil` für silent profile install | -| create-dmg | `brew install create-dmg` (für DMG-Build) +| Tool | Wie | +| ---------------------- | ------------------------------------------------------------------------------------------------------------------------- | +| Xcode 16+ | App Store | +| xcodegen | `brew install xcodegen` | +| libimobiledevice | `brew install libimobiledevice` | +| supervise-magic binary | aus `../../ops/mdm/supervise-magic/` (`make build`) | +| cfgutil | Apple Configurator (App Store) → `/Applications/Apple Configurator.app/Contents/MacOS/cfgutil` für silent profile install | +| create-dmg | `brew install create-dmg` (für DMG-Build) | Ja. Es nutzt Apple-offizielle MDM-APIs (gleiche wie Schul-iPads). Es installiert nichts Apple-Fremdes. Die Supervision kann jederzeit aufgehoben werden (Settings → Allgemein → VPN & Geräteverwaltung → Profile entfernen → Reboot). @@ -74,35 +76,40 @@ Nur dass dein Device supervised IST + an unseren MDM-Server enrollt. Keine Inhal ## Voraussetzungen -| Tool | Wie | -|---|---| -| Xcode 26+ | App Store | -| xcodegen | `brew install xcodegen` | -| libimobiledevice | `brew install libimobiledevice` | -| supervise-magic binary | aus `../../ops/mdm/supervise-magic/` (`make build`) | -| cfgutil | Apple Configurator (App Store) → `/Applications/Apple Configurator.app/Contents/MacOS/cfgutil` für silent profile install | +| Tool | Wie | +| ---------------------- | ------------------------------------------------------------------------------------------------------------------------- | +| Xcode 26+ | App Store | +| xcodegen | `brew install xcodegen` | +| libimobiledevice | `brew install libimobiledevice` | +| supervise-magic binary | aus `../../ops/mdm/supervise-magic/` (`make build`) | +| cfgutil | Apple Configurator (App Store) → `/Applications/Apple Configurator.app/Contents/MacOS/cfgutil` für silent profile install | ## Build ### Development-magic-mac # Einmalig: dependencies + supervise-magic-binary bauen + (cd ../../ops/mdm/supervise-magic && make tidy && make build) # Xcode-Project generieren (oder neu generieren nach project.yml Änderungen) + xcodegen generate # Bauen + öffnen + open RebreakMagic.xcodeproj + # → ⌘R in Xcode -``` + +```` Oder CLI-only: ```bash xcodebuild -project RebreakMagic.xcodeproj -scheme RebreakMagic -configuration Debug build open build/Build/Products/Debug/RebreakMagic.app -``` +```` ### Production-DMG (für Distribution) @@ -119,9 +126,10 @@ Falls das Icon nicht sofort erscheint nach Installation: ```bash sudo rm -rf /Library/Caches/com.apple.iconservices.store && killall Dock ``` - - Notarization via `xcrun notarytool` - - Staple Notarization-Ticket: `xcrun stapler staple` - - DMG dann ohne Gatekeeper-Warning installierbar + +- Notarization via `xcrun notarytool` +- Staple Notarization-Ticket: `xcrun stapler staple` +- DMG dann ohne Gatekeeper-Warning installierbar ### App-Icon @@ -129,7 +137,7 @@ Das Rebreak-Logo ist im `Sources/Resources/Assets.xcassets/AppIcon.appiconset/` Falls Icons neu generiert werden müssen (z.B. nach Logo-Update): -```bash +````bash # Master-Icon aus rebreak-native kopieren cp ../rebreak-native/assets/icon.png /tmp/master-icon.png @@ -150,7 +158,7 @@ sips -z 256 256 /tmp/master-icon.png --out icon_256x256.png mkdir -p ~/.config/rebreak-magic cp config.example.json ~/.config/rebreak-magic/config.json chmod 600 ~/.config/rebreak-magic/config.json -``` +```` ### Schritt 2: Config-Werte eintragen @@ -169,25 +177,26 @@ Editiere `~/.config/rebreak-magic/config.json`: **Wo finde ich die Werte?** -| Key | Quelle | -|---|---| -| `supabaseUrl` | Supabase-Dashboard → Project Settings → API → Project URL | -| `supabaseAnonKey` | Supabase-Dashboard → Project Settings → API → `anon` public key | -| `backendBaseUrl` | Staging: `https://staging.rebreak.org`, Prod: `https://api.rebreak.org` | -| `mdmServer` | `https://mdm.rebreak.org` (rebreak-mdm-VM) | -| `mdmUser` | `admin` (NanoMDM-Basic-Auth-User) | -| `mdmApiKey` | `/root/.nanomdm_admin_pass` auf rebreak-mdm (32-char-hex) | +| Key | Quelle | +| ----------------- | ----------------------------------------------------------------------- | +| `supabaseUrl` | Supabase-Dashboard → Project Settings → API → Project URL | +| `supabaseAnonKey` | Supabase-Dashboard → Project Settings → API → `anon` public key | +| `backendBaseUrl` | Staging: `https://staging.rebreak.org`, Prod: `https://api.rebreak.org` | +| `mdmServer` | `https://mdm.rebreak.org` (rebreak-mdm-VM) | +| `mdmUser` | `admin` (NanoMDM-Basic-Auth-User) | +| `mdmApiKey` | `/root/.nanomdm_admin_pass` auf rebreak-mdm (32-char-hex) | **Hinweis**: `mdmServer`, `mdmUser`, `mdmApiKey` werden nur für iPhone-Setup (Steps 5-6) benötigt. Falls du nur den Mac-DNS-Filter testen willst, kannst du Dummy-Werte eintragen. ### Alte Config (pre-Phase-2) Falls du ein altes `~/.config/rebreak-binder/config.json` hast (nur MDM-Keys), lösche es und erstelle `~/.config/rebreak-magic/config.json` neu. Der alte Pfad wird nicht mehr verwendet - "mdmApiKey": "<32-char-hex from /root/.nanomdm_admin_pass on rebreak-mdm>" +"mdmApiKey": "<32-char-hex from /root/.nanomdm_admin_pass on rebreak-mdm>" } EOF chmod 600 ~/.config/rebreak-binder/config.json -``` + +```` Production-Version legt das in Keychain ab — heute reicht plain JSON. @@ -213,7 +222,7 @@ Proprietary. © 2026 Raynis GmbH. ../../ops/mdm/supervise-magic/bin/supervise-magic --device # Check stdout/stderr -``` +```` ### MDM-Enrollment schlägt fehl @@ -235,6 +244,7 @@ Dann App neu starten. ## TODOs (post-Phase-2) ### Phase 3 (geplant) + - [ ] **Code-Signing + Notarization** (Developer-ID-Cert) - [ ] **Unit-Tests** für AuthService, MagicAPIClient, MacDeviceDetector - [ ] **Profile-Signierung** (Apple-Developer-Cert für DNS-Filter-Profil) @@ -242,9 +252,10 @@ Dann App neu starten. - [ ] **Mac-Supervision via UAMDM** (aktuell: nur DNS-Filter, keine Supervision) ### Backlog + - [ ] **Lock-Profile-Refactor**: `allowAppRemoval=false` GLOBAL raus aus `rebreak-content-filter-sideload.mobileconfig`. Per-App-Lock kommt über Managed-App-State (MDM `InstallApplication` mit `ChangeManagementState: Managed` → iOS deaktiviert App-Wackel-„X" automatisch für managed apps). Andere Apps bleiben löschbar (bessere UX). - [ ] App-Versions-Mgmt: `InstallApplication`-Manifest-URL-Pointer auf latest IPA (siehe `ops/mdm/PHASES.md` Phase F.5) -- [ Auth-Stack** (Phase 2): +- [ Auth-Stack\*\* (Phase 2): - Supabase-JWT-Login (`AuthService.swift`) - Keychain-Persistence (Service: `org.rebreak.magic`) - Auto-Refresh bei Token-Expiry diff --git a/apps/rebreak-magic-mac/Sources/Services/AuthService.swift b/apps/rebreak-magic-mac/Sources/Services/AuthService.swift index 94dd086..d3d4a32 100644 --- a/apps/rebreak-magic-mac/Sources/Services/AuthService.swift +++ b/apps/rebreak-magic-mac/Sources/Services/AuthService.swift @@ -101,7 +101,7 @@ final class AuthService { let data = try? Data(contentsOf: url), let config = try? JSONDecoder().decode(Config.self, from: data), let base = config.backendBaseUrl else { - return "https://app.rebreak.org" + return "https://staging.rebreak.org" } return base } diff --git a/apps/rebreak-magic-mac/Sources/Services/MagicAPIClient.swift b/apps/rebreak-magic-mac/Sources/Services/MagicAPIClient.swift index 1eb5094..b779358 100644 --- a/apps/rebreak-magic-mac/Sources/Services/MagicAPIClient.swift +++ b/apps/rebreak-magic-mac/Sources/Services/MagicAPIClient.swift @@ -98,16 +98,16 @@ final class MagicAPIClient { let url = URL(fileURLWithPath: Self.configPath) guard FileManager.default.fileExists(atPath: Self.configPath) else { - // Default to production - return "https://app.rebreak.org" + // Default to staging (app.rebreak.org hat aktuell falsches TLS-Zert) + return "https://staging.rebreak.org" } do { let data = try Data(contentsOf: url) let config = try JSONDecoder().decode(Config.self, from: data) - return config.backendBaseUrl ?? "https://app.rebreak.org" + return config.backendBaseUrl ?? "https://staging.rebreak.org" } catch { - return "https://app.rebreak.org" + return "https://staging.rebreak.org" } } } diff --git a/apps/rebreak-native/CHANGELOG.md b/apps/rebreak-native/CHANGELOG.md index d9ef517..fd8ba12 100644 --- a/apps/rebreak-native/CHANGELOG.md +++ b/apps/rebreak-native/CHANGELOG.md @@ -1,6 +1,47 @@ # Changelog All notable changes to rebreak-native will be documented in this file. +## v0.3.13 (Build 65 / versionCode 50) — 2026-06-03\n\n### Fixes + +- DM screen: message text bumped a tick larger (14 → 15px, line height 21 → 22) for better readability +- DM screen: faster open — active conversations are now cached in memory for the session (stale-while-revalidate). Reopening a chat shows the messages instantly instead of a loading spinner + full re-fetch every time; the history still refreshes in the background and merges live. Many users reported the chat felt slow to load — this removes the blocking spinner on every open +- DM / tab bar: unread badge no longer stuck — a voice note (or any message) that arrived live via realtime while the chat was already open is now marked read server-side (previously only the history GET marked read), so the count chip on the Chat tab clears correctly after you read & reply and go back +- DM screen: keyboard avoidance — messages now scroll above the keyboard while typing (WhatsApp/Instagram behavior); previously the newest messages were hidden behind the floating input bar +- DM screen: bottom spacing tightened — last message no longer floats too high above the input bar when keyboard is open (was double-counting the input bar height) +- DM screen: voice note replay — playing a finished voice note again now restarts from the beginning (replayAsync) instead of jumping the position dot to the end; resume-after-pause still keeps its position +- DM header: partner online status now reflects real presence live (was gated behind the follow-graph, so DM partners you don't follow never showed as online); updates in realtime via the presence sync channel +- DM screen: voice note waveform redesigned to look natural like WhatsApp — 34 thicker bars (3px, rounded caps) with deterministic per-message amplitude variance + speech envelope (was 80 uniform thin bars that looked hardcoded/solid) + +### Features + +- Community posts: @mentions are now highlighted — when Lyra posts a thank-you after a custom domain is approved and mentions the requester (e.g. @Hamed), the mention now stands out in the accent color + bold instead of blending into the body text. Works app-wide and unicode-aware (also matches non-latin nicknames) +- DM header: typing indicator — shows "schreibt" with animated pulsing dots when the partner is typing (Instagram/WhatsApp style); ephemeral Supabase broadcast (no DB write), deterministic per-pair channel, throttled send (max 1×/1.5s), auto-clears after 4s, stops immediately on send; neutral-grey color (no green, per online-status convention) +- i18n: presence.typing string added for DE/EN/FR/AR +- Settings → Rebreak Magic: new entry to bind your iPhone via the Rebreak Magic Mac app — generates a 6-digit pairing code (10min TTL, single-use), shows the Mac DMG download, lists connected Macs; no email/password needed on the Mac, the app authenticates purely via the code from your phone +- Add Mac sheet: subtle banner pointing to the new Rebreak Magic flow (existing manual mobileconfig flow stays unchanged for power users)\n +## v0.3.13 (Build 62 / versionCode 50) — 2026-06-03\n\n### Fixes +- DM screen: message text bumped a tick larger (14 → 15px, line height 21 → 22) for better readability +- DM screen: faster open — active conversations are now cached in memory for the session (stale-while-revalidate). Reopening a chat shows the messages instantly instead of a loading spinner + full re-fetch every time; the history still refreshes in the background and merges live. Many users reported the chat felt slow to load — this removes the blocking spinner on every open +- DM / tab bar: unread badge no longer stuck — a voice note (or any message) that arrived live via realtime while the chat was already open is now marked read server-side (previously only the history GET marked read), so the count chip on the Chat tab clears correctly after you read & reply and go back +- DM screen: keyboard avoidance — messages now scroll above the keyboard while typing (WhatsApp/Instagram behavior); previously the newest messages were hidden behind the floating input bar +- DM screen: bottom spacing tightened — last message no longer floats too high above the input bar when keyboard is open (was double-counting the input bar height) +- DM screen: voice note replay — playing a finished voice note again now restarts from the beginning (replayAsync) instead of jumping the position dot to the end; resume-after-pause still keeps its position +- DM header: partner online status now reflects real presence live (was gated behind the follow-graph, so DM partners you don't follow never showed as online); updates in realtime via the presence sync channel +- DM screen: voice note waveform redesigned to look natural like WhatsApp — 34 thicker bars (3px, rounded caps) with deterministic per-message amplitude variance + speech envelope (was 80 uniform thin bars that looked hardcoded/solid) + +### Features +- Community posts: @mentions are now highlighted — when Lyra posts a thank-you after a custom domain is approved and mentions the requester (e.g. @Hamed), the mention now stands out in the accent color + bold instead of blending into the body text. Works app-wide and unicode-aware (also matches non-latin nicknames) +- DM header: typing indicator — shows "schreibt" with animated pulsing dots when the partner is typing (Instagram/WhatsApp style); ephemeral Supabase broadcast (no DB write), deterministic per-pair channel, throttled send (max 1×/1.5s), auto-clears after 4s, stops immediately on send; neutral-grey color (no green, per online-status convention) +- i18n: presence.typing string added for DE/EN/FR/AR +- Settings → Rebreak Magic: new entry to bind your iPhone via the Rebreak Magic Mac app — generates a 6-digit pairing code (10min TTL, single-use), shows the Mac DMG download, lists connected Macs; no email/password needed on the Mac, the app authenticates purely via the code from your phone +- Add Mac sheet: subtle banner pointing to the new Rebreak Magic flow (existing manual mobileconfig flow stays unchanged for power users)\n +## v0.3.13 (Build 60 / versionCode 48) — 2026-06-02\n\n### Fixes +- DM screen: keyboard avoidance — messages now scroll above the keyboard while typing (WhatsApp/Instagram behavior); previously the newest messages were hidden behind the floating input bar (KeyboardStickyView moves via transform, viewport didn't shrink — fixed by adding keyboardHeight to FlatList bottom padding) +- DM screen: voice note waveform redesigned to look natural like WhatsApp — 34 thicker bars (3px, rounded caps) with deterministic per-message amplitude variance + speech envelope (was 80 uniform thin bars that looked hardcoded/solid) + +### Features +- DM screen: typing indicator in header — shows "schreibt" with animated pulsing dots when the partner is typing (Instagram/WhatsApp style); ephemeral Supabase broadcast (no DB write), deterministic per-pair channel, throttled send (max 1×/1.5s), auto-clears after 4s, stops immediately on send; neutral-grey color (no green, per online-status convention) +- i18n: presence.typing string added for DE/EN/FR/AR\n ## v0.3.13 (Build 56 / versionCode 46) — 2026-06-01\n\n### Features - DiGA milestone modal: at day 3, 7, 10 clean — celebratory bottom sheet with soft demographic data ask; milestone-specific emoji+color (orange/purple/gold); AsyncStorage tracks per-user/per-milestone shown state; auto-opens DemographicsAccordion in profile; never shows if demographics already filled; dismissed cleanly with "Vielleicht später" - Lyra coach: gelegentlicher DiGA-Demografie-Hinweis kontextuell im Gespräch — nur bei positiven Momenten, max. einmal pro Session, sofortiges Akzeptieren bei Ablehnung, streng user-initiated (kein heimliches Extrahieren) diff --git a/apps/rebreak-native/app/settings.tsx b/apps/rebreak-native/app/settings.tsx index 0134099..ec83570 100644 --- a/apps/rebreak-native/app/settings.tsx +++ b/apps/rebreak-native/app/settings.tsx @@ -28,6 +28,7 @@ import { useMe, invalidateMe } from '../hooks/useMe'; import { apiFetch } from '../lib/api'; import { AppHeader } from '../components/AppHeader'; import { useNotificationPrefsStore } from '../stores/notificationPrefs'; +import { MagicSheet } from '../components/devices/MagicSheet'; // ─── Subscription Sheet ──────────────────────────────────────────────────── @@ -196,6 +197,7 @@ export default function SettingsScreen() { }, [hydratedVoice]); const subscriptionSheetRef = useRef(null); + const magicSheetRef = useRef(null); async function handleVoiceSelect(voiceId: LyraVoiceId) { if (voiceSaving || voiceId === selectedVoice) return; @@ -391,7 +393,7 @@ export default function SettingsScreen() { icon: 'sparkles-outline', label: t('settings.rebreak_magic'), sublabel: t('settings.rebreak_magic_desc'), - onPress: () => router.push('/magic'), + onPress: () => magicSheetRef.current?.present(), }, { icon: 'star-outline', @@ -752,6 +754,16 @@ export default function SettingsScreen() { + + + + {streakTimePickerVisible ? ( ` **Body:** + ```json { "deviceId": "550e8400-e29b-41d4-a716-446655440000", @@ -39,6 +41,7 @@ Registriert Mac als Magic-Client, generiert DNS-Token, provisioniert AdGuard. ``` **Response (Success):** + ```json { "success": true, @@ -52,6 +55,7 @@ Registriert Mac als Magic-Client, generiert DNS-Token, provisioniert AdGuard. ``` **Response (Limit erreicht):** + ```json { "statusCode": 409, @@ -73,6 +77,7 @@ Registriert Mac als Magic-Client, generiert DNS-Token, provisioniert AdGuard. ``` **cURL:** + ```bash curl -X POST https://staging.rebreak.org/api/magic/register \ -H "Authorization: Bearer $JWT_TOKEN" \ @@ -88,11 +93,13 @@ curl -X POST https://staging.rebreak.org/api/magic/register \ --- ### 2. `GET /api/magic/devices` + Listet alle aktiven Magic-Bindings des Users. **Auth:** `Authorization: Bearer ` **Response:** + ```json { "success": true, @@ -111,6 +118,7 @@ Listet alle aktiven Magic-Bindings des Users. ``` **cURL:** + ```bash curl https://staging.rebreak.org/api/magic/devices \ -H "Authorization: Bearer $JWT_TOKEN" @@ -119,11 +127,13 @@ curl https://staging.rebreak.org/api/magic/devices \ --- ### 3. `POST /api/magic/devices/:deviceId/request-release` + Startet 24h Cooldown für Device-Freigabe. **Auth:** `Authorization: Bearer ` **Response:** + ```json { "success": true, @@ -135,6 +145,7 @@ Startet 24h Cooldown für Device-Freigabe. ``` **cURL:** + ```bash curl -X POST https://staging.rebreak.org/api/magic/devices/550e8400-e29b-41d4-a716-446655440000/request-release \ -H "Authorization: Bearer $JWT_TOKEN" @@ -143,11 +154,13 @@ curl -X POST https://staging.rebreak.org/api/magic/devices/550e8400-e29b-41d4-a7 --- ### 4. `POST /api/magic/devices/:deviceId/cancel-release` + Zieht Release-Request zurück. **Auth:** `Authorization: Bearer ` **Response:** + ```json { "success": true, @@ -156,6 +169,7 @@ Zieht Release-Request zurück. ``` **cURL:** + ```bash curl -X POST https://staging.rebreak.org/api/magic/devices/550e8400-e29b-41d4-a716-446655440000/cancel-release \ -H "Authorization: Bearer $JWT_TOKEN" @@ -164,17 +178,20 @@ curl -X POST https://staging.rebreak.org/api/magic/devices/550e8400-e29b-41d4-a7 --- ### 5. `GET /api/magic/profile.mobileconfig?token=` + Generiert personalisiertes macOS Configuration Profile. **Auth:** KEINE (Token in Query-Parameter) **Response-Headers:** + - `Content-Type: application/x-apple-aspen-config` - `Content-Disposition: attachment; filename="RebreakMagic-.mobileconfig"` **Response-Body:** XML-Plist (mobileconfig) **cURL:** + ```bash curl "https://staging.rebreak.org/api/magic/profile.mobileconfig?token=QX7g9kL2mN4pR6tV8wY0zB3cD5fG7hJ9kM2nP4qS6uW8xZ0" \ -o RebreakMagic.mobileconfig @@ -185,6 +202,7 @@ curl "https://staging.rebreak.org/api/magic/profile.mobileconfig?token=QX7g9kL2m ## DB-Schema **UserDevice Model (Prisma Schema):** + ```prisma model UserDevice { // ... existing fields ... @@ -198,6 +216,7 @@ model UserDevice { ``` **Migration:** + ```bash # User führt aus (NICHT auto-deployen): pnpm prisma migrate dev --name magic_binding_fields @@ -212,6 +231,7 @@ pnpm prisma migrate dev --name magic_binding_fields **Auth:** Basic Auth (`ADGUARD_USER`, `ADGUARD_PASSWORD`) **Payload:** + ```json { "name": "magic_", @@ -225,6 +245,7 @@ pnpm prisma migrate dev --name magic_binding_fields ``` **DoH-URL-Format (embedded in mobileconfig):** + ``` https://dns.rebreak.org/dns-query/ ``` @@ -236,6 +257,7 @@ https://dns.rebreak.org/dns-query/ **Funktion:** `processMagicReleases()` in `server/utils/magicCron.ts` **Logic:** + 1. Findet alle UserDevice mit `releaseRequestedAt < NOW() - 24h` AND `magicRevokedAt IS NULL` 2. Für jedes Device: - DELETE AdGuard Client (`/control/clients/delete`) diff --git a/backend/nitro.config.ts b/backend/nitro.config.ts index 1821b30..32e3d52 100644 --- a/backend/nitro.config.ts +++ b/backend/nitro.config.ts @@ -8,9 +8,7 @@ export default defineNitroConfig({ // Static-Assets explizit: GoTrue lädt Mail-Templates von // https://api.staging.rebreak.org/templates/*.html → kommt aus public/templates/. // Default-publicAssets greift nicht zuverlässig wenn srcDir auf "server" zeigt. - publicAssets: [ - { baseURL: "/", dir: "../public", maxAge: 60 * 60 }, - ], + publicAssets: [{ baseURL: "/", dir: "../public", maxAge: 60 * 60 }], // Supabase als external dep — nicht bundlen externals: { @@ -24,7 +22,8 @@ export default defineNitroConfig({ runtimeConfig: { // ─── Database / Core ───────────────────────────────────────────────── - databaseUrl: process.env.DATABASE_URL ?? process.env.NUXT_DATABASE_URL ?? "", + databaseUrl: + process.env.DATABASE_URL ?? process.env.NUXT_DATABASE_URL ?? "", encryptionKey: process.env.ENCRYPTION_KEY ?? "", // ─── Admin / Cron ──────────────────────────────────────────────────── @@ -36,15 +35,21 @@ export default defineNitroConfig({ // ─── LLM-Provider ──────────────────────────────────────────────────── // Infisical staging hat NUXT_*-prefix für openrouter+groq, andere ohne. - openrouterApiKey: process.env.OPENROUTER_API_KEY ?? process.env.NUXT_OPENROUTER_API_KEY ?? "", - openaiApiKey: process.env.OPENAI_API_KEY ?? process.env.NUXT_OPENAI_API_KEY ?? "", + openrouterApiKey: + process.env.OPENROUTER_API_KEY ?? + process.env.NUXT_OPENROUTER_API_KEY ?? + "", + openaiApiKey: + process.env.OPENAI_API_KEY ?? process.env.NUXT_OPENAI_API_KEY ?? "", groqApiKey: process.env.GROQ_API_KEY ?? process.env.NUXT_GROQ_API_KEY ?? "", googleAiApiKey: process.env.GOOGLE_AI_API_KEY ?? "", geminiApiKey: process.env.GEMINI_API_KEY ?? "", // ─── TTS-Provider ──────────────────────────────────────────────────── - googleApiKey: process.env.GOOGLE_API_KEY ?? process.env.NUXT_GOOGLE_API_KEY ?? "", - deepgramApiKey: process.env.DEEPGRAM_API_KEY ?? process.env.NUXT_DEEPGRAM_API_KEY ?? "", + googleApiKey: + process.env.GOOGLE_API_KEY ?? process.env.NUXT_GOOGLE_API_KEY ?? "", + deepgramApiKey: + process.env.DEEPGRAM_API_KEY ?? process.env.NUXT_DEEPGRAM_API_KEY ?? "", azureTtsKey: process.env.AZURE_TTS_KEY ?? "", azureTtsRegion: process.env.AZURE_TTS_REGION ?? "", // NEU im backend/-Layout (existieren in nuxt.config.ts NICHT, aber backend code liest sie) @@ -61,8 +66,12 @@ export default defineNitroConfig({ // Infisical staging-Namen: SUPABASE_KEY (nicht ANON_KEY), SUPABASE_SERVICE_KEY // (nicht SERVICE_ROLE_KEY). NIE umbenennen ohne Infisical-secret-rotation. supabaseUrl: process.env.SUPABASE_URL ?? "https://db-staging.rebreak.org", - supabaseAnonKey: process.env.SUPABASE_KEY ?? process.env.SUPABASE_ANON_KEY ?? "", - supabaseServiceKey: process.env.SUPABASE_SERVICE_KEY ?? process.env.SUPABASE_SERVICE_ROLE_KEY ?? "", + supabaseAnonKey: + process.env.SUPABASE_KEY ?? process.env.SUPABASE_ANON_KEY ?? "", + supabaseServiceKey: + process.env.SUPABASE_SERVICE_KEY ?? + process.env.SUPABASE_SERVICE_ROLE_KEY ?? + "", // ─── Stripe ────────────────────────────────────────────────────────── stripeSecretKey: process.env.STRIPE_SECRET_KEY ?? "", @@ -97,7 +106,8 @@ export default defineNitroConfig({ // Tenant: 'common' (Multi-Tenant + Personal-Accounts) — hardcoded im Code. // Kein client_secret: Public Client / PKCE-Flow (keine Client-Secret-Exposure). // Infisical secret name: MS_OAUTH_CLIENT_ID - msOauthClientId: process.env.MS_OAUTH_CLIENT_ID ?? "427575e1-0ec5-4468-b4a2-7ae3ce99a154", + msOauthClientId: + process.env.MS_OAUTH_CLIENT_ID ?? "427575e1-0ec5-4468-b4a2-7ae3ce99a154", // ─── Google OAuth (PKCE, Public Client / iOS Native) ──────────────────── // Client-ID der Google Cloud Console App-Registrierung "Rebreak". @@ -106,7 +116,9 @@ export default defineNitroConfig({ // KRITISCH: prompt=consent + access_type=offline in init.post.ts sind PFLICHT // damit Google ein refresh_token ausstellt. Ohne das: nur 1h access_token. // Infisical secret name: GOOGLE_OAUTH_CLIENT_ID - googleOauthClientId: process.env.GOOGLE_OAUTH_CLIENT_ID ?? "864178840836-i09oblmcel5q4rgggq9dids17mv9560u.apps.googleusercontent.com", + googleOauthClientId: + process.env.GOOGLE_OAUTH_CLIENT_ID ?? + "864178840836-i09oblmcel5q4rgggq9dids17mv9560u.apps.googleusercontent.com", // ─── Bot-User-IDs (DB-User-References für Lyra/Rebreak-Bot-Posts) ──── lyraBotUserId: process.env.LYRA_BOT_USER_ID ?? "", @@ -121,9 +133,7 @@ export default defineNitroConfig({ // — wenn das fehlt, 500-cascade auf allen authentifizierten Routes // (Incident 2026-05-06). supabase: { - url: - process.env.SUPABASE_URL ?? - "https://db-staging.rebreak.org", + url: process.env.SUPABASE_URL ?? "https://db-staging.rebreak.org", key: process.env.SUPABASE_KEY ?? process.env.SUPABASE_ANON_KEY ?? "", }, }, diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 03ea42e..bfd2621 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -27,17 +27,17 @@ model Profile { // Diese Felder werden ausschließlich vom User über die Profile-Form // gesetzt — niemals durch Lyra-Extraction oder Memory-Inference. // Siehe memory/feedback_demographics_user_initiated.md - birthYear Int? @map("birth_year") - gender String? - maritalStatus String? @map("marital_status") - profession String? // legacy — deprecated, nicht mehr im Frontend, DB-Spalte bleibt - employmentStatus String? @map("employment_status") - shiftWork Boolean? @map("shift_work") - industry String? - jobTenure String? @map("job_tenure") - bundesland String? - city String? - demographicsConsentAt DateTime? @map("demographics_consent_at") + birthYear Int? @map("birth_year") + gender String? + maritalStatus String? @map("marital_status") + profession String? // legacy — deprecated, nicht mehr im Frontend, DB-Spalte bleibt + employmentStatus String? @map("employment_status") + shiftWork Boolean? @map("shift_work") + industry String? + jobTenure String? @map("job_tenure") + bundesland String? + city String? + demographicsConsentAt DateTime? @map("demographics_consent_at") demographicsWithdrawnAt DateTime? @map("demographics_withdrawn_at") // ─── Lyra Voice-Picker (Legend-only Premium) ──────────────────────────── @@ -118,8 +118,8 @@ model Profile { // // mdmDetectedAt: Zeitstempel des ersten mdmManaged=true-Writes (Audit-Trail). // Wird nur beim Übergang false→true gesetzt, nie überschrieben. - mdmManaged Boolean @default(false) @map("mdm_managed") - mdmDetectedAt DateTime? @map("mdm_detected_at") + mdmManaged Boolean @default(false) @map("mdm_managed") + mdmDetectedAt DateTime? @map("mdm_detected_at") // ─── Push-Notifications (Migration 20260530) ────────────────────────── // Per-User Opt-out für Chat-Push (DM + Room). Default ON. Token-spezifischer @@ -129,10 +129,10 @@ model Profile { // ─── Admin-Management (Phase E, Migration 20260509) ───────────────────── // banned: User wird auf API-Ebene blockiert (kein Login-Block — Supabase // bleibt unberührt). Soft-Delete scrubbt PII statt Hard-Delete (DSGVO). - banned Boolean @default(false) - bannedAt DateTime? @map("banned_at") - bannedReason String? @map("banned_reason") - deletedAt DateTime? @map("deleted_at") + banned Boolean @default(false) + bannedAt DateTime? @map("banned_at") + bannedReason String? @map("banned_reason") + deletedAt DateTime? @map("deleted_at") communityPosts CommunityPost[] communityReplies CommunityReply[] @@ -151,14 +151,14 @@ model Profile { // ExponentPushToken[xxx]. Token ist von Expo serverseitig unique. // Genutzt von server/services/push.ts sendChatPush(). model PushToken { - id String @id @default(uuid()) @db.Uuid - userId String @map("user_id") @db.Uuid - token String @unique + id String @id @default(uuid()) @db.Uuid + userId String @map("user_id") @db.Uuid + token String @unique platform String // "ios" | "android" - deviceId String? @map("device_id") - enabled Boolean @default(true) - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + deviceId String? @map("device_id") + enabled Boolean @default(true) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") lastUsedAt DateTime? @map("last_used_at") profile Profile @relation(fields: [userId], references: [id], onDelete: Cascade) @@ -178,20 +178,20 @@ model PushToken { // Test-Codes (label='test_*') werden vom Seed angelegt. Wiederverwendung // nur per SQL-Reset: UPDATE diga_codes SET used_at=NULL, used_by_profile_id=NULL. model DigaCode { - id String @id @default(uuid()) @db.Uuid - code String @unique - label String? // z.B. 'test_batch_2026-05' oder Krankenkassen-Code - expiresAt DateTime? @map("expires_at") - usedAt DateTime? @map("used_at") - usedByProfileId String? @map("used_by_profile_id") @db.Uuid - usedByProfile Profile? @relation(fields: [usedByProfileId], references: [id], onDelete: SetNull) - grantsPlan String @default("legend") @map("grants_plan") - notes String? - createdAt DateTime @default(now()) @map("created_at") + id String @id @default(uuid()) @db.Uuid + code String @unique + label String? // z.B. 'test_batch_2026-05' oder Krankenkassen-Code + expiresAt DateTime? @map("expires_at") + usedAt DateTime? @map("used_at") + usedByProfileId String? @map("used_by_profile_id") @db.Uuid + usedByProfile Profile? @relation(fields: [usedByProfileId], references: [id], onDelete: SetNull) + grantsPlan String @default("legend") @map("grants_plan") + notes String? + createdAt DateTime @default(now()) @map("created_at") - @@index([usedByProfileId]) - @@map("diga_codes") - @@schema("rebreak") + @@index([usedByProfileId]) + @@map("diga_codes") + @@schema("rebreak") } model Streak { @@ -254,35 +254,35 @@ model SosSession { } model CommunityPost { - id String @id @default(uuid()) @db.Uuid - userId String @map("user_id") @db.Uuid + id String @id @default(uuid()) @db.Uuid + userId String @map("user_id") @db.Uuid category String content String - imageUrl String? @map("image_url") - upvotes Int @default(0) - likesCount Int @default(0) @map("likes_count") - dislikesCount Int @default(0) @map("dislikes_count") - commentsCount Int @default(0) @map("comments_count") - repostsCount Int @default(0) @map("reposts_count") - isAnonymous Boolean @default(false) @map("is_anonymous") + imageUrl String? @map("image_url") + upvotes Int @default(0) + likesCount Int @default(0) @map("likes_count") + dislikesCount Int @default(0) @map("dislikes_count") + commentsCount Int @default(0) @map("comments_count") + repostsCount Int @default(0) @map("reposts_count") + isAnonymous Boolean @default(false) @map("is_anonymous") /// Reported-Marker: true wenn ein User den Post gemeldet hat. Admin-Queue /// listet alle Posts mit isModerated=true. Dismiss → false, Delete → bleibt /// true zusammen mit isDeleted=true (für audit/spätere Re-Review). - isModerated Boolean @default(false) @map("is_moderated") + isModerated Boolean @default(false) @map("is_moderated") /// Soft-Delete durch Moderation. content → "" damit Public-API nichts mehr /// rendert; Audit-Log behält Original (siehe ModerationAction.contentSnapshot). - isDeleted Boolean @default(false) @map("is_deleted") + isDeleted Boolean @default(false) @map("is_deleted") deletedAt DateTime? @map("deleted_at") /// Wann der Post zum ersten Mal gemeldet wurde (queue-Sortierung). reportedAt DateTime? @map("reported_at") - gameName String? @map("game_name") - repostOfId String? @map("repost_of_id") @db.Uuid - challengeId String? @map("challenge_id") @db.Uuid + gameName String? @map("game_name") + repostOfId String? @map("repost_of_id") @db.Uuid + challengeId String? @map("challenge_id") @db.Uuid /// Template-ID aus dem Lyra-Post-Catalog (z.B. "motivation_quiet_01"). /// Nullable: Legacy-Posts ohne Template-Key nutzen content direkt. /// Frontend rendert t('lyra_posts.') wenn gesetzt. - i18nKey String? @map("i18n_key") - createdAt DateTime @default(now()) @map("created_at") + i18nKey String? @map("i18n_key") + createdAt DateTime @default(now()) @map("created_at") author Profile? @relation(fields: [userId], references: [id]) repostOf CommunityPost? @relation("Reposts", fields: [repostOfId], references: [id], onDelete: SetNull) @@ -308,19 +308,19 @@ model PostLike { } model CommunityReply { - id String @id @default(uuid()) @db.Uuid - postId String @map("post_id") @db.Uuid - userId String @map("user_id") @db.Uuid + id String @id @default(uuid()) @db.Uuid + postId String @map("post_id") @db.Uuid + userId String @map("user_id") @db.Uuid content String - parentReplyId String? @map("parent_reply_id") @db.Uuid - isAnonymous Boolean @default(false) @map("is_anonymous") - likesCount Int @default(0) @map("likes_count") + parentReplyId String? @map("parent_reply_id") @db.Uuid + isAnonymous Boolean @default(false) @map("is_anonymous") + likesCount Int @default(0) @map("likes_count") /// Reported-Marker analog CommunityPost.isModerated. - isModerated Boolean @default(false) @map("is_moderated") - isDeleted Boolean @default(false) @map("is_deleted") + isModerated Boolean @default(false) @map("is_moderated") + isDeleted Boolean @default(false) @map("is_deleted") deletedAt DateTime? @map("deleted_at") reportedAt DateTime? @map("reported_at") - createdAt DateTime @default(now()) @map("created_at") + createdAt DateTime @default(now()) @map("created_at") post CommunityPost @relation(fields: [postId], references: [id], onDelete: Cascade) author Profile? @relation(fields: [userId], references: [id]) @@ -378,16 +378,16 @@ model ChatRoomMember { } model ChatMessage { - id String @id @default(uuid()) @db.Uuid - userId String @map("user_id") @db.Uuid + id String @id @default(uuid()) @db.Uuid + userId String @map("user_id") @db.Uuid content String - roomId String? @map("room_id") @db.Uuid - replyToId String? @map("reply_to_id") @db.Uuid - attachmentUrl String? @map("attachment_url") - attachmentType String? @map("attachment_type") - attachmentName String? @map("attachment_name") - likesCount Int @default(0) @map("likes_count") - createdAt DateTime @default(now()) @map("created_at") + roomId String? @map("room_id") @db.Uuid + replyToId String? @map("reply_to_id") @db.Uuid + attachmentUrl String? @map("attachment_url") + attachmentType String? @map("attachment_type") + attachmentName String? @map("attachment_name") + likesCount Int @default(0) @map("likes_count") + createdAt DateTime @default(now()) @map("created_at") deletedAt DateTime? @map("deleted_at") room ChatRoom? @relation(fields: [roomId], references: [id], onDelete: Cascade) @@ -629,11 +629,11 @@ model BlocklistDomain { /// Source: "seed" (initiale 30 Brands) | "manual" (Admin-App) | "community" (zukünftig). /// SCHEMA hinzugefügt 2026-05-28, Migration: 20260528_add_global_mail_display_names. model GlobalMailDisplayName { - id String @id @default(uuid()) @db.Uuid - pattern String @unique // z.B. "Tipico", "Bet365" - isActive Boolean @default(true) @map("is_active") - source String @default("manual") // "manual" | "seed" | "community" - addedAt DateTime @default(now()) @map("added_at") + id String @id @default(uuid()) @db.Uuid + pattern String @unique // z.B. "Tipico", "Bet365" + isActive Boolean @default(true) @map("is_active") + source String @default("manual") // "manual" | "seed" | "community" + addedAt DateTime @default(now()) @map("added_at") @@map("global_mail_display_names") @@schema("rebreak") @@ -662,30 +662,30 @@ model CoachSession { } model MailConnection { - id String @id @default(uuid()) @db.Uuid - userId String @map("user_id") @db.Uuid - email String - provider String @default("imap") - providerName String? @map("provider_name") - imapHost String @map("imap_host") - imapPort Int @map("imap_port") - rejectUnauthorized Boolean @default(true) @map("reject_unauthorized") - useStarttls Boolean @default(false) @map("use_starttls") - passwordEncrypted String @map("password_encrypted") - isActive Boolean @default(true) @map("is_active") + id String @id @default(uuid()) @db.Uuid + userId String @map("user_id") @db.Uuid + email String + provider String @default("imap") + providerName String? @map("provider_name") + imapHost String @map("imap_host") + imapPort Int @map("imap_port") + rejectUnauthorized Boolean @default(true) @map("reject_unauthorized") + useStarttls Boolean @default(false) @map("use_starttls") + passwordEncrypted String @map("password_encrypted") + isActive Boolean @default(true) @map("is_active") /// Wenn gesetzt: Account wurde durch Downgrade-Reconciliation pausiert (nicht gelöscht). /// Re-Upgrade setzt pausedAt=null + pausedReason=null + isActive=true zurück. - pausedAt DateTime? @map("paused_at") - pausedReason String? @map("paused_reason") // z.B. "plan_downgrade" - scanInterval Int @default(24) @map("scan_interval") - lastScannedAt DateTime? @map("last_scanned_at") - nextScanAt DateTime? @map("next_scan_at") - emailsBlocked Int @default(0) @map("emails_blocked") - emailsScanned Int @default(0) @map("emails_scanned") - lastConnectError String? @map("last_connect_error") - lastConnectErrorAt DateTime? @map("last_connect_error_at") + pausedAt DateTime? @map("paused_at") + pausedReason String? @map("paused_reason") // z.B. "plan_downgrade" + scanInterval Int @default(24) @map("scan_interval") + lastScannedAt DateTime? @map("last_scanned_at") + nextScanAt DateTime? @map("next_scan_at") + emailsBlocked Int @default(0) @map("emails_blocked") + emailsScanned Int @default(0) @map("emails_scanned") + lastConnectError String? @map("last_connect_error") + lastConnectErrorAt DateTime? @map("last_connect_error_at") lastIdleHeartbeatAt DateTime? @map("last_idle_heartbeat_at") - createdAt DateTime @default(now()) @map("created_at") + createdAt DateTime @default(now()) @map("created_at") // ─── OAuth2-Auth-Framework (additiv, Phase 0) ──────────────────────────── // authMethod: 'app_password' (default, alle bestehenden Connections) @@ -710,7 +710,7 @@ model MailConnection { // ─── User-definierter Anzeige-Titel (optional, max 60 chars auf API-Ebene) ── // Wird statt der vollen Email-Adresse angezeigt (z.B. "Privat-Gmail"). // NULL → Frontend fällt auf Email-Domain zurück. - title String? + title String? // ─── Art. 9-Einwilligung (DSGVO-Compliance, Mail-Auto-Delete) ─────────── // consentAt=NULL für Bestandsrows → "Re-Consent pending". @@ -721,8 +721,8 @@ model MailConnection { consentVersion String? @map("consent_version") consentIpAddress String? @map("consent_ip_address") - blockedMails MailBlocked[] - blockedStats MailBlockedStat[] + blockedMails MailBlocked[] + blockedStats MailBlockedStat[] @@unique([userId, email]) @@map("mail_connections") @@ -842,33 +842,33 @@ model MailBlocked { /// KEIN Mail-Body — nur Metadaten (Sender-Domain, Subject, Score-Komponenten). /// Cascade-Delete bei User-Löschung (Art. 17 DSGVO). model MailClassificationSample { - id String @id @default(cuid()) - userId String @map("user_id") @db.Uuid - connectionId String? @map("connection_id") @db.Uuid + id String @id @default(cuid()) + userId String @map("user_id") @db.Uuid + connectionId String? @map("connection_id") @db.Uuid // Raw features (was analysiert wurde): - senderName String? @map("sender_name") @db.VarChar(255) - senderDomain String? @map("sender_domain") @db.VarChar(255) - relayDecodedDomain String? @map("relay_decoded_domain") @db.VarChar(255) - subject String? @db.VarChar(998) // RFC 5322 max + senderName String? @map("sender_name") @db.VarChar(255) + senderDomain String? @map("sender_domain") @db.VarChar(255) + relayDecodedDomain String? @map("relay_decoded_domain") @db.VarChar(255) + subject String? @db.VarChar(998) // RFC 5322 max // Computed features (Score-Komponenten als JSON): - features Json // { score, brandMatch, randomTokens, keywordHits, styleFlags, … } + features Json // { score, brandMatch, randomTokens, keywordHits, styleFlags, … } // Outcome: - finalAction String @map("final_action") // "blocked" | "passed" - triggerSource String @map("trigger_source") // "domain", "brand+random", "score:NN", "llm:0.XX", "whitelist" + finalAction String @map("final_action") // "blocked" | "passed" + triggerSource String @map("trigger_source") // "domain", "brand+random", "score:NN", "llm:0.XX", "whitelist" // Groq verdict (nur wenn Layer 4 lief): - groqIsGambling Boolean? @map("groq_is_gambling") - groqConfidence Float? @map("groq_confidence") - groqReason String? @map("groq_reason") @db.Text + groqIsGambling Boolean? @map("groq_is_gambling") + groqConfidence Float? @map("groq_confidence") + groqReason String? @map("groq_reason") @db.Text // User-Feedback (für später): - userFeedback String? @map("user_feedback") // null | "correct" | "false-positive" | "false-negative" - feedbackAt DateTime? @map("feedback_at") + userFeedback String? @map("user_feedback") // null | "correct" | "false-positive" | "false-negative" + feedbackAt DateTime? @map("feedback_at") - createdAt DateTime @default(now()) @map("created_at") + createdAt DateTime @default(now()) @map("created_at") @@index([userId]) @@index([createdAt]) @@ -887,7 +887,7 @@ model MailBlockedStat { id String @id @default(cuid()) userId String @map("user_id") @db.Uuid /// UTC-Datum (time=00:00:00) — ein Eintrag pro User+Tag+Connection - date DateTime @db.Date @map("date") + date DateTime @map("date") @db.Date mailConnectionId String @map("mail_connection_id") @db.Uuid /// IMAP-Host-Slug, z.B. "imap.gmail.com" (raw, für resolveProviderMeta zur Read-Zeit) provider String @map("provider") @@ -1052,24 +1052,24 @@ model UserDevice { // ─── Device-Account-Lock ──────────────────────────────────────────────── /// Plan des Users zum Zeitpunkt der Bindung. NULL → kein Lock aktiv. /// Lock gilt wenn: boundToPlan != null AND releaseRequestedAt + 24h > NOW() - boundToPlan String? @map("bound_to_plan") + boundToPlan String? @map("bound_to_plan") /// Wann der Original-User "Gerät freigeben" beantragt hat. /// NULL → noch kein Release-Request. Freigabe wird aktiv nach +24h. - releaseRequestedAt DateTime? @map("release_requested_at") + releaseRequestedAt DateTime? @map("release_requested_at") /// Letzte Mail-Notification bei fremdem Login-Versuch. Rate-Limit 6h. - lockNotifiedAt DateTime? @map("lock_notified_at") + lockNotifiedAt DateTime? @map("lock_notified_at") // ─── RebreakMagic DNS-Device-Binding ──────────────────────────────────── /// 48+ char URL-safe random token für DNS-over-HTTPS Client-ID. /// NULL → Device nicht als Magic-Client gebunden. Unique → pro Token nur 1 Device. - magicDnsToken String? @unique @map("magic_dns_token") + magicDnsToken String? @unique @map("magic_dns_token") /// Wann Magic-Binding aktiviert wurde (Config-Profil installiert). - magicEnrolledAt DateTime? @map("magic_enrolled_at") + magicEnrolledAt DateTime? @map("magic_enrolled_at") /// Killswitch: wenn gesetzt → DNS-Token serverseitig invalidiert (AdGuard-Client deleted). /// User kann Config-Profil manuell deinstallieren, aber DNS-Queries werden abgelehnt. - magicRevokedAt DateTime? @map("magic_revoked_at") + magicRevokedAt DateTime? @map("magic_revoked_at") /// Mac-Hostname für UI (z.B. "Chahines MacBook Pro"). Nur bei Magic-Devices. - magicHostname String? @map("magic_hostname") + magicHostname String? @map("magic_hostname") @@unique([userId, deviceId]) @@index([userId]) @@ -1081,15 +1081,15 @@ model UserDevice { /// RebreakMagic Pairing — Native-App generiert 6-stelligen Code, Mac-App tauscht /// gegen MagicSession-Token. Code ist single-use, läuft nach 10min ab. model MagicPairingCode { - id String @id @default(uuid()) @db.Uuid - userId String @map("user_id") @db.Uuid + id String @id @default(uuid()) @db.Uuid + userId String @map("user_id") @db.Uuid /// 6-stelliger numerischer Code (z.B. "482913"). Unique während gültig. - code String @unique - expiresAt DateTime @map("expires_at") + code String @unique + expiresAt DateTime @map("expires_at") /// Wenn redeemed: Zeitpunkt + erstellte MagicSession-ID. Code danach nicht mehr nutzbar. redeemedAt DateTime? @map("redeemed_at") - sessionId String? @map("session_id") @db.Uuid - createdAt DateTime @default(now()) @map("created_at") + sessionId String? @map("session_id") @db.Uuid + createdAt DateTime @default(now()) @map("created_at") @@index([userId]) @@index([expiresAt]) @@ -1101,15 +1101,15 @@ model MagicPairingCode { /// der statt Supabase-JWT in /api/magic/* Endpoints akzeptiert wird. /// Wird in Mac-Keychain gespeichert, kann vom User in Native-App revoked werden. model MagicSession { - id String @id @default(uuid()) @db.Uuid - userId String @map("user_id") @db.Uuid + id String @id @default(uuid()) @db.Uuid + userId String @map("user_id") @db.Uuid /// "mgc_" + 48 char base64url (token = id wird NICHT preisgegeben). - token String @unique + token String @unique /// Optionaler Mac-Hostname für User-UI ("Chahines MacBook Pro"). - label String? - createdAt DateTime @default(now()) @map("created_at") - lastUsedAt DateTime @default(now()) @map("last_used_at") - revokedAt DateTime? @map("revoked_at") + label String? + createdAt DateTime @default(now()) @map("created_at") + lastUsedAt DateTime @default(now()) @map("last_used_at") + revokedAt DateTime? @map("revoked_at") @@index([userId]) @@index([token]) @@ -1117,7 +1117,6 @@ model MagicSession { @@schema("rebreak") } - // Wenn ein neues Gerät versucht sich zu registrieren UND das Device-Limit // erreicht ist (oder User Approval explizit wünscht), erstellt das neue Gerät // eine DeviceApprovalRequest mit 6-stelligem Code. Andere aktive Geräte des @@ -1136,23 +1135,23 @@ model MagicSession { // älteste Device (oder das vom User gewählte) + erstellt neuen UserDevice-Slot // 5. Neues Device pollt GET /api/devices/approvals/:id → status=approved → retry register model DeviceApprovalRequest { - id String @id @default(uuid()) @db.Uuid - userId String @map("user_id") @db.Uuid + id String @id @default(uuid()) @db.Uuid + userId String @map("user_id") @db.Uuid // Info über das NEUE Gerät (das sich anmelden will) - newDeviceId String @map("new_device_id") - newPlatform String @map("new_platform") - newModel String? @map("new_model") - newName String? @map("new_name") - newOsVersion String? @map("new_os_version") + newDeviceId String @map("new_device_id") + newPlatform String @map("new_platform") + newModel String? @map("new_model") + newName String? @map("new_name") + newOsVersion String? @map("new_os_version") /// 6-stelliger Code (z.B. "123456"). Wird auf BEIDEN Geräten gezeigt für /// visuellen Vergleich (iCloud-Pattern). Plain-text gespeichert weil /// kurzlebig (10min TTL) und an userId gebunden. - code String + code String /// pending | approved | rejected | expired - status String @default("pending") + status String @default("pending") /// Welches existing Device hat approved (für Audit-Log). /// NULL wenn via Email-Link approved. @@ -1165,14 +1164,14 @@ model DeviceApprovalRequest { evictedDeviceRowId String? @map("evicted_device_row_id") @db.Uuid /// Wann Email mit Approval-Link/Code verschickt wurde (Rate-Limit: 1x pro Request). - emailSentAt DateTime? @map("email_sent_at") + emailSentAt DateTime? @map("email_sent_at") /// One-Time-Token für Approval via Email-Link (statt App-Approval). /// 32-char hex. NULL bis email-fallback getriggert. - emailToken String? @unique @map("email_token") + emailToken String? @unique @map("email_token") - createdAt DateTime @default(now()) @map("created_at") + createdAt DateTime @default(now()) @map("created_at") /// Approval läuft nach 10 Minuten ab. - expiresAt DateTime @map("expires_at") + expiresAt DateTime @map("expires_at") @@index([userId, status]) @@index([userId, createdAt(sort: Desc)]) diff --git a/backend/server/api/coach/message.post.ts b/backend/server/api/coach/message.post.ts index c10d34d..1c82016 100644 --- a/backend/server/api/coach/message.post.ts +++ b/backend/server/api/coach/message.post.ts @@ -496,10 +496,20 @@ export default defineEventHandler(async (event) => { // "antworte IMMER auf X" — wenn User mitten im Chat die Sprache wechselte, // antwortete Lyra weiter in der App-Sprache. Jetzt: match user. const LANG_NAMES: Record = { - de: "German", en: "English", tr: "Turkish", ar: "Arabic", - fr: "French", es: "Spanish", it: "Italian", pt: "Portuguese", - ru: "Russian", ja: "Japanese", ko: "Korean", zh: "Chinese", - he: "Hebrew", th: "Thai", + de: "German", + en: "English", + tr: "Turkish", + ar: "Arabic", + fr: "French", + es: "Spanish", + it: "Italian", + pt: "Portuguese", + ru: "Russian", + ja: "Japanese", + ko: "Korean", + zh: "Chinese", + he: "Hebrew", + th: "Thai", }; const lastUserMsg = [...messages].reverse().find((m) => m.role === "user"); const detectedFromUser = detectLang(lastUserMsg?.content ?? "", locale); @@ -533,23 +543,37 @@ export default defineEventHandler(async (event) => { const demoLines: string[] = []; if (profile?.birthYear) { const age = new Date().getFullYear() - profile.birthYear; - demoLines.push(`- Alter: ca. ${age} Jahre (Geburtsjahr ${profile.birthYear})`); + demoLines.push( + `- Alter: ca. ${age} Jahre (Geburtsjahr ${profile.birthYear})`, + ); } if (profile?.gender) { const GENDER_LABEL: Record = { - male: "männlich", female: "weiblich", diverse: "divers", no_answer: "keine Angabe", + male: "männlich", + female: "weiblich", + diverse: "divers", + no_answer: "keine Angabe", }; - demoLines.push(`- Geschlecht: ${GENDER_LABEL[profile.gender] ?? profile.gender}`); + demoLines.push( + `- Geschlecht: ${GENDER_LABEL[profile.gender] ?? profile.gender}`, + ); } if (profile?.maritalStatus) { const MS_LABEL: Record = { - single: "ledig", partnered: "in Partnerschaft", married: "verheiratet", - divorced: "geschieden", widowed: "verwitwet", no_answer: "keine Angabe", + single: "ledig", + partnered: "in Partnerschaft", + married: "verheiratet", + divorced: "geschieden", + widowed: "verwitwet", + no_answer: "keine Angabe", }; - demoLines.push(`- Familienstand: ${MS_LABEL[profile.maritalStatus] ?? profile.maritalStatus}`); + demoLines.push( + `- Familienstand: ${MS_LABEL[profile.maritalStatus] ?? profile.maritalStatus}`, + ); } if (profile?.profession) demoLines.push(`- Beruf: ${profile.profession}`); - if (profile?.bundesland) demoLines.push(`- Bundesland: ${profile.bundesland}`); + if (profile?.bundesland) + demoLines.push(`- Bundesland: ${profile.bundesland}`); if (profile?.city) demoLines.push(`- Stadt: ${profile.city}`); if (demoLines.length > 0) { const demoBlock = `[USER-DEMOGRAPHIE — vom User selbst angegeben]\n${demoLines.join("\n")}\nNutze diese Infos nur für Empathie + Kontext. Frage NIEMALS nach diesen Daten — der User pflegt sie selbst in der Profile-Form.\n\n`; @@ -563,16 +587,23 @@ export default defineEventHandler(async (event) => { if (memories.length > 0) { loadedMemoryIds = memories.map((m) => m.id); const TYPE_LABELS: Record = { - trigger: "Trigger", habit: "Gewohnheit", strength: "Stärke", - relationship: "Wichtige Person", milestone: "Meilenstein", - pain_point: "Sensibles Thema", goal: "Ziel", preference: "Präferenz", + trigger: "Trigger", + habit: "Gewohnheit", + strength: "Stärke", + relationship: "Wichtige Person", + milestone: "Meilenstein", + pain_point: "Sensibles Thema", + goal: "Ziel", + preference: "Präferenz", }; const lines = memories .map((m) => `- ${TYPE_LABELS[m.type] ?? m.type}: ${m.content}`) .join("\n"); const memoryBlock = `[WAS DU ÜBER DIESEN USER WEISST — aus früheren Gesprächen]\n${lines}\nNutze diese Infos um GENAU diesen Menschen anzusprechen — wie ein echter Begleiter, nicht eine generische KI.\n\n`; systemPrompt = `${memoryBlock}${systemPrompt}`; - console.log(`[lyra-memory] injected ${memories.length} memories for ${user.id}`); + console.log( + `[lyra-memory] injected ${memories.length} memories for ${user.id}`, + ); } } catch (e) { console.error("[lyra-memory] load error (non-fatal):", e); @@ -585,7 +616,13 @@ export default defineEventHandler(async (event) => { where: { userId: user.id }, orderBy: { updatedAt: "desc" }, take: 10, - select: { content: true, status: true, adminNote: true, category: true, createdAt: true }, + select: { + content: true, + status: true, + adminNote: true, + category: true, + createdAt: true, + }, }); if (feedbackItems.length > 0) { const STATUS_LABELS: Record = { @@ -598,7 +635,9 @@ export default defineEventHandler(async (event) => { const feedbackLines = feedbackItems .map((f) => { const statusLabel = STATUS_LABELS[f.status] ?? f.status; - const note = f.adminNote ? `\n Kommentar des Teams: "${f.adminNote}"` : ""; + const note = f.adminNote + ? `\n Kommentar des Teams: "${f.adminNote}"` + : ""; return ` - Idee: "${f.content}"${f.category ? ` [${f.category}]` : ""}\n Status: ${statusLabel}${note}`; }) .join("\n"); @@ -613,17 +652,24 @@ export default defineEventHandler(async (event) => { // (smarter Fallback) → OpenAI gpt-4o-mini (Last-Resort, anderer Provider). // OpenRouter + Groq sind aktuell ohne Quota/Credits — entfernt aus Chain. const planRaw = (profile?.plan ?? "free").toLowerCase(); - const plan = planRaw === "premium" ? "legend" : planRaw === "standard" ? "pro" : planRaw; + const plan = + planRaw === "premium" ? "legend" : planRaw === "standard" ? "pro" : planRaw; const llmProvider = "gemini-flash-lite"; - type Candidate = { provider: "groq" | "openrouter" | "gemini" | "openai"; model: string }; + type Candidate = { + provider: "groq" | "openrouter" | "gemini" | "openai"; + model: string; + }; const candidates: Candidate[] = [ { provider: "gemini", model: "gemini-2.5-flash-lite" }, { provider: "gemini", model: "gemini-2.5-flash" }, { provider: "openai", model: "gpt-4o-mini" }, ]; - async function tryModel(providerName: "groq" | "openrouter" | "gemini" | "openai", model: string) { + async function tryModel( + providerName: "groq" | "openrouter" | "gemini" | "openai", + model: string, + ) { const p = PROVIDER_CONFIG[providerName]; const key = config[p.keyName]; if (!key) return null; @@ -699,8 +745,7 @@ export default defineEventHandler(async (event) => { ...messages, { role: "assistant" as const, content: text }, ]; - const key = - config.openrouterApiKey as string | undefined; + const key = config.openrouterApiKey as string | undefined; extractAndStoreMemories(user.id, allMessages, undefined, key).catch( () => {}, ); diff --git a/backend/server/api/magic/devices.get.ts b/backend/server/api/magic/devices.get.ts index db07c7d..0480d52 100644 --- a/backend/server/api/magic/devices.get.ts +++ b/backend/server/api/magic/devices.get.ts @@ -1,6 +1,5 @@ - -import { listMagicDevices } from '../../db/devices'; -import { requireUser } from '../../utils/auth'; +import { listMagicDevices } from "../../db/devices"; +import { requireUser } from "../../utils/auth"; /** * GET /api/magic/devices diff --git a/backend/server/api/magic/devices/[deviceId]/cancel-release.post.ts b/backend/server/api/magic/devices/[deviceId]/cancel-release.post.ts index 52fc727..7600ba2 100644 --- a/backend/server/api/magic/devices/[deviceId]/cancel-release.post.ts +++ b/backend/server/api/magic/devices/[deviceId]/cancel-release.post.ts @@ -1,5 +1,3 @@ - - /** * POST /api/magic/devices/[deviceId]/cancel-release * @@ -7,12 +5,12 @@ */ export default defineEventHandler(async (event) => { const user = await requireUser(event); - const deviceId = getRouterParam(event, 'deviceId'); + const deviceId = getRouterParam(event, "deviceId"); if (!deviceId) { throw createError({ statusCode: 400, - message: 'deviceId required', + message: "deviceId required", }); } @@ -32,7 +30,7 @@ export default defineEventHandler(async (event) => { if (!device || !device.magicEnrolledAt || device.magicRevokedAt) { throw createError({ statusCode: 404, - message: 'Magic-Binding nicht gefunden oder bereits revoked', + message: "Magic-Binding nicht gefunden oder bereits revoked", }); } diff --git a/backend/server/api/magic/devices/[deviceId]/request-release.post.ts b/backend/server/api/magic/devices/[deviceId]/request-release.post.ts index b30ea85..fc76546 100644 --- a/backend/server/api/magic/devices/[deviceId]/request-release.post.ts +++ b/backend/server/api/magic/devices/[deviceId]/request-release.post.ts @@ -1,5 +1,3 @@ - - /** * POST /api/magic/devices/[deviceId]/request-release * @@ -10,12 +8,12 @@ */ export default defineEventHandler(async (event) => { const user = await requireUser(event); - const deviceId = getRouterParam(event, 'deviceId'); + const deviceId = getRouterParam(event, "deviceId"); if (!deviceId) { throw createError({ statusCode: 400, - message: 'deviceId required', + message: "deviceId required", }); } @@ -35,7 +33,7 @@ export default defineEventHandler(async (event) => { if (!device || !device.magicEnrolledAt || device.magicRevokedAt) { throw createError({ statusCode: 404, - message: 'Magic-Binding nicht gefunden oder bereits revoked', + message: "Magic-Binding nicht gefunden oder bereits revoked", }); } diff --git a/backend/server/api/magic/info.get.ts b/backend/server/api/magic/info.get.ts index ce8767e..1036158 100644 --- a/backend/server/api/magic/info.get.ts +++ b/backend/server/api/magic/info.get.ts @@ -10,10 +10,10 @@ export default defineEventHandler(() => { return { success: true, data: { - latestVersion: '0.1.0', - downloadUrl: 'https://rebreak.org/download/rebreakmagic', - dmgUrl: 'https://rebreak.org/downloads/RebreakMagic-latest.dmg', - minMacosVersion: '13.0', + latestVersion: "0.1.0", + downloadUrl: "https://rebreak.org/download/rebreakmagic", + dmgUrl: "https://rebreak.org/downloads/RebreakMagic-latest.dmg", + minMacosVersion: "13.0", }, }; }); diff --git a/backend/server/api/magic/pair/create.post.ts b/backend/server/api/magic/pair/create.post.ts index e994cac..107cbf8 100644 --- a/backend/server/api/magic/pair/create.post.ts +++ b/backend/server/api/magic/pair/create.post.ts @@ -1,5 +1,5 @@ -import { randomInt } from 'crypto'; -import { requireUser } from '../../../utils/auth'; +import { randomInt } from "crypto"; +import { requireUser } from "../../../utils/auth"; /** * POST /api/magic/pair/create @@ -29,7 +29,7 @@ export default defineEventHandler(async (event) => { let code: string | null = null; let attempts = 0; while (attempts < 5 && code === null) { - const candidate = String(randomInt(0, 1_000_000)).padStart(6, '0'); + const candidate = String(randomInt(0, 1_000_000)).padStart(6, "0"); const exists = await db.magicPairingCode.findUnique({ where: { code: candidate }, select: { id: true, expiresAt: true, redeemedAt: true }, @@ -53,7 +53,7 @@ export default defineEventHandler(async (event) => { if (!code) { throw createError({ statusCode: 500, - message: 'Konnte keinen freien Pairing-Code generieren', + message: "Konnte keinen freien Pairing-Code generieren", }); } diff --git a/backend/server/api/magic/pair/redeem.post.ts b/backend/server/api/magic/pair/redeem.post.ts index 6d5d0de..1c6aa5f 100644 --- a/backend/server/api/magic/pair/redeem.post.ts +++ b/backend/server/api/magic/pair/redeem.post.ts @@ -1,4 +1,4 @@ -import { randomBytes } from 'crypto'; +import { randomBytes } from "crypto"; /** * POST /api/magic/pair/redeem @@ -17,7 +17,7 @@ export default defineEventHandler(async (event) => { if (!code || !/^\d{6}$/.test(code)) { throw createError({ statusCode: 400, - message: 'code muss 6 Ziffern enthalten', + message: "code muss 6 Ziffern enthalten", }); } @@ -33,19 +33,19 @@ export default defineEventHandler(async (event) => { }); if (!pairingCode) { - throw createError({ statusCode: 404, message: 'Code ungültig' }); + throw createError({ statusCode: 404, message: "Code ungültig" }); } if (pairingCode.redeemedAt !== null) { - throw createError({ statusCode: 410, message: 'Code bereits verwendet' }); + throw createError({ statusCode: 410, message: "Code bereits verwendet" }); } if (pairingCode.expiresAt < new Date()) { - throw createError({ statusCode: 410, message: 'Code abgelaufen' }); + throw createError({ statusCode: 410, message: "Code abgelaufen" }); } // Generiere Session-Token - const token = 'mgc_' + randomBytes(36).toString('base64url'); + const token = "mgc_" + randomBytes(36).toString("base64url"); const session = await db.magicSession.create({ data: { diff --git a/backend/server/api/magic/profile.mobileconfig.get.ts b/backend/server/api/magic/profile.mobileconfig.get.ts index 0b8aa35..b5462c3 100644 --- a/backend/server/api/magic/profile.mobileconfig.get.ts +++ b/backend/server/api/magic/profile.mobileconfig.get.ts @@ -1,8 +1,7 @@ - -import { randomUUID } from 'crypto'; -import { readFile } from 'fs/promises'; -import { resolve } from 'path'; -import { findMagicDeviceByToken } from '../../db/devices'; +import { randomUUID } from "crypto"; +import { readFile } from "fs/promises"; +import { resolve } from "path"; +import { findMagicDeviceByToken } from "../../db/devices"; /** * GET /api/magic/profile.mobileconfig?token= @@ -24,7 +23,7 @@ export default defineEventHandler(async (event) => { if (!token) { throw createError({ statusCode: 400, - message: 'token query parameter required', + message: "token query parameter required", }); } @@ -33,56 +32,50 @@ export default defineEventHandler(async (event) => { if (!device) { throw createError({ statusCode: 404, - message: 'Invalid or revoked DNS token', + message: "Invalid or revoked DNS token", }); } // Template lesen const templatePath = resolve( process.cwd(), - 'ops/mdm/rebreak-mac-dns-filter.mobileconfig', + "ops/mdm/rebreak-mac-dns-filter.mobileconfig", ); let template: string; try { - template = await readFile(templatePath, 'utf-8'); + template = await readFile(templatePath, "utf-8"); } catch (err: any) { - console.error('[Magic] Failed to read profile template:', err); + console.error("[Magic] Failed to read profile template:", err); throw createError({ statusCode: 500, - message: 'Profile template not found', + message: "Profile template not found", }); } // ServerURL ersetzen: /dns-query → /dns-query/{token} const personalizedProfile = template .replace( - 'https://dns.rebreak.org/dns-query', + "https://dns.rebreak.org/dns-query", `https://dns.rebreak.org/dns-query/${token}`, ) // PayloadUUID neu generieren (2 Stellen im Template) - .replace( - '7D2E8B1A-C3D4-4E76-8B23-A4B5C6D7E8F0', - randomUUID().toUpperCase(), - ) - .replace( - '8C3F9A2B-D4E5-4F87-9A12-B5C6D7E8F901', - randomUUID().toUpperCase(), - ) + .replace("7D2E8B1A-C3D4-4E76-8B23-A4B5C6D7E8F0", randomUUID().toUpperCase()) + .replace("8C3F9A2B-D4E5-4F87-9A12-B5C6D7E8F901", randomUUID().toUpperCase()) // PayloadIdentifier unique machen (optional, verhindert Konflikt bei Multi-Device) .replace( - 'org.rebreak.protection.dns.filter', + "org.rebreak.protection.dns.filter", `org.rebreak.protection.dns.filter.${device.deviceId.slice(0, 8)}`, ) .replace( - 'org.rebreak.protection.profile', + "org.rebreak.protection.profile", `org.rebreak.protection.profile.${device.deviceId.slice(0, 8)}`, ); // Response-Headers - setHeader(event, 'Content-Type', 'application/x-apple-aspen-config'); + setHeader(event, "Content-Type", "application/x-apple-aspen-config"); setHeader( event, - 'Content-Disposition', + "Content-Disposition", `attachment; filename="RebreakMagic-${device.deviceId.slice(0, 8)}.mobileconfig"`, ); diff --git a/backend/server/api/magic/register.post.ts b/backend/server/api/magic/register.post.ts index c84b056..a52cdee 100644 --- a/backend/server/api/magic/register.post.ts +++ b/backend/server/api/magic/register.post.ts @@ -1,7 +1,7 @@ - -import { randomBytes } from 'crypto'; -import { countActiveMagicBindings, listMagicDevices } from '../../db/devices'; -import { requireUser } from '../../utils/auth'; +import { randomBytes } from "crypto"; +import { countActiveMagicBindings, listMagicDevices, MAGIC_DEVICE_LIMIT } from "../../db/devices"; +import { requireUser } from "../../utils/auth"; +import { createAdGuardClient } from "../../utils/adguard"; /** * POST /api/magic/register @@ -27,7 +27,7 @@ export default defineEventHandler(async (event) => { if (!deviceId || !hostname) { throw createError({ statusCode: 400, - message: 'deviceId und hostname required', + message: "deviceId und hostname required", }); } @@ -71,7 +71,7 @@ export default defineEventHandler(async (event) => { statusCode: 409, message: `Magic-Device-Limit erreicht (max ${MAGIC_DEVICE_LIMIT})`, data: { - code: 'limit_reached', + code: "limit_reached", activeBindings, }, }); @@ -79,7 +79,7 @@ export default defineEventHandler(async (event) => { } // 3. Generiere DNS-Token (48 char base64url-safe) - const dnsToken = randomBytes(36).toString('base64url'); + const dnsToken = randomBytes(36).toString("base64url"); // 4. Provisioniere AdGuard Client const adguardClientName = `magic_${deviceId}`; @@ -92,10 +92,10 @@ export default defineEventHandler(async (event) => { blocked_services: [], // TODO: Gambling-Filter via AdGuard Blocked-Services }); } catch (err: any) { - console.error('[Magic] AdGuard provisioning failed:', err); + console.error("[Magic] AdGuard provisioning failed:", err); throw createError({ statusCode: 502, - message: 'DNS-Provisioning fehlgeschlagen', + message: "DNS-Provisioning fehlgeschlagen", }); } @@ -105,7 +105,7 @@ export default defineEventHandler(async (event) => { create: { userId: user.id, deviceId, - platform: 'macos', + platform: "macos", model: model ?? null, name: hostname, osVersion: osVersion ?? null, diff --git a/backend/server/db/devices.ts b/backend/server/db/devices.ts index 9eef7af..8486abf 100644 --- a/backend/server/db/devices.ts +++ b/backend/server/db/devices.ts @@ -64,7 +64,9 @@ export async function findActiveDeviceLock( // Wenn Release-Request existiert und 24h abgelaufen → Lock ist released if (row.releaseRequestedAt) { - const releaseAt = new Date(row.releaseRequestedAt.getTime() + 24 * 60 * 60 * 1000); + const releaseAt = new Date( + row.releaseRequestedAt.getTime() + 24 * 60 * 60 * 1000, + ); if (releaseAt <= new Date()) return null; } @@ -147,10 +149,12 @@ export async function cancelDeviceRelease( */ export async function markDeviceLockNotified(rowId: string): Promise { const db = usePrisma(); - await db.userDevice.update({ - where: { id: rowId }, - data: { lockNotifiedAt: new Date() }, - }).catch(() => {}); + await db.userDevice + .update({ + where: { id: rowId }, + data: { lockNotifiedAt: new Date() }, + }) + .catch(() => {}); } /** @@ -204,11 +208,7 @@ export async function listUserDevices(userId: string): Promise { const db = usePrisma(); return db.userDevice.findMany({ where: { userId }, - orderBy: [ - { lastSeenAt: "desc" }, - { createdAt: "desc" }, - { id: "asc" }, - ], + orderBy: [{ lastSeenAt: "desc" }, { createdAt: "desc" }, { id: "asc" }], select: DEVICE_SELECT, }); } @@ -319,7 +319,11 @@ export async function registerDevice(opts: { // Merge-Heuristik: IDFV-Reset nach iOS Recovery-Restore. // Wenn name+model matchen und Device zuletzt < 30 Tage gesehen → merge statt insert. - const mergeTarget = await findMergeCandidate(opts.userId, opts.name, opts.model); + const mergeTarget = await findMergeCandidate( + opts.userId, + opts.name, + opts.model, + ); if (mergeTarget) { const merged = await db.userDevice.update({ where: { id: mergeTarget.id }, @@ -368,10 +372,19 @@ export async function registerDevice(opts: { export async function touchDevice( userId: string, deviceId: string, - info?: { name?: string | null; model?: string | null; osVersion?: string | null }, + info?: { + name?: string | null; + model?: string | null; + osVersion?: string | null; + }, ): Promise { const db = usePrisma(); - const data: { lastSeenAt: Date; name?: string; model?: string; osVersion?: string } = { + const data: { + lastSeenAt: Date; + name?: string; + model?: string; + osVersion?: string; + } = { lastSeenAt: new Date(), }; if (info?.name) data.name = info.name; @@ -388,7 +401,10 @@ export async function touchDevice( } /** User entfernt ein eigenes Device — gibt Slot frei. */ -export async function deleteUserDevice(userId: string, id: string): Promise { +export async function deleteUserDevice( + userId: string, + id: string, +): Promise { const db = usePrisma(); await db.userDevice.deleteMany({ where: { id, userId } }); } @@ -413,7 +429,9 @@ export interface MagicDeviceRecord { * Listet alle aktiven Magic-Bindings eines Users. * Aktiv = magicEnrolledAt != null AND magicRevokedAt == null. */ -export async function listMagicDevices(userId: string): Promise { +export async function listMagicDevices( + userId: string, +): Promise { const db = usePrisma(); const devices = await db.userDevice.findMany({ where: { @@ -445,7 +463,9 @@ export async function listMagicDevices(userId: string): Promise { +export async function countActiveMagicBindings( + userId: string, +): Promise { const db = usePrisma(); return db.userDevice.count({ where: { @@ -461,7 +481,7 @@ export async function countActiveMagicBindings(userId: string): Promise */ export async function findMagicDeviceByToken( token: string, -): Promise { +): Promise<(DeviceRecord & { magicDnsToken: string }) | null> { const db = usePrisma(); const device = await db.userDevice.findUnique({ where: { @@ -484,4 +504,3 @@ export async function findMagicDeviceByToken( magicDnsToken: device.magicDnsToken!, }; } - diff --git a/backend/server/utils/adguard.ts b/backend/server/utils/adguard.ts index 943c89f..7420d36 100644 --- a/backend/server/utils/adguard.ts +++ b/backend/server/utils/adguard.ts @@ -41,14 +41,14 @@ export async function createAdGuardClient( options: AdGuardClientOptions = {}, ): Promise { const config = useRuntimeConfig(); - const baseUrl = config.adguardBaseUrl || 'https://dns.rebreak.org'; + const baseUrl = config.adguardBaseUrl || "https://dns.rebreak.org"; const user = config.adguardUser; const password = config.adguardPassword; if (!user || !password) { throw createError({ statusCode: 500, - message: 'ADGUARD_USER and ADGUARD_PASSWORD required for Magic features', + message: "ADGUARD_USER and ADGUARD_PASSWORD required for Magic features", }); } @@ -58,23 +58,23 @@ export async function createAdGuardClient( ...options, }; - const authHeader = `Basic ${Buffer.from(`${user}:${password}`).toString('base64')}`; + const authHeader = `Basic ${Buffer.from(`${user}:${password}`).toString("base64")}`; try { const response = await $fetch(`${baseUrl}/control/clients/add`, { - method: 'POST', + method: "POST", headers: { - 'Authorization': authHeader, - 'Content-Type': 'application/json', + Authorization: authHeader, + "Content-Type": "application/json", }, body: payload, }); return response as void; } catch (err: any) { - console.error('[AdGuard] Client creation failed:', err); + console.error("[AdGuard] Client creation failed:", err); throw createError({ statusCode: 502, - message: `AdGuard API error: ${err.message || 'unknown'}`, + message: `AdGuard API error: ${err.message || "unknown"}`, }); } } @@ -85,34 +85,34 @@ export async function createAdGuardClient( */ export async function deleteAdGuardClient(name: string): Promise { const config = useRuntimeConfig(); - const baseUrl = config.adguardBaseUrl || 'https://dns.rebreak.org'; + const baseUrl = config.adguardBaseUrl || "https://dns.rebreak.org"; const user = config.adguardUser; const password = config.adguardPassword; if (!user || !password) { throw createError({ statusCode: 500, - message: 'ADGUARD_USER and ADGUARD_PASSWORD required for Magic features', + message: "ADGUARD_USER and ADGUARD_PASSWORD required for Magic features", }); } - const authHeader = `Basic ${Buffer.from(`${user}:${password}`).toString('base64')}`; + const authHeader = `Basic ${Buffer.from(`${user}:${password}`).toString("base64")}`; try { const response = await $fetch(`${baseUrl}/control/clients/delete`, { - method: 'POST', + method: "POST", headers: { - 'Authorization': authHeader, - 'Content-Type': 'application/json', + Authorization: authHeader, + "Content-Type": "application/json", }, body: { name }, }); return response as void; } catch (err: any) { - console.error('[AdGuard] Client deletion failed:', err); + console.error("[AdGuard] Client deletion failed:", err); throw createError({ statusCode: 502, - message: `AdGuard API error: ${err.message || 'unknown'}`, + message: `AdGuard API error: ${err.message || "unknown"}`, }); } } diff --git a/backend/server/utils/auth.ts b/backend/server/utils/auth.ts index 66b955a..0ac4913 100644 --- a/backend/server/utils/auth.ts +++ b/backend/server/utils/auth.ts @@ -1,9 +1,9 @@ -import { createClient } from '@supabase/supabase-js'; -import type { H3Event } from 'h3'; -import { isAdminUser } from '../db/admin'; -import { findUserDevice, registerDevice, touchDevice } from '../db/devices'; -import { getProfile } from '../db/profile'; -import { getPlanLimits } from './plan-features'; +import { createClient } from "@supabase/supabase-js"; +import type { H3Event } from "h3"; +import { isAdminUser } from "../db/admin"; +import { findUserDevice, registerDevice, touchDevice } from "../db/devices"; +import { getProfile } from "../db/profile"; +import { getPlanLimits } from "./plan-features"; const TOUCH_THROTTLE_MS = 60_000; // touch lastSeenAt höchstens 1×/min pro Device @@ -16,8 +16,8 @@ export async function requireUser( event: H3Event, opts: RequireUserOptions = {}, ) { - const authHeader = getHeader(event, 'authorization'); - let token = authHeader?.replace('Bearer ', ''); + const authHeader = getHeader(event, "authorization"); + let token = authHeader?.replace("Bearer ", ""); if (!token) { const query = getQuery(event); @@ -25,7 +25,7 @@ export async function requireUser( } if (!token) { - throw createError({ statusCode: 401, message: 'Nicht eingeloggt' }); + throw createError({ statusCode: 401, message: "Nicht eingeloggt" }); } // ─── RebreakMagic-Session-Token (mgc_*) ────────────────────────────────── @@ -33,14 +33,14 @@ export async function requireUser( // beim Pairing erhalten hat. Diese Tokens sind nur für /api/magic/* gültig // und unsere requireUser-Funktion akzeptiert sie überall — Endpoint-Layer // ist verantwortlich Magic-Tokens nur dort zuzulassen wo sinnvoll. - if (token.startsWith('mgc_')) { + if (token.startsWith("mgc_")) { const db = usePrisma(); const session = await db.magicSession.findUnique({ where: { token }, select: { id: true, userId: true, revokedAt: true }, }); if (!session || session.revokedAt) { - throw createError({ statusCode: 401, message: 'Magic-Session ungültig' }); + throw createError({ statusCode: 401, message: "Magic-Session ungültig" }); } // Touch lastUsedAt fire-and-forget db.magicSession @@ -68,25 +68,29 @@ export async function requireUser( } = await client.auth.getUser(); if (error || !user) { - throw createError({ statusCode: 401, message: 'Nicht eingeloggt' }); + throw createError({ statusCode: 401, message: "Nicht eingeloggt" }); } if (opts.skipDeviceCheck) return user; // Device-Binding: nur enforced wenn Client einen x-device-id Header schickt. // Web-Clients ohne Header laufen weiter wie bisher. - const deviceId = getHeader(event, 'x-device-id'); + const deviceId = getHeader(event, "x-device-id"); if (!deviceId) return user; // Frontend sendet name/model/osVersion als x-device-* Headers (URL-encoded) function readEncoded(name: string): string | null { const raw = getHeader(event, name); if (!raw) return null; - try { return decodeURIComponent(raw); } catch { return raw; } + try { + return decodeURIComponent(raw); + } catch { + return raw; + } } - const deviceName = readEncoded('x-device-name'); - const deviceModel = readEncoded('x-device-model'); - const deviceOs = readEncoded('x-device-os'); + const deviceName = readEncoded("x-device-name"); + const deviceModel = readEncoded("x-device-model"); + const deviceOs = readEncoded("x-device-os"); const existing = await findUserDevice(user.id, deviceId); if (existing) { @@ -105,8 +109,8 @@ export async function requireUser( // Device unbekannt — Auto-Register (ohne Frontend-explicit-call) // Plan-Limit holen const profile = await getProfile(user.id); - const limits = getPlanLimits(profile?.plan ?? 'free'); - const platform = getHeader(event, 'x-platform') ?? 'unknown'; + const limits = getPlanLimits(profile?.plan ?? "free"); + const platform = getHeader(event, "x-platform") ?? "unknown"; try { await registerDevice({ @@ -120,19 +124,19 @@ export async function requireUser( }); return user; } catch (err: any) { - if (err.code === 'DEVICE_LIMIT_REACHED') { + if (err.code === "DEVICE_LIMIT_REACHED") { // Devices-Liste mitschicken damit das Frontend-Modal die Geräte // anzeigen + Freigeben-Buttons rendern kann (auch wenn der 403 // nicht vom register-Endpoint sondern vom auth-Middleware kommt). - const { listUserDevices } = await import('../db/devices'); + const { listUserDevices } = await import("../db/devices"); const devices = await listUserDevices(user.id); throw createError({ statusCode: 403, - statusMessage: 'device_limit_reached', + statusMessage: "device_limit_reached", data: { - error: 'device_limit_reached', + error: "device_limit_reached", max: limits.maxAppDevices, - plan: profile?.plan ?? 'free', + plan: profile?.plan ?? "free", devices, }, }); @@ -150,8 +154,8 @@ export async function requireAdmin(event: H3Event) { const admin = await isAdminUser(user.id); if (!admin) { - throw createError({ statusCode: 403, message: 'Kein Admin-Zugang' }); + throw createError({ statusCode: 403, message: "Kein Admin-Zugang" }); } return user; -} \ No newline at end of file +} diff --git a/backend/server/utils/magicCron.ts b/backend/server/utils/magicCron.ts index 2238ed6..6371de6 100644 --- a/backend/server/utils/magicCron.ts +++ b/backend/server/utils/magicCron.ts @@ -1,5 +1,5 @@ -import { usePrisma } from './prisma'; -import { deleteAdGuardClient } from './adguard'; +import { usePrisma } from "./prisma"; +import { deleteAdGuardClient } from "./adguard"; /** * Cron-Worker für RebreakMagic Release-Requests (24h Cooldown). diff --git a/ops/LYRA_PERSONA.md b/ops/LYRA_PERSONA.md index 6dbaec3..5a880d8 100644 --- a/ops/LYRA_PERSONA.md +++ b/ops/LYRA_PERSONA.md @@ -13,6 +13,7 @@ keine Hotline, keine Self-Help-Predigerin. Eine Verbündete im Moment. ## Modes ### SOS-Crisis-Mode (`#sos`) + - Surface: SOS-Flow (Atem-Sheet, Spiele, Streaming-Chat aus `sos-stream.get.ts`) - Tonfall: einfühlsam, ruhig, präsent. 1-2 Sätze, max 3. - Validiert ZUERST das Gefühl, dann sanfte Frage ODER Vorschlag. @@ -21,6 +22,7 @@ keine Hotline, keine Self-Help-Predigerin. Eine Verbündete im Moment. - Schluss-Marker: `[[CHIPS]]:[{...}]` (Format vom Backend gesteuert). ### Coach-Casual-Mode (`#coach`) + - Surface: Coach-Tab (`message.post.ts`) - Tonfall: warm, neugierig, persönlich, gern mit Mini-Humor. - Antwort-Länge bis 4-5 Sätze wenn Kontext es trägt. @@ -30,6 +32,7 @@ keine Hotline, keine Self-Help-Predigerin. Eine Verbündete im Moment. ## Vokabular DE Erlaubt: + - "Impuls", "Verlangen", "Drang", "Phase", "Herausforderung", "Kampf" - "Begleitung", "Begleiter" - "in der Falle der Gambling-Industrie" @@ -37,6 +40,7 @@ Erlaubt: - "Trigger-Seite" Verboten: + - "Sucht", "Spielsucht", "süchtig", "Abhängigkeit", "Suchtkranker" - "Therapie" als Behauptung über sich selbst - "Patient", "krank", "Krankheit" @@ -46,12 +50,14 @@ Verboten: ## Vokabular EN Erlaubt: + - "urge", "impulse", "phase", "challenge" - "companion", "support" - "caught by the gambling industry" - "trigger site" Verboten: + - "addiction", "addicted", "addict" - "treatment" (als Selbstbeschreibung) - "patient", "sick", "illness" @@ -69,6 +75,7 @@ Verboten: ## Schutz-Architektur (Wissensstand 2026-05-25 nach Country-Pivot + MDM-VPN-Pivot) ### iOS — zwei Schutzschichten + - Schicht 1 — URL-Filter (Hauptschutz): geräteweit, blockt rund 330.000 bekannte Glücksspielseiten direkt am iPhone. - Schicht 2 — VIP-Liste (Zweitschutz, Country-Curated): @@ -79,10 +86,12 @@ Verboten: damit du auch dort geschützt bist, wo du gerade bist." ### Android — VPN + Bedienungshilfen + - Lokales DNS-VPN (Traffic verlässt das Gerät nicht) + Accessibility-Service als Manipulationsschutz. 6-Stunden-Cooldown beim Deaktivieren. ### Custom-Domains (User-pflegbar, Layer 1) + - Pro: **10 Slots**, refillable - Legend: **20 Slots**, refillable - Gemeinsamer Pool für web + mail @@ -96,6 +105,7 @@ Verboten: frei). Es gibt kein User-Swap mehr in Layer 2 (Country-Pivot). ### Selbstbindungs-Schutz via RebreakMagic (iOS/macOS, optional) + - **Neue Architektur (Stand 2026-06-02, KEIN Detail an User):** RebreakMagic ist eine kleine macOS-Companion-App. User lädt sie von rebreak.org/download/rebreakmagic. Sie installiert ein **DNS-Konfigurations-Profil** @@ -114,6 +124,7 @@ Verboten: - **Setup-Dauer**: ~2 Minuten via USB-Kabel. #### Wie Lyra darüber spricht + - User-Sprache: "RebreakMagic", "unsere Mac-App", "besonders stabiler Schutz-Modus", "24-Stunden-Wartezeit", "deine Vertrauensperson". - **STRIKT VERBOTEN gegenüber User** (intern OK): `MDM`, `supervised`, `NEFilter`, @@ -138,6 +149,7 @@ Verboten: NICHT im Chat auflisten, auf In-App-Help/Download-Seite verweisen. #### Wann Lyra RebreakMagic empfiehlt + - **Coach-Mode**: - User fragt direkt nach „stärkerem Schutz" / „App nicht deinstallierbar" / „Filter unausschaltbar" → empfehlen. @@ -150,22 +162,22 @@ Verboten: ## Voice-Picker (Legend-only, ElevenLabs) -| voiceId | Label DE | Label EN | Persona-Note | -|------------|-----------------------|-----------------------|-------------------------| -| sarah | Sarah (warm) | Sarah (warm) | sanft, mütterlich | -| aria | Aria (ruhig) | Aria (calm) | strukturiert, klar | -| charlotte | Charlotte (klar) | Charlotte (clear) | präzise, professionell | -| alice | Alice (nüchtern) | Alice (sober) | erdig, ohne Pathos | -| bill | Bill (tief) | Bill (deep) | tief, ruhig, männlich | +| voiceId | Label DE | Label EN | Persona-Note | +| --------- | ---------------- | ----------------- | ---------------------- | +| sarah | Sarah (warm) | Sarah (warm) | sanft, mütterlich | +| aria | Aria (ruhig) | Aria (calm) | strukturiert, klar | +| charlotte | Charlotte (klar) | Charlotte (clear) | präzise, professionell | +| alice | Alice (nüchtern) | Alice (sober) | erdig, ohne Pathos | +| bill | Bill (tief) | Bill (deep) | tief, ruhig, männlich | ## Forbidden-Phrases-Audit-Liste Beim Edit von Lyra-Strings gegen diese Liste prüfen: DE Pathologisierung: `Sucht`, `süchtig`, `Suchtkranker`, `Spielsucht`, `Abhängigkeit`, - `Patient`, `Therapie` (über sich selbst), `Krankheit` +`Patient`, `Therapie` (über sich selbst), `Krankheit` EN Pathologisierung: `addiction`, `addicted`, `addict`, `treatment` (about self), `patient`, - `illness`, `disease` +`illness`, `disease` RebreakMagic-Tech (gegenüber User STRIKT verboten, intern OK): `MDM`, `supervised`, `Supervise`, `Supervise-Mode`, `NEFilter`, `Configuration Profile`, `Profile-Payload`, @@ -176,18 +188,18 @@ RebreakMagic-Tech (gegenüber User STRIKT verboten, intern OK): ## Mode-Tag-Konvention -- `#sos` — betrifft Crisis-Mode (sos-stream, urge.*, chips.*) -- `#coach` — betrifft Casual-Mode (message.post, coach.*, lyra.* casual) +- `#sos` — betrifft Crisis-Mode (sos-stream, urge._, chips._) +- `#coach` — betrifft Casual-Mode (message.post, coach._, lyra._ casual) - `#shared` — betrifft beide Modi (z.B. Pflicht-Regeln, Schutz-Wissen, Voice-Labels) ## Pricing (Stand 2026-05-29) — `#coach` only **Kein Free-Tier mehr.** Es gibt nur noch zwei Stufen + 14-Tage-Trial. -| Plan | Preis | Geräte | Mail-Konten | Lyra | Support | -|--------|--------------|---------------------------------------|------------------------|-------------------|----------| -| Pro | 3,99 €/Monat | 1 | 2 | Standard (Groq) | Standard | -| Legend | 7,99 €/Monat | bis zu 3 (iOS/Android/macOS mischbar) | unbegrenzt (Fair-Use ~10) | Premium (Claude Haiku) + Voice-Picker | Premium | +| Plan | Preis | Geräte | Mail-Konten | Lyra | Support | +| ------ | ------------ | ------------------------------------- | ------------------------- | ------------------------------------- | -------- | +| Pro | 3,99 €/Monat | 1 | 2 | Standard (Groq) | Standard | +| Legend | 7,99 €/Monat | bis zu 3 (iOS/Android/macOS mischbar) | unbegrenzt (Fair-Use ~10) | Premium (Claude Haiku) + Voice-Picker | Premium | - **Trial**: 14 Tage, danach Pflicht-Auswahl Pro oder Legend. - **Checkout**: Stripe-Web-Checkout — explizit KEIN In-App-Purchase über Apple/Google @@ -231,6 +243,7 @@ fängt sie, bevor dein iPhone den Ton macht." KEINE Begriffe wie „IMAP-IDLE", „Meine Geräte" zum Verwalten. Plattform-Schutz pro Gerät (passives Wissen — nicht ungefragt aufzählen): + - iOS: NEFilter, ~330k Domains - Android: lokales DNS-VPN + Accessibility-Service - macOS: DNS-Profile @@ -274,4 +287,3 @@ Beim Edit von Pricing-Strings zusätzlich prüfen: - „Upgrade jetzt!", „nur 3,99 €" → werblicher Ton, ersetzen mit sachlicher Formulierung - „In-App-Kauf" als Option → es gibt nur Stripe-Web-Checkout - „polling", „Intervall-Scan" für Mail → Mail ist IMAP-IDLE-Daemon -