W3YH-145 Einmal-Code-Handoff (Gate-Seite) + verteiltes Rate-Limit
added
- Opaker Einmal-Code als Nachfolger des entfernten signierten Handoffs, zunächst nur Gate-seitig und noch nicht aktiv: `src/lib/private/handoff-code.ts` erzeugt 32-Byte-Zufallscodes (base64url) und speichert ausschließlich den SHA-256-Hash. Migration `20260714_private_handoff_one_time_codes.sql` legt `private_handoff_codes` (RLS deny-all) mit den Security-Definer-RPCs `issue_private_handoff_code` (nur `authenticated`, TTL hart auf ≤120 s begrenzt) und `consume_private_handoff_code` (atomarer Consume per einzelnem UPDATE) an.
- `POST /api/private/handoff/redeem`: Server-zu-Server-Einlösung für Terminal/Gym mit timing-sicherem Bearer-Secret (`[secret-var]`, fail-closed bei fehlendem oder <32-Zeichen-Secret), IP-Rate-Limit, einheitlicher Fehlerantwort für unbekannt/abgelaufen/eingelöst und erneutem Allowlist-Check beim Einlösen — Autorität liegt am Redeem, nicht nur beim Ausstellen.
- Verteiltes Rate-Limit: Migration `20260714_private_auth_rate_limits.sql` mit `private_rate_limits`-Tabelle (RLS deny-all) und atomarer Upsert-RPC `consume_private_rate_limit`; `src/lib/private/distributed-rate-limit.ts` nutzt sie für Magic-Link-, Passwort- und Redeem-Endpunkte und fällt bei RPC-Fehlern auf den bisherigen In-Memory-Limiter derselben Instanz zurück (lokale Bremse bleibt als Untergrenze immer aktiv).
changed
- `PrivateAppTarget` kennt jetzt `handoffStatus: "redirect-only" | "one-time-code"` plus `handoffPath`; `/private/go/[app]` stellt im neuen Modus nach dem `requirePrivateAppAccess()`-Guard einen Einmal-Code aus und leitet ohne Session-Geheimnis zur Ziel-App. Terminal und Gym bleiben bewusst auf `redirect-only`, bis ihre Empfänger deploybar versioniert sind und der Live-Smoke grün war.
- `.env.example` um `[secret-var]` ergänzt; `docs/comfort-login-scope.md` (Schritt 3 + P0-Rest) auf den neuen Stand gezogen.
verification
- 34 Node-Vertragstests grün (12 neue: Code-Format/Einzigartigkeit, Hash-statt-Code, TTL-Grenze, Audience-/Format-Validierung, fail-closed Secret-Vergleich, RPC-Mapping, Fallback bei RPC-Fehlern und kaputten Payloads, lokal-blockiert-erreicht-RPC-nicht).
- `npx tsc --noEmit --incremental false`, ESLint über alle geänderten Dateien und direkter `npx next build` (ohne schreibenden Sync-Vorspann) grün; `git status` zeigt keine Build-Mutationen an Quelldateien.
hosted-apply (nachtrag , mit dominiks freigabe)
- Alle drei [id]-Migrationen per Supabase-MCP auf das Shared-Projekt angewendet und verifiziert: `authenticated` hat auf `family_members`/`grocery_members` nur noch `UPDATE (user_id)`; `private_handoff_codes` und `private_rate_limits` sind live mit RLS an und null Policies (deny-all). Rollback-Punkt dokumentiert: vorher lagen Default-Grants (volles UPDATE für anon+authenticated) an, Wiederherstellung via `grant update on table ... to authenticated` bzw. Drop der neuen Objekte.
- Funktions-Smoke auf Hosted: `consume_private_rate_limit` mit limit=1 erlaubt den ersten und blockiert den zweiten Call (retry-after 60 s); `consume_private_handoff_code` liefert für unbekannte Hashes einheitlich null Zeilen. Test-Bucket anschließend entfernt.
open
- Öffentliche Supabase-Sign-ups abschalten — nicht per MCP steuerbar, braucht das Dashboard (Authentication → Sign In/Providers → „Allow new users to sign up" aus). Terminal/Gym server-/RLS-seitig allowlisten.
- Empfänger-Routen `/auth/gate` in Terminal/Gym bauen (Terminal braucht weiter den deploybaren Repo-Patch), Secret in Vercel + Ziel-Apps setzen, dann `handoffStatus` umschalten.
- Replay-/Live-Smoke (Code einmal einlösbar, nach 120 s ungültig, falsche Audience abgewiesen) und Dominiks authentifizierter Gate-Smoke. Kein Commit, Push oder Deploy ausgelöst.