81 Commits

Author SHA1 Message Date
chahinebrini
1ae86c03f4 feat(lyra): coach system prompt v3 — Lock-Modus Setup-Steps (Safari + AirDrop)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 01:22:25 +02:00
chahinebrini
4c31be94b1 feat(lyra): coach system prompt — Self-Bind-Detection + Raynis-Branding + MDM-Korrektur
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 01:15:37 +02:00
chahinebrini
8e562c982d feat(backend): MDM-Managed Flag — migration + endpoint + guards
- Prisma migration: users.mdm_managed (Boolean DEFAULT false) + users.mdm_detected_at (DateTime?)
- setMdmManaged() helper in server/db/profile.ts
- POST /api/users/me/mdm-status — App reports MDM status to backend
- cooldown/status + cooldown/request — early-return 400 when mdm_managed
- protection/state — response extended with mdmManaged: boolean

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 00:46:44 +02:00
chahinebrini
8f2ef2cc98 feat(mdm,vip): MDM-VPN-Pivot + Layer-2-Country-Curated + Custom-Domain-Refactor
MDM-VPN-Pivot (Phase F.2 done):
- ops/mdm/profiles/rebreak-iphone-protection.mobileconfig auf v5 mit
  com.apple.vpn.managed Payload + OnDemandUserOverrideDisabled. iPhone-User
  kann ReBreak-VPN-Profile nicht entfernen und "Bedarf verbinden"-Toggle
  ist disabled. allowEnablingRestrictions empirisch widerlegt für FC-Toggle-
  Lock — out.
- DEV-removable Variante als Test-Profile dazu.
- Bootstrap-Tool (rebreak-supervise.sh) + Supervision-Identity-Setup-Doc.
- PHASES.md updated mit empirischen Befunden.

App-side MDM-Detect (Pfad-a Banner-Logic):
- modules/rebreak-protection: getDeviceState() returnt mdmManaged via
  Heuristik NETunnelProviderManager.count > 1 (App selbst kann nur einen
  eigenen erstellen, MDM-Push fügt einen zweiten hinzu).
- DeviceLayers.mdmManaged?: boolean Type.
- blocker.tsx: lockedIn-Bedingung erweitert um mdmManaged. Bei MDM-managed
  iPhones wird der App-Lock-Card (FC-Authorization-Toggle UI) ausgeblendet
  weil der per-App FC-Toggle nicht lockbar ist und durch den MDM-VPN-Layer
  redundant.

Layer-2-Country-Curated-Pivot:
- backend: vip-swap.post.ts raus, suggest.post.ts rein. Curated-domains
  durch admin (separate Tabelle/Pfad), getrennt von User-Custom-Domains.
- Admin-APIs für curated-domain Pflege (index.get + [id].patch).
- seed-country-blocklists Script für initiale Curated-Domain-Liste.
- protection/webcontent-domains.get refactored für Country-Curated-Pfad.
- Migration drop_vip_swap_fields.sql + schema.prisma adjusted.
- docs/concepts/layer2-country-pivot.md mit Architektur + Decision-Trail.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 07:11:47 +02:00
chahinebrini
dc841b4275 feat(coach): Lyra kennt die iOS-Schutz-Architektur (Layer 1/2 + VIP)
Coach-Prompt (SOS + Casual) um das Zwei-Schichten-Wissen ergänzt:
Layer 1 = URL-Filter (~330k Domains), Layer 2 = VIP-Liste (Zweitschutz,
50/Land + Custom-Domains). Stale NEFilterDataProvider-Erwähnungen korrigiert.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 21:09:00 +02:00
chahinebrini
265859467a feat(vip): Curated-Domain-Vorschläge — Suggest-Backend
User können länderspezifische Glücksspielseiten für die kuratierte
VIP-Layer-2-Liste vorschlagen — wichtig für Länder mit kurzer
Starter-Liste (z.B. TN).

- Schema: CuratedDomain (domain, country, status, suggestedByUserId);
  Migration 20260522_curated_domains
- webcontent-domains.get.ts komponiert jetzt JSON-Basis + DB-approved
  Curated-Domains pro Land
- POST /api/curated-domains/suggest legt einen suggested-Eintrag an

Admin-Approve (Endpoint + Admin-App-View) folgt als nächster Block.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 21:09:00 +02:00
chahinebrini
f555c5e4d8 feat(vip): Tunesien (TN) als VIP-Land + kuratierte Starter-Liste
TN-User fielen bisher mangels TN-Liste auf die DE-Liste zurück. Jetzt
eigene (kurze) TN-Starter-Liste: mbet216.com, 2xbet365.com, cesar365.com,
icombet.com, unibet365.net (von einem TN-Test-User gemeldet).

TN in COUNTRY_KEYS (webcontent-Endpoint) + VIP_COUNTRIES (Geräte-Region-
Auflösung + Add-Check). Native Region-Logik ist generisch (Locale.region
→ JSON-Key) — kein Native-Code nötig. gambling-domains.json _meta v3.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 20:52:20 +02:00
chahinebrini
efa3e04c4e test(vip): MAX_VIP_CUSTOM temporär 30→3 für Swap-Dialog-Test
⚠️ TEMP — damit der VIP-Swap-Dialog schon ab der 4. Web-Domain triggert
statt erst ab 31. NACH DEM TEST auf 30 zurücksetzen.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 20:13:34 +02:00
chahinebrini
93eb3aceec feat(vip): VIP-Slot-Replace Backend — Swap mit 24h-Cooldown
Wenn die VIP-Liste (Layer 2) voll ist (>30 eigene Web-Domains) und der
User eine neue Custom-Domain hinzufügt, ersetzt er bewusst eine
bestehende — der Tausch greift in der VIP erst nach 24h Cooldown.

- Schema: UserCustomDomain.vipDeferUntil + vipEvictAt
  (Migration 20260522_add_vip_swap_fields, additiv + nullable)
- getWebCustomDomains: filtert deferred (noch nicht in VIP) + evicted
  (Cooldown durch → raus) — lazy ausgewertet, kein Cron
- POST /api/custom-domains: neue Web-Domain über dem 30er-Cap → wird
  zurückgestellt (vipDeferUntil gesetzt), Response-Flag vipFull
- POST /api/custom-domains/vip-swap: setzt effectiveAt = jetzt+24h auf
  neue + ersetzte Domain
- Layer 1 bleibt unberührt — die neue Domain ist dort sofort aktiv

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 20:05:44 +02:00
chahinebrini
704958320b refactor(domains): gemeinsamer 10/20-Slot-Pool, Free-Tier entfernt
Custom-Domain-Slots sind jetzt EIN gemeinsamer Pool für web + mail
(Pro 10 / Legend 20) statt getrennter web/mail-Buckets. Free-Tier ist
entfallen — PLAN_LIMITS hat nur noch pro + legend, getPlanLimits
defaultet auf pro.

Backend:
- plan-features: customDomains ist eine Zahl (CustomDomainLimits weg)
- index.post: Slot-Check gegen Gesamt-Count, Fehler einheitlich LIMIT_REACHED
- index.get: liefert { items, count, limit }
- change-preview + coach/message an die neue Form angepasst

Frontend:
- useCustomDomains: count/limit (Zahlen) statt countsByType/limits
- AddDomainSheet: ein generischer Limit-Hinweis (error_limit_reached)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 18:40:28 +02:00
chahinebrini
fe156a5f58 feat(blocker/vip): Freigabe-Button, landabhängige VIP-Liste, Hybrid-Komposition + Add-Check
Blocker-UI:
- FilterTile: Trash-Button → status-aware Freigabe-Button (Freigeben/Erneut/
  in-Prüfung); RemoveDomainSheet entfernt — kein Domain-Entfernen mehr in der UI
- VIP-Liste landabhängig: zeigt die komponierte Endpoint-Liste statt nur
  eigener Customs; Land über Geräte-Region (expo-localization)
- VIP-Realtime: refetch bei Domain-Add/Approve/Reject, pulsierender Ring
  für neue/active/submitted Chips

VIP-Komposition (webcontent-domains):
- Hybrid: Customs auf 30 gekappt, 20 Slots fest für die kuratierte Top-Liste
  reserviert — Customs können die Top-Gambling-Domains nicht verdrängen

Add-Check (custom-domains POST), für web reaktiviert — 3 Fälle gegen
Layer 1 (global) + Layer 2 (kuratierte VIP):
- weder global noch kuratiert → normaler active-Eintrag
- global + kuratiert → alreadyProtected, kein Slot
- global, nicht kuratiert → inGlobalNotVip; per addToVip als status=approved
  speicherbar (kein Slot, nur VIP-Liste)

DE-Gambling-Liste 30→36, nach Relevanz sortiert (erste 20 = reservierte Plätze)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 17:27:10 +02:00
chahinebrini
ced749018b fix(custom-domains): web-Domains nicht am alreadyGlobal-Check abweisen
Der alreadyGlobal-Pre-Check (Domain schon in der 208k-Layer-1-Blocklist →
kein Custom-Slot) gilt jetzt nur noch fuer Mail-Typen. Fuer type='web'
uebersprungen: Web-Custom-Domains speisen die Layer-2-VIP-Liste, eine separate
Schicht — eine global (Layer 1) gelistete Domain muss in die VIP koennen,
gerade weil Layer 2 das Netz ist, wenn Layer 1 aus ist.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 22:14:18 +02:00
chahinebrini
bc65c7172c fix(protection): webcontent-domains — fehlenden getWebCustomDomains-Import
Der Endpoint nutzte getWebCustomDomains ohne Import → ReferenceError → HTTP 500.
server/db/ ist nicht auto-importiert (nur server/utils/), daher expliziter
Import wie in allen anderen 15+ db/domains-Konsumenten.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 21:53:49 +02:00
chahinebrini
9dfccfcbaa fix(protection): webcontent-domains-Endpoint konsolidiert — VIP-Komposition
Ersetzt die statische v1 des Endpoints durch die per-User-VIP-Komposition:
eigene Web-Custom-Domains zuerst + globale Auffuellung → dedup → Cap 50.
v1-Reste entfernt (backend/data/, serverAssets-Eintrag) — eine Datenquelle
(backend/server/data/gambling-domains.json, direkter JSON-Import).
pnpm build:backend gruen verifiziert.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 21:21:22 +02:00
chahinebrini
86ed603a45 feat(protection): GET /api/protection/webcontent-domains — runtime-updatebare Layer-2-Domain-Liste
Neuer Nitro-Endpoint serviert die kuratierte Gambling-Domain-Liste pro Land
(DE/GB/FR) aus backend/data/gambling-domains.json. Auth wie alle
/api/protection/*-Routen (requireUser). 50-Domain-Cap pro Land serverseitig
erzwungen. Liste pflegen = JSON editieren + _meta.version hochzaehlen + deploy.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 20:21:54 +02:00
chahinebrini
34005803da fix(protection): cooldown-elapse must set protectionDisabledAt in state endpoint
Bug: nach Cooldown-Ablauf reaktivierte sich der Schutz automatisch.

Root-Cause: Race zwischen zwei Endpoints die abgelaufene Cooldowns auflösen.
/api/cooldown/status setzt korrekt profile.protectionDisabledAt. /api/
protection/state — alle 5s während Cooldown gepollt — machte beim Ablauf
NUR resolveCooldown(), ohne protectionDisabledAt. state.get gewann das Race
fast immer → protectionDisabledAt blieb null → protectionShouldBeActive=true
→ Frontend-Bypass-Detection reaktivierte den Schutz.

Fix: state.get.ts setzt im expired-Branch ebenfalls protectionDisabledAt +
cooldownJustResolved-Flag.
2026-05-20 04:17:24 +02:00
chahinebrini
bdfcc40a6c feat(auth-hook): send-email hook for dynamic sender-name + i18n subject
GoTrue-Send-Email-Hook (v2.155+) → backend rendert Mails selbst, schickt
via Brevo Transactional API. Vorteil: pro Mail-Typ × Locale eigener
Sender-Display-Name UND Subject (GoTrue's eingebauter Mailer ist global
statisch).

Files:
- backend/server/utils/mail/templates.ts — 5 mail-types × 4 locales
  Sender-Name, Subject, Title, Body, HelpText, Privacy-Label.
  HTML-Renderer mit Nunito + Icon + DSGVO-Footer (analog public/templates).
- backend/server/utils/mail/brevo.ts — Brevo Transactional API client
  (POST /v3/smtp/email, separate REST-API-Key vom SMTP-Key)
- backend/server/api/auth-hooks/send-email.post.ts — Endpoint mit
  Standard-Webhooks Signature-Verify (HMAC-SHA256, timing-safe compare,
  multi-secret-rotation support)
- backend/nitro.config.ts — runtimeConfig: brevoApiKey,
  hookSendEmailSecrets, mailSenderEmail

Requires (außerhalb dieses commits):
- Infisical staging: BREVO_API_KEY (✓), HOOK_SEND_EMAIL_SECRETS (✓)
- docker-compose: GOTRUE_HOOK_SEND_EMAIL_{ENABLED,URI,SECRETS} (✓)
- GoTrue upgrade v2.154 → v2.189 (✓, mit auth.factor_type owner-transfer)
2026-05-19 18:04:14 +02:00
chahinebrini
19b569927a fix(presence): missing imports in 3 endpoints
Same pattern as touchLastSeen: getLastSeenBatch, setPresenceVisible,
getFollowingIds wurden im db/profile.ts implementiert aber nicht in den
Endpoints importiert. Alle 3 warfen 500 ReferenceError → grüner Dot
zeigte sich nie + Toggle silently failed.

Nitro's auto-import covered nur defineEventHandler/getQuery etc., NICHT
unsere eigenen db-layer helper.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 07:16:18 +02:00
chahinebrini
c89f541069 fix(presence): missing touchLastSeen import in /api/me/last-seen
Endpoint warf 500 jedem Heartbeat-Call (ReferenceError) — Floodete
pm2-Logs. Import war im DB-Layer-File implementiert aber nicht vom
Endpoint importiert.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 06:35:11 +02:00
chahinebrini
0ca0afb1e1 feat(presence): online-status backend (Phase 1)
Insta-style Online-Status mit Following-Filter + User-opt-out:
- Profile.lastSeenAt + Profile.presenceVisible (default true)
- GET /api/presence/last-seen?userIds=... batch, server-side filter
  durch Follow-Relation + presenceVisible
- GET /api/me/following → User-IDs für client-side Channel-Filter
  (Supabase Realtime Presence hat keine server-side Filter)
- POST /api/me/presence-visibility Toggle
- POST /api/me/last-seen Heartbeat (Phase-1-Fallback bis Edge-Function)
- /api/auth/me extended um presenceVisible für Settings-Initial-State

DB-Layer nutzt raw SQL bis Migration auf staging gelaufen ist
(Prisma-Client refresh erst nach CI generate).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 06:23:08 +02:00
chahinebrini
3c73a8b44a feat(community/posts): ?userId= query filter
Foreign-Profile-Page rendert User-Posts jetzt via PostCard mit dem
gleichen Daten-Shape wie Index. `?userId=<uuid>` filtert auf Posts
dieses Users — kombinierbar mit `category=...`, Bot-Kategorie-Mapping
wird übersteuert wenn explizite userId mitgegeben.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 04:06:13 +02:00
chahinebrini
bca00e03a5 feat(social/profile): approvedDomainsCount in foreign-profile response
Frontend Foreign-Profile-Page rendert 3 Stat-Cards (Posts/Followers/
Approved) — counter für approved DomainSubmissions hat im Endpoint
gefehlt → undefined im UI.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 03:45:17 +02:00
chahinebrini
c6e2116084 feat(community): admin/lyra-post Multi-Locale (motivation/tipp/etc.)
User-Bug: Admin-getriggerter Lyra-Post (Topic 'motivation' o.ä.) zeigte
trotz FR-locale im Frontend immer Deutsch. Root: LYRA_SYSTEM_PROMPT hatte
hartcodiert 'Auf Deutsch' → Groq output war immer DE.

Selbe Multi-Locale-Pattern wie approve.post.ts:
- 4 parallele Groq-Calls (Promise.allSettled) für de/en/fr/ar
- System-Prompt nimmt Lang-Parameter: 'Antwort AUSSCHLIESSLICH in <lang>'
- Content als JSON {de,en,fr,ar} gespeichert
- customContent-Path (Admin tippt selbst Text) bleibt plain — Admin schreibt
  in seiner gewählten Sprache, PostCard zeigt allen Usern denselben Text

Frontend (PostCard.tsx) parsed JSON bereits richtig via
resolveLocalizedJsonContent (vorheriger Commit 44a3348).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 00:44:05 +02:00
chahinebrini
44a3348845 feat(community): Domain-Approval-Lyra-Posts multi-locale (de/en/fr/ar)
Bug: User mit FR-locale sahen Lyra-Confirmation-Posts trotzdem auf Deutsch
(Banner/Tabs richtig FR). Root: approve.post.ts generierte den Text via
Groq mit hartcodiertem 'auf Deutsch'-Prompt, speicherte als plain content.

Server (approve.post.ts):
- 4 parallele Groq-Calls (Promise.allSettled) — de + en + fr + ar
- Per-Locale-PROMPT_CFG mit subject/action/statsLine/thanksSegment-Texten
- Locale-aware Number-Format (toLocaleString('de-DE'|'en-US'|'fr-FR'|'ar-EG'))
- Content als JSON {de:'...',en:'...',fr:'...',ar:'...'} gespeichert
- Mindestens DE muss gelingen, sonst kein Post (Sicherheit gegen halbe Posts)
- ~4x Groq-cost pro Post (sehr günstig bei Llama-3.3-70b, parallel-latency
  bleibt ähnlich)

Frontend (PostCard.tsx):
- resolveLocalizedJsonContent() — try-parsed JSON content
- Wenn JSON-Object mit Locale-Keys → pickt i18n.language, fällt auf DE → EN
- Sonst plain content (Legacy-Posts, Comments, User-Posts unverändert)
- Quick-Reject auf '{' first-char vermeidet JSON.parse-Overhead für 99.9%
  der Text-Posts

Legacy-Posts in DB bleiben DE-only (kein retroaktiver Multi-Locale-Rewrite).
Neue Posts ab Deploy haben alle 4 Sprachen.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 00:29:02 +02:00
chahinebrini
2e409efaf0 feat(onboarding/android + backend/lyra-i18n): platform-dispatch + post-catalog scaffold
Android-Onboarding (Platform.OS dispatch in ProtectionSlide):
- Neue Phasen für Android: preexplain_vpn → preexplain_a11y → a11y_pending
- AppState-Listener: nach Settings-Rückkehr auto-poll isAccessibilityEnabled
  → wenn live, armTamperLock + finish (kein Fokus-Klick nötig)
- onboardingAssets: 8 neue Mappings (android_vpn + android_a11y × 4 Locales)
- Screenshots: vpn-permission + a11y-rebreak-row pro Locale
- Locale-Keys: protection_url_android, protection_lock_android, cta_open_a11y,
  cta_check_a11y, dialog_button_vpn_ok, dialog_button_a11y_toggle, tap_marker_hint_*

Lyra-Post i18n Phase 1 (Scaffold, feature-flag OFF by default):
- schema.prisma: CommunityPost.i18nKey String? (nullable)
- migration 20260517_add_lyra_post_i18n_key: ALTER TABLE ADD COLUMN i18n_key
  (NICHT auto-deployed — `prisma migrate deploy` als separater Step)
- server/lib/lyraPostCatalog.ts: 15 Templates skelettiert + pickRandomTemplate
- cron/lyra-post: USE_TEMPLATE_CATALOG=true Branch → speichert i18nKey;
  default false → LLM-Path unverändert (zero-risk-deployment)
- community.createPost: optionaler i18nKey-Parameter
- posts.get: i18nKey in API-Response
- PostCard: 3-Zeilen-Branch — i18nKey ? t('lyra_posts.'+id) : content
- stores/community: i18nKey?: string|null im Interface
- de.json: lyra_posts-Block mit 15 IDs + DE-Texten

Single-Banner-Verhalten auf Android verifiziert:
lockedIn=urlFilter && appDeletionLock funktioniert weiter — auf Android
alias appDeletionLock ← tamperLock; onboarding arms tamperLock, also
nach onboarding-done direkt ProtectionLockedCard sichtbar.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 23:48:25 +02:00
chahinebrini
56bb59915d feat(debug,protection): Force Reset for Android screenshot-capture
Bug-context: user reports nach Cooldown-Disable auf v0.2.1 Android-Build
reactiviert sich Schutz auto → a11y-Settings bleibt blockiert → keine Screenshots
möglich. v0.3.0 hat den Backend-protectionDisabledAt-Guard der das verhindert,
aber Test-Devices brauchen ein direktes Reset-Tool für Multi-Locale-Screenshots.

Backend:
- POST /api/protection/dev-force-disabled — sets protectionDisabledAt=NOW()
  ohne Cooldown-Vorlauf. Production-Guard (rebreak.org-non-staging → 403).

Frontend:
- /debug Android-Section refactored: "Force Reset + Settings öffnen" Button
- Bundle aus 3 Steps:
  1. native forceDisable (VPN stop + tamper disarm + filter_enabled=false)
  2. backend dev-force-disabled (Anti-Auto-Reactivation-Mark)
  3. Settings → Bedienungshilfen öffnen
- Danach: User toggled ReBreak-Service in Android-Settings manuell off
  → frischer a11y-deep-link-Trigger für nächste Screenshot-Iteration

Also: fix /onboarding/welcome → /onboarding (Duo-Rewrite hat den alten Pfad
gelöscht). Route 404 auf Android sichtbar wenn User in debug-toggle 'welcome'
oder 'nickname' tappt.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 22:33:40 +02:00
chahinebrini
22385d7d67 feat(stripe,onboarding): tier-rename + TTS audio button in lyra bubble
## Stripe Checkout Rename

Alte Legacy-Tier-Namen 'standard/pro' (von alter Tier-Struktur) waren
irreführend — heute heißt es 'pro/legend'. Cleanup:

- ENV-Var-Namen: STRIPE_PRICE_<PLAN>_<BILLING> (computed) statt
  hardcoded STANDARD/PRO Mapping. Erwartet:
    STRIPE_PRICE_PRO_MONTHLY
    STRIPE_PRICE_PRO_YEARLY
    STRIPE_PRICE_LEGEND_MONTHLY
    STRIPE_PRICE_LEGEND_YEARLY
- 'quarterly' billing entfernt (Strategist-Verdict: nur monthly + yearly,
  '2 Monate gratis' bei yearly).
- metadata enthält jetzt billing zusätzlich zu plan.

Webhook-Audit: bereits korrekt (mapped session.metadata.plan → pro/legend/free
via simple switch).

User-Action benötigt (Stripe Test-Dashboard):
- 4 Products + Prices anlegen mit 14-Tage-Trial
- Pricing pro Strategist: Pro 3,99/Mo + 39,90/Yr (2mo gratis),
  Legend 7,99/Mo + 79,90/Yr
- Webhook-Endpoint: https://staging.rebreak.org/api/stripe/webhook
  (Events: checkout.session.completed, customer.subscription.{updated,deleted})
- ENV-Vars (incl. STRIPE_WEBHOOK_SECRET) in Infisical pflegen

## TTS Audio-Button in LyraBubble

DiGA-Accessibility: Screen-Reader-Alternative + Lese-Hürden-Mitigation.

- lib/lyraSpeech.ts: one-shot TTS-Helper (vereinfacht aus SosTtsQueue)
  - Fetch /api/coach/speak mit Auth-Token
  - Bytes → Base64 → temp-file → expo-av Audio.Sound
  - Stop-fn: abortet in-flight fetch + unloaded sound
  - Status-callback: idle | loading | playing
- LyraBubble: Audio-Button rechts oben (orange Pill, 34×34)
  - Icon: volume-medium / hourglass / stop je nach status
  - Auto-stop bei text-change (Slide-Switch) + unmount
  - A11y-Labels in 4 Sprachen (audio_play / audio_loading / audio_stop)

Bubble-paddingRight erhöht auf 50 für Button-Platz.

## Locales

de/en/fr/ar: onboarding.lyra.audio_play / audio_loading / audio_stop

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 20:51:11 +02:00
chahinebrini
3c5c9ebfba feat(onboarding): polish bundle — nickname validation, diga format, confetti, FAQ accordion, lyra-voice tuned
## Nickname-Validation + Duplicate-Check

Bug-Prevention: User konnte einen bereits vergebenen Nickname setzen, was
zu Verwirrung führte (zwei User mit selbem Alias). + Profanity-Filter.

Backend:
- GET /api/profile/check-nickname?nickname=X — returns {available, reason?}
  reasons: 'too_short' | 'too_long' | 'profanity' | 'taken'
- Min 3, max 32 chars
- Profanity-Set (hardcoded, ~20 Wörter DE/EN — slurs + bot-impersonation
  wie "admin", "lyra", etc.)
- Case-insensitive lookup, ignoriert eigenen Nickname (= behalten ok)
- Soft-deleted Profile sind ausgeschlossen

Frontend:
- NicknameSlide refactored mit Live-Debounce (450ms)
- Race-guard via checkSeqRef damit veraltete Antworten verworfen werden
- Visueller Feedback: Border-Color (success/error/transparent), Status-
  Icon im Input (hourglass/checkmark/X), inline Error-Text statt Alert
- Save-Button disabled wenn invalid
- Network-Error: fail-soft, lass Server-Side bei Save validieren

## DiGA-Code Auto-Format

Live-Format-Mask: User tippt "REBREAKTEST001" → wird zu "REBREAK-TEST-001"
beim Tippen. Strip-then-segment Logik:
  1. Alles außer A-Z0-9 entfernen
  2. Erste 7 chars = "REBREAK", Rest in 4+restliche Blöcke

Liberal — erlaubt User dashes händisch zu setzen (wird neu segmentiert).

## DoneSlide Confetti + FAQ

- Confetti-Overlay mit 22 Partikeln, gestaffelt 40ms, native-driver Animation
  (translateY + drift + rotate + opacity fade). One-shot beim Mount.
- Inline Top-5-FAQ Accordion unter dem Checkmark-Hero. Tap auf row → expand
  + zeige Antwort. Nutzt existing help.faq_q1..q5 + .faq_a1..a5 locale keys.

## Lyra Voice-Review (Agent)

lyra-persona Agent hat alle Lyra-Speech-Texte in 4 Sprachen reviewed:
- Welcome entstigmatisiert (kein "Glücksspiel"-Trigger im First-Touch)
- Plan vermenschlicht (Erklärungs- statt Verkaufs-Ton)
- DiGA-Choice sanfter (Geschenk-Frame statt Zugangs-Frame)
- protection_lock parallelisiert mit "blaue Falle"-Warnung
- FR/AR Stilglättung (Lyra-Femininum konsistent, AR Frage-Forms)

## Locale-Additions

- onboarding.nickname.error_{too_short, too_long, profanity, taken} × 4 langs
- onboarding.done.faq_section_title × 4 langs
- Lyra-bodies × 4 langs (vom Agent getuned)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 20:09:53 +02:00
chahinebrini
1596a4ea7a feat(protection,onboarding): anti-auto-reactivation + protection pre-explainer + custom sheets
## Backend: Anti-Auto-Reactivation nach Cooldown

Bug: nach Cooldown-Ablauf wurde der URL-Filter automatisch wieder
reaktiviert (enforceProtection-Loop fängt 'recoveringFromBypass'-Phase ab).
Damit war der Cooldown-Schritt entwertet — User konnte nicht wirklich
abschalten, weil die App den Schutz sofort wieder hochfuhr.

Fix: Profile.protectionDisabledAt (DateTime nullable). Wird in
/api/cooldown/status auf cooldown-auto-resolve gesetzt. /api/protection/state
gibt dann protectionShouldBeActive=false zurück → Frontend macht KEINE
Auto-Reactivation. User muss explizit re-aktivieren (CTA in der App).

- Migration 20260517_protection_disabled_at
- Schema: Profile.protectionDisabledAt
- /api/cooldown/status: setzt das Feld auf expired+resolve
- /api/protection/state: includes profile.protectionDisabledAt in shouldBeActive-Berechnung
- /api/protection/mark-active (POST, NEU): clears das Feld, vom Frontend
  auto-aufgerufen nach erfolgreichem activateUrlFilter

Bypass-Recovery durch externe iOS-Settings-Disable (nicht cooldown-bezogen)
funktioniert weiter — protectionDisabledAt ist dann null, alte Logik greift.

## Frontend: ProtectionOffSheet (Custom-Sheet statt Alert.alert)

Bisheriges native Alert mit OK+Reactivate-Buttons hat keine visuelle
Hierarchy (iOS macht beide gleich). Ersetzt mit FormSheet:
 - Großer blauer Primary "Schutz wieder einschalten"
 - Ghost-Link "Später"
 - Swipe-down / Backdrop-Tap zum Schließen

## Frontend: ProtectionSlide mit Pre-Explainer (Screenshot + Pulse-Marker)

User-Request: vor dem iOS-Permission-Dialog ein Erklärungs-Screen zeigen
damit der User weiß wo er tappen muss (Apple's "Don't Allow" ist groß+
blau = Trap, "Allow" ist der unscheinbare Button unten).

- components/onboarding/ScreenshotPointer.tsx — Reanimated pulsing red
  ring, positionierbar via {xPercent, yPercent}
- lib/onboardingAssets.ts — locale-aware require()-Map für Screenshot-
  Assets mit de-Fallback
- assets/onboarding/de/ — 4 iOS-Screenshots vom User (url_filter +
  screen_time permission dialogs + 2 confirm screens)
- ProtectionSlide refactored: internal phase state preexplain_url →
  preexplain_lock → done. Jede Phase zeigt Screenshot + Pulse-Marker auf
  korrekten Button + Lyra-Bubble + activate-CTA.

## Locale-Keys

- onboarding.lyra.protection_url.body, onboarding.lyra.protection_lock.body
- onboarding.protection.url_title, .lock_title, .tap_marker_hint
- onboarding.protection.applock_failed_*, applock_skip
- blocker.protection_off_later, reactivate_btn (refined)

## Bugfix: de.json JSON-syntax

Smart-quote-typo: schließendes "" nach „Erlauben" und „Fortfahren" war
ein plain ASCII " (U+0022) statt U+201D, was den JSON-String früh
terminiert hat. Metro+Hermes warfen "unrecognized Unicode —".
Fix: escapte \" verwendet — JSON-safe.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 19:05:37 +02:00
chahinebrini
68074aa7b7 feat(diga): redeem-code endpoint + 10 test codes seeded
DiGA-Pfad-Foundation: User mit Rezept-Code löst im Onboarding ein, wird auf
plan='legend' hochgestuft (Default), Onboarding-Step springt auf 'done',
diga_code_redeemed_at als Audit-Trail. Trial-Modell wird übersprungen.

- Prisma model DigaCode (code unique, expires_at, used_at, used_by_profile_id,
  grants_plan, notes, label)
- Profile.digaCodeRedeemedAt für Reverse-Audit
- Migration 20260517_add_diga_codes mit Table + FK + Index
- Seed: REBREAK-TEST-001..010 (single-use, reset via SQL für erneutes Testen)
- POST /api/onboarding/redeem-diga-code — atomare Transaction, klare 400-Errors
  (not_found | already_used | expired | invalid_input)

Frontend (Duo-Onboarding) dockt später an — diese Backend-Foundation steht.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 15:52:53 +02:00
chahinebrini
38a8517259 feat(onboarding): interactive welcome + nickname spotlight tour
Stage 1+2 des post-signup Onboarding-Flows:
- Welcome-Screen: dark-slate Full-Screen mit Pulse-Hero, 3 Mission-Bullets,
  DSGVO-Box, CTA "Los geht's"
- Nickname-Spotlight via react-native-copilot ums TextInput in /profile/edit,
  auto-start wenn step='nickname', nach Save → step='block' + back to /(app)
- Backend: Profile.onboardingStep enum (welcome/nickname/block/done),
  Migration mit Backfill (existing → done), PATCH /api/profile/me/onboarding-step,
  /api/auth/me erweitert
- Frontend: CopilotProvider in root, Routing-Gate in (app)/_layout, useMe um
  onboardingStep ergänzt
- i18n (de/en/fr) für onboarding.welcome.* + onboarding.nickname_spotlight.*

Stage 3 (Block-Aktivierung-Spotlight) folgt in nächster Session — der bestehende
ProtectionOnboardingSheet auf Android wird daran angebunden.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 21:00:20 +02:00
chahinebrini
2cc0b20fc8 fix(backend/dm): include attachmentUrl + replyTo in history response
Map-response dropped attachment fields and reply reference even though
getDmHistory loads them — caused images and reply quotes to disappear
on conversation re-open.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 08:49:59 +02:00
chahinebrini
00ec716694 fix(mail): skip Gmail system folders in scan + raise subject-keyword score to 50
Fix 1 (scan-internal): filter out \All, \Drafts, \Sent, \Trash, \Flagged via
specialUse — stops [Gmail]/All Mail from consuming the SCAN_LIMIT=200 and
blocking new INBOX mails from reaching fetch range. \Junk/\Spam stay in scope.
Folders without specialUse (iCloud, GMX) pass through untouched — no false
exclusions without confirmed metadata.

Fix 2 (mail-classifier): raise SUBJECT_GAMBLING_KEYWORD from 35 to 50 so a
single unambiguous casino/jackpot/freispiel subject hit alone reaches the
SCORE_BLOCK_MIDRANGE threshold and triggers a block. Previously 35 pts fell
short when sender domain was generic and display name empty.

Tests: 9 new cases added (2 Fix-2 classifier + 4 Fix-1 folder-filter unit +
1 computeScore score=50 exact assertion). All 265 tests green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 05:12:14 +02:00
chahinebrini
bf6affb3eb fix(mail): Gmail-Delete als Trash-Move + Scan-Trigger nach Custom-Domain-Add
Fix 1 (scan-internal): Gmail ignoriert IMAP EXPUNGE — stattdessen messageMove()
in Trash-Folder (via specialUse='\\Trash', Fallback '[Gmail]/Trash'). Verhindert
dass Gambling-Mails bei Gmail-Usern in 'All Mail' verbleiben statt zu verschwinden.
Alle anderen Provider (iCloud, Outlook, IONOS) bleiben beim bestehenden
messageDelete() + EXPUNGE-Fallback.

Fix 2 (custom-domains): Nach erfolgreichem mail_domain-Add fire-and-forget
$fetch auf /api/mail/scan-internal — damit neue Mail-Patterns sofort (< 5s)
wirken statt erst beim nächsten 30min-Cron. Scan-Fehler blockieren den POST nicht.

Tests: 16 neue Tests (gmail-delete-strategy + scan-trigger). 259 passed, 0 failed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 05:03:09 +02:00
chahinebrini
a2680f6e19 feat(backend): auto-detect kind from pattern when body omits kind/type
POST /api/custom-domains now accepts a third body variant — { pattern }
without an explicit kind or type — which the resolver infers from the
pattern shape so the frontend can ship a single dynamic input field
instead of asking the user to choose between Seite / E-Mail in advance.

- pattern contains '@'             → treat as kind='mail', strip the
                                     local-part, store as mail_domain
                                     after the same TLD / DOMAIN_RE
                                     validation as the explicit-kind path
- pattern contains '.' (no '@')   → treat as kind='web'
- neither                          → 400 INVALID_PATTERN with a clear
                                     message ("Bitte eine Domain oder
                                     Mail-Adresse eingeben")

Variant A ({ pattern, kind }) and Variant B ({ domain, type }) stay
fully supported, plus a `kind: 'auto'` keyword if a client prefers an
explicit opt-in to the auto-detect path. The display-name path is still
locked off in v1.0 — pure tokens without dots route into the same
INVALID_PATTERN response, which keeps the v1.0 guarantee intact.

Plan-limits.test.ts grew the matching test cases — auto-detect for a
domain, auto-detect for a full address (local-part stripped to mail_-
domain), auto-detect rejection for a bare token. All existing tests
keep their pass status.
2026-05-16 02:49:48 +02:00
chahinebrini
f19d00017a feat: pre-check global blocklist on add + collapse Mails on load
User found that adding bet365.com (which is in the 208k global filter)
silently took a custom-domain slot — they paid a slot for something
the global blocklist already covered. Two pieces:

1. backend/custom-domains/index.post.ts: before any slot-limit check or
   DB insert, look the domain up in blocklist_domain (active rows). If
   present, return 200 { alreadyGlobal: true, domain }. No row gets
   written, no slot consumed. The existing frontend hook + AddSheet
   already handle the alreadyGlobal flag — they surface the
   "bereits global blockiert" alert and don't refresh as if the entry
   landed in the user's list.

2. blocker.tsx default mailOpen state flipped from true to false so the
   Eigene Mails section starts collapsed on page load. Domains stays
   the primary affordance; mail-patterns are an opt-in expansion.
2026-05-16 02:42:42 +02:00
chahinebrini
1215356990 feat(backend): add POST /api/devices/check-lock for native auth flow
Native app uses supabase.auth.signInWithPassword directly, bypassing
/api/auth/login. This authenticated endpoint runs the same device-lock
check post-auth: 409 DEVICE_LOCKED if bound to another user, 200+bind
if Pro/Legend user, no-op bind for Free users. CORS headers extended
to include x-device-name/model/os. 34 tests green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 02:38:59 +02:00
chahinebrini
34491ad220 feat(backend): denormalize domain_submissions.type for admin + lyra + notifications
User asked for the admin review tooling — and the lyra-bot community
post / notification text that goes out with each submission — to know
whether a submission is a website-domain or a mail-sender-domain. Until
now the type lived only on user_custom_domains and the submission
inherited it implicitly via the foreign key. Reading it back for the
admin list or the lyra prompt meant joining the source row every time.

- migration 20260516_domain_submission_type adds a type column to
  rebreak.domain_submissions with a default of 'web' and backfills
  every existing row from its linked user_custom_domains.type. The
  backfill is idempotent (UPDATE … FROM with the type comparison).
- Composite index (type, status) so the admin pending-list can scope
  by category without scanning the whole table.
- submitDomainForReview now copies the source row's type into the new
  submission. The submit endpoint picks it up to vary the auto-generated
  community-vote post copy: a website framing for type='web' and an
  "Mail-Absender"-framing for type='mail_domain'. The user's nickname
  is the only PII referenced.
- adminApproveSubmission returns the type alongside the domain so the
  approve endpoint's Lyra-bot Groq prompt can swap its subject/action
  labels per category. Reject path unchanged — the notification just
  carries the bare domain string, no type framing needed.
- BlocklistDomain stays type-agnostic on purpose. The mail-daemon's
  getBlocklistedDomainsSet is a flat string-set match against sender
  domain or URL host, and works for both categories without splitting.
  Adding a type there would be redundant work in v1.0 — revisit only
  if we ever need a UI to surface what category each global entry
  came from.

38/38 backend tests pass (8 admin/domains, 30 plan-limits including
5 new for the type-copy semantics and community-post text variants).
2026-05-16 02:24:42 +02:00
chahinebrini
c1250836a3 fix(backend): remove display-name pattern support for v1.0
User explicitly chose to drop display-name matching from v1.0 after
the UX trap surfaced — a user typing "EXTRASPIN" without a domain got
a 400 INVALID_DOMAIN back, which is a confusing dead-end. v1.1 will
ship a dedicated display-name UI; until then mail input is domain-only.

- resolveTypeAndValue returns a discriminated union — kind='mail' with
  no dot or @ now resolves to { ok: false, error: 'INVALID_MAIL_DOMAIN' }
  instead of silently turning into a mail_display_name row.
- Full-address mail input (local@domain.tld) still gets its local-part
  stripped server-side so the stored value is always a clean domain.
- Variant-B body { type: 'mail_display_name' } returns 400
  DISPLAY_NAME_NOT_SUPPORTED for direct API consumers.
- The DISPLAY_NAME_PATTERN regex is gone — the path that used it can
  no longer be reached.
- classifyMail's Layer 2.6 (the display-name substring match) is
  intentionally left in place as dead code with a v1.1 marker, so
  re-enabling later is just wiring the input field back up and feeding
  the customDisplayNames array.
- Tests rewritten: the two pre-existing display-name tests now assert
  the 400 INVALID_MAIL_DOMAIN path, plus a new positive case for the
  full-address local-part strip. 217 vitest passes, 4 pre-existing skips.

Staging DB clean — the type column hasn't been deployed yet so no
mail_display_name rows exist to backfill.
2026-05-16 02:17:50 +02:00
chahinebrini
f2b81eef54 feat(backend/plan): separate web/mail slot pools + display-name submit lock
plan-features.customDomains is now { web, mail } per plan instead of a
single number. Free 5+5, Pro 5+5, Legend 10+10 — the user explicitly
chose separate pools so users don't have to trade a website slot for a
mail-pattern slot or vice versa.

- countActiveCustomDomainsSplit(userId) groupBy type → { web, mail }
  (mail aggregates mail_domain + mail_display_name). Old single-count
  function stays as a deprecated alias for any caller still on it.
- POST /api/custom-domains: body-compat accepts both { pattern, kind }
  (current frontend) and { domain, type } (legacy / direct). kind='mail'
  is split into mail_domain vs mail_display_name server-side based on
  whether the pattern looks like a domain. Slot check is per-bucket;
  errors are WEB_LIMIT_REACHED / MAIL_LIMIT_REACHED so the UI can show
  the right limit-reached message per tab.
- GET /api/custom-domains: response shape extended to
  { items, counts: { web, mail }, limits: { web, mail } } so the
  frontend can drive the per-tab counter without client-side estimation.
- POST /api/custom-domains/:id/submit: hard-blocks mail_display_name
  with 400 DISPLAY_NAME_NOT_SUBMITTABLE. Display-name submission to the
  global blocklist is deferred to v1.1 — would require a schema split
  on BlocklistDomain that's risky pre-TestFlight. mail_domain still
  flows through the community-vote pipeline like web entries.
- auth/me.get.ts, plan/change-preview.get.ts, coach/message.post.ts
  updated for the new shape (Lyra prompts untouched, only template
  variables split web vs mail counts).

24 vitest cases in backend/tests/custom-domains/plan-limits.test.ts
cover the new shape, body compat, bucket logic, and the submit guard;
216/216 total backend tests pass.
2026-05-16 02:03:26 +02:00
chahinebrini
7dbcac6700 feat(backend): custom mail patterns — display-name match + type-aware api
Completes the custom-mail-patterns feature (schema + migration shipped
in ba170af alongside the chat-tab-badge commit — apologies for the
mishap, agent staging collided with mine). This is the actual logic
that makes the new type column do work:

- mail-classifier.ts: new layer 2.6 between brand+random-token detect
  and the score-based heuristic. Case-insensitive substring match of
  the From-display-name against the user's customDisplayNames list.
  Hard-block when matched, skip score entirely.
- db/domains.ts: getCustomMailDisplayNames(userId) reads the new
  type=mail_display_name rows. countActiveCustomDomains stays a shared
  total — matches the user's pick of a single 5/5/10 pool spanning
  web + mail patterns rather than separate counts per type.
- scan-internal.post.ts and scan.post.ts both preload the display-name
  list per user before the message loop and thread it into classifyMail.
- POST /api/custom-domains accepts { pattern, kind: 'web' | 'mail' }
  with the server inferring the concrete type — 'mail' splits into
  mail_domain when the input contains a TLD-like shape, otherwise
  mail_display_name. Existing { domain } body shape stays accepted
  for backwards compatibility with older clients.
- POST /api/custom-domains/:id/submit treats both mail types as
  community-submittable. The user explicitly chose this; the admin
  review pipeline is the backstop against display-name false positives.
- vitest cases cover: substring match, case insensitivity, no-match
  fallthrough to score, mail_domain still flowing through the existing
  domain-set path, and shared-pool slot counts (3 web + 2 mail_domain
  + 1 mail_display_name = 6 against the 10-slot legend cap).
2026-05-16 01:53:59 +02:00
chahinebrini
1bc38e0732 feat(backend): device-account binding for pro/legend users
Closes the bypass loophole where a Pro/Legend user could log out in a
craving moment, sign in with a fresh Free account on the same iPhone,
and watch the NEFilter blocklist shrink from 208k Casino domains to
the curated 30-domain stub. The user is the patient — the addiction
itself is the attacker.

When a Pro/Legend account signs in via x-device-id, the device is
bound to that user_id (UserDevice.boundToPlan = 'pro'|'legend' …).
A subsequent login attempt from a different account on the same
device returns 409 DEVICE_LOCKED. The original user gets a Resend
email naming the nickname only (no firstName / email leaked per
the anonymity rule) with a link to either confirm the foreign attempt
or release the device.

Release flow:
  - POST /api/devices/:id/request-release schedules releaseAt = now + 24h
  - POST /api/devices/:id/cancel-release reverts it
  - a Nitro plugin cron sweeps both (24h-requested releases AND
    30-day-idle auto-releases) hourly

Free -> Free swaps stay unrestricted so onboarding on a second-hand
iPhone keeps working. Free -> Pro upgrade binds going forward; a
Pro -> Free downgrade keeps the existing lock so the bypass vector
stays closed.

Lock check runs BEFORE Supabase auth in /api/auth/login to avoid
giving a timing oracle for account enumeration. The dummy-UUID filter
in findActiveDeviceLock is the trick: it queries "someone else's
lock" with a userId that can never match.

DSGVO: ON DELETE CASCADE on UserDevice means an Art-17 deletion of
the original user releases all their locks automatically (Hans-Mueller
hand-off noted in the migration SQL).

24 vitest cases cover bind / lock / request-release-24h /
cancel-release / 30-day-idle-release / email rate-limit (1 per 6h) /
DSGVO cascade / multi-device Legend.

Migration to deploy after push:
  infisical run -- npx prisma migrate deploy --schema backend/prisma/schema.prisma

Frontend follow-up (separate task):
  - Sign-In: handle 409 DEVICE_LOCKED with a dedicated error UI
  - Settings/Devices page: "Release device" button + 24h countdown
  - GET /api/devices to include boundToPlan + releaseRequestedAt
2026-05-16 00:29:35 +02:00
chahinebrini
0e4c3787c2 feat(backend): DoH handshake endpoint for protected-device auto-activation
POST /api/devices/protected/handshake — server-to-server endpoint called by
the AdGuard log-watcher whenever a Mac with our DNS-profile makes a DoH query
with its dnsToken embedded in the path (/dns-query/<token>).

- Idempotent: pending → active on first hit, lastDnsQueryAt always updated
- Auth: shared secret via x-handshake-secret (Infisical: HANDSHAKE_SECRET,
  must be set before enabling the watcher)
- Revoked tokens are silently ignored (no info leak to potential attackers)
- Realtime publication added so the native app auto-advances the AddMacSheet
  flow when status flips (no "I've installed it" button needed anymore)
2026-05-15 22:41:17 +02:00
chahinebrini
5b1f89e749 feat(backend): device-info schema + merge heuristic + test-user detection
- Schema: lyraVoiceId stays, new os_version column on user_devices (Migration 20260515)
- registerDevice() merge-heuristic: if existing record matches userId + same name +
  same model + lastSeen < 30 days, update existing instead of inserting new.
  Fixes iOS IDFV-reset creating phantom devices on Recovery-Restore.
- register.post.ts: accepts osVersion in body, maps isCurrent in error-path payload
- New util testUser.ts: isTestUser(email) — explicit allowlist for charioanouar@gmail.com
  plus existing @rebreak.internal suffix

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 21:16:05 +02:00
chahinebrini
76f8595a4f feat(backend): lyra voice picker for legend (mvp)
- profile.ts: Whitelist (null | iFSsEDGbm0FiEd2IVH4w | Gt7OshJCH7MuzX96wFHi) + setLyraVoiceId()
- profile/me/lyra-voice.patch.ts: neuer Endpoint, Legend-Gate (403 legend_only),
  Validation gegen Whitelist (400 invalid_voice_id). DB-Wert bleibt bei Plan-Downgrade.
- coach/speak.post.ts: ElevenLabs-Voice-Prioritätskette nimmt userLyraVoiceId
  zuerst (nur wenn plan === legend), sonst voiceCfg / config / env / FALLBACK.
- auth/me.get.ts: lyraVoiceId in der Profile-Response damit Frontend hydriert.

Schema-Feld lyraVoiceId existiert bereits aus migration
20260507_profile_demographics_and_trial — keine neue Migration nötig.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 22:15:42 +02:00
chahinebrini
f2e3c00943 refactor(mail): remove groq llm layer — deterministic pipeline only
User-Direktive: Mail-Filter bleibt auf dem deterministischen Score+Layer-2.5-Stack.
Groq-LLM Borderline-Call (Layer 4) entfernt. Layer 2.5 Brand+Random fängt den
Apple Hide-My-Email Fall (icloud.com-Adressen mit kryptischen Local-Parts +
Brand-DisplayName) weiterhin sauber via Hard-Block.

Score-Mid-Range 25-79 entscheidet jetzt deterministisch: ≥50 → BLOCK, sonst PASS.

Damit auch DSGVO-P0-Items aus dem Hans-Müller-Review obsolet
(AVV-Annex Groq, Drittland-USA-Consent-Toggle, Datenschutzerklärung-Absatz).

- mail-classifier.ts: callGroqClassifier + redactLocalPartForLLM + groq-Feld raus
- scan.post.ts + scan-internal.post.ts: groqApiKey-Param raus, groq*-Sample-Felder raus
- mail-classifier.test.ts: Groq-Tests + redactLocalPart-Tests entfernt, 46 Tests grün

DB-Spalten in mail_classification_samples (groq_*) bleiben als legacy nullable —
Cleanup-Migration optional in späterem Sprint.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 22:15:32 +02:00
chahinebrini
343f9ab567 fix(mail): DSGVO Art. 17 — manuelles Sample-Cleanup bei Account-Delete
MailClassificationSample hat keine userId-FK-Cascade im Schema (connectionId
ist nullable). Samples ohne connectionId blieben nach deleteAllMailConnections()
als Orphans stehen. Neuer Helper deleteUserMailClassificationSamples() löscht
explizit nach userId — wird in delete.delete.ts parallel zu anderen Lösch-Ops
ausgeführt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 22:05:35 +02:00
chahinebrini
bdd93668ae feat(mail): multi-layer classifier — Brand+Random, Relay-Decoder, Score, Groq + ML-Sampling
Layer 0–4 Klassifikations-Pipeline in mail-classifier.ts:
- Layer 2: Domain-Hard-Block + Relay-Decoder (=domain.tld aus SendGrid/Mailchimp-Bounces)
- Layer 2.5: Brand+Random-Token-Hard-Block (Gambling-Brand-Normalisierung + Random-Token-Detection)
  verhindert LLM-Call für bekannte Gambling-Relayer (Gamblezen, BetandPlay etc.)
- Layer 3: Score 0–100 (TS-Gewichte: Domain-Keywords, Subject-Keywords, Name-Match,
  Geld-Pattern, Urgency, All-Caps, Short-Random-Domain, Brand/Random-Ergänzungen)
- Layer 4: Groq Llama 3.3 70B Borderline-Klassifikation (Score 25–75)
  mit Local-Part-Redaction (DSGVO: nur behalten wenn local-part selbst Keyword enthält)
- Layer 5: MailClassificationSample-Insert nach jeder Klassifikation (ML-Phase 3)

Migrations:
- 20260514_add_mail_blocked_trigger_source: ADD COLUMN trigger_source auf mail_blocked
- 20260514_add_mail_classification_sample: CREATE TABLE mail_classification_samples

50 neue Tests (mail-classifier.test.ts): alle Layer, beide Screenshot-Beispiele (Gamblezen +
BetandPlay) bestätigt als Layer-2.5-Hard-Block ohne LLM-Call, Whitelist, Score, Redaction.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 22:05:35 +02:00
chahinebrini
8075c8e79c feat(mail): outlook-OAuth scan + daemon initial-sweep + page polish v4
USP-Confirmed: Outlook-OAuth Casino-Bonus-Mail wurde end-to-end gefiltert
(User-verifiziert). Mit dieser Welle ist der Daemon plus alle Scan-Pfade
OAuth-aware.

Backend — Mail-Stack (mo):

- backend/server/utils/mail-auth.ts NEU: zentraler resolveImapAuth-Helper
  kapselt OAuth-vs-AppPassword-Entscheidung. 5-min-Token-Expiry-Puffer,
  race-condition-sicheres Refresh via refreshAndSaveTokens.
- scan.post.ts + scan-internal.post.ts nutzen jetzt resolveImapAuth statt
  decrypt(passwordEncrypted). Vorher: Outlook-Connections wurden still
  übersprungen weil passwordEncrypted='' → decrypt failed. Cron + manueller
  Scan-Button funktionieren jetzt für OAuth-Connections.
- imap-idle: Initial-Sweep via triggerScan(conn) direkt nach Connect-Success.
  Neue Outlook-Connections kriegen sofort einen Full-Folder-Scan statt bis
  zu 30 Min Cron-Lag zu warten. scan-internal scannt ohnehin schon alle
  Folders via imap.list() (Junk, Spam, Archive, Custom) — Multi-Folder-
  Anforderung ist damit erfüllt.

Frontend — Mail-Page Polish v4 (rebreak-native-ui):

- MailDistributionChart: Donut zurück auf 200px (240 wuchs auch in der
  Breite und quetschte die Legend), "Live"-Pill-Header komplett raus
  (paddingTop von 16 auf 13 reduziert für tighteres Layout)
- mail.tsx Page-Hierarchie: "Mehr Infos"-Collapsible wandert von unter
  der Postfach-Liste direkt unter den Hero-Donut. Sub-Beschreibung
  "Blockiert — letzte 30 Tage" entfernt — Title reicht.
- Account-Card Expanded: adaptive Bar-Chart über Connection-Age
  (too-new <24h zeigt Empty-State, 1-14d Day-Buckets via Backend
  ?connectionId=, 15-90d client-Week-Aggregation, >90d Month)
- Account-Card Expanded: Scan-Button "Jetzt scannen" mit Refresh-Icon
  (Memory: kein Pen-Icon, refresh ok). Spinner während Scan, Feedback
  mit Blocked-Count nach Success.

Eskalations-Hinweis (nicht in dieser Welle):
- POST /api/mail/scan akzeptiert noch keinen connectionId-Filter →
  Scan-Button-Tap scannt aktuell alle Connections statt nur die
  angeklickte. Kleiner Folge-Patch, nicht blocking.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 23:55:18 +02:00
chahinebrini
432d9d27a3 feat(mail-page): hero-donut + FAB + collapsible bar-chart + legend truncation
UX-Welle nach User-Feedback aus dem ersten Live-Test der Mail-Page:

Page-Hierarchie neu (top → bottom):

1. HALF-DONUT als HERO-Karte — bisherige "BLOCKIERT XX über N Postfächer Live"-
   Banner-Card weg, Inhalt ist jetzt Title-Zeile innerhalb der Donut-Karte
   (rendert nur ab ≥2 Connections; Fallback-Stats-Row für 0-1 Connections)
2. Postfach-Liste (Account-Cards aus letztem Refactor — schlanker Header)
3. NEU: "Mehr Infos"-Collapsible — Bar-Chart "Blockiert letzte 30 Tage"
   liegt jetzt versteckt drin (default collapsed)
4. Activity-Log "Kürzlich blockiert" (unverändert)
5. NEU: FAB unten rechts — 56pt brandOrange Kreis mit "+"-Icon,
   öffnet ConnectMailSheet. Section-Header-Plus-Button entfällt.

Half-Donut Legend-Truncation:
- ≤3 Connections → alle anzeigen
- =4 Connections → alle anzeigen
- ≥5 Connections → Top-3 by blocked-count + "Sonstige"-Bucket
  · Donut: 4 Segmente (Top-3 + OTHER_COLOR grau)
  · Legend: 4 Zeilen (Top-3 fett, "weitere"-Zeile in regular grau)

Backend: GET /api/mail/stats/blocked-by-day?connectionId=<uuid> als
optionaler Filter (für per-Connection-Bar-Chart in expanded Account-Card,
in dieser Welle noch nicht im UI verdrahtet — Erweiterung kommt wenn
gewünscht).

FAB-Details (iOS-diskreter Shadow statt Material-Glow):
- position absolute, right 24, bottom = tabBarHeight + insets.bottom + 16
- 56pt, borderRadius 28, brandOrange BG, weißes Plus-Icon
- ScrollView paddingBottom angehoben damit kein Content unter dem FAB clipped

Edge-Cases:
- 0 Accounts → FAB sichtbar, Donut/Stats/Charts/Log versteckt + EmptyState
- 1 Account → Donut hidden (nur mit ≥2 Connections sinnvoll), Fallback-Stats-Row
- limitReached + FAB-Tap → bestehender Plan-Alert (FAB ist visuell nicht disabled)

Memory: Pull-to-refresh + bestehendes 30s-Status-Polling reichen für "wartet
auf erste verbindung"→"aktiv"-Übergang nach OAuth-Connect (Daemon-Heartbeat
braucht initial 2-9min, mo-Befund). UX-Polish-Option für später: in der
Initial-Phase einen freundlicheren "Verbinde gerade…"-Status anzeigen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 22:39:45 +02:00