fix(magic): explicit imports + staging defaults + sheet height
- backend/api/magic/register: explicit import of MAGIC_DEVICE_LIMIT and createAdGuardClient (Nitro auto-import was missing them → ReferenceError → HTTP 500 on /api/magic/register) - mac-app: default backendBaseUrl falls back to staging.rebreak.org (app.rebreak.org serves wrong TLS cert) - native MagicSheet: fallback download/dmg URLs point to staging - native settings: Magic sheet capped at detents=[0.85] so AppHeader stays visible - bundles all in-flight Magic feature work (pair create/redeem, device endpoints, schema, adguard utils, mac-app, locales)
This commit is contained in:
parent
941dd60f36
commit
77edd67cbe
@ -3,6 +3,7 @@
|
|||||||
## ✅ Implementierte Features
|
## ✅ Implementierte Features
|
||||||
|
|
||||||
### 1. Auth-Stack (bereits implementiert)
|
### 1. Auth-Stack (bereits implementiert)
|
||||||
|
|
||||||
- ✅ `AuthService.swift` — Supabase-Login + Keychain-Persistence
|
- ✅ `AuthService.swift` — Supabase-Login + Keychain-Persistence
|
||||||
- ✅ `KeychainStore` — in AuthService integriert (Service: `org.rebreak.magic`)
|
- ✅ `KeychainStore` — in AuthService integriert (Service: `org.rebreak.magic`)
|
||||||
- ✅ `MagicAPIClient.swift` — Backend-API-Client für `/api/magic/*`
|
- ✅ `MagicAPIClient.swift` — Backend-API-Client für `/api/magic/*`
|
||||||
@ -10,12 +11,14 @@
|
|||||||
- ✅ `MacProfileInstaller.swift` — Profile-Download + Installation via `profiles` command
|
- ✅ `MacProfileInstaller.swift` — Profile-Download + Installation via `profiles` command
|
||||||
|
|
||||||
### 2. Login-UI (bereits implementiert)
|
### 2. Login-UI (bereits implementiert)
|
||||||
|
|
||||||
- ✅ `LoginView.swift` — Email/Passwort-Login
|
- ✅ `LoginView.swift` — Email/Passwort-Login
|
||||||
- ✅ Error-Handling für InvalidCredentials
|
- ✅ Error-Handling für InvalidCredentials
|
||||||
- ✅ Link zu "Noch kein Account? rebreak.org/signup"
|
- ✅ Link zu "Noch kein Account? rebreak.org/signup"
|
||||||
- ✅ Integration in `ContentView.swift` via `model.showingLogin`
|
- ✅ Integration in `ContentView.swift` via `model.showingLogin`
|
||||||
|
|
||||||
### 3. Mac-Registration-Flow (NEU implementiert)
|
### 3. Mac-Registration-Flow (NEU implementiert)
|
||||||
|
|
||||||
- ✅ **Neuer WizardStep `.macRegistration`** (rawValue 0, vor .welcome)
|
- ✅ **Neuer WizardStep `.macRegistration`** (rawValue 0, vor .welcome)
|
||||||
- ✅ **`MacRegistrationView.swift`** — UI für Mac-Device-Registrierung:
|
- ✅ **`MacRegistrationView.swift`** — UI für Mac-Device-Registrierung:
|
||||||
- Zeigt Mac-Info (hostname, model, deviceId via IOPlatformUUID)
|
- Zeigt Mac-Info (hostname, model, deviceId via IOPlatformUUID)
|
||||||
@ -29,11 +32,14 @@
|
|||||||
- `reset()` setzt auch `magicRegistration = nil` und `registrationError = nil`
|
- `reset()` setzt auch `magicRegistration = nil` und `registrationError = nil`
|
||||||
|
|
||||||
### 4. Menu-Erweiterung
|
### 4. Menu-Erweiterung
|
||||||
|
|
||||||
- ✅ Neues Command-Menu "Account" mit "Abmelden" (⌘⇧L)
|
- ✅ Neues Command-Menu "Account" mit "Abmelden" (⌘⇧L)
|
||||||
- ✅ `handleLogout()` in WizardModel ruft `AuthService.signOut()` + reset
|
- ✅ `handleLogout()` in WizardModel ruft `AuthService.signOut()` + reset
|
||||||
|
|
||||||
### 5. Workflow-Integration
|
### 5. Workflow-Integration
|
||||||
|
|
||||||
**Neuer Flow:**
|
**Neuer Flow:**
|
||||||
|
|
||||||
1. App startet → `authState` aus Keychain laden
|
1. App startet → `authState` aus Keychain laden
|
||||||
2. Wenn kein Auth → `LoginView`
|
2. Wenn kein Auth → `LoginView`
|
||||||
3. Nach Login → `.macRegistration` (Mac registrieren + Profil installieren)
|
3. Nach Login → `.macRegistration` (Mac registrieren + Profil installieren)
|
||||||
@ -41,9 +47,11 @@
|
|||||||
5. Rest unverändert: preflight → supervise → enroll → configure → done
|
5. Rest unverändert: preflight → supervise → enroll → configure → done
|
||||||
|
|
||||||
## 📂 Neue Files
|
## 📂 Neue Files
|
||||||
|
|
||||||
- ✅ `Sources/Views/MacRegistrationView.swift` (218 Zeilen)
|
- ✅ `Sources/Views/MacRegistrationView.swift` (218 Zeilen)
|
||||||
|
|
||||||
## 📝 Geänderte Files
|
## 📝 Geänderte Files
|
||||||
|
|
||||||
1. **`Sources/Models/WizardStep.swift`**
|
1. **`Sources/Models/WizardStep.swift`**
|
||||||
- Neuer Case `.macRegistration = 0`
|
- Neuer Case `.macRegistration = 0`
|
||||||
- `.welcome` wurde von rawValue 0 → 1 (alle anderen +1)
|
- `.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:**
|
**Benötigte Werte:**
|
||||||
|
|
||||||
- `supabaseUrl` + `supabaseAnonKey` — von Supabase-Dashboard
|
- `supabaseUrl` + `supabaseAnonKey` — von Supabase-Dashboard
|
||||||
- `backendBaseUrl` — staging: `https://staging.rebreak.org`, prod: `https://api.rebreak.org`
|
- `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)
|
- `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):**
|
**Warnings (harmlos):**
|
||||||
|
|
||||||
- `no 'async' operations occur within 'await' expression` bei `MainActor.run` (expected, korrekt)
|
- `no 'async' operations occur within 'await' expression` bei `MainActor.run` (expected, korrekt)
|
||||||
|
|
||||||
## 📋 Login-Flow-Ablauf
|
## 📋 Login-Flow-Ablauf
|
||||||
|
|||||||
@ -10,6 +10,7 @@ End-User-Wizard für Self-Binding eines Macs + iPhones an Rebreak. Macht in eine
|
|||||||
6. **Configure** — NanoMDM pusht: Lock-Profile + Take-Management + Settings(mdmSupervised=true)
|
6. **Configure** — NanoMDM pusht: Lock-Profile + Take-Management + Settings(mdmSupervised=true)
|
||||||
|
|
||||||
Resultat:
|
Resultat:
|
||||||
|
|
||||||
- **Mac**: DNS-Filter aktiv (Gambling-Domains blockiert via DoH-ClientID)
|
- **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)
|
- **iPhone**: supervised by "Rebreak", App nicht löschbar, NEFilter aktiv (kein User-Toggle in Settings)
|
||||||
|
|
||||||
@ -33,6 +34,7 @@ Normalerweise muss ein iPhone **komplett zurückgesetzt** werden um es zu superv
|
|||||||
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.
|
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-Account** (Login via Supabase-Auth)
|
||||||
- **Rebreak-App** muss VOR Wizard-Start aus TestFlight installiert sein (nur für iPhone-Binding)
|
- **Rebreak-App** muss VOR Wizard-Start aus TestFlight installiert sein (nur für iPhone-Binding)
|
||||||
- **Config-File** mit Supabase + Backend-URLs (siehe Config-Section)
|
- **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
|
## Voraussetzungen
|
||||||
|
|
||||||
| Tool | Wie |
|
| Tool | Wie |
|
||||||
|---|---|
|
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| Xcode 16+ | App Store |
|
| Xcode 16+ | App Store |
|
||||||
| xcodegen | `brew install xcodegen` |
|
| xcodegen | `brew install xcodegen` |
|
||||||
| libimobiledevice | `brew install libimobiledevice` |
|
| libimobiledevice | `brew install libimobiledevice` |
|
||||||
| supervise-magic binary | aus `../../ops/mdm/supervise-magic/` (`make build`) |
|
| 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 |
|
| 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)
|
| 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).
|
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
|
## Voraussetzungen
|
||||||
|
|
||||||
| Tool | Wie |
|
| Tool | Wie |
|
||||||
|---|---|
|
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| Xcode 26+ | App Store |
|
| Xcode 26+ | App Store |
|
||||||
| xcodegen | `brew install xcodegen` |
|
| xcodegen | `brew install xcodegen` |
|
||||||
| libimobiledevice | `brew install libimobiledevice` |
|
| libimobiledevice | `brew install libimobiledevice` |
|
||||||
| supervise-magic binary | aus `../../ops/mdm/supervise-magic/` (`make build`) |
|
| 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 |
|
| cfgutil | Apple Configurator (App Store) → `/Applications/Apple Configurator.app/Contents/MacOS/cfgutil` für silent profile install |
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
### Development-magic-mac
|
### Development-magic-mac
|
||||||
|
|
||||||
# Einmalig: dependencies + supervise-magic-binary bauen
|
# Einmalig: dependencies + supervise-magic-binary bauen
|
||||||
|
|
||||||
(cd ../../ops/mdm/supervise-magic && make tidy && make build)
|
(cd ../../ops/mdm/supervise-magic && make tidy && make build)
|
||||||
|
|
||||||
# Xcode-Project generieren (oder neu generieren nach project.yml Änderungen)
|
# Xcode-Project generieren (oder neu generieren nach project.yml Änderungen)
|
||||||
|
|
||||||
xcodegen generate
|
xcodegen generate
|
||||||
|
|
||||||
# Bauen + öffnen
|
# Bauen + öffnen
|
||||||
|
|
||||||
open RebreakMagic.xcodeproj
|
open RebreakMagic.xcodeproj
|
||||||
|
|
||||||
# → ⌘R in Xcode
|
# → ⌘R in Xcode
|
||||||
```
|
|
||||||
|
````
|
||||||
|
|
||||||
Oder CLI-only:
|
Oder CLI-only:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
xcodebuild -project RebreakMagic.xcodeproj -scheme RebreakMagic -configuration Debug build
|
xcodebuild -project RebreakMagic.xcodeproj -scheme RebreakMagic -configuration Debug build
|
||||||
open build/Build/Products/Debug/RebreakMagic.app
|
open build/Build/Products/Debug/RebreakMagic.app
|
||||||
```
|
````
|
||||||
|
|
||||||
### Production-DMG (für Distribution)
|
### Production-DMG (für Distribution)
|
||||||
|
|
||||||
@ -119,9 +126,10 @@ Falls das Icon nicht sofort erscheint nach Installation:
|
|||||||
```bash
|
```bash
|
||||||
sudo rm -rf /Library/Caches/com.apple.iconservices.store && killall Dock
|
sudo rm -rf /Library/Caches/com.apple.iconservices.store && killall Dock
|
||||||
```
|
```
|
||||||
- Notarization via `xcrun notarytool`
|
|
||||||
- Staple Notarization-Ticket: `xcrun stapler staple`
|
- Notarization via `xcrun notarytool`
|
||||||
- DMG dann ohne Gatekeeper-Warning installierbar
|
- Staple Notarization-Ticket: `xcrun stapler staple`
|
||||||
|
- DMG dann ohne Gatekeeper-Warning installierbar
|
||||||
|
|
||||||
### App-Icon
|
### 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):
|
Falls Icons neu generiert werden müssen (z.B. nach Logo-Update):
|
||||||
|
|
||||||
```bash
|
````bash
|
||||||
# Master-Icon aus rebreak-native kopieren
|
# Master-Icon aus rebreak-native kopieren
|
||||||
cp ../rebreak-native/assets/icon.png /tmp/master-icon.png
|
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
|
mkdir -p ~/.config/rebreak-magic
|
||||||
cp config.example.json ~/.config/rebreak-magic/config.json
|
cp config.example.json ~/.config/rebreak-magic/config.json
|
||||||
chmod 600 ~/.config/rebreak-magic/config.json
|
chmod 600 ~/.config/rebreak-magic/config.json
|
||||||
```
|
````
|
||||||
|
|
||||||
### Schritt 2: Config-Werte eintragen
|
### Schritt 2: Config-Werte eintragen
|
||||||
|
|
||||||
@ -169,25 +177,26 @@ Editiere `~/.config/rebreak-magic/config.json`:
|
|||||||
|
|
||||||
**Wo finde ich die Werte?**
|
**Wo finde ich die Werte?**
|
||||||
|
|
||||||
| Key | Quelle |
|
| Key | Quelle |
|
||||||
|---|---|
|
| ----------------- | ----------------------------------------------------------------------- |
|
||||||
| `supabaseUrl` | Supabase-Dashboard → Project Settings → API → Project URL |
|
| `supabaseUrl` | Supabase-Dashboard → Project Settings → API → Project URL |
|
||||||
| `supabaseAnonKey` | Supabase-Dashboard → Project Settings → API → `anon` public key |
|
| `supabaseAnonKey` | Supabase-Dashboard → Project Settings → API → `anon` public key |
|
||||||
| `backendBaseUrl` | Staging: `https://staging.rebreak.org`, Prod: `https://api.rebreak.org` |
|
| `backendBaseUrl` | Staging: `https://staging.rebreak.org`, Prod: `https://api.rebreak.org` |
|
||||||
| `mdmServer` | `https://mdm.rebreak.org` (rebreak-mdm-VM) |
|
| `mdmServer` | `https://mdm.rebreak.org` (rebreak-mdm-VM) |
|
||||||
| `mdmUser` | `admin` (NanoMDM-Basic-Auth-User) |
|
| `mdmUser` | `admin` (NanoMDM-Basic-Auth-User) |
|
||||||
| `mdmApiKey` | `/root/.nanomdm_admin_pass` auf rebreak-mdm (32-char-hex) |
|
| `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.
|
**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)
|
### 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
|
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
|
EOF
|
||||||
chmod 600 ~/.config/rebreak-binder/config.json
|
chmod 600 ~/.config/rebreak-binder/config.json
|
||||||
```
|
|
||||||
|
````
|
||||||
|
|
||||||
Production-Version legt das in Keychain ab — heute reicht plain 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 <udid>
|
../../ops/mdm/supervise-magic/bin/supervise-magic --device <udid>
|
||||||
# Check stdout/stderr
|
# Check stdout/stderr
|
||||||
```
|
````
|
||||||
|
|
||||||
### MDM-Enrollment schlägt fehl
|
### MDM-Enrollment schlägt fehl
|
||||||
|
|
||||||
@ -235,6 +244,7 @@ Dann App neu starten.
|
|||||||
## TODOs (post-Phase-2)
|
## TODOs (post-Phase-2)
|
||||||
|
|
||||||
### Phase 3 (geplant)
|
### Phase 3 (geplant)
|
||||||
|
|
||||||
- [ ] **Code-Signing + Notarization** (Developer-ID-Cert)
|
- [ ] **Code-Signing + Notarization** (Developer-ID-Cert)
|
||||||
- [ ] **Unit-Tests** für AuthService, MagicAPIClient, MacDeviceDetector
|
- [ ] **Unit-Tests** für AuthService, MagicAPIClient, MacDeviceDetector
|
||||||
- [ ] **Profile-Signierung** (Apple-Developer-Cert für DNS-Filter-Profil)
|
- [ ] **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)
|
- [ ] **Mac-Supervision via UAMDM** (aktuell: nur DNS-Filter, keine Supervision)
|
||||||
|
|
||||||
### Backlog
|
### 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).
|
- [ ] **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)
|
- [ ] 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`)
|
- Supabase-JWT-Login (`AuthService.swift`)
|
||||||
- Keychain-Persistence (Service: `org.rebreak.magic`)
|
- Keychain-Persistence (Service: `org.rebreak.magic`)
|
||||||
- Auto-Refresh bei Token-Expiry
|
- Auto-Refresh bei Token-Expiry
|
||||||
|
|||||||
@ -101,7 +101,7 @@ final class AuthService {
|
|||||||
let data = try? Data(contentsOf: url),
|
let data = try? Data(contentsOf: url),
|
||||||
let config = try? JSONDecoder().decode(Config.self, from: data),
|
let config = try? JSONDecoder().decode(Config.self, from: data),
|
||||||
let base = config.backendBaseUrl else {
|
let base = config.backendBaseUrl else {
|
||||||
return "https://app.rebreak.org"
|
return "https://staging.rebreak.org"
|
||||||
}
|
}
|
||||||
return base
|
return base
|
||||||
}
|
}
|
||||||
|
|||||||
@ -98,16 +98,16 @@ final class MagicAPIClient {
|
|||||||
|
|
||||||
let url = URL(fileURLWithPath: Self.configPath)
|
let url = URL(fileURLWithPath: Self.configPath)
|
||||||
guard FileManager.default.fileExists(atPath: Self.configPath) else {
|
guard FileManager.default.fileExists(atPath: Self.configPath) else {
|
||||||
// Default to production
|
// Default to staging (app.rebreak.org hat aktuell falsches TLS-Zert)
|
||||||
return "https://app.rebreak.org"
|
return "https://staging.rebreak.org"
|
||||||
}
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let data = try Data(contentsOf: url)
|
let data = try Data(contentsOf: url)
|
||||||
let config = try JSONDecoder().decode(Config.self, from: data)
|
let config = try JSONDecoder().decode(Config.self, from: data)
|
||||||
return config.backendBaseUrl ?? "https://app.rebreak.org"
|
return config.backendBaseUrl ?? "https://staging.rebreak.org"
|
||||||
} catch {
|
} catch {
|
||||||
return "https://app.rebreak.org"
|
return "https://staging.rebreak.org"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,47 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
All notable changes to rebreak-native will be documented in this file.
|
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
|
## 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"
|
- 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)
|
- 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)
|
||||||
|
|||||||
@ -28,6 +28,7 @@ import { useMe, invalidateMe } from '../hooks/useMe';
|
|||||||
import { apiFetch } from '../lib/api';
|
import { apiFetch } from '../lib/api';
|
||||||
import { AppHeader } from '../components/AppHeader';
|
import { AppHeader } from '../components/AppHeader';
|
||||||
import { useNotificationPrefsStore } from '../stores/notificationPrefs';
|
import { useNotificationPrefsStore } from '../stores/notificationPrefs';
|
||||||
|
import { MagicSheet } from '../components/devices/MagicSheet';
|
||||||
|
|
||||||
// ─── Subscription Sheet ────────────────────────────────────────────────────
|
// ─── Subscription Sheet ────────────────────────────────────────────────────
|
||||||
|
|
||||||
@ -196,6 +197,7 @@ export default function SettingsScreen() {
|
|||||||
}, [hydratedVoice]);
|
}, [hydratedVoice]);
|
||||||
|
|
||||||
const subscriptionSheetRef = useRef<TrueSheet>(null);
|
const subscriptionSheetRef = useRef<TrueSheet>(null);
|
||||||
|
const magicSheetRef = useRef<TrueSheet>(null);
|
||||||
|
|
||||||
async function handleVoiceSelect(voiceId: LyraVoiceId) {
|
async function handleVoiceSelect(voiceId: LyraVoiceId) {
|
||||||
if (voiceSaving || voiceId === selectedVoice) return;
|
if (voiceSaving || voiceId === selectedVoice) return;
|
||||||
@ -391,7 +393,7 @@ export default function SettingsScreen() {
|
|||||||
icon: 'sparkles-outline',
|
icon: 'sparkles-outline',
|
||||||
label: t('settings.rebreak_magic'),
|
label: t('settings.rebreak_magic'),
|
||||||
sublabel: t('settings.rebreak_magic_desc'),
|
sublabel: t('settings.rebreak_magic_desc'),
|
||||||
onPress: () => router.push('/magic'),
|
onPress: () => magicSheetRef.current?.present(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: 'star-outline',
|
icon: 'star-outline',
|
||||||
@ -752,6 +754,16 @@ export default function SettingsScreen() {
|
|||||||
<SubscriptionSheet plan={plan} colors={colors} t={t} />
|
<SubscriptionSheet plan={plan} colors={colors} t={t} />
|
||||||
</TrueSheet>
|
</TrueSheet>
|
||||||
|
|
||||||
|
<TrueSheet
|
||||||
|
ref={magicSheetRef}
|
||||||
|
detents={[0.85]}
|
||||||
|
cornerRadius={20}
|
||||||
|
grabber
|
||||||
|
backgroundColor={colors.surface}
|
||||||
|
>
|
||||||
|
<MagicSheet colors={colors} />
|
||||||
|
</TrueSheet>
|
||||||
|
|
||||||
{streakTimePickerVisible ? (
|
{streakTimePickerVisible ? (
|
||||||
<StreakTimePickerSheet
|
<StreakTimePickerSheet
|
||||||
hour={streakReminderTime.hour}
|
hour={streakReminderTime.hour}
|
||||||
|
|||||||
@ -56,8 +56,8 @@ export function MagicSheet({ colors }: { colors: ColorScheme }) {
|
|||||||
} catch {
|
} catch {
|
||||||
setInfo({
|
setInfo({
|
||||||
latestVersion: '0.1.0',
|
latestVersion: '0.1.0',
|
||||||
downloadUrl: 'https://rebreak.org/download/rebreakmagic',
|
downloadUrl: 'https://staging.rebreak.org/download/rebreakmagic',
|
||||||
dmgUrl: 'https://rebreak.org/downloads/RebreakMagic-latest.dmg',
|
dmgUrl: 'https://staging.rebreak.org/downloads/RebreakMagic-latest.dmg',
|
||||||
minMacosVersion: '13.0',
|
minMacosVersion: '13.0',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -397,18 +397,40 @@
|
|||||||
},
|
},
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"lyra": {
|
"lyra": {
|
||||||
"welcome": { "body": "أهلاً، أنا Lyra. سعيدة بوجودك هنا — الخطوة الأولى هي الأصعب، وقد قمت بها بالفعل." },
|
"welcome": {
|
||||||
"privacy": { "body": "قبل أن نبدأ — وعد. نعرفك فقط باسمك المستعار. لا اسم حقيقي، لا تتبع، لا إعلانات. أنت في أمان هنا." },
|
"body": "أهلاً، أنا Lyra. سعيدة بوجودك هنا — الخطوة الأولى هي الأصعب، وقد قمت بها بالفعل."
|
||||||
"nickname": { "body": "بم أناديك؟ اختر اسماً مستعاراً — يراه المجتمع فقط، دون الحاجة لاسم حقيقي." },
|
},
|
||||||
"diga_choice": { "body": "هل لديك رمز وصفة طبية من تأمينك الصحي؟ إذن كل شيء مفتوح لك." },
|
"privacy": {
|
||||||
|
"body": "قبل أن نبدأ — وعد. نعرفك فقط باسمك المستعار. لا اسم حقيقي، لا تتبع، لا إعلانات. أنت في أمان هنا."
|
||||||
|
},
|
||||||
|
"nickname": {
|
||||||
|
"body": "بم أناديك؟ اختر اسماً مستعاراً — يراه المجتمع فقط، دون الحاجة لاسم حقيقي."
|
||||||
|
},
|
||||||
|
"diga_choice": {
|
||||||
|
"body": "هل لديك رمز وصفة طبية من تأمينك الصحي؟ إذن كل شيء مفتوح لك."
|
||||||
|
},
|
||||||
"diga_code": { "body": "اكتب رمزك — سأتحقق منه لك." },
|
"diga_code": { "body": "اكتب رمزك — سأتحقق منه لك." },
|
||||||
"plan": { "body": "لكي تستمر الحماية على جهازك، نحتاج إلى خطة — أول 14 يوماً مجاناً. ما الذي يناسبك؟" },
|
"plan": {
|
||||||
"payment": { "body": "خطوة قصيرة: أكّد تجربتك. يمكنك الإلغاء في أي وقت — كل شيء يتم عبر Apple." },
|
"body": "لكي تستمر الحماية على جهازك، نحتاج إلى خطة — أول 14 يوماً مجاناً. ما الذي يناسبك؟"
|
||||||
"protection": { "body": "الآن الجزء الأهم — الحماية على جهازك. هل أنت مستعد؟" },
|
},
|
||||||
"protection_url": { "body": "ستظهر نافذة iOS. اضغط «السماح» — الزر السفلي (وليس الأزرق الكبير في الأعلى — هذا هو الفخ)." },
|
"payment": {
|
||||||
"protection_lock": { "body": "الآن قفل التطبيق. iOS يطلب الوصول إلى مدة استخدام الجهاز — اضغط «متابعة»، مرة أخرى الزر السفلي (وليس الأزرق)." },
|
"body": "خطوة قصيرة: أكّد تجربتك. يمكنك الإلغاء في أي وقت — كل شيء يتم عبر Apple."
|
||||||
"protection_url_android": { "body": "سيطلب Android إذن VPN. اضغط «موافق» — هذا ليس VPN حقيقي، الفلتر يعمل محلياً على جهازك." },
|
},
|
||||||
"protection_lock_android": { "body": "الخطوة الأخيرة: سأفتح إعدادات إمكانية الوصول. ابحث عن «ReBreak» وفعّل المفتاح — ثم ارجع إلى التطبيق." },
|
"protection": {
|
||||||
|
"body": "الآن الجزء الأهم — الحماية على جهازك. هل أنت مستعد؟"
|
||||||
|
},
|
||||||
|
"protection_url": {
|
||||||
|
"body": "ستظهر نافذة iOS. اضغط «السماح» — الزر السفلي (وليس الأزرق الكبير في الأعلى — هذا هو الفخ)."
|
||||||
|
},
|
||||||
|
"protection_lock": {
|
||||||
|
"body": "الآن قفل التطبيق. iOS يطلب الوصول إلى مدة استخدام الجهاز — اضغط «متابعة»، مرة أخرى الزر السفلي (وليس الأزرق)."
|
||||||
|
},
|
||||||
|
"protection_url_android": {
|
||||||
|
"body": "سيطلب Android إذن VPN. اضغط «موافق» — هذا ليس VPN حقيقي، الفلتر يعمل محلياً على جهازك."
|
||||||
|
},
|
||||||
|
"protection_lock_android": {
|
||||||
|
"body": "الخطوة الأخيرة: سأفتح إعدادات إمكانية الوصول. ابحث عن «ReBreak» وفعّل المفتاح — ثم ارجع إلى التطبيق."
|
||||||
|
},
|
||||||
"done": { "body": "تم. اليوم الأول من سلسلتك — ولست وحدك." },
|
"done": { "body": "تم. اليوم الأول من سلسلتك — ولست وحدك." },
|
||||||
"audio_play": "تفعيل الصوت",
|
"audio_play": "تفعيل الصوت",
|
||||||
"audio_loading": "جاري تحميل الصوت...",
|
"audio_loading": "جاري تحميل الصوت...",
|
||||||
@ -1370,6 +1392,7 @@
|
|||||||
},
|
},
|
||||||
"presence": {
|
"presence": {
|
||||||
"online": "متصل",
|
"online": "متصل",
|
||||||
|
"typing": "يكتب",
|
||||||
"just_now": "الآن",
|
"just_now": "الآن",
|
||||||
"minutes_ago": "منذ %{minutes} دقيقة",
|
"minutes_ago": "منذ %{minutes} دقيقة",
|
||||||
"hours_ago": "منذ %{hours} ساعة",
|
"hours_ago": "منذ %{hours} ساعة",
|
||||||
|
|||||||
@ -462,19 +462,45 @@
|
|||||||
},
|
},
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"lyra": {
|
"lyra": {
|
||||||
"welcome": { "body": "Hi, ich bin Lyra. Schön dass du da bist — der erste Schritt ist oft der schwerste, und den hast du schon gemacht." },
|
"welcome": {
|
||||||
"privacy": { "body": "Bevor wir loslegen — ein Versprechen. Wir kennen dich nur unter deinem Alias. Kein Klarname, keine Tracker, keine Werbung. Du bist sicher hier." },
|
"body": "Hi, ich bin Lyra. Schön dass du da bist — der erste Schritt ist oft der schwerste, und den hast du schon gemacht."
|
||||||
"nickname": { "body": "Wie soll ich dich nennen? Wähle einen Alias — den sieht nur die Community, kein echter Name nötig." },
|
},
|
||||||
"diga_choice": { "body": "Hast du einen Rezept-Code von deiner Krankenkasse? Dann ist alles für dich freigeschaltet." },
|
"privacy": {
|
||||||
"diga_code": { "body": "Tippe deinen Code ein — ich prüfe ihn für dich." },
|
"body": "Bevor wir loslegen — ein Versprechen. Wir kennen dich nur unter deinem Alias. Kein Klarname, keine Tracker, keine Werbung. Du bist sicher hier."
|
||||||
"plan": { "body": "Damit der Schutz auf deinem Gerät läuft, brauchen wir einen Plan — die ersten 14 Tage sind gratis. Was passt zu dir?" },
|
},
|
||||||
"payment": { "body": "Kurzer Schritt: bestätige deinen Trial. Du kannst jederzeit kündigen — das läuft direkt über Apple." },
|
"nickname": {
|
||||||
"protection": { "body": "Jetzt der wichtigste Teil — der Schutz auf deinem Gerät. Bereit?" },
|
"body": "Wie soll ich dich nennen? Wähle einen Alias — den sieht nur die Community, kein echter Name nötig."
|
||||||
"protection_url": { "body": "Gleich kommt ein iOS-Dialog. Tippe \"Erlauben\" — den unteren Button (nicht den großen blauen oben — das ist die Falle)." },
|
},
|
||||||
"protection_lock": { "body": "Jetzt der App-Schutz. iOS fragt nach Bildschirmzeit-Zugriff — tippe \"Fortfahren\", wieder den unteren Button (nicht den blauen)." },
|
"diga_choice": {
|
||||||
"protection_url_android": { "body": "Gleich fragt Android nach VPN-Erlaubnis. Tippe \"OK\" — das ist kein echtes VPN, der Filter läuft lokal auf deinem Gerät." },
|
"body": "Hast du einen Rezept-Code von deiner Krankenkasse? Dann ist alles für dich freigeschaltet."
|
||||||
"protection_lock_android": { "body": "Letzter Schritt: Ich öffne gleich die Bedienungshilfen. Such dort \"ReBreak\" und schalte den Schalter an — komm dann einfach wieder zurück." },
|
},
|
||||||
"done": { "body": "Geschafft. Tag 1 deiner neuen Streak — und du gehst nicht allein." },
|
"diga_code": {
|
||||||
|
"body": "Tippe deinen Code ein — ich prüfe ihn für dich."
|
||||||
|
},
|
||||||
|
"plan": {
|
||||||
|
"body": "Damit der Schutz auf deinem Gerät läuft, brauchen wir einen Plan — die ersten 14 Tage sind gratis. Was passt zu dir?"
|
||||||
|
},
|
||||||
|
"payment": {
|
||||||
|
"body": "Kurzer Schritt: bestätige deinen Trial. Du kannst jederzeit kündigen — das läuft direkt über Apple."
|
||||||
|
},
|
||||||
|
"protection": {
|
||||||
|
"body": "Jetzt der wichtigste Teil — der Schutz auf deinem Gerät. Bereit?"
|
||||||
|
},
|
||||||
|
"protection_url": {
|
||||||
|
"body": "Gleich kommt ein iOS-Dialog. Tippe \"Erlauben\" — den unteren Button (nicht den großen blauen oben — das ist die Falle)."
|
||||||
|
},
|
||||||
|
"protection_lock": {
|
||||||
|
"body": "Jetzt der App-Schutz. iOS fragt nach Bildschirmzeit-Zugriff — tippe \"Fortfahren\", wieder den unteren Button (nicht den blauen)."
|
||||||
|
},
|
||||||
|
"protection_url_android": {
|
||||||
|
"body": "Gleich fragt Android nach VPN-Erlaubnis. Tippe \"OK\" — das ist kein echtes VPN, der Filter läuft lokal auf deinem Gerät."
|
||||||
|
},
|
||||||
|
"protection_lock_android": {
|
||||||
|
"body": "Letzter Schritt: Ich öffne gleich die Bedienungshilfen. Such dort \"ReBreak\" und schalte den Schalter an — komm dann einfach wieder zurück."
|
||||||
|
},
|
||||||
|
"done": {
|
||||||
|
"body": "Geschafft. Tag 1 deiner neuen Streak — und du gehst nicht allein."
|
||||||
|
},
|
||||||
"audio_play": "Stimme einschalten",
|
"audio_play": "Stimme einschalten",
|
||||||
"audio_loading": "Lade Stimme...",
|
"audio_loading": "Lade Stimme...",
|
||||||
"audio_stop": "Wiedergabe stoppen",
|
"audio_stop": "Wiedergabe stoppen",
|
||||||
|
|||||||
@ -462,19 +462,43 @@
|
|||||||
},
|
},
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"lyra": {
|
"lyra": {
|
||||||
"welcome": { "body": "Hi, I'm Lyra. Glad you're here — the first step is often the hardest, and you've already taken it." },
|
"welcome": {
|
||||||
"privacy": { "body": "Before we start — a promise. We only know you by your alias. No real name, no trackers, no ads. You're safe here." },
|
"body": "Hi, I'm Lyra. Glad you're here — the first step is often the hardest, and you've already taken it."
|
||||||
"nickname": { "body": "What should I call you? Pick an alias — only the community sees it, no real name needed." },
|
},
|
||||||
"diga_choice": { "body": "Do you have a prescription code from your health insurance? Then everything's unlocked for you." },
|
"privacy": {
|
||||||
|
"body": "Before we start — a promise. We only know you by your alias. No real name, no trackers, no ads. You're safe here."
|
||||||
|
},
|
||||||
|
"nickname": {
|
||||||
|
"body": "What should I call you? Pick an alias — only the community sees it, no real name needed."
|
||||||
|
},
|
||||||
|
"diga_choice": {
|
||||||
|
"body": "Do you have a prescription code from your health insurance? Then everything's unlocked for you."
|
||||||
|
},
|
||||||
"diga_code": { "body": "Type your code — I'll check it for you." },
|
"diga_code": { "body": "Type your code — I'll check it for you." },
|
||||||
"plan": { "body": "To keep the protection running on your device, we need a plan — first 14 days are free. What feels right for you?" },
|
"plan": {
|
||||||
"payment": { "body": "Quick step: confirm your trial. You can cancel anytime — it all runs through Apple." },
|
"body": "To keep the protection running on your device, we need a plan — first 14 days are free. What feels right for you?"
|
||||||
"protection": { "body": "Now the important part — the protection on your device. Ready?" },
|
},
|
||||||
"protection_url": { "body": "An iOS dialog is coming. Tap \"Allow\" — the bottom button (not the big blue one on top — that's the trap)." },
|
"payment": {
|
||||||
"protection_lock": { "body": "Now the app lock. iOS asks for Screen Time access — tap \"Continue\", again the bottom button (not the blue one)." },
|
"body": "Quick step: confirm your trial. You can cancel anytime — it all runs through Apple."
|
||||||
"protection_url_android": { "body": "Android will ask for VPN permission. Tap \"OK\" — this isn't a real VPN; the filter runs locally on your device." },
|
},
|
||||||
"protection_lock_android": { "body": "Last step: I'll open Accessibility settings now. Find \"ReBreak\" there and flip the switch on — then come right back." },
|
"protection": {
|
||||||
"done": { "body": "Done. Day 1 of your new streak — and you're not walking alone." },
|
"body": "Now the important part — the protection on your device. Ready?"
|
||||||
|
},
|
||||||
|
"protection_url": {
|
||||||
|
"body": "An iOS dialog is coming. Tap \"Allow\" — the bottom button (not the big blue one on top — that's the trap)."
|
||||||
|
},
|
||||||
|
"protection_lock": {
|
||||||
|
"body": "Now the app lock. iOS asks for Screen Time access — tap \"Continue\", again the bottom button (not the blue one)."
|
||||||
|
},
|
||||||
|
"protection_url_android": {
|
||||||
|
"body": "Android will ask for VPN permission. Tap \"OK\" — this isn't a real VPN; the filter runs locally on your device."
|
||||||
|
},
|
||||||
|
"protection_lock_android": {
|
||||||
|
"body": "Last step: I'll open Accessibility settings now. Find \"ReBreak\" there and flip the switch on — then come right back."
|
||||||
|
},
|
||||||
|
"done": {
|
||||||
|
"body": "Done. Day 1 of your new streak — and you're not walking alone."
|
||||||
|
},
|
||||||
"audio_play": "Enable voice",
|
"audio_play": "Enable voice",
|
||||||
"audio_loading": "Loading voice...",
|
"audio_loading": "Loading voice...",
|
||||||
"audio_stop": "Stop playback",
|
"audio_stop": "Stop playback",
|
||||||
|
|||||||
@ -395,19 +395,43 @@
|
|||||||
},
|
},
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"lyra": {
|
"lyra": {
|
||||||
"welcome": { "body": "Salut, je suis Lyra. Contente que tu sois là — le premier pas est souvent le plus dur, et tu l'as déjà fait." },
|
"welcome": {
|
||||||
"privacy": { "body": "Avant de commencer — une promesse. On te connaît uniquement par ton alias. Pas de vrai nom, pas de trackers, pas de pub. Tu es en sécurité ici." },
|
"body": "Salut, je suis Lyra. Contente que tu sois là — le premier pas est souvent le plus dur, et tu l'as déjà fait."
|
||||||
"nickname": { "body": "Comment je t'appelle ? Choisis un alias — seule la communauté le voit, pas de vrai nom nécessaire." },
|
},
|
||||||
"diga_choice": { "body": "Tu as un code d'ordonnance de ta caisse d'assurance ? Alors tout est débloqué pour toi." },
|
"privacy": {
|
||||||
|
"body": "Avant de commencer — une promesse. On te connaît uniquement par ton alias. Pas de vrai nom, pas de trackers, pas de pub. Tu es en sécurité ici."
|
||||||
|
},
|
||||||
|
"nickname": {
|
||||||
|
"body": "Comment je t'appelle ? Choisis un alias — seule la communauté le voit, pas de vrai nom nécessaire."
|
||||||
|
},
|
||||||
|
"diga_choice": {
|
||||||
|
"body": "Tu as un code d'ordonnance de ta caisse d'assurance ? Alors tout est débloqué pour toi."
|
||||||
|
},
|
||||||
"diga_code": { "body": "Tape ton code — je le vérifie pour toi." },
|
"diga_code": { "body": "Tape ton code — je le vérifie pour toi." },
|
||||||
"plan": { "body": "Pour faire tourner la protection sur ton appareil, il nous faut un plan — les 14 premiers jours sont offerts. Qu'est-ce qui te convient ?" },
|
"plan": {
|
||||||
"payment": { "body": "Étape rapide : confirme ton essai. Tu peux annuler à tout moment — tout passe par Apple." },
|
"body": "Pour faire tourner la protection sur ton appareil, il nous faut un plan — les 14 premiers jours sont offerts. Qu'est-ce qui te convient ?"
|
||||||
"protection": { "body": "Maintenant la partie importante — la protection sur ton appareil. Prêt ?" },
|
},
|
||||||
"protection_url": { "body": "Une fenêtre iOS va apparaître. Touche « Autoriser » — le bouton du bas (pas le grand bleu en haut — c'est le piège)." },
|
"payment": {
|
||||||
"protection_lock": { "body": "Maintenant le verrou d'app. iOS demande l'accès à Temps d'écran — touche « Continuer », encore le bouton du bas (pas le bleu)." },
|
"body": "Étape rapide : confirme ton essai. Tu peux annuler à tout moment — tout passe par Apple."
|
||||||
"protection_url_android": { "body": "Android va demander la permission VPN. Touche « OK » — ce n'est pas un vrai VPN, le filtre tourne localement sur ton téléphone." },
|
},
|
||||||
"protection_lock_android": { "body": "Dernière étape : j'ouvre les paramètres d'Accessibilité. Trouve « ReBreak » et active l'interrupteur — puis reviens dans l'app." },
|
"protection": {
|
||||||
"done": { "body": "Voilà. Jour 1 de ta nouvelle série — et tu n'es pas seul." },
|
"body": "Maintenant la partie importante — la protection sur ton appareil. Prêt ?"
|
||||||
|
},
|
||||||
|
"protection_url": {
|
||||||
|
"body": "Une fenêtre iOS va apparaître. Touche « Autoriser » — le bouton du bas (pas le grand bleu en haut — c'est le piège)."
|
||||||
|
},
|
||||||
|
"protection_lock": {
|
||||||
|
"body": "Maintenant le verrou d'app. iOS demande l'accès à Temps d'écran — touche « Continuer », encore le bouton du bas (pas le bleu)."
|
||||||
|
},
|
||||||
|
"protection_url_android": {
|
||||||
|
"body": "Android va demander la permission VPN. Touche « OK » — ce n'est pas un vrai VPN, le filtre tourne localement sur ton téléphone."
|
||||||
|
},
|
||||||
|
"protection_lock_android": {
|
||||||
|
"body": "Dernière étape : j'ouvre les paramètres d'Accessibilité. Trouve « ReBreak » et active l'interrupteur — puis reviens dans l'app."
|
||||||
|
},
|
||||||
|
"done": {
|
||||||
|
"body": "Voilà. Jour 1 de ta nouvelle série — et tu n'es pas seul."
|
||||||
|
},
|
||||||
"audio_play": "Activer la voix",
|
"audio_play": "Activer la voix",
|
||||||
"audio_loading": "Chargement de la voix...",
|
"audio_loading": "Chargement de la voix...",
|
||||||
"audio_stop": "Arrêter la lecture",
|
"audio_stop": "Arrêter la lecture",
|
||||||
@ -1354,6 +1378,7 @@
|
|||||||
},
|
},
|
||||||
"presence": {
|
"presence": {
|
||||||
"online": "En ligne",
|
"online": "En ligne",
|
||||||
|
"typing": "écrit",
|
||||||
"just_now": "à l'instant",
|
"just_now": "à l'instant",
|
||||||
"minutes_ago": "il y a %{minutes} min",
|
"minutes_ago": "il y a %{minutes} min",
|
||||||
"hours_ago": "il y a %{hours} h",
|
"hours_ago": "il y a %{hours} h",
|
||||||
|
|||||||
@ -4,15 +4,18 @@ Dieses Dokument listet alle ENV-Variablen die das Rebreak-Backend benötigt.
|
|||||||
Alle Secrets werden via **Infisical** injected. NIEMALS `.env`-Files committen.
|
Alle Secrets werden via **Infisical** injected. NIEMALS `.env`-Files committen.
|
||||||
|
|
||||||
## Core / Database
|
## Core / Database
|
||||||
|
|
||||||
- `DATABASE_URL` — PostgreSQL Connection-String (Supabase self-hosted)
|
- `DATABASE_URL` — PostgreSQL Connection-String (Supabase self-hosted)
|
||||||
- `ENCRYPTION_KEY` — AES-256 Key für sensible DB-Fields (z.B. mdmDnsToken)
|
- `ENCRYPTION_KEY` — AES-256 Key für sensible DB-Fields (z.B. mdmDnsToken)
|
||||||
|
|
||||||
## Admin / Cron
|
## Admin / Cron
|
||||||
|
|
||||||
- `ADMIN_SECRET` — Shared Secret für Admin-Endpoints
|
- `ADMIN_SECRET` — Shared Secret für Admin-Endpoints
|
||||||
- `CRON_SECRET` — Auth-Header für Cron-Trigger-Endpoints
|
- `CRON_SECRET` — Auth-Header für Cron-Trigger-Endpoints
|
||||||
- `HANDSHAKE_SECRET` — AdGuard→Backend DoH-Handshake
|
- `HANDSHAKE_SECRET` — AdGuard→Backend DoH-Handshake
|
||||||
|
|
||||||
## LLM-Provider
|
## LLM-Provider
|
||||||
|
|
||||||
- `OPENROUTER_API_KEY` / `NUXT_OPENROUTER_API_KEY`
|
- `OPENROUTER_API_KEY` / `NUXT_OPENROUTER_API_KEY`
|
||||||
- `OPENAI_API_KEY` / `NUXT_OPENAI_API_KEY`
|
- `OPENAI_API_KEY` / `NUXT_OPENAI_API_KEY`
|
||||||
- `GROQ_API_KEY` / `NUXT_GROQ_API_KEY`
|
- `GROQ_API_KEY` / `NUXT_GROQ_API_KEY`
|
||||||
@ -20,6 +23,7 @@ Alle Secrets werden via **Infisical** injected. NIEMALS `.env`-Files committen.
|
|||||||
- `GEMINI_API_KEY`
|
- `GEMINI_API_KEY`
|
||||||
|
|
||||||
## TTS-Provider
|
## TTS-Provider
|
||||||
|
|
||||||
- `GOOGLE_API_KEY` / `NUXT_GOOGLE_API_KEY`
|
- `GOOGLE_API_KEY` / `NUXT_GOOGLE_API_KEY`
|
||||||
- `DEEPGRAM_API_KEY` / `NUXT_DEEPGRAM_API_KEY`
|
- `DEEPGRAM_API_KEY` / `NUXT_DEEPGRAM_API_KEY`
|
||||||
- `AZURE_TTS_KEY`, `AZURE_TTS_REGION`
|
- `AZURE_TTS_KEY`, `AZURE_TTS_REGION`
|
||||||
@ -27,34 +31,41 @@ Alle Secrets werden via **Infisical** injected. NIEMALS `.env`-Files committen.
|
|||||||
- `ELEVENLABS_API_KEY`, `ELEVENLABS_VOICE_ID`
|
- `ELEVENLABS_API_KEY`, `ELEVENLABS_VOICE_ID`
|
||||||
|
|
||||||
## Supabase (Server-only)
|
## Supabase (Server-only)
|
||||||
|
|
||||||
- `SUPABASE_URL` — Default: `https://db-staging.rebreak.org`
|
- `SUPABASE_URL` — Default: `https://db-staging.rebreak.org`
|
||||||
- `SUPABASE_KEY` / `SUPABASE_ANON_KEY`
|
- `SUPABASE_KEY` / `SUPABASE_ANON_KEY`
|
||||||
- `SUPABASE_SERVICE_KEY` / `SUPABASE_SERVICE_ROLE_KEY`
|
- `SUPABASE_SERVICE_KEY` / `SUPABASE_SERVICE_ROLE_KEY`
|
||||||
|
|
||||||
## Stripe
|
## Stripe
|
||||||
|
|
||||||
- `STRIPE_SECRET_KEY`
|
- `STRIPE_SECRET_KEY`
|
||||||
- `STRIPE_WEBHOOK_SECRET`
|
- `STRIPE_WEBHOOK_SECRET`
|
||||||
- `STRIPE_PUBLISHABLE_KEY` (public)
|
- `STRIPE_PUBLISHABLE_KEY` (public)
|
||||||
|
|
||||||
## Email / External APIs
|
## Email / External APIs
|
||||||
|
|
||||||
- `RESEND_API_KEY`
|
- `RESEND_API_KEY`
|
||||||
- `BREVO_API_KEY` — Brevo Transactional API
|
- `BREVO_API_KEY` — Brevo Transactional API
|
||||||
- `HOOK_SEND_EMAIL_SECRETS` — Comma-separated Webhook-Secrets (Standard-Webhooks Format)
|
- `HOOK_SEND_EMAIL_SECRETS` — Comma-separated Webhook-Secrets (Standard-Webhooks Format)
|
||||||
- `MAIL_SENDER_EMAIL` — Default: `welcome@rebreak.org`
|
- `MAIL_SENDER_EMAIL` — Default: `welcome@rebreak.org`
|
||||||
|
|
||||||
## **RebreakMagic DNS-over-HTTPS (NEU 2026-06-01)**
|
## **RebreakMagic DNS-over-HTTPS (NEU 2026-06-01)**
|
||||||
|
|
||||||
- `ADGUARD_BASE_URL` — Default: `https://dns.rebreak.org`
|
- `ADGUARD_BASE_URL` — Default: `https://dns.rebreak.org`
|
||||||
- `ADGUARD_USER` — Admin-User für AdGuard Home REST API
|
- `ADGUARD_USER` — Admin-User für AdGuard Home REST API
|
||||||
- `ADGUARD_PASSWORD` — Admin-Password für AdGuard Home REST API
|
- `ADGUARD_PASSWORD` — Admin-Password für AdGuard Home REST API
|
||||||
|
|
||||||
## OAuth
|
## OAuth
|
||||||
|
|
||||||
- `MS_OAUTH_CLIENT_ID` — Microsoft Azure App-Registrierung (PKCE, Public Client)
|
- `MS_OAUTH_CLIENT_ID` — Microsoft Azure App-Registrierung (PKCE, Public Client)
|
||||||
- `GOOGLE_OAUTH_CLIENT_ID` — Google Cloud Console iOS-App (PKCE S256)
|
- `GOOGLE_OAUTH_CLIENT_ID` — Google Cloud Console iOS-App (PKCE S256)
|
||||||
|
|
||||||
## Bot-User-IDs
|
## Bot-User-IDs
|
||||||
|
|
||||||
- `LYRA_BOT_USER_ID` — DB-User-UUID für Lyra-Bot-Posts
|
- `LYRA_BOT_USER_ID` — DB-User-UUID für Lyra-Bot-Posts
|
||||||
- `REBREAK_BOT_USER_ID` — DB-User-UUID für Rebreak-System-Posts
|
- `REBREAK_BOT_USER_ID` — DB-User-UUID für Rebreak-System-Posts
|
||||||
|
|
||||||
## Public (Client-readable)
|
## Public (Client-readable)
|
||||||
|
|
||||||
- `APP_URL` — Default: `https://staging.rebreak.org`
|
- `APP_URL` — Default: `https://staging.rebreak.org`
|
||||||
- `API_BASE` — Default: `https://staging.rebreak.org`
|
- `API_BASE` — Default: `https://staging.rebreak.org`
|
||||||
|
|||||||
@ -24,11 +24,13 @@ AdGuard Home (Hetzner) — Filtering + Logging per Client-ID
|
|||||||
## Endpoints
|
## Endpoints
|
||||||
|
|
||||||
### 1. `POST /api/magic/register`
|
### 1. `POST /api/magic/register`
|
||||||
|
|
||||||
Registriert Mac als Magic-Client, generiert DNS-Token, provisioniert AdGuard.
|
Registriert Mac als Magic-Client, generiert DNS-Token, provisioniert AdGuard.
|
||||||
|
|
||||||
**Auth:** `Authorization: Bearer <jwt>`
|
**Auth:** `Authorization: Bearer <jwt>`
|
||||||
|
|
||||||
**Body:**
|
**Body:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"deviceId": "550e8400-e29b-41d4-a716-446655440000",
|
"deviceId": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
@ -39,6 +41,7 @@ Registriert Mac als Magic-Client, generiert DNS-Token, provisioniert AdGuard.
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Response (Success):**
|
**Response (Success):**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
@ -52,6 +55,7 @@ Registriert Mac als Magic-Client, generiert DNS-Token, provisioniert AdGuard.
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Response (Limit erreicht):**
|
**Response (Limit erreicht):**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"statusCode": 409,
|
"statusCode": 409,
|
||||||
@ -73,6 +77,7 @@ Registriert Mac als Magic-Client, generiert DNS-Token, provisioniert AdGuard.
|
|||||||
```
|
```
|
||||||
|
|
||||||
**cURL:**
|
**cURL:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST https://staging.rebreak.org/api/magic/register \
|
curl -X POST https://staging.rebreak.org/api/magic/register \
|
||||||
-H "Authorization: Bearer $JWT_TOKEN" \
|
-H "Authorization: Bearer $JWT_TOKEN" \
|
||||||
@ -88,11 +93,13 @@ curl -X POST https://staging.rebreak.org/api/magic/register \
|
|||||||
---
|
---
|
||||||
|
|
||||||
### 2. `GET /api/magic/devices`
|
### 2. `GET /api/magic/devices`
|
||||||
|
|
||||||
Listet alle aktiven Magic-Bindings des Users.
|
Listet alle aktiven Magic-Bindings des Users.
|
||||||
|
|
||||||
**Auth:** `Authorization: Bearer <jwt>`
|
**Auth:** `Authorization: Bearer <jwt>`
|
||||||
|
|
||||||
**Response:**
|
**Response:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
@ -111,6 +118,7 @@ Listet alle aktiven Magic-Bindings des Users.
|
|||||||
```
|
```
|
||||||
|
|
||||||
**cURL:**
|
**cURL:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl https://staging.rebreak.org/api/magic/devices \
|
curl https://staging.rebreak.org/api/magic/devices \
|
||||||
-H "Authorization: Bearer $JWT_TOKEN"
|
-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`
|
### 3. `POST /api/magic/devices/:deviceId/request-release`
|
||||||
|
|
||||||
Startet 24h Cooldown für Device-Freigabe.
|
Startet 24h Cooldown für Device-Freigabe.
|
||||||
|
|
||||||
**Auth:** `Authorization: Bearer <jwt>`
|
**Auth:** `Authorization: Bearer <jwt>`
|
||||||
|
|
||||||
**Response:**
|
**Response:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
@ -135,6 +145,7 @@ Startet 24h Cooldown für Device-Freigabe.
|
|||||||
```
|
```
|
||||||
|
|
||||||
**cURL:**
|
**cURL:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST https://staging.rebreak.org/api/magic/devices/550e8400-e29b-41d4-a716-446655440000/request-release \
|
curl -X POST https://staging.rebreak.org/api/magic/devices/550e8400-e29b-41d4-a716-446655440000/request-release \
|
||||||
-H "Authorization: Bearer $JWT_TOKEN"
|
-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`
|
### 4. `POST /api/magic/devices/:deviceId/cancel-release`
|
||||||
|
|
||||||
Zieht Release-Request zurück.
|
Zieht Release-Request zurück.
|
||||||
|
|
||||||
**Auth:** `Authorization: Bearer <jwt>`
|
**Auth:** `Authorization: Bearer <jwt>`
|
||||||
|
|
||||||
**Response:**
|
**Response:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
@ -156,6 +169,7 @@ Zieht Release-Request zurück.
|
|||||||
```
|
```
|
||||||
|
|
||||||
**cURL:**
|
**cURL:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST https://staging.rebreak.org/api/magic/devices/550e8400-e29b-41d4-a716-446655440000/cancel-release \
|
curl -X POST https://staging.rebreak.org/api/magic/devices/550e8400-e29b-41d4-a716-446655440000/cancel-release \
|
||||||
-H "Authorization: Bearer $JWT_TOKEN"
|
-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=<dnsToken>`
|
### 5. `GET /api/magic/profile.mobileconfig?token=<dnsToken>`
|
||||||
|
|
||||||
Generiert personalisiertes macOS Configuration Profile.
|
Generiert personalisiertes macOS Configuration Profile.
|
||||||
|
|
||||||
**Auth:** KEINE (Token in Query-Parameter)
|
**Auth:** KEINE (Token in Query-Parameter)
|
||||||
|
|
||||||
**Response-Headers:**
|
**Response-Headers:**
|
||||||
|
|
||||||
- `Content-Type: application/x-apple-aspen-config`
|
- `Content-Type: application/x-apple-aspen-config`
|
||||||
- `Content-Disposition: attachment; filename="RebreakMagic-<deviceId>.mobileconfig"`
|
- `Content-Disposition: attachment; filename="RebreakMagic-<deviceId>.mobileconfig"`
|
||||||
|
|
||||||
**Response-Body:** XML-Plist (mobileconfig)
|
**Response-Body:** XML-Plist (mobileconfig)
|
||||||
|
|
||||||
**cURL:**
|
**cURL:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl "https://staging.rebreak.org/api/magic/profile.mobileconfig?token=QX7g9kL2mN4pR6tV8wY0zB3cD5fG7hJ9kM2nP4qS6uW8xZ0" \
|
curl "https://staging.rebreak.org/api/magic/profile.mobileconfig?token=QX7g9kL2mN4pR6tV8wY0zB3cD5fG7hJ9kM2nP4qS6uW8xZ0" \
|
||||||
-o RebreakMagic.mobileconfig
|
-o RebreakMagic.mobileconfig
|
||||||
@ -185,6 +202,7 @@ curl "https://staging.rebreak.org/api/magic/profile.mobileconfig?token=QX7g9kL2m
|
|||||||
## DB-Schema
|
## DB-Schema
|
||||||
|
|
||||||
**UserDevice Model (Prisma Schema):**
|
**UserDevice Model (Prisma Schema):**
|
||||||
|
|
||||||
```prisma
|
```prisma
|
||||||
model UserDevice {
|
model UserDevice {
|
||||||
// ... existing fields ...
|
// ... existing fields ...
|
||||||
@ -198,6 +216,7 @@ model UserDevice {
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Migration:**
|
**Migration:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# User führt aus (NICHT auto-deployen):
|
# User führt aus (NICHT auto-deployen):
|
||||||
pnpm prisma migrate dev --name magic_binding_fields
|
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`)
|
**Auth:** Basic Auth (`ADGUARD_USER`, `ADGUARD_PASSWORD`)
|
||||||
|
|
||||||
**Payload:**
|
**Payload:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"name": "magic_<deviceId>",
|
"name": "magic_<deviceId>",
|
||||||
@ -225,6 +245,7 @@ pnpm prisma migrate dev --name magic_binding_fields
|
|||||||
```
|
```
|
||||||
|
|
||||||
**DoH-URL-Format (embedded in mobileconfig):**
|
**DoH-URL-Format (embedded in mobileconfig):**
|
||||||
|
|
||||||
```
|
```
|
||||||
https://dns.rebreak.org/dns-query/<dnsToken>
|
https://dns.rebreak.org/dns-query/<dnsToken>
|
||||||
```
|
```
|
||||||
@ -236,6 +257,7 @@ https://dns.rebreak.org/dns-query/<dnsToken>
|
|||||||
**Funktion:** `processMagicReleases()` in `server/utils/magicCron.ts`
|
**Funktion:** `processMagicReleases()` in `server/utils/magicCron.ts`
|
||||||
|
|
||||||
**Logic:**
|
**Logic:**
|
||||||
|
|
||||||
1. Findet alle UserDevice mit `releaseRequestedAt < NOW() - 24h` AND `magicRevokedAt IS NULL`
|
1. Findet alle UserDevice mit `releaseRequestedAt < NOW() - 24h` AND `magicRevokedAt IS NULL`
|
||||||
2. Für jedes Device:
|
2. Für jedes Device:
|
||||||
- DELETE AdGuard Client (`/control/clients/delete`)
|
- DELETE AdGuard Client (`/control/clients/delete`)
|
||||||
|
|||||||
@ -8,9 +8,7 @@ export default defineNitroConfig({
|
|||||||
// Static-Assets explizit: GoTrue lädt Mail-Templates von
|
// Static-Assets explizit: GoTrue lädt Mail-Templates von
|
||||||
// https://api.staging.rebreak.org/templates/*.html → kommt aus public/templates/.
|
// https://api.staging.rebreak.org/templates/*.html → kommt aus public/templates/.
|
||||||
// Default-publicAssets greift nicht zuverlässig wenn srcDir auf "server" zeigt.
|
// Default-publicAssets greift nicht zuverlässig wenn srcDir auf "server" zeigt.
|
||||||
publicAssets: [
|
publicAssets: [{ baseURL: "/", dir: "../public", maxAge: 60 * 60 }],
|
||||||
{ baseURL: "/", dir: "../public", maxAge: 60 * 60 },
|
|
||||||
],
|
|
||||||
|
|
||||||
// Supabase als external dep — nicht bundlen
|
// Supabase als external dep — nicht bundlen
|
||||||
externals: {
|
externals: {
|
||||||
@ -24,7 +22,8 @@ export default defineNitroConfig({
|
|||||||
|
|
||||||
runtimeConfig: {
|
runtimeConfig: {
|
||||||
// ─── Database / Core ─────────────────────────────────────────────────
|
// ─── 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 ?? "",
|
encryptionKey: process.env.ENCRYPTION_KEY ?? "",
|
||||||
|
|
||||||
// ─── Admin / Cron ────────────────────────────────────────────────────
|
// ─── Admin / Cron ────────────────────────────────────────────────────
|
||||||
@ -36,15 +35,21 @@ export default defineNitroConfig({
|
|||||||
|
|
||||||
// ─── LLM-Provider ────────────────────────────────────────────────────
|
// ─── LLM-Provider ────────────────────────────────────────────────────
|
||||||
// Infisical staging hat NUXT_*-prefix für openrouter+groq, andere ohne.
|
// Infisical staging hat NUXT_*-prefix für openrouter+groq, andere ohne.
|
||||||
openrouterApiKey: process.env.OPENROUTER_API_KEY ?? process.env.NUXT_OPENROUTER_API_KEY ?? "",
|
openrouterApiKey:
|
||||||
openaiApiKey: process.env.OPENAI_API_KEY ?? process.env.NUXT_OPENAI_API_KEY ?? "",
|
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 ?? "",
|
groqApiKey: process.env.GROQ_API_KEY ?? process.env.NUXT_GROQ_API_KEY ?? "",
|
||||||
googleAiApiKey: process.env.GOOGLE_AI_API_KEY ?? "",
|
googleAiApiKey: process.env.GOOGLE_AI_API_KEY ?? "",
|
||||||
geminiApiKey: process.env.GEMINI_API_KEY ?? "",
|
geminiApiKey: process.env.GEMINI_API_KEY ?? "",
|
||||||
|
|
||||||
// ─── TTS-Provider ────────────────────────────────────────────────────
|
// ─── TTS-Provider ────────────────────────────────────────────────────
|
||||||
googleApiKey: process.env.GOOGLE_API_KEY ?? process.env.NUXT_GOOGLE_API_KEY ?? "",
|
googleApiKey:
|
||||||
deepgramApiKey: process.env.DEEPGRAM_API_KEY ?? process.env.NUXT_DEEPGRAM_API_KEY ?? "",
|
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 ?? "",
|
azureTtsKey: process.env.AZURE_TTS_KEY ?? "",
|
||||||
azureTtsRegion: process.env.AZURE_TTS_REGION ?? "",
|
azureTtsRegion: process.env.AZURE_TTS_REGION ?? "",
|
||||||
// NEU im backend/-Layout (existieren in nuxt.config.ts NICHT, aber backend code liest sie)
|
// 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
|
// Infisical staging-Namen: SUPABASE_KEY (nicht ANON_KEY), SUPABASE_SERVICE_KEY
|
||||||
// (nicht SERVICE_ROLE_KEY). NIE umbenennen ohne Infisical-secret-rotation.
|
// (nicht SERVICE_ROLE_KEY). NIE umbenennen ohne Infisical-secret-rotation.
|
||||||
supabaseUrl: process.env.SUPABASE_URL ?? "https://db-staging.rebreak.org",
|
supabaseUrl: process.env.SUPABASE_URL ?? "https://db-staging.rebreak.org",
|
||||||
supabaseAnonKey: process.env.SUPABASE_KEY ?? process.env.SUPABASE_ANON_KEY ?? "",
|
supabaseAnonKey:
|
||||||
supabaseServiceKey: process.env.SUPABASE_SERVICE_KEY ?? process.env.SUPABASE_SERVICE_ROLE_KEY ?? "",
|
process.env.SUPABASE_KEY ?? process.env.SUPABASE_ANON_KEY ?? "",
|
||||||
|
supabaseServiceKey:
|
||||||
|
process.env.SUPABASE_SERVICE_KEY ??
|
||||||
|
process.env.SUPABASE_SERVICE_ROLE_KEY ??
|
||||||
|
"",
|
||||||
|
|
||||||
// ─── Stripe ──────────────────────────────────────────────────────────
|
// ─── Stripe ──────────────────────────────────────────────────────────
|
||||||
stripeSecretKey: process.env.STRIPE_SECRET_KEY ?? "",
|
stripeSecretKey: process.env.STRIPE_SECRET_KEY ?? "",
|
||||||
@ -97,7 +106,8 @@ export default defineNitroConfig({
|
|||||||
// Tenant: 'common' (Multi-Tenant + Personal-Accounts) — hardcoded im Code.
|
// Tenant: 'common' (Multi-Tenant + Personal-Accounts) — hardcoded im Code.
|
||||||
// Kein client_secret: Public Client / PKCE-Flow (keine Client-Secret-Exposure).
|
// Kein client_secret: Public Client / PKCE-Flow (keine Client-Secret-Exposure).
|
||||||
// Infisical secret name: MS_OAUTH_CLIENT_ID
|
// 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) ────────────────────
|
// ─── Google OAuth (PKCE, Public Client / iOS Native) ────────────────────
|
||||||
// Client-ID der Google Cloud Console App-Registrierung "Rebreak".
|
// 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
|
// KRITISCH: prompt=consent + access_type=offline in init.post.ts sind PFLICHT
|
||||||
// damit Google ein refresh_token ausstellt. Ohne das: nur 1h access_token.
|
// damit Google ein refresh_token ausstellt. Ohne das: nur 1h access_token.
|
||||||
// Infisical secret name: GOOGLE_OAUTH_CLIENT_ID
|
// 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) ────
|
// ─── Bot-User-IDs (DB-User-References für Lyra/Rebreak-Bot-Posts) ────
|
||||||
lyraBotUserId: process.env.LYRA_BOT_USER_ID ?? "",
|
lyraBotUserId: process.env.LYRA_BOT_USER_ID ?? "",
|
||||||
@ -121,9 +133,7 @@ export default defineNitroConfig({
|
|||||||
// — wenn das fehlt, 500-cascade auf allen authentifizierten Routes
|
// — wenn das fehlt, 500-cascade auf allen authentifizierten Routes
|
||||||
// (Incident 2026-05-06).
|
// (Incident 2026-05-06).
|
||||||
supabase: {
|
supabase: {
|
||||||
url:
|
url: process.env.SUPABASE_URL ?? "https://db-staging.rebreak.org",
|
||||||
process.env.SUPABASE_URL ??
|
|
||||||
"https://db-staging.rebreak.org",
|
|
||||||
key: process.env.SUPABASE_KEY ?? process.env.SUPABASE_ANON_KEY ?? "",
|
key: process.env.SUPABASE_KEY ?? process.env.SUPABASE_ANON_KEY ?? "",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -27,17 +27,17 @@ model Profile {
|
|||||||
// Diese Felder werden ausschließlich vom User über die Profile-Form
|
// Diese Felder werden ausschließlich vom User über die Profile-Form
|
||||||
// gesetzt — niemals durch Lyra-Extraction oder Memory-Inference.
|
// gesetzt — niemals durch Lyra-Extraction oder Memory-Inference.
|
||||||
// Siehe memory/feedback_demographics_user_initiated.md
|
// Siehe memory/feedback_demographics_user_initiated.md
|
||||||
birthYear Int? @map("birth_year")
|
birthYear Int? @map("birth_year")
|
||||||
gender String?
|
gender String?
|
||||||
maritalStatus String? @map("marital_status")
|
maritalStatus String? @map("marital_status")
|
||||||
profession String? // legacy — deprecated, nicht mehr im Frontend, DB-Spalte bleibt
|
profession String? // legacy — deprecated, nicht mehr im Frontend, DB-Spalte bleibt
|
||||||
employmentStatus String? @map("employment_status")
|
employmentStatus String? @map("employment_status")
|
||||||
shiftWork Boolean? @map("shift_work")
|
shiftWork Boolean? @map("shift_work")
|
||||||
industry String?
|
industry String?
|
||||||
jobTenure String? @map("job_tenure")
|
jobTenure String? @map("job_tenure")
|
||||||
bundesland String?
|
bundesland String?
|
||||||
city String?
|
city String?
|
||||||
demographicsConsentAt DateTime? @map("demographics_consent_at")
|
demographicsConsentAt DateTime? @map("demographics_consent_at")
|
||||||
demographicsWithdrawnAt DateTime? @map("demographics_withdrawn_at")
|
demographicsWithdrawnAt DateTime? @map("demographics_withdrawn_at")
|
||||||
|
|
||||||
// ─── Lyra Voice-Picker (Legend-only Premium) ────────────────────────────
|
// ─── Lyra Voice-Picker (Legend-only Premium) ────────────────────────────
|
||||||
@ -118,8 +118,8 @@ model Profile {
|
|||||||
//
|
//
|
||||||
// mdmDetectedAt: Zeitstempel des ersten mdmManaged=true-Writes (Audit-Trail).
|
// mdmDetectedAt: Zeitstempel des ersten mdmManaged=true-Writes (Audit-Trail).
|
||||||
// Wird nur beim Übergang false→true gesetzt, nie überschrieben.
|
// Wird nur beim Übergang false→true gesetzt, nie überschrieben.
|
||||||
mdmManaged Boolean @default(false) @map("mdm_managed")
|
mdmManaged Boolean @default(false) @map("mdm_managed")
|
||||||
mdmDetectedAt DateTime? @map("mdm_detected_at")
|
mdmDetectedAt DateTime? @map("mdm_detected_at")
|
||||||
|
|
||||||
// ─── Push-Notifications (Migration 20260530) ──────────────────────────
|
// ─── Push-Notifications (Migration 20260530) ──────────────────────────
|
||||||
// Per-User Opt-out für Chat-Push (DM + Room). Default ON. Token-spezifischer
|
// 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) ─────────────────────
|
// ─── Admin-Management (Phase E, Migration 20260509) ─────────────────────
|
||||||
// banned: User wird auf API-Ebene blockiert (kein Login-Block — Supabase
|
// banned: User wird auf API-Ebene blockiert (kein Login-Block — Supabase
|
||||||
// bleibt unberührt). Soft-Delete scrubbt PII statt Hard-Delete (DSGVO).
|
// bleibt unberührt). Soft-Delete scrubbt PII statt Hard-Delete (DSGVO).
|
||||||
banned Boolean @default(false)
|
banned Boolean @default(false)
|
||||||
bannedAt DateTime? @map("banned_at")
|
bannedAt DateTime? @map("banned_at")
|
||||||
bannedReason String? @map("banned_reason")
|
bannedReason String? @map("banned_reason")
|
||||||
deletedAt DateTime? @map("deleted_at")
|
deletedAt DateTime? @map("deleted_at")
|
||||||
|
|
||||||
communityPosts CommunityPost[]
|
communityPosts CommunityPost[]
|
||||||
communityReplies CommunityReply[]
|
communityReplies CommunityReply[]
|
||||||
@ -151,14 +151,14 @@ model Profile {
|
|||||||
// ExponentPushToken[xxx]. Token ist von Expo serverseitig unique.
|
// ExponentPushToken[xxx]. Token ist von Expo serverseitig unique.
|
||||||
// Genutzt von server/services/push.ts sendChatPush().
|
// Genutzt von server/services/push.ts sendChatPush().
|
||||||
model PushToken {
|
model PushToken {
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
userId String @map("user_id") @db.Uuid
|
userId String @map("user_id") @db.Uuid
|
||||||
token String @unique
|
token String @unique
|
||||||
platform String // "ios" | "android"
|
platform String // "ios" | "android"
|
||||||
deviceId String? @map("device_id")
|
deviceId String? @map("device_id")
|
||||||
enabled Boolean @default(true)
|
enabled Boolean @default(true)
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||||
lastUsedAt DateTime? @map("last_used_at")
|
lastUsedAt DateTime? @map("last_used_at")
|
||||||
|
|
||||||
profile Profile @relation(fields: [userId], references: [id], onDelete: Cascade)
|
profile Profile @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
@ -178,20 +178,20 @@ model PushToken {
|
|||||||
// Test-Codes (label='test_*') werden vom Seed angelegt. Wiederverwendung
|
// 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.
|
// nur per SQL-Reset: UPDATE diga_codes SET used_at=NULL, used_by_profile_id=NULL.
|
||||||
model DigaCode {
|
model DigaCode {
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
code String @unique
|
code String @unique
|
||||||
label String? // z.B. 'test_batch_2026-05' oder Krankenkassen-Code
|
label String? // z.B. 'test_batch_2026-05' oder Krankenkassen-Code
|
||||||
expiresAt DateTime? @map("expires_at")
|
expiresAt DateTime? @map("expires_at")
|
||||||
usedAt DateTime? @map("used_at")
|
usedAt DateTime? @map("used_at")
|
||||||
usedByProfileId String? @map("used_by_profile_id") @db.Uuid
|
usedByProfileId String? @map("used_by_profile_id") @db.Uuid
|
||||||
usedByProfile Profile? @relation(fields: [usedByProfileId], references: [id], onDelete: SetNull)
|
usedByProfile Profile? @relation(fields: [usedByProfileId], references: [id], onDelete: SetNull)
|
||||||
grantsPlan String @default("legend") @map("grants_plan")
|
grantsPlan String @default("legend") @map("grants_plan")
|
||||||
notes String?
|
notes String?
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
@@index([usedByProfileId])
|
@@index([usedByProfileId])
|
||||||
@@map("diga_codes")
|
@@map("diga_codes")
|
||||||
@@schema("rebreak")
|
@@schema("rebreak")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Streak {
|
model Streak {
|
||||||
@ -254,35 +254,35 @@ model SosSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model CommunityPost {
|
model CommunityPost {
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
userId String @map("user_id") @db.Uuid
|
userId String @map("user_id") @db.Uuid
|
||||||
category String
|
category String
|
||||||
content String
|
content String
|
||||||
imageUrl String? @map("image_url")
|
imageUrl String? @map("image_url")
|
||||||
upvotes Int @default(0)
|
upvotes Int @default(0)
|
||||||
likesCount Int @default(0) @map("likes_count")
|
likesCount Int @default(0) @map("likes_count")
|
||||||
dislikesCount Int @default(0) @map("dislikes_count")
|
dislikesCount Int @default(0) @map("dislikes_count")
|
||||||
commentsCount Int @default(0) @map("comments_count")
|
commentsCount Int @default(0) @map("comments_count")
|
||||||
repostsCount Int @default(0) @map("reposts_count")
|
repostsCount Int @default(0) @map("reposts_count")
|
||||||
isAnonymous Boolean @default(false) @map("is_anonymous")
|
isAnonymous Boolean @default(false) @map("is_anonymous")
|
||||||
/// Reported-Marker: true wenn ein User den Post gemeldet hat. Admin-Queue
|
/// Reported-Marker: true wenn ein User den Post gemeldet hat. Admin-Queue
|
||||||
/// listet alle Posts mit isModerated=true. Dismiss → false, Delete → bleibt
|
/// listet alle Posts mit isModerated=true. Dismiss → false, Delete → bleibt
|
||||||
/// true zusammen mit isDeleted=true (für audit/spätere Re-Review).
|
/// 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
|
/// Soft-Delete durch Moderation. content → "" damit Public-API nichts mehr
|
||||||
/// rendert; Audit-Log behält Original (siehe ModerationAction.contentSnapshot).
|
/// 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")
|
deletedAt DateTime? @map("deleted_at")
|
||||||
/// Wann der Post zum ersten Mal gemeldet wurde (queue-Sortierung).
|
/// Wann der Post zum ersten Mal gemeldet wurde (queue-Sortierung).
|
||||||
reportedAt DateTime? @map("reported_at")
|
reportedAt DateTime? @map("reported_at")
|
||||||
gameName String? @map("game_name")
|
gameName String? @map("game_name")
|
||||||
repostOfId String? @map("repost_of_id") @db.Uuid
|
repostOfId String? @map("repost_of_id") @db.Uuid
|
||||||
challengeId String? @map("challenge_id") @db.Uuid
|
challengeId String? @map("challenge_id") @db.Uuid
|
||||||
/// Template-ID aus dem Lyra-Post-Catalog (z.B. "motivation_quiet_01").
|
/// Template-ID aus dem Lyra-Post-Catalog (z.B. "motivation_quiet_01").
|
||||||
/// Nullable: Legacy-Posts ohne Template-Key nutzen content direkt.
|
/// Nullable: Legacy-Posts ohne Template-Key nutzen content direkt.
|
||||||
/// Frontend rendert t('lyra_posts.<i18nKey>') wenn gesetzt.
|
/// Frontend rendert t('lyra_posts.<i18nKey>') wenn gesetzt.
|
||||||
i18nKey String? @map("i18n_key")
|
i18nKey String? @map("i18n_key")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
author Profile? @relation(fields: [userId], references: [id])
|
author Profile? @relation(fields: [userId], references: [id])
|
||||||
repostOf CommunityPost? @relation("Reposts", fields: [repostOfId], references: [id], onDelete: SetNull)
|
repostOf CommunityPost? @relation("Reposts", fields: [repostOfId], references: [id], onDelete: SetNull)
|
||||||
@ -308,19 +308,19 @@ model PostLike {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model CommunityReply {
|
model CommunityReply {
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
postId String @map("post_id") @db.Uuid
|
postId String @map("post_id") @db.Uuid
|
||||||
userId String @map("user_id") @db.Uuid
|
userId String @map("user_id") @db.Uuid
|
||||||
content String
|
content String
|
||||||
parentReplyId String? @map("parent_reply_id") @db.Uuid
|
parentReplyId String? @map("parent_reply_id") @db.Uuid
|
||||||
isAnonymous Boolean @default(false) @map("is_anonymous")
|
isAnonymous Boolean @default(false) @map("is_anonymous")
|
||||||
likesCount Int @default(0) @map("likes_count")
|
likesCount Int @default(0) @map("likes_count")
|
||||||
/// Reported-Marker analog CommunityPost.isModerated.
|
/// Reported-Marker analog CommunityPost.isModerated.
|
||||||
isModerated Boolean @default(false) @map("is_moderated")
|
isModerated Boolean @default(false) @map("is_moderated")
|
||||||
isDeleted Boolean @default(false) @map("is_deleted")
|
isDeleted Boolean @default(false) @map("is_deleted")
|
||||||
deletedAt DateTime? @map("deleted_at")
|
deletedAt DateTime? @map("deleted_at")
|
||||||
reportedAt DateTime? @map("reported_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)
|
post CommunityPost @relation(fields: [postId], references: [id], onDelete: Cascade)
|
||||||
author Profile? @relation(fields: [userId], references: [id])
|
author Profile? @relation(fields: [userId], references: [id])
|
||||||
@ -378,16 +378,16 @@ model ChatRoomMember {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model ChatMessage {
|
model ChatMessage {
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
userId String @map("user_id") @db.Uuid
|
userId String @map("user_id") @db.Uuid
|
||||||
content String
|
content String
|
||||||
roomId String? @map("room_id") @db.Uuid
|
roomId String? @map("room_id") @db.Uuid
|
||||||
replyToId String? @map("reply_to_id") @db.Uuid
|
replyToId String? @map("reply_to_id") @db.Uuid
|
||||||
attachmentUrl String? @map("attachment_url")
|
attachmentUrl String? @map("attachment_url")
|
||||||
attachmentType String? @map("attachment_type")
|
attachmentType String? @map("attachment_type")
|
||||||
attachmentName String? @map("attachment_name")
|
attachmentName String? @map("attachment_name")
|
||||||
likesCount Int @default(0) @map("likes_count")
|
likesCount Int @default(0) @map("likes_count")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
deletedAt DateTime? @map("deleted_at")
|
deletedAt DateTime? @map("deleted_at")
|
||||||
|
|
||||||
room ChatRoom? @relation(fields: [roomId], references: [id], onDelete: Cascade)
|
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).
|
/// Source: "seed" (initiale 30 Brands) | "manual" (Admin-App) | "community" (zukünftig).
|
||||||
/// SCHEMA hinzugefügt 2026-05-28, Migration: 20260528_add_global_mail_display_names.
|
/// SCHEMA hinzugefügt 2026-05-28, Migration: 20260528_add_global_mail_display_names.
|
||||||
model GlobalMailDisplayName {
|
model GlobalMailDisplayName {
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
pattern String @unique // z.B. "Tipico", "Bet365"
|
pattern String @unique // z.B. "Tipico", "Bet365"
|
||||||
isActive Boolean @default(true) @map("is_active")
|
isActive Boolean @default(true) @map("is_active")
|
||||||
source String @default("manual") // "manual" | "seed" | "community"
|
source String @default("manual") // "manual" | "seed" | "community"
|
||||||
addedAt DateTime @default(now()) @map("added_at")
|
addedAt DateTime @default(now()) @map("added_at")
|
||||||
|
|
||||||
@@map("global_mail_display_names")
|
@@map("global_mail_display_names")
|
||||||
@@schema("rebreak")
|
@@schema("rebreak")
|
||||||
@ -662,30 +662,30 @@ model CoachSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model MailConnection {
|
model MailConnection {
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
userId String @map("user_id") @db.Uuid
|
userId String @map("user_id") @db.Uuid
|
||||||
email String
|
email String
|
||||||
provider String @default("imap")
|
provider String @default("imap")
|
||||||
providerName String? @map("provider_name")
|
providerName String? @map("provider_name")
|
||||||
imapHost String @map("imap_host")
|
imapHost String @map("imap_host")
|
||||||
imapPort Int @map("imap_port")
|
imapPort Int @map("imap_port")
|
||||||
rejectUnauthorized Boolean @default(true) @map("reject_unauthorized")
|
rejectUnauthorized Boolean @default(true) @map("reject_unauthorized")
|
||||||
useStarttls Boolean @default(false) @map("use_starttls")
|
useStarttls Boolean @default(false) @map("use_starttls")
|
||||||
passwordEncrypted String @map("password_encrypted")
|
passwordEncrypted String @map("password_encrypted")
|
||||||
isActive Boolean @default(true) @map("is_active")
|
isActive Boolean @default(true) @map("is_active")
|
||||||
/// Wenn gesetzt: Account wurde durch Downgrade-Reconciliation pausiert (nicht gelöscht).
|
/// Wenn gesetzt: Account wurde durch Downgrade-Reconciliation pausiert (nicht gelöscht).
|
||||||
/// Re-Upgrade setzt pausedAt=null + pausedReason=null + isActive=true zurück.
|
/// Re-Upgrade setzt pausedAt=null + pausedReason=null + isActive=true zurück.
|
||||||
pausedAt DateTime? @map("paused_at")
|
pausedAt DateTime? @map("paused_at")
|
||||||
pausedReason String? @map("paused_reason") // z.B. "plan_downgrade"
|
pausedReason String? @map("paused_reason") // z.B. "plan_downgrade"
|
||||||
scanInterval Int @default(24) @map("scan_interval")
|
scanInterval Int @default(24) @map("scan_interval")
|
||||||
lastScannedAt DateTime? @map("last_scanned_at")
|
lastScannedAt DateTime? @map("last_scanned_at")
|
||||||
nextScanAt DateTime? @map("next_scan_at")
|
nextScanAt DateTime? @map("next_scan_at")
|
||||||
emailsBlocked Int @default(0) @map("emails_blocked")
|
emailsBlocked Int @default(0) @map("emails_blocked")
|
||||||
emailsScanned Int @default(0) @map("emails_scanned")
|
emailsScanned Int @default(0) @map("emails_scanned")
|
||||||
lastConnectError String? @map("last_connect_error")
|
lastConnectError String? @map("last_connect_error")
|
||||||
lastConnectErrorAt DateTime? @map("last_connect_error_at")
|
lastConnectErrorAt DateTime? @map("last_connect_error_at")
|
||||||
lastIdleHeartbeatAt DateTime? @map("last_idle_heartbeat_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) ────────────────────────────
|
// ─── OAuth2-Auth-Framework (additiv, Phase 0) ────────────────────────────
|
||||||
// authMethod: 'app_password' (default, alle bestehenden Connections)
|
// authMethod: 'app_password' (default, alle bestehenden Connections)
|
||||||
@ -710,7 +710,7 @@ model MailConnection {
|
|||||||
// ─── User-definierter Anzeige-Titel (optional, max 60 chars auf API-Ebene) ──
|
// ─── User-definierter Anzeige-Titel (optional, max 60 chars auf API-Ebene) ──
|
||||||
// Wird statt der vollen Email-Adresse angezeigt (z.B. "Privat-Gmail").
|
// Wird statt der vollen Email-Adresse angezeigt (z.B. "Privat-Gmail").
|
||||||
// NULL → Frontend fällt auf Email-Domain zurück.
|
// NULL → Frontend fällt auf Email-Domain zurück.
|
||||||
title String?
|
title String?
|
||||||
|
|
||||||
// ─── Art. 9-Einwilligung (DSGVO-Compliance, Mail-Auto-Delete) ───────────
|
// ─── Art. 9-Einwilligung (DSGVO-Compliance, Mail-Auto-Delete) ───────────
|
||||||
// consentAt=NULL für Bestandsrows → "Re-Consent pending".
|
// consentAt=NULL für Bestandsrows → "Re-Consent pending".
|
||||||
@ -721,8 +721,8 @@ model MailConnection {
|
|||||||
consentVersion String? @map("consent_version")
|
consentVersion String? @map("consent_version")
|
||||||
consentIpAddress String? @map("consent_ip_address")
|
consentIpAddress String? @map("consent_ip_address")
|
||||||
|
|
||||||
blockedMails MailBlocked[]
|
blockedMails MailBlocked[]
|
||||||
blockedStats MailBlockedStat[]
|
blockedStats MailBlockedStat[]
|
||||||
|
|
||||||
@@unique([userId, email])
|
@@unique([userId, email])
|
||||||
@@map("mail_connections")
|
@@map("mail_connections")
|
||||||
@ -842,33 +842,33 @@ model MailBlocked {
|
|||||||
/// KEIN Mail-Body — nur Metadaten (Sender-Domain, Subject, Score-Komponenten).
|
/// KEIN Mail-Body — nur Metadaten (Sender-Domain, Subject, Score-Komponenten).
|
||||||
/// Cascade-Delete bei User-Löschung (Art. 17 DSGVO).
|
/// Cascade-Delete bei User-Löschung (Art. 17 DSGVO).
|
||||||
model MailClassificationSample {
|
model MailClassificationSample {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
userId String @map("user_id") @db.Uuid
|
userId String @map("user_id") @db.Uuid
|
||||||
connectionId String? @map("connection_id") @db.Uuid
|
connectionId String? @map("connection_id") @db.Uuid
|
||||||
|
|
||||||
// Raw features (was analysiert wurde):
|
// Raw features (was analysiert wurde):
|
||||||
senderName String? @map("sender_name") @db.VarChar(255)
|
senderName String? @map("sender_name") @db.VarChar(255)
|
||||||
senderDomain String? @map("sender_domain") @db.VarChar(255)
|
senderDomain String? @map("sender_domain") @db.VarChar(255)
|
||||||
relayDecodedDomain String? @map("relay_decoded_domain") @db.VarChar(255)
|
relayDecodedDomain String? @map("relay_decoded_domain") @db.VarChar(255)
|
||||||
subject String? @db.VarChar(998) // RFC 5322 max
|
subject String? @db.VarChar(998) // RFC 5322 max
|
||||||
|
|
||||||
// Computed features (Score-Komponenten als JSON):
|
// Computed features (Score-Komponenten als JSON):
|
||||||
features Json // { score, brandMatch, randomTokens, keywordHits, styleFlags, … }
|
features Json // { score, brandMatch, randomTokens, keywordHits, styleFlags, … }
|
||||||
|
|
||||||
// Outcome:
|
// Outcome:
|
||||||
finalAction String @map("final_action") // "blocked" | "passed"
|
finalAction String @map("final_action") // "blocked" | "passed"
|
||||||
triggerSource String @map("trigger_source") // "domain", "brand+random", "score:NN", "llm:0.XX", "whitelist"
|
triggerSource String @map("trigger_source") // "domain", "brand+random", "score:NN", "llm:0.XX", "whitelist"
|
||||||
|
|
||||||
// Groq verdict (nur wenn Layer 4 lief):
|
// Groq verdict (nur wenn Layer 4 lief):
|
||||||
groqIsGambling Boolean? @map("groq_is_gambling")
|
groqIsGambling Boolean? @map("groq_is_gambling")
|
||||||
groqConfidence Float? @map("groq_confidence")
|
groqConfidence Float? @map("groq_confidence")
|
||||||
groqReason String? @map("groq_reason") @db.Text
|
groqReason String? @map("groq_reason") @db.Text
|
||||||
|
|
||||||
// User-Feedback (für später):
|
// User-Feedback (für später):
|
||||||
userFeedback String? @map("user_feedback") // null | "correct" | "false-positive" | "false-negative"
|
userFeedback String? @map("user_feedback") // null | "correct" | "false-positive" | "false-negative"
|
||||||
feedbackAt DateTime? @map("feedback_at")
|
feedbackAt DateTime? @map("feedback_at")
|
||||||
|
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
@ -887,7 +887,7 @@ model MailBlockedStat {
|
|||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
userId String @map("user_id") @db.Uuid
|
userId String @map("user_id") @db.Uuid
|
||||||
/// UTC-Datum (time=00:00:00) — ein Eintrag pro User+Tag+Connection
|
/// 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
|
mailConnectionId String @map("mail_connection_id") @db.Uuid
|
||||||
/// IMAP-Host-Slug, z.B. "imap.gmail.com" (raw, für resolveProviderMeta zur Read-Zeit)
|
/// IMAP-Host-Slug, z.B. "imap.gmail.com" (raw, für resolveProviderMeta zur Read-Zeit)
|
||||||
provider String @map("provider")
|
provider String @map("provider")
|
||||||
@ -1052,24 +1052,24 @@ model UserDevice {
|
|||||||
// ─── Device-Account-Lock ────────────────────────────────────────────────
|
// ─── Device-Account-Lock ────────────────────────────────────────────────
|
||||||
/// Plan des Users zum Zeitpunkt der Bindung. NULL → kein Lock aktiv.
|
/// Plan des Users zum Zeitpunkt der Bindung. NULL → kein Lock aktiv.
|
||||||
/// Lock gilt wenn: boundToPlan != null AND releaseRequestedAt + 24h > NOW()
|
/// 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.
|
/// Wann der Original-User "Gerät freigeben" beantragt hat.
|
||||||
/// NULL → noch kein Release-Request. Freigabe wird aktiv nach +24h.
|
/// 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.
|
/// 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 ────────────────────────────────────
|
// ─── RebreakMagic DNS-Device-Binding ────────────────────────────────────
|
||||||
/// 48+ char URL-safe random token für DNS-over-HTTPS Client-ID.
|
/// 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.
|
/// 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).
|
/// 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).
|
/// Killswitch: wenn gesetzt → DNS-Token serverseitig invalidiert (AdGuard-Client deleted).
|
||||||
/// User kann Config-Profil manuell deinstallieren, aber DNS-Queries werden abgelehnt.
|
/// 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.
|
/// 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])
|
@@unique([userId, deviceId])
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@ -1081,15 +1081,15 @@ model UserDevice {
|
|||||||
/// RebreakMagic Pairing — Native-App generiert 6-stelligen Code, Mac-App tauscht
|
/// RebreakMagic Pairing — Native-App generiert 6-stelligen Code, Mac-App tauscht
|
||||||
/// gegen MagicSession-Token. Code ist single-use, läuft nach 10min ab.
|
/// gegen MagicSession-Token. Code ist single-use, läuft nach 10min ab.
|
||||||
model MagicPairingCode {
|
model MagicPairingCode {
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
userId String @map("user_id") @db.Uuid
|
userId String @map("user_id") @db.Uuid
|
||||||
/// 6-stelliger numerischer Code (z.B. "482913"). Unique während gültig.
|
/// 6-stelliger numerischer Code (z.B. "482913"). Unique während gültig.
|
||||||
code String @unique
|
code String @unique
|
||||||
expiresAt DateTime @map("expires_at")
|
expiresAt DateTime @map("expires_at")
|
||||||
/// Wenn redeemed: Zeitpunkt + erstellte MagicSession-ID. Code danach nicht mehr nutzbar.
|
/// Wenn redeemed: Zeitpunkt + erstellte MagicSession-ID. Code danach nicht mehr nutzbar.
|
||||||
redeemedAt DateTime? @map("redeemed_at")
|
redeemedAt DateTime? @map("redeemed_at")
|
||||||
sessionId String? @map("session_id") @db.Uuid
|
sessionId String? @map("session_id") @db.Uuid
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([expiresAt])
|
@@index([expiresAt])
|
||||||
@ -1101,15 +1101,15 @@ model MagicPairingCode {
|
|||||||
/// der statt Supabase-JWT in /api/magic/* Endpoints akzeptiert wird.
|
/// der statt Supabase-JWT in /api/magic/* Endpoints akzeptiert wird.
|
||||||
/// Wird in Mac-Keychain gespeichert, kann vom User in Native-App revoked werden.
|
/// Wird in Mac-Keychain gespeichert, kann vom User in Native-App revoked werden.
|
||||||
model MagicSession {
|
model MagicSession {
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
userId String @map("user_id") @db.Uuid
|
userId String @map("user_id") @db.Uuid
|
||||||
/// "mgc_" + 48 char base64url (token = id wird NICHT preisgegeben).
|
/// "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").
|
/// Optionaler Mac-Hostname für User-UI ("Chahines MacBook Pro").
|
||||||
label String?
|
label String?
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
lastUsedAt DateTime @default(now()) @map("last_used_at")
|
lastUsedAt DateTime @default(now()) @map("last_used_at")
|
||||||
revokedAt DateTime? @map("revoked_at")
|
revokedAt DateTime? @map("revoked_at")
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([token])
|
@@index([token])
|
||||||
@ -1117,7 +1117,6 @@ model MagicSession {
|
|||||||
@@schema("rebreak")
|
@@schema("rebreak")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Wenn ein neues Gerät versucht sich zu registrieren UND das Device-Limit
|
// 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
|
// erreicht ist (oder User Approval explizit wünscht), erstellt das neue Gerät
|
||||||
// eine DeviceApprovalRequest mit 6-stelligem Code. Andere aktive Geräte des
|
// 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
|
// ä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
|
// 5. Neues Device pollt GET /api/devices/approvals/:id → status=approved → retry register
|
||||||
model DeviceApprovalRequest {
|
model DeviceApprovalRequest {
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
userId String @map("user_id") @db.Uuid
|
userId String @map("user_id") @db.Uuid
|
||||||
|
|
||||||
// Info über das NEUE Gerät (das sich anmelden will)
|
// Info über das NEUE Gerät (das sich anmelden will)
|
||||||
newDeviceId String @map("new_device_id")
|
newDeviceId String @map("new_device_id")
|
||||||
newPlatform String @map("new_platform")
|
newPlatform String @map("new_platform")
|
||||||
newModel String? @map("new_model")
|
newModel String? @map("new_model")
|
||||||
newName String? @map("new_name")
|
newName String? @map("new_name")
|
||||||
newOsVersion String? @map("new_os_version")
|
newOsVersion String? @map("new_os_version")
|
||||||
|
|
||||||
/// 6-stelliger Code (z.B. "123456"). Wird auf BEIDEN Geräten gezeigt für
|
/// 6-stelliger Code (z.B. "123456"). Wird auf BEIDEN Geräten gezeigt für
|
||||||
/// visuellen Vergleich (iCloud-Pattern). Plain-text gespeichert weil
|
/// visuellen Vergleich (iCloud-Pattern). Plain-text gespeichert weil
|
||||||
/// kurzlebig (10min TTL) und an userId gebunden.
|
/// kurzlebig (10min TTL) und an userId gebunden.
|
||||||
code String
|
code String
|
||||||
|
|
||||||
/// pending | approved | rejected | expired
|
/// pending | approved | rejected | expired
|
||||||
status String @default("pending")
|
status String @default("pending")
|
||||||
|
|
||||||
/// Welches existing Device hat approved (für Audit-Log).
|
/// Welches existing Device hat approved (für Audit-Log).
|
||||||
/// NULL wenn via Email-Link approved.
|
/// NULL wenn via Email-Link approved.
|
||||||
@ -1165,14 +1164,14 @@ model DeviceApprovalRequest {
|
|||||||
evictedDeviceRowId String? @map("evicted_device_row_id") @db.Uuid
|
evictedDeviceRowId String? @map("evicted_device_row_id") @db.Uuid
|
||||||
|
|
||||||
/// Wann Email mit Approval-Link/Code verschickt wurde (Rate-Limit: 1x pro Request).
|
/// 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).
|
/// One-Time-Token für Approval via Email-Link (statt App-Approval).
|
||||||
/// 32-char hex. NULL bis email-fallback getriggert.
|
/// 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.
|
/// Approval läuft nach 10 Minuten ab.
|
||||||
expiresAt DateTime @map("expires_at")
|
expiresAt DateTime @map("expires_at")
|
||||||
|
|
||||||
@@index([userId, status])
|
@@index([userId, status])
|
||||||
@@index([userId, createdAt(sort: Desc)])
|
@@index([userId, createdAt(sort: Desc)])
|
||||||
|
|||||||
@ -496,10 +496,20 @@ export default defineEventHandler(async (event) => {
|
|||||||
// "antworte IMMER auf X" — wenn User mitten im Chat die Sprache wechselte,
|
// "antworte IMMER auf X" — wenn User mitten im Chat die Sprache wechselte,
|
||||||
// antwortete Lyra weiter in der App-Sprache. Jetzt: match user.
|
// antwortete Lyra weiter in der App-Sprache. Jetzt: match user.
|
||||||
const LANG_NAMES: Record<string, string> = {
|
const LANG_NAMES: Record<string, string> = {
|
||||||
de: "German", en: "English", tr: "Turkish", ar: "Arabic",
|
de: "German",
|
||||||
fr: "French", es: "Spanish", it: "Italian", pt: "Portuguese",
|
en: "English",
|
||||||
ru: "Russian", ja: "Japanese", ko: "Korean", zh: "Chinese",
|
tr: "Turkish",
|
||||||
he: "Hebrew", th: "Thai",
|
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 lastUserMsg = [...messages].reverse().find((m) => m.role === "user");
|
||||||
const detectedFromUser = detectLang(lastUserMsg?.content ?? "", locale);
|
const detectedFromUser = detectLang(lastUserMsg?.content ?? "", locale);
|
||||||
@ -533,23 +543,37 @@ export default defineEventHandler(async (event) => {
|
|||||||
const demoLines: string[] = [];
|
const demoLines: string[] = [];
|
||||||
if (profile?.birthYear) {
|
if (profile?.birthYear) {
|
||||||
const age = new Date().getFullYear() - 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) {
|
if (profile?.gender) {
|
||||||
const GENDER_LABEL: Record<string, string> = {
|
const GENDER_LABEL: Record<string, string> = {
|
||||||
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) {
|
if (profile?.maritalStatus) {
|
||||||
const MS_LABEL: Record<string, string> = {
|
const MS_LABEL: Record<string, string> = {
|
||||||
single: "ledig", partnered: "in Partnerschaft", married: "verheiratet",
|
single: "ledig",
|
||||||
divorced: "geschieden", widowed: "verwitwet", no_answer: "keine Angabe",
|
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?.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 (profile?.city) demoLines.push(`- Stadt: ${profile.city}`);
|
||||||
if (demoLines.length > 0) {
|
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`;
|
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) {
|
if (memories.length > 0) {
|
||||||
loadedMemoryIds = memories.map((m) => m.id);
|
loadedMemoryIds = memories.map((m) => m.id);
|
||||||
const TYPE_LABELS: Record<string, string> = {
|
const TYPE_LABELS: Record<string, string> = {
|
||||||
trigger: "Trigger", habit: "Gewohnheit", strength: "Stärke",
|
trigger: "Trigger",
|
||||||
relationship: "Wichtige Person", milestone: "Meilenstein",
|
habit: "Gewohnheit",
|
||||||
pain_point: "Sensibles Thema", goal: "Ziel", preference: "Präferenz",
|
strength: "Stärke",
|
||||||
|
relationship: "Wichtige Person",
|
||||||
|
milestone: "Meilenstein",
|
||||||
|
pain_point: "Sensibles Thema",
|
||||||
|
goal: "Ziel",
|
||||||
|
preference: "Präferenz",
|
||||||
};
|
};
|
||||||
const lines = memories
|
const lines = memories
|
||||||
.map((m) => `- ${TYPE_LABELS[m.type] ?? m.type}: ${m.content}`)
|
.map((m) => `- ${TYPE_LABELS[m.type] ?? m.type}: ${m.content}`)
|
||||||
.join("\n");
|
.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`;
|
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}`;
|
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) {
|
} catch (e) {
|
||||||
console.error("[lyra-memory] load error (non-fatal):", e);
|
console.error("[lyra-memory] load error (non-fatal):", e);
|
||||||
@ -585,7 +616,13 @@ export default defineEventHandler(async (event) => {
|
|||||||
where: { userId: user.id },
|
where: { userId: user.id },
|
||||||
orderBy: { updatedAt: "desc" },
|
orderBy: { updatedAt: "desc" },
|
||||||
take: 10,
|
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) {
|
if (feedbackItems.length > 0) {
|
||||||
const STATUS_LABELS: Record<string, string> = {
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
@ -598,7 +635,9 @@ export default defineEventHandler(async (event) => {
|
|||||||
const feedbackLines = feedbackItems
|
const feedbackLines = feedbackItems
|
||||||
.map((f) => {
|
.map((f) => {
|
||||||
const statusLabel = STATUS_LABELS[f.status] ?? f.status;
|
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}`;
|
return ` - Idee: "${f.content}"${f.category ? ` [${f.category}]` : ""}\n Status: ${statusLabel}${note}`;
|
||||||
})
|
})
|
||||||
.join("\n");
|
.join("\n");
|
||||||
@ -613,17 +652,24 @@ export default defineEventHandler(async (event) => {
|
|||||||
// (smarter Fallback) → OpenAI gpt-4o-mini (Last-Resort, anderer Provider).
|
// (smarter Fallback) → OpenAI gpt-4o-mini (Last-Resort, anderer Provider).
|
||||||
// OpenRouter + Groq sind aktuell ohne Quota/Credits — entfernt aus Chain.
|
// OpenRouter + Groq sind aktuell ohne Quota/Credits — entfernt aus Chain.
|
||||||
const planRaw = (profile?.plan ?? "free").toLowerCase();
|
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";
|
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[] = [
|
const candidates: Candidate[] = [
|
||||||
{ provider: "gemini", model: "gemini-2.5-flash-lite" },
|
{ provider: "gemini", model: "gemini-2.5-flash-lite" },
|
||||||
{ provider: "gemini", model: "gemini-2.5-flash" },
|
{ provider: "gemini", model: "gemini-2.5-flash" },
|
||||||
{ provider: "openai", model: "gpt-4o-mini" },
|
{ 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 p = PROVIDER_CONFIG[providerName];
|
||||||
const key = config[p.keyName];
|
const key = config[p.keyName];
|
||||||
if (!key) return null;
|
if (!key) return null;
|
||||||
@ -699,8 +745,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
...messages,
|
...messages,
|
||||||
{ role: "assistant" as const, content: text },
|
{ role: "assistant" as const, content: text },
|
||||||
];
|
];
|
||||||
const key =
|
const key = config.openrouterApiKey as string | undefined;
|
||||||
config.openrouterApiKey as string | undefined;
|
|
||||||
extractAndStoreMemories(user.id, allMessages, undefined, key).catch(
|
extractAndStoreMemories(user.id, allMessages, undefined, key).catch(
|
||||||
() => {},
|
() => {},
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
|
import { listMagicDevices } from "../../db/devices";
|
||||||
import { listMagicDevices } from '../../db/devices';
|
import { requireUser } from "../../utils/auth";
|
||||||
import { requireUser } from '../../utils/auth';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/magic/devices
|
* GET /api/magic/devices
|
||||||
|
|||||||
@ -1,5 +1,3 @@
|
|||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/magic/devices/[deviceId]/cancel-release
|
* POST /api/magic/devices/[deviceId]/cancel-release
|
||||||
*
|
*
|
||||||
@ -7,12 +5,12 @@
|
|||||||
*/
|
*/
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const user = await requireUser(event);
|
const user = await requireUser(event);
|
||||||
const deviceId = getRouterParam(event, 'deviceId');
|
const deviceId = getRouterParam(event, "deviceId");
|
||||||
|
|
||||||
if (!deviceId) {
|
if (!deviceId) {
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 400,
|
statusCode: 400,
|
||||||
message: 'deviceId required',
|
message: "deviceId required",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -32,7 +30,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
if (!device || !device.magicEnrolledAt || device.magicRevokedAt) {
|
if (!device || !device.magicEnrolledAt || device.magicRevokedAt) {
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 404,
|
statusCode: 404,
|
||||||
message: 'Magic-Binding nicht gefunden oder bereits revoked',
|
message: "Magic-Binding nicht gefunden oder bereits revoked",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,3 @@
|
|||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/magic/devices/[deviceId]/request-release
|
* POST /api/magic/devices/[deviceId]/request-release
|
||||||
*
|
*
|
||||||
@ -10,12 +8,12 @@
|
|||||||
*/
|
*/
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const user = await requireUser(event);
|
const user = await requireUser(event);
|
||||||
const deviceId = getRouterParam(event, 'deviceId');
|
const deviceId = getRouterParam(event, "deviceId");
|
||||||
|
|
||||||
if (!deviceId) {
|
if (!deviceId) {
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 400,
|
statusCode: 400,
|
||||||
message: 'deviceId required',
|
message: "deviceId required",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -35,7 +33,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
if (!device || !device.magicEnrolledAt || device.magicRevokedAt) {
|
if (!device || !device.magicEnrolledAt || device.magicRevokedAt) {
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 404,
|
statusCode: 404,
|
||||||
message: 'Magic-Binding nicht gefunden oder bereits revoked',
|
message: "Magic-Binding nicht gefunden oder bereits revoked",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -10,10 +10,10 @@ export default defineEventHandler(() => {
|
|||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
latestVersion: '0.1.0',
|
latestVersion: "0.1.0",
|
||||||
downloadUrl: 'https://rebreak.org/download/rebreakmagic',
|
downloadUrl: "https://rebreak.org/download/rebreakmagic",
|
||||||
dmgUrl: 'https://rebreak.org/downloads/RebreakMagic-latest.dmg',
|
dmgUrl: "https://rebreak.org/downloads/RebreakMagic-latest.dmg",
|
||||||
minMacosVersion: '13.0',
|
minMacosVersion: "13.0",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { randomInt } from 'crypto';
|
import { randomInt } from "crypto";
|
||||||
import { requireUser } from '../../../utils/auth';
|
import { requireUser } from "../../../utils/auth";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/magic/pair/create
|
* POST /api/magic/pair/create
|
||||||
@ -29,7 +29,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
let code: string | null = null;
|
let code: string | null = null;
|
||||||
let attempts = 0;
|
let attempts = 0;
|
||||||
while (attempts < 5 && code === null) {
|
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({
|
const exists = await db.magicPairingCode.findUnique({
|
||||||
where: { code: candidate },
|
where: { code: candidate },
|
||||||
select: { id: true, expiresAt: true, redeemedAt: true },
|
select: { id: true, expiresAt: true, redeemedAt: true },
|
||||||
@ -53,7 +53,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
if (!code) {
|
if (!code) {
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 500,
|
statusCode: 500,
|
||||||
message: 'Konnte keinen freien Pairing-Code generieren',
|
message: "Konnte keinen freien Pairing-Code generieren",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { randomBytes } from 'crypto';
|
import { randomBytes } from "crypto";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/magic/pair/redeem
|
* POST /api/magic/pair/redeem
|
||||||
@ -17,7 +17,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
if (!code || !/^\d{6}$/.test(code)) {
|
if (!code || !/^\d{6}$/.test(code)) {
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 400,
|
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) {
|
if (!pairingCode) {
|
||||||
throw createError({ statusCode: 404, message: 'Code ungültig' });
|
throw createError({ statusCode: 404, message: "Code ungültig" });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pairingCode.redeemedAt !== null) {
|
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()) {
|
if (pairingCode.expiresAt < new Date()) {
|
||||||
throw createError({ statusCode: 410, message: 'Code abgelaufen' });
|
throw createError({ statusCode: 410, message: "Code abgelaufen" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generiere Session-Token
|
// Generiere Session-Token
|
||||||
const token = 'mgc_' + randomBytes(36).toString('base64url');
|
const token = "mgc_" + randomBytes(36).toString("base64url");
|
||||||
|
|
||||||
const session = await db.magicSession.create({
|
const session = await db.magicSession.create({
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@ -1,8 +1,7 @@
|
|||||||
|
import { randomUUID } from "crypto";
|
||||||
import { randomUUID } from 'crypto';
|
import { readFile } from "fs/promises";
|
||||||
import { readFile } from 'fs/promises';
|
import { resolve } from "path";
|
||||||
import { resolve } from 'path';
|
import { findMagicDeviceByToken } from "../../db/devices";
|
||||||
import { findMagicDeviceByToken } from '../../db/devices';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/magic/profile.mobileconfig?token=<dnsToken>
|
* GET /api/magic/profile.mobileconfig?token=<dnsToken>
|
||||||
@ -24,7 +23,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
if (!token) {
|
if (!token) {
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 400,
|
statusCode: 400,
|
||||||
message: 'token query parameter required',
|
message: "token query parameter required",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -33,56 +32,50 @@ export default defineEventHandler(async (event) => {
|
|||||||
if (!device) {
|
if (!device) {
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 404,
|
statusCode: 404,
|
||||||
message: 'Invalid or revoked DNS token',
|
message: "Invalid or revoked DNS token",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Template lesen
|
// Template lesen
|
||||||
const templatePath = resolve(
|
const templatePath = resolve(
|
||||||
process.cwd(),
|
process.cwd(),
|
||||||
'ops/mdm/rebreak-mac-dns-filter.mobileconfig',
|
"ops/mdm/rebreak-mac-dns-filter.mobileconfig",
|
||||||
);
|
);
|
||||||
let template: string;
|
let template: string;
|
||||||
try {
|
try {
|
||||||
template = await readFile(templatePath, 'utf-8');
|
template = await readFile(templatePath, "utf-8");
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('[Magic] Failed to read profile template:', err);
|
console.error("[Magic] Failed to read profile template:", err);
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 500,
|
statusCode: 500,
|
||||||
message: 'Profile template not found',
|
message: "Profile template not found",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ServerURL ersetzen: /dns-query → /dns-query/{token}
|
// ServerURL ersetzen: /dns-query → /dns-query/{token}
|
||||||
const personalizedProfile = template
|
const personalizedProfile = template
|
||||||
.replace(
|
.replace(
|
||||||
'https://dns.rebreak.org/dns-query',
|
"https://dns.rebreak.org/dns-query",
|
||||||
`https://dns.rebreak.org/dns-query/${token}`,
|
`https://dns.rebreak.org/dns-query/${token}`,
|
||||||
)
|
)
|
||||||
// PayloadUUID neu generieren (2 Stellen im Template)
|
// PayloadUUID neu generieren (2 Stellen im Template)
|
||||||
.replace(
|
.replace("7D2E8B1A-C3D4-4E76-8B23-A4B5C6D7E8F0", randomUUID().toUpperCase())
|
||||||
'7D2E8B1A-C3D4-4E76-8B23-A4B5C6D7E8F0',
|
.replace("8C3F9A2B-D4E5-4F87-9A12-B5C6D7E8F901", randomUUID().toUpperCase())
|
||||||
randomUUID().toUpperCase(),
|
|
||||||
)
|
|
||||||
.replace(
|
|
||||||
'8C3F9A2B-D4E5-4F87-9A12-B5C6D7E8F901',
|
|
||||||
randomUUID().toUpperCase(),
|
|
||||||
)
|
|
||||||
// PayloadIdentifier unique machen (optional, verhindert Konflikt bei Multi-Device)
|
// PayloadIdentifier unique machen (optional, verhindert Konflikt bei Multi-Device)
|
||||||
.replace(
|
.replace(
|
||||||
'org.rebreak.protection.dns.filter',
|
"org.rebreak.protection.dns.filter",
|
||||||
`org.rebreak.protection.dns.filter.${device.deviceId.slice(0, 8)}`,
|
`org.rebreak.protection.dns.filter.${device.deviceId.slice(0, 8)}`,
|
||||||
)
|
)
|
||||||
.replace(
|
.replace(
|
||||||
'org.rebreak.protection.profile',
|
"org.rebreak.protection.profile",
|
||||||
`org.rebreak.protection.profile.${device.deviceId.slice(0, 8)}`,
|
`org.rebreak.protection.profile.${device.deviceId.slice(0, 8)}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Response-Headers
|
// Response-Headers
|
||||||
setHeader(event, 'Content-Type', 'application/x-apple-aspen-config');
|
setHeader(event, "Content-Type", "application/x-apple-aspen-config");
|
||||||
setHeader(
|
setHeader(
|
||||||
event,
|
event,
|
||||||
'Content-Disposition',
|
"Content-Disposition",
|
||||||
`attachment; filename="RebreakMagic-${device.deviceId.slice(0, 8)}.mobileconfig"`,
|
`attachment; filename="RebreakMagic-${device.deviceId.slice(0, 8)}.mobileconfig"`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
|
import { randomBytes } from "crypto";
|
||||||
import { randomBytes } from 'crypto';
|
import { countActiveMagicBindings, listMagicDevices, MAGIC_DEVICE_LIMIT } from "../../db/devices";
|
||||||
import { countActiveMagicBindings, listMagicDevices } from '../../db/devices';
|
import { requireUser } from "../../utils/auth";
|
||||||
import { requireUser } from '../../utils/auth';
|
import { createAdGuardClient } from "../../utils/adguard";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/magic/register
|
* POST /api/magic/register
|
||||||
@ -27,7 +27,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
if (!deviceId || !hostname) {
|
if (!deviceId || !hostname) {
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 400,
|
statusCode: 400,
|
||||||
message: 'deviceId und hostname required',
|
message: "deviceId und hostname required",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -71,7 +71,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
statusCode: 409,
|
statusCode: 409,
|
||||||
message: `Magic-Device-Limit erreicht (max ${MAGIC_DEVICE_LIMIT})`,
|
message: `Magic-Device-Limit erreicht (max ${MAGIC_DEVICE_LIMIT})`,
|
||||||
data: {
|
data: {
|
||||||
code: 'limit_reached',
|
code: "limit_reached",
|
||||||
activeBindings,
|
activeBindings,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -79,7 +79,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. Generiere DNS-Token (48 char base64url-safe)
|
// 3. Generiere DNS-Token (48 char base64url-safe)
|
||||||
const dnsToken = randomBytes(36).toString('base64url');
|
const dnsToken = randomBytes(36).toString("base64url");
|
||||||
|
|
||||||
// 4. Provisioniere AdGuard Client
|
// 4. Provisioniere AdGuard Client
|
||||||
const adguardClientName = `magic_${deviceId}`;
|
const adguardClientName = `magic_${deviceId}`;
|
||||||
@ -92,10 +92,10 @@ export default defineEventHandler(async (event) => {
|
|||||||
blocked_services: [], // TODO: Gambling-Filter via AdGuard Blocked-Services
|
blocked_services: [], // TODO: Gambling-Filter via AdGuard Blocked-Services
|
||||||
});
|
});
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('[Magic] AdGuard provisioning failed:', err);
|
console.error("[Magic] AdGuard provisioning failed:", err);
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 502,
|
statusCode: 502,
|
||||||
message: 'DNS-Provisioning fehlgeschlagen',
|
message: "DNS-Provisioning fehlgeschlagen",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -105,7 +105,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
create: {
|
create: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
deviceId,
|
deviceId,
|
||||||
platform: 'macos',
|
platform: "macos",
|
||||||
model: model ?? null,
|
model: model ?? null,
|
||||||
name: hostname,
|
name: hostname,
|
||||||
osVersion: osVersion ?? null,
|
osVersion: osVersion ?? null,
|
||||||
|
|||||||
@ -64,7 +64,9 @@ export async function findActiveDeviceLock(
|
|||||||
|
|
||||||
// Wenn Release-Request existiert und 24h abgelaufen → Lock ist released
|
// Wenn Release-Request existiert und 24h abgelaufen → Lock ist released
|
||||||
if (row.releaseRequestedAt) {
|
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;
|
if (releaseAt <= new Date()) return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -147,10 +149,12 @@ export async function cancelDeviceRelease(
|
|||||||
*/
|
*/
|
||||||
export async function markDeviceLockNotified(rowId: string): Promise<void> {
|
export async function markDeviceLockNotified(rowId: string): Promise<void> {
|
||||||
const db = usePrisma();
|
const db = usePrisma();
|
||||||
await db.userDevice.update({
|
await db.userDevice
|
||||||
where: { id: rowId },
|
.update({
|
||||||
data: { lockNotifiedAt: new Date() },
|
where: { id: rowId },
|
||||||
}).catch(() => {});
|
data: { lockNotifiedAt: new Date() },
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -204,11 +208,7 @@ export async function listUserDevices(userId: string): Promise<DeviceRecord[]> {
|
|||||||
const db = usePrisma();
|
const db = usePrisma();
|
||||||
return db.userDevice.findMany({
|
return db.userDevice.findMany({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
orderBy: [
|
orderBy: [{ lastSeenAt: "desc" }, { createdAt: "desc" }, { id: "asc" }],
|
||||||
{ lastSeenAt: "desc" },
|
|
||||||
{ createdAt: "desc" },
|
|
||||||
{ id: "asc" },
|
|
||||||
],
|
|
||||||
select: DEVICE_SELECT,
|
select: DEVICE_SELECT,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -319,7 +319,11 @@ export async function registerDevice(opts: {
|
|||||||
|
|
||||||
// Merge-Heuristik: IDFV-Reset nach iOS Recovery-Restore.
|
// Merge-Heuristik: IDFV-Reset nach iOS Recovery-Restore.
|
||||||
// Wenn name+model matchen und Device zuletzt < 30 Tage gesehen → merge statt insert.
|
// 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) {
|
if (mergeTarget) {
|
||||||
const merged = await db.userDevice.update({
|
const merged = await db.userDevice.update({
|
||||||
where: { id: mergeTarget.id },
|
where: { id: mergeTarget.id },
|
||||||
@ -368,10 +372,19 @@ export async function registerDevice(opts: {
|
|||||||
export async function touchDevice(
|
export async function touchDevice(
|
||||||
userId: string,
|
userId: string,
|
||||||
deviceId: string,
|
deviceId: string,
|
||||||
info?: { name?: string | null; model?: string | null; osVersion?: string | null },
|
info?: {
|
||||||
|
name?: string | null;
|
||||||
|
model?: string | null;
|
||||||
|
osVersion?: string | null;
|
||||||
|
},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const db = usePrisma();
|
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(),
|
lastSeenAt: new Date(),
|
||||||
};
|
};
|
||||||
if (info?.name) data.name = info.name;
|
if (info?.name) data.name = info.name;
|
||||||
@ -388,7 +401,10 @@ export async function touchDevice(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** User entfernt ein eigenes Device — gibt Slot frei. */
|
/** User entfernt ein eigenes Device — gibt Slot frei. */
|
||||||
export async function deleteUserDevice(userId: string, id: string): Promise<void> {
|
export async function deleteUserDevice(
|
||||||
|
userId: string,
|
||||||
|
id: string,
|
||||||
|
): Promise<void> {
|
||||||
const db = usePrisma();
|
const db = usePrisma();
|
||||||
await db.userDevice.deleteMany({ where: { id, userId } });
|
await db.userDevice.deleteMany({ where: { id, userId } });
|
||||||
}
|
}
|
||||||
@ -413,7 +429,9 @@ export interface MagicDeviceRecord {
|
|||||||
* Listet alle aktiven Magic-Bindings eines Users.
|
* Listet alle aktiven Magic-Bindings eines Users.
|
||||||
* Aktiv = magicEnrolledAt != null AND magicRevokedAt == null.
|
* Aktiv = magicEnrolledAt != null AND magicRevokedAt == null.
|
||||||
*/
|
*/
|
||||||
export async function listMagicDevices(userId: string): Promise<MagicDeviceRecord[]> {
|
export async function listMagicDevices(
|
||||||
|
userId: string,
|
||||||
|
): Promise<MagicDeviceRecord[]> {
|
||||||
const db = usePrisma();
|
const db = usePrisma();
|
||||||
const devices = await db.userDevice.findMany({
|
const devices = await db.userDevice.findMany({
|
||||||
where: {
|
where: {
|
||||||
@ -445,7 +463,9 @@ export async function listMagicDevices(userId: string): Promise<MagicDeviceRecor
|
|||||||
/**
|
/**
|
||||||
* Zählt aktive Magic-Bindings für Limit-Check.
|
* Zählt aktive Magic-Bindings für Limit-Check.
|
||||||
*/
|
*/
|
||||||
export async function countActiveMagicBindings(userId: string): Promise<number> {
|
export async function countActiveMagicBindings(
|
||||||
|
userId: string,
|
||||||
|
): Promise<number> {
|
||||||
const db = usePrisma();
|
const db = usePrisma();
|
||||||
return db.userDevice.count({
|
return db.userDevice.count({
|
||||||
where: {
|
where: {
|
||||||
@ -461,7 +481,7 @@ export async function countActiveMagicBindings(userId: string): Promise<number>
|
|||||||
*/
|
*/
|
||||||
export async function findMagicDeviceByToken(
|
export async function findMagicDeviceByToken(
|
||||||
token: string,
|
token: string,
|
||||||
): Promise<DeviceRecord & { magicDnsToken: string } | null> {
|
): Promise<(DeviceRecord & { magicDnsToken: string }) | null> {
|
||||||
const db = usePrisma();
|
const db = usePrisma();
|
||||||
const device = await db.userDevice.findUnique({
|
const device = await db.userDevice.findUnique({
|
||||||
where: {
|
where: {
|
||||||
@ -484,4 +504,3 @@ export async function findMagicDeviceByToken(
|
|||||||
magicDnsToken: device.magicDnsToken!,
|
magicDnsToken: device.magicDnsToken!,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -41,14 +41,14 @@ export async function createAdGuardClient(
|
|||||||
options: AdGuardClientOptions = {},
|
options: AdGuardClientOptions = {},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const config = useRuntimeConfig();
|
const config = useRuntimeConfig();
|
||||||
const baseUrl = config.adguardBaseUrl || 'https://dns.rebreak.org';
|
const baseUrl = config.adguardBaseUrl || "https://dns.rebreak.org";
|
||||||
const user = config.adguardUser;
|
const user = config.adguardUser;
|
||||||
const password = config.adguardPassword;
|
const password = config.adguardPassword;
|
||||||
|
|
||||||
if (!user || !password) {
|
if (!user || !password) {
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 500,
|
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,
|
...options,
|
||||||
};
|
};
|
||||||
|
|
||||||
const authHeader = `Basic ${Buffer.from(`${user}:${password}`).toString('base64')}`;
|
const authHeader = `Basic ${Buffer.from(`${user}:${password}`).toString("base64")}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await $fetch(`${baseUrl}/control/clients/add`, {
|
const response = await $fetch(`${baseUrl}/control/clients/add`, {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': authHeader,
|
Authorization: authHeader,
|
||||||
'Content-Type': 'application/json',
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: payload,
|
body: payload,
|
||||||
});
|
});
|
||||||
return response as void;
|
return response as void;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('[AdGuard] Client creation failed:', err);
|
console.error("[AdGuard] Client creation failed:", err);
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 502,
|
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<void> {
|
export async function deleteAdGuardClient(name: string): Promise<void> {
|
||||||
const config = useRuntimeConfig();
|
const config = useRuntimeConfig();
|
||||||
const baseUrl = config.adguardBaseUrl || 'https://dns.rebreak.org';
|
const baseUrl = config.adguardBaseUrl || "https://dns.rebreak.org";
|
||||||
const user = config.adguardUser;
|
const user = config.adguardUser;
|
||||||
const password = config.adguardPassword;
|
const password = config.adguardPassword;
|
||||||
|
|
||||||
if (!user || !password) {
|
if (!user || !password) {
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 500,
|
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 {
|
try {
|
||||||
const response = await $fetch(`${baseUrl}/control/clients/delete`, {
|
const response = await $fetch(`${baseUrl}/control/clients/delete`, {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': authHeader,
|
Authorization: authHeader,
|
||||||
'Content-Type': 'application/json',
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: { name },
|
body: { name },
|
||||||
});
|
});
|
||||||
return response as void;
|
return response as void;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('[AdGuard] Client deletion failed:', err);
|
console.error("[AdGuard] Client deletion failed:", err);
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 502,
|
statusCode: 502,
|
||||||
message: `AdGuard API error: ${err.message || 'unknown'}`,
|
message: `AdGuard API error: ${err.message || "unknown"}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
import { createClient } from '@supabase/supabase-js';
|
import { createClient } from "@supabase/supabase-js";
|
||||||
import type { H3Event } from 'h3';
|
import type { H3Event } from "h3";
|
||||||
import { isAdminUser } from '../db/admin';
|
import { isAdminUser } from "../db/admin";
|
||||||
import { findUserDevice, registerDevice, touchDevice } from '../db/devices';
|
import { findUserDevice, registerDevice, touchDevice } from "../db/devices";
|
||||||
import { getProfile } from '../db/profile';
|
import { getProfile } from "../db/profile";
|
||||||
import { getPlanLimits } from './plan-features';
|
import { getPlanLimits } from "./plan-features";
|
||||||
|
|
||||||
const TOUCH_THROTTLE_MS = 60_000; // touch lastSeenAt höchstens 1×/min pro Device
|
const TOUCH_THROTTLE_MS = 60_000; // touch lastSeenAt höchstens 1×/min pro Device
|
||||||
|
|
||||||
@ -16,8 +16,8 @@ export async function requireUser(
|
|||||||
event: H3Event,
|
event: H3Event,
|
||||||
opts: RequireUserOptions = {},
|
opts: RequireUserOptions = {},
|
||||||
) {
|
) {
|
||||||
const authHeader = getHeader(event, 'authorization');
|
const authHeader = getHeader(event, "authorization");
|
||||||
let token = authHeader?.replace('Bearer ', '');
|
let token = authHeader?.replace("Bearer ", "");
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
const query = getQuery(event);
|
const query = getQuery(event);
|
||||||
@ -25,7 +25,7 @@ export async function requireUser(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
throw createError({ statusCode: 401, message: 'Nicht eingeloggt' });
|
throw createError({ statusCode: 401, message: "Nicht eingeloggt" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── RebreakMagic-Session-Token (mgc_*) ──────────────────────────────────
|
// ─── 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
|
// beim Pairing erhalten hat. Diese Tokens sind nur für /api/magic/* gültig
|
||||||
// und unsere requireUser-Funktion akzeptiert sie überall — Endpoint-Layer
|
// und unsere requireUser-Funktion akzeptiert sie überall — Endpoint-Layer
|
||||||
// ist verantwortlich Magic-Tokens nur dort zuzulassen wo sinnvoll.
|
// ist verantwortlich Magic-Tokens nur dort zuzulassen wo sinnvoll.
|
||||||
if (token.startsWith('mgc_')) {
|
if (token.startsWith("mgc_")) {
|
||||||
const db = usePrisma();
|
const db = usePrisma();
|
||||||
const session = await db.magicSession.findUnique({
|
const session = await db.magicSession.findUnique({
|
||||||
where: { token },
|
where: { token },
|
||||||
select: { id: true, userId: true, revokedAt: true },
|
select: { id: true, userId: true, revokedAt: true },
|
||||||
});
|
});
|
||||||
if (!session || session.revokedAt) {
|
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
|
// Touch lastUsedAt fire-and-forget
|
||||||
db.magicSession
|
db.magicSession
|
||||||
@ -68,25 +68,29 @@ export async function requireUser(
|
|||||||
} = await client.auth.getUser();
|
} = await client.auth.getUser();
|
||||||
|
|
||||||
if (error || !user) {
|
if (error || !user) {
|
||||||
throw createError({ statusCode: 401, message: 'Nicht eingeloggt' });
|
throw createError({ statusCode: 401, message: "Nicht eingeloggt" });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (opts.skipDeviceCheck) return user;
|
if (opts.skipDeviceCheck) return user;
|
||||||
|
|
||||||
// Device-Binding: nur enforced wenn Client einen x-device-id Header schickt.
|
// Device-Binding: nur enforced wenn Client einen x-device-id Header schickt.
|
||||||
// Web-Clients ohne Header laufen weiter wie bisher.
|
// 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;
|
if (!deviceId) return user;
|
||||||
|
|
||||||
// Frontend sendet name/model/osVersion als x-device-* Headers (URL-encoded)
|
// Frontend sendet name/model/osVersion als x-device-* Headers (URL-encoded)
|
||||||
function readEncoded(name: string): string | null {
|
function readEncoded(name: string): string | null {
|
||||||
const raw = getHeader(event, name);
|
const raw = getHeader(event, name);
|
||||||
if (!raw) return null;
|
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 deviceName = readEncoded("x-device-name");
|
||||||
const deviceModel = readEncoded('x-device-model');
|
const deviceModel = readEncoded("x-device-model");
|
||||||
const deviceOs = readEncoded('x-device-os');
|
const deviceOs = readEncoded("x-device-os");
|
||||||
|
|
||||||
const existing = await findUserDevice(user.id, deviceId);
|
const existing = await findUserDevice(user.id, deviceId);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
@ -105,8 +109,8 @@ export async function requireUser(
|
|||||||
// Device unbekannt — Auto-Register (ohne Frontend-explicit-call)
|
// Device unbekannt — Auto-Register (ohne Frontend-explicit-call)
|
||||||
// Plan-Limit holen
|
// Plan-Limit holen
|
||||||
const profile = await getProfile(user.id);
|
const profile = await getProfile(user.id);
|
||||||
const limits = getPlanLimits(profile?.plan ?? 'free');
|
const limits = getPlanLimits(profile?.plan ?? "free");
|
||||||
const platform = getHeader(event, 'x-platform') ?? 'unknown';
|
const platform = getHeader(event, "x-platform") ?? "unknown";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await registerDevice({
|
await registerDevice({
|
||||||
@ -120,19 +124,19 @@ export async function requireUser(
|
|||||||
});
|
});
|
||||||
return user;
|
return user;
|
||||||
} catch (err: any) {
|
} 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
|
// Devices-Liste mitschicken damit das Frontend-Modal die Geräte
|
||||||
// anzeigen + Freigeben-Buttons rendern kann (auch wenn der 403
|
// anzeigen + Freigeben-Buttons rendern kann (auch wenn der 403
|
||||||
// nicht vom register-Endpoint sondern vom auth-Middleware kommt).
|
// 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);
|
const devices = await listUserDevices(user.id);
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 403,
|
statusCode: 403,
|
||||||
statusMessage: 'device_limit_reached',
|
statusMessage: "device_limit_reached",
|
||||||
data: {
|
data: {
|
||||||
error: 'device_limit_reached',
|
error: "device_limit_reached",
|
||||||
max: limits.maxAppDevices,
|
max: limits.maxAppDevices,
|
||||||
plan: profile?.plan ?? 'free',
|
plan: profile?.plan ?? "free",
|
||||||
devices,
|
devices,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -150,7 +154,7 @@ export async function requireAdmin(event: H3Event) {
|
|||||||
|
|
||||||
const admin = await isAdminUser(user.id);
|
const admin = await isAdminUser(user.id);
|
||||||
if (!admin) {
|
if (!admin) {
|
||||||
throw createError({ statusCode: 403, message: 'Kein Admin-Zugang' });
|
throw createError({ statusCode: 403, message: "Kein Admin-Zugang" });
|
||||||
}
|
}
|
||||||
|
|
||||||
return user;
|
return user;
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { usePrisma } from './prisma';
|
import { usePrisma } from "./prisma";
|
||||||
import { deleteAdGuardClient } from './adguard';
|
import { deleteAdGuardClient } from "./adguard";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cron-Worker für RebreakMagic Release-Requests (24h Cooldown).
|
* Cron-Worker für RebreakMagic Release-Requests (24h Cooldown).
|
||||||
|
|||||||
@ -13,6 +13,7 @@ keine Hotline, keine Self-Help-Predigerin. Eine Verbündete im Moment.
|
|||||||
## Modes
|
## Modes
|
||||||
|
|
||||||
### SOS-Crisis-Mode (`#sos`)
|
### SOS-Crisis-Mode (`#sos`)
|
||||||
|
|
||||||
- Surface: SOS-Flow (Atem-Sheet, Spiele, Streaming-Chat aus `sos-stream.get.ts`)
|
- 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.
|
- Tonfall: einfühlsam, ruhig, präsent. 1-2 Sätze, max 3.
|
||||||
- Validiert ZUERST das Gefühl, dann sanfte Frage ODER Vorschlag.
|
- 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).
|
- Schluss-Marker: `[[CHIPS]]:[{...}]` (Format vom Backend gesteuert).
|
||||||
|
|
||||||
### Coach-Casual-Mode (`#coach`)
|
### Coach-Casual-Mode (`#coach`)
|
||||||
|
|
||||||
- Surface: Coach-Tab (`message.post.ts`)
|
- Surface: Coach-Tab (`message.post.ts`)
|
||||||
- Tonfall: warm, neugierig, persönlich, gern mit Mini-Humor.
|
- Tonfall: warm, neugierig, persönlich, gern mit Mini-Humor.
|
||||||
- Antwort-Länge bis 4-5 Sätze wenn Kontext es trägt.
|
- 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
|
## Vokabular DE
|
||||||
|
|
||||||
Erlaubt:
|
Erlaubt:
|
||||||
|
|
||||||
- "Impuls", "Verlangen", "Drang", "Phase", "Herausforderung", "Kampf"
|
- "Impuls", "Verlangen", "Drang", "Phase", "Herausforderung", "Kampf"
|
||||||
- "Begleitung", "Begleiter"
|
- "Begleitung", "Begleiter"
|
||||||
- "in der Falle der Gambling-Industrie"
|
- "in der Falle der Gambling-Industrie"
|
||||||
@ -37,6 +40,7 @@ Erlaubt:
|
|||||||
- "Trigger-Seite"
|
- "Trigger-Seite"
|
||||||
|
|
||||||
Verboten:
|
Verboten:
|
||||||
|
|
||||||
- "Sucht", "Spielsucht", "süchtig", "Abhängigkeit", "Suchtkranker"
|
- "Sucht", "Spielsucht", "süchtig", "Abhängigkeit", "Suchtkranker"
|
||||||
- "Therapie" als Behauptung über sich selbst
|
- "Therapie" als Behauptung über sich selbst
|
||||||
- "Patient", "krank", "Krankheit"
|
- "Patient", "krank", "Krankheit"
|
||||||
@ -46,12 +50,14 @@ Verboten:
|
|||||||
## Vokabular EN
|
## Vokabular EN
|
||||||
|
|
||||||
Erlaubt:
|
Erlaubt:
|
||||||
|
|
||||||
- "urge", "impulse", "phase", "challenge"
|
- "urge", "impulse", "phase", "challenge"
|
||||||
- "companion", "support"
|
- "companion", "support"
|
||||||
- "caught by the gambling industry"
|
- "caught by the gambling industry"
|
||||||
- "trigger site"
|
- "trigger site"
|
||||||
|
|
||||||
Verboten:
|
Verboten:
|
||||||
|
|
||||||
- "addiction", "addicted", "addict"
|
- "addiction", "addicted", "addict"
|
||||||
- "treatment" (als Selbstbeschreibung)
|
- "treatment" (als Selbstbeschreibung)
|
||||||
- "patient", "sick", "illness"
|
- "patient", "sick", "illness"
|
||||||
@ -69,6 +75,7 @@ Verboten:
|
|||||||
## Schutz-Architektur (Wissensstand 2026-05-25 nach Country-Pivot + MDM-VPN-Pivot)
|
## Schutz-Architektur (Wissensstand 2026-05-25 nach Country-Pivot + MDM-VPN-Pivot)
|
||||||
|
|
||||||
### iOS — zwei Schutzschichten
|
### iOS — zwei Schutzschichten
|
||||||
|
|
||||||
- Schicht 1 — URL-Filter (Hauptschutz): geräteweit, blockt rund 330.000 bekannte
|
- Schicht 1 — URL-Filter (Hauptschutz): geräteweit, blockt rund 330.000 bekannte
|
||||||
Glücksspielseiten direkt am iPhone.
|
Glücksspielseiten direkt am iPhone.
|
||||||
- Schicht 2 — VIP-Liste (Zweitschutz, Country-Curated):
|
- Schicht 2 — VIP-Liste (Zweitschutz, Country-Curated):
|
||||||
@ -79,10 +86,12 @@ Verboten:
|
|||||||
damit du auch dort geschützt bist, wo du gerade bist."
|
damit du auch dort geschützt bist, wo du gerade bist."
|
||||||
|
|
||||||
### Android — VPN + Bedienungshilfen
|
### Android — VPN + Bedienungshilfen
|
||||||
|
|
||||||
- Lokales DNS-VPN (Traffic verlässt das Gerät nicht) + Accessibility-Service
|
- Lokales DNS-VPN (Traffic verlässt das Gerät nicht) + Accessibility-Service
|
||||||
als Manipulationsschutz. 6-Stunden-Cooldown beim Deaktivieren.
|
als Manipulationsschutz. 6-Stunden-Cooldown beim Deaktivieren.
|
||||||
|
|
||||||
### Custom-Domains (User-pflegbar, Layer 1)
|
### Custom-Domains (User-pflegbar, Layer 1)
|
||||||
|
|
||||||
- Pro: **10 Slots**, refillable
|
- Pro: **10 Slots**, refillable
|
||||||
- Legend: **20 Slots**, refillable
|
- Legend: **20 Slots**, refillable
|
||||||
- Gemeinsamer Pool für web + mail
|
- Gemeinsamer Pool für web + mail
|
||||||
@ -96,6 +105,7 @@ Verboten:
|
|||||||
frei). Es gibt kein User-Swap mehr in Layer 2 (Country-Pivot).
|
frei). Es gibt kein User-Swap mehr in Layer 2 (Country-Pivot).
|
||||||
|
|
||||||
### Selbstbindungs-Schutz via RebreakMagic (iOS/macOS, optional)
|
### Selbstbindungs-Schutz via RebreakMagic (iOS/macOS, optional)
|
||||||
|
|
||||||
- **Neue Architektur (Stand 2026-06-02, KEIN Detail an User):**
|
- **Neue Architektur (Stand 2026-06-02, KEIN Detail an User):**
|
||||||
RebreakMagic ist eine kleine macOS-Companion-App. User lädt sie von
|
RebreakMagic ist eine kleine macOS-Companion-App. User lädt sie von
|
||||||
rebreak.org/download/rebreakmagic. Sie installiert ein **DNS-Konfigurations-Profil**
|
rebreak.org/download/rebreakmagic. Sie installiert ein **DNS-Konfigurations-Profil**
|
||||||
@ -114,6 +124,7 @@ Verboten:
|
|||||||
- **Setup-Dauer**: ~2 Minuten via USB-Kabel.
|
- **Setup-Dauer**: ~2 Minuten via USB-Kabel.
|
||||||
|
|
||||||
#### Wie Lyra darüber spricht
|
#### Wie Lyra darüber spricht
|
||||||
|
|
||||||
- User-Sprache: "RebreakMagic", "unsere Mac-App", "besonders stabiler Schutz-Modus",
|
- User-Sprache: "RebreakMagic", "unsere Mac-App", "besonders stabiler Schutz-Modus",
|
||||||
"24-Stunden-Wartezeit", "deine Vertrauensperson".
|
"24-Stunden-Wartezeit", "deine Vertrauensperson".
|
||||||
- **STRIKT VERBOTEN gegenüber User** (intern OK): `MDM`, `supervised`, `NEFilter`,
|
- **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.
|
NICHT im Chat auflisten, auf In-App-Help/Download-Seite verweisen.
|
||||||
|
|
||||||
#### Wann Lyra RebreakMagic empfiehlt
|
#### Wann Lyra RebreakMagic empfiehlt
|
||||||
|
|
||||||
- **Coach-Mode**:
|
- **Coach-Mode**:
|
||||||
- User fragt direkt nach „stärkerem Schutz" / „App nicht deinstallierbar" /
|
- User fragt direkt nach „stärkerem Schutz" / „App nicht deinstallierbar" /
|
||||||
„Filter unausschaltbar" → empfehlen.
|
„Filter unausschaltbar" → empfehlen.
|
||||||
@ -150,22 +162,22 @@ Verboten:
|
|||||||
|
|
||||||
## Voice-Picker (Legend-only, ElevenLabs)
|
## Voice-Picker (Legend-only, ElevenLabs)
|
||||||
|
|
||||||
| voiceId | Label DE | Label EN | Persona-Note |
|
| voiceId | Label DE | Label EN | Persona-Note |
|
||||||
|------------|-----------------------|-----------------------|-------------------------|
|
| --------- | ---------------- | ----------------- | ---------------------- |
|
||||||
| sarah | Sarah (warm) | Sarah (warm) | sanft, mütterlich |
|
| sarah | Sarah (warm) | Sarah (warm) | sanft, mütterlich |
|
||||||
| aria | Aria (ruhig) | Aria (calm) | strukturiert, klar |
|
| aria | Aria (ruhig) | Aria (calm) | strukturiert, klar |
|
||||||
| charlotte | Charlotte (klar) | Charlotte (clear) | präzise, professionell |
|
| charlotte | Charlotte (klar) | Charlotte (clear) | präzise, professionell |
|
||||||
| alice | Alice (nüchtern) | Alice (sober) | erdig, ohne Pathos |
|
| alice | Alice (nüchtern) | Alice (sober) | erdig, ohne Pathos |
|
||||||
| bill | Bill (tief) | Bill (deep) | tief, ruhig, männlich |
|
| bill | Bill (tief) | Bill (deep) | tief, ruhig, männlich |
|
||||||
|
|
||||||
## Forbidden-Phrases-Audit-Liste
|
## Forbidden-Phrases-Audit-Liste
|
||||||
|
|
||||||
Beim Edit von Lyra-Strings gegen diese Liste prüfen:
|
Beim Edit von Lyra-Strings gegen diese Liste prüfen:
|
||||||
|
|
||||||
DE Pathologisierung: `Sucht`, `süchtig`, `Suchtkranker`, `Spielsucht`, `Abhängigkeit`,
|
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`,
|
EN Pathologisierung: `addiction`, `addicted`, `addict`, `treatment` (about self), `patient`,
|
||||||
`illness`, `disease`
|
`illness`, `disease`
|
||||||
|
|
||||||
RebreakMagic-Tech (gegenüber User STRIKT verboten, intern OK):
|
RebreakMagic-Tech (gegenüber User STRIKT verboten, intern OK):
|
||||||
`MDM`, `supervised`, `Supervise`, `Supervise-Mode`, `NEFilter`, `Configuration Profile`, `Profile-Payload`,
|
`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
|
## Mode-Tag-Konvention
|
||||||
|
|
||||||
- `#sos` — betrifft Crisis-Mode (sos-stream, urge.*, chips.*)
|
- `#sos` — betrifft Crisis-Mode (sos-stream, urge._, chips._)
|
||||||
- `#coach` — betrifft Casual-Mode (message.post, coach.*, lyra.* casual)
|
- `#coach` — betrifft Casual-Mode (message.post, coach._, lyra._ casual)
|
||||||
- `#shared` — betrifft beide Modi (z.B. Pflicht-Regeln, Schutz-Wissen, Voice-Labels)
|
- `#shared` — betrifft beide Modi (z.B. Pflicht-Regeln, Schutz-Wissen, Voice-Labels)
|
||||||
|
|
||||||
## Pricing (Stand 2026-05-29) — `#coach` only
|
## Pricing (Stand 2026-05-29) — `#coach` only
|
||||||
|
|
||||||
**Kein Free-Tier mehr.** Es gibt nur noch zwei Stufen + 14-Tage-Trial.
|
**Kein Free-Tier mehr.** Es gibt nur noch zwei Stufen + 14-Tage-Trial.
|
||||||
|
|
||||||
| Plan | Preis | Geräte | Mail-Konten | Lyra | Support |
|
| Plan | Preis | Geräte | Mail-Konten | Lyra | Support |
|
||||||
|--------|--------------|---------------------------------------|------------------------|-------------------|----------|
|
| ------ | ------------ | ------------------------------------- | ------------------------- | ------------------------------------- | -------- |
|
||||||
| Pro | 3,99 €/Monat | 1 | 2 | Standard (Groq) | Standard |
|
| 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 |
|
| 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.
|
- **Trial**: 14 Tage, danach Pflicht-Auswahl Pro oder Legend.
|
||||||
- **Checkout**: Stripe-Web-Checkout — explizit KEIN In-App-Purchase über Apple/Google
|
- **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.
|
„Meine Geräte" zum Verwalten.
|
||||||
|
|
||||||
Plattform-Schutz pro Gerät (passives Wissen — nicht ungefragt aufzählen):
|
Plattform-Schutz pro Gerät (passives Wissen — nicht ungefragt aufzählen):
|
||||||
|
|
||||||
- iOS: NEFilter, ~330k Domains
|
- iOS: NEFilter, ~330k Domains
|
||||||
- Android: lokales DNS-VPN + Accessibility-Service
|
- Android: lokales DNS-VPN + Accessibility-Service
|
||||||
- macOS: DNS-Profile
|
- 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
|
- „Upgrade jetzt!", „nur 3,99 €" → werblicher Ton, ersetzen mit sachlicher Formulierung
|
||||||
- „In-App-Kauf" als Option → es gibt nur Stripe-Web-Checkout
|
- „In-App-Kauf" als Option → es gibt nur Stripe-Web-Checkout
|
||||||
- „polling", „Intervall-Scan" für Mail → Mail ist IMAP-IDLE-Daemon
|
- „polling", „Intervall-Scan" für Mail → Mail ist IMAP-IDLE-Daemon
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user