Content-Moderation auf jedem Inhalt, GDPR-Paket vollständig, Windows-Installer aus der Pipeline
Foto: MChe Lee auf Unsplash
Nach der Tooling-Welle der Vorwoche hatte diese Woche einen klaren roten Faden: Sicherheit. Nicht im IT-Audit-Sinn, sondern in dem Sinn, in dem eine Plattform sich auf das Aufgehen der Tore vorbereitet. Was passiert, wenn der erste Nutzer von außen einen Kommentar mit beleidigendem Inhalt schreibt? Wenn jemand sein Konto löschen will, aber bereits Geschichten veröffentlicht hat, die andere lesen und kommentieren? Wenn die Plattform in einen geplanten Wartungsmodus muss, ohne dass Suchmaschinen die provisorische Seite anstelle der echten indexieren? Wenn ein Tester berichtet, „ich kann nicht auf Windows installieren"?
Sieben dieser Fragen hatten am Wochenanfang noch keine sauberen Antworten. Bis Mittwoch sind sie alle beantwortet. Dazu kam dann mit etwas Schwung am Schluss noch die tägliche Social-Media-Anbindung an Facebook, Instagram und Bluesky, eine Wettbewerbs-Funktion, das Tester-Panel mit Checkliste, ein paar Team-Erweiterungen auf der Über-uns-Seite und am Donnerstag ist Feiertag.
Content Safety jetzt auf jedem Inhalt
Bis Anfang der Woche moderierte OutaStory nichts. Kommentare wurden so übernommen, wie sie kamen; Kapitel-Texte ebenso; die KI-generierten Cover gingen ungeprüft durch den Publish-Wizard. Das hielt durch die Closed-Alpha-Phase, weil die Tester-Liste handverlesen ist — sobald ein einzelner unbekannter Account von außen veröffentlichen darf, wird das untragbar. Diese Woche zog die Moderation einmal komplett durch den Stapel.
Die Architektur folgt einem einzigen Muster, das jetzt für vier Oberflächen gleich funktioniert: Kommentare, Seiten-Kommentare, Geschichts-Kapitel und Buch-Cover. Azure Content Safety ist der Anbieter; vier Kategorien (Hate, Sexual, Violence, Selfharm) plus eine konfigurierbare Schwelle pro Kategorie und pro Oberfläche entscheiden, ob der Inhalt durchgeht, automatisch markiert oder direkt abgelehnt wird. Das Lese-/Schreibmuster lautet fail-open: wenn der Azure-Service nicht erreichbar ist, geht der Inhalt durch, wird aber mit Failed=true markiert und landet automatisch in der Moderator-Warteschlange. Die Plattform bleibt nutzbar, kein einzelner Service-Ausfall schließt die Schreibseite.
Kommentare und Seiten-Kommentare (PR #464)
Den Auftakt machten die zwei kürzesten Inhalts-Strecken: Geschichts-Kommentare unter /stories/{slug} und Seiten-Kommentare auf den Kapitel-Detailseiten. Beide laufen jetzt durch denselben CommentModerationService, der bei jeder neuen Eingabe AnalyzeTextAsync aufruft, das Ergebnis gegen die ContentSafetyThreshold-Tabelle prüft und je nach Treffer den Status auf Approved, Flagged oder Rejected setzt. Markierte Kommentare bleiben für die Verfasser:innen sichtbar (man möchte den eigenen Beitrag im Verlauf nicht plötzlich vermissen), tauchen aber in der Moderator-Warteschlange /moderation/comments auf und sind für andere Leser ausgeblendet, bis ein Moderator zustimmt oder ablehnt.
Die Schwellen sind über /admin/content-safety-thresholds administrierbar — ein einzelnes Formular pro Oberfläche mit vier Slidern (Allow if severity ≤ X, Flag if severity > X and ≤ Y, Reject if severity > Y). Die Werte stehen je nach Oberfläche unterschiedlich. Auf Kommentaren ist Sexual strenger geschnitten als auf Geschichts-Kapiteln (Kapitel-Inhalt kann je nach Genre und Altersfreigabe deutlich expliziter sein als ein Kommentar darunter); Hate ist überall hart auf Reject ab Severity 4. Die Schwellen werden über IConfiguration mit Defaults vorbelegt, sodass eine leere ContentSafetyThreshold-Tabelle eine funktionsfähige Konfiguration liefert.
Die Geheimnisse für die Azure-Resource (Endpoint, Key, Location, SubscriptionId) gehen den üblichen Weg: tools/content-safety-keys/Set-ContentSafetyKeys.ps1 schreibt sie in dieser Reihenfolge nach AppHost-User-Secrets → Key Vault → GitHub Actions Secrets. Identisches Muster wie bei Stripe, SendGrid, Sentry, BZSt und allen Vendor-Tools.
Geschichts-Kapitel (PR #477)
Vier Tage später wurde dieselbe Architektur auf die Schreibseite ausgeweitet. Jedes Kapitel durchläuft beim Veröffentlichen oder Aktualisieren den ChapterModerationService, der den Text in 9.500-Zeichen-Stücke teilt (Azure Content Safetys harte Eingabe-Grenze ist 10.000), jedes Stück einzeln analysiert und das Ergebnis nach der Worst-Case-Severity zusammenfasst. Das Stück-für-Stück-Vorgehen ist nicht nur eine technische Bedingung — es löst auch ein praktisches Problem: ein einzelner harter Treffer am Anfang würde sonst durch ein gemütliches Romance-Mitte verdünnt. Worst-Case heißt: wenn ein Abschnitt Sexual=Severity 6 trifft, ist das gesamte Kapitel auf Sexual=Severity 6, unabhängig davon, was Stück 4 von 12 sagt.
Geflaggte Kapitel werden veröffentlicht-aber-markiert: sichtbar nur für die Verfasser:innen plus Moderator-Liste, ausgeblendet auf den Listing-Seiten, mit einem grünen-orange-roten Status-Chip im Editor. Abgelehnte Kapitel werden nicht abgewiesen — wir lassen den Verfasser:innen die Möglichkeit, den Entwurf zu bearbeiten — aber sind für niemand außer dem Verfasser sichtbar und tragen einen prominenten roten Hinweis auf der Editor-Seite.
Der ChapterModerationReportPublisher schreibt zusätzlich eine strukturierte Zeile pro Treffer in die Moderation-Tabelle — Severity pro Kategorie, Zeitstempel, Worst-Case-Stück mit den ersten 200 Zeichen des Triggers. Das ist der Audit-Pfad, an dem ein Moderator nachvollziehen kann, warum genau das System reagiert hat. Severity-0-Inhalte hinterlassen nichts in der Tabelle; sie sind unauffällig.
KI-generierte Buch-Cover (PR #477)
Die dritte Oberfläche ist die ungewöhnlichste. Bei den anderen prüft Content Safety bestehenden, vom Nutzer eingegebenen Text oder Bild — bei den Covern liegt das Problem davor: die Cover entstehen aus einem KI-Prompt, den unser Cover-Generator an gpt-image-1.5 schickt, und wir wollen verhindern, dass der ImageProcessor überhaupt erst ein Cover generiert, dessen Prompt schon problematisch ist.
Die Lösung läuft in zwei Schritten und dehnt die Content-Safety-Mauer eine Etappe nach vorne. Schritt eins: der CoverGenerationModerator prüft den Prompt-Text mit AnalyzeTextAsync, bevor irgendetwas an Azure OpenAI geschickt wird. Wird der Prompt geflaggt oder abgelehnt, geht die Anfrage gar nicht erst raus — kein OpenAI-Aufruf, kein generiertes Bild, keine Speicherkosten. Schritt zwei: nachdem ein Cover generiert wurde, läuft es durch AnalyzeImageAsync (die neue Bild-Lane in IContentSafetyClient). Das ist Belt-and-Suspenders — der Prompt ist sauber, der Stil-Filter von OpenAI ist eingeschaltet, und trotzdem prüft Content Safety das Output-Bild noch einmal. Wenn der Image-Check schlägt, geht das Bild nicht an den Storage und der Generator versucht es mit einem neutralisierten Prompt erneut.
AnalyzeImageAsync hat einen eigenen Pre-Check für die Azure-Eingabe-Grenze (4 MB für Bilder) und liefert dieselbe fail-open-Semantik wie der Text-Pfad: bei Azure-Fehlern wird das Cover durchgelassen, aber mit Failed=true markiert und manuell in /moderation/covers zur Sichtung gestellt.
In Summe deckt die Mauer jetzt vier Oberflächen ab und führt eine fünfte Spalte ins Datenbankschema ein: ContentSafetyThreshold mit (SurfaceType, CategoryName)-Index. Auf der UI-Seite wurde der bestehende /admin/content-safety-thresholds-Bildschirm zu einer kompletten Übersicht erweitert — pro Oberfläche eine Karte mit den vier Kategorie-Schwellen, alle gleichzeitig auf einer Seite sichtbar. Bricht eine Schwelle, ist sie nicht versteckt.
GDPR Phase 1–3: vollständige Datenausgabe, gegrace-windowte Konto-Löschung und ein Auth0-Reaper
Das /profile/privacy-Panel hatte vor dieser Woche einen DSAR-Knopf (Data Subject Access Request, der von der DSGVO geforderte Datenauszug), der ein knappes JSON mit Auth0-Profil und ein paar Author-Spalten zurückgab. Das war ein Minimum — was die DSGVO eigentlich verlangt, sind alle personenbezogenen Daten plus ein klares Bild davon, was bei einer Konto-Löschung passiert. Diese Woche wurden Phase 1, 2 und 3 abgeschlossen.
Foto: Windows auf Unsplash
Phase 1: vollständige Datenausgabe (PR #391, #392)
Der DSAR-Endpoint produziert jetzt ein strukturiertes JSON mit allem, was OutaStory über eine Person gespeichert hat:
- Auth0-Profil: E-Mail, Name, Login-Anzahl, letzter Login, alle benutzerdefinierten App-Metadaten.
- Author-Datensatz: Bio, Avatar-URL, alle Profil-Spalten.
- Geschichten: Titel, Slugs, Kapitel-Anzahl, Veröffentlichungs-Status, geschätzte Lesezeit.
- Kommentare: jeden Geschichts- und Seiten-Kommentar mit Zeitstempel und Ziel-Slug.
- Lese-Fortschritte: pro Geschichte, mit Kapitel- und Seiten-Position.
- Reaktionen / Likes / Lesezeichen: alle Interaktionen.
- Newsletter-Abonnements: Status pro Liste (täglich / wöchentlich), Locale.
- Support-Tickets: alle eröffneten Tickets mit Status und Inhalt.
- Push-Geräte: Push-Token-Hashes (keine Klartext-Tokens), Plattform, letzte Aktivität.
- Moderation-Historie: alle Inhalte, die geflaggt oder abgelehnt wurden, mit Begründung.
Ein offener Bug-Punkt aus dem ersten Versuch: die DSAR-Handler innerhalb von /profile/privacy priesen den AuthState nicht korrekt, wodurch der erste Klick auf „Daten herunterladen" gelegentlich 401-ed (PR #391, klassische Race-Condition zwischen Blazor-Server-Render und Cookie-Auth-Hand-off — dieselbe Klasse wie die Admin-Page-401s aus der Vorwoche).
Phase 2: Revenue-Share-Block in der Datenausgabe (PR #393)
Die DSAR-Datei trägt jetzt zusätzlich einen Revenue-Share-Block: alle in RevenueShareLedger zugeordneten Beträge, die monatlichen Aggregate, die ausgezahlten Beträge, die Stripe-Connect-Konto-ID und die in der Datenbank gehaltenen Steuer-Form-Records (1099-K für US-Autoren, DPI-Datensatz für deutsche Autoren). Das ist ausdrücklich getrennt von der Auth0-Personen-Information — es ist Vergütungs-Information, die getrennt gelagert wird und einer eigenen Aufbewahrungsfrist unterliegt. Der Block dokumentiert das auch direkt im JSON: "retentionNote": "kept for 10 years per § 147 AO and § 257 HGB".
Phase 3: Konto-Löschung mit Gnadenfrist (PR #395, #396, #397, #398)
Hier liegt die meiste Substanz. Bisher gab es keine Konto-Löschungs-Funktion — nur ein E-Mail-an-Support-Verfahren. Jetzt steht unter /profile/privacy ein Löschen-Knopf, der einen mehrstufigen Workflow auslöst:
- Eintrag in
AccountDeletionRequest, mitRequestedAt,GracePeriodEnds = RequestedAt + 30 Tageund einer Wahl pro Inhalts-Typ: was passiert mit deinen Geschichten? mit deinen Kommentaren? mit deinen Reaktionen? Drei Optionen pro Typ: mit löschen, anonymisieren (der Beitrag bleibt sichtbar, aber „Anonymer Autor" / „Anonymer Kommentar" ersetzt den Namen) oder dem System spenden (für Geschichten: die Geschichte bleibt im Katalog unter dem Namen einer „Community"-Pseudo-Identität, die einer offenen Lizenz unterliegt; für Kommentare: gleiche Anonymisierung wie Option 2). Das ist ausführlich inassets/Announcements/2026-05-15-…dokumentiert (= diese Seite) und auf der/profile/privacy-Seite mit Beispielen erklärt. - Status-Banner auf jeder Plattform-Seite während der Gnadenfrist: „Dein Konto wird am [Datum] gelöscht. [Stornieren]." (PR #395, später angepasst in #400, weil der Stornieren-Link initial auf eine 404 zeigte). Das Banner respektiert die Pluralität — ein Klick öffnet ein Dialog mit den Wahl-Optionen, sodass die Person die Entscheidung noch einmal überprüfen oder ändern kann.
- Tägliche Background-Aufgabe (
AccountPurgeService, PR #396), die nach Ablauf der 30-Tage-Frist die Konto-Löschung durchführt. Der Service prüft am Anfang einen "Service ist eingeschaltet"-Flag, ein "es liegt eine Lease vor"-Flag (verhindert paralleles Laufen auf zwei Container-Replicas) und einen Batching-Cap (max 50 Konten pro Tick), bevor er die Löschungen ausführt. - Auth0-Hook (PR #397): nach erfolgreichem Löschen aus der eigenen Datenbank wird der Auth0-Benutzer via
Auth0.ManagementApi.Clients.Users.DeleteAsyncentfernt. Das war die heikelste Stelle — wenn der Auth0-Call fehlschlägt, wird der Eintrag in eineAccountDeletionFailure-Tabelle geschrieben statt die ganze Transaktion rückgängig zu machen. Manuelle Aufräum-Arbeit ist möglich, aber die Datenbank-Löschung bleibt erhalten — sonst säße die Person mit einer aktiven E-Mail-Login-Möglichkeit, aber leerem Konto da. - Auth0-Orphan-Reaper (PR #401, letzter Phase-3-Follow-up): ein nächtlicher Job, der nach Auth0-Benutzern sucht, die in unserer Datenbank keinen
Author-Datensatz mehr haben. Sie werden gegen dieAccountDeletionRequest-Tabelle abgeglichen und, wenn passend, mit demselbenDeleteAsync-Aufruf entfernt. Das schließt den Kreis: kein Auth0-Account übersteht eine erfolgreiche OutaStory-Löschung.
Die Wahl pro Inhalts-Typ (PR #398) ist die Stelle, an der wir die meisten Stunden eingebracht haben. Es gibt eine echte Spannung zwischen dem DSGVO-Recht auf Löschung (alles weg) und dem Interesse der Community an Kontinuität (eine Geschichte mit 200 Kommentaren ist nicht nur der Autor — die Diskussion gehört nicht ausschließlich ihm/ihr). Die deutsche Datenschutz-Konferenz hat 2020 dazu eine Stellungnahme veröffentlicht, die genau diese Anonymisierungs-Option als Kompromiss empfiehlt, und unser Anwalt wies darauf hin. Die UI lässt die Wahl als bewusste Entscheidung erleben: jede Option zeigt genau, was passieren wird, mit einem kleinen Beispiel-Block. „Anonymisieren: Deine Geschichte ‚Der vergessene Krieg' bleibt für andere lesbar, aber der Autor-Name wird auf ‚Anonymer Autor' gesetzt; deine Kommentare und Reaktionen werden in derselben Weise anonymisiert."
Coming-Soon und Maintenance Gates
Bisher konnte die Plattform nicht in einen geplanten Wartungsmodus geschaltet werden. Das war für die Closed Alpha kein Problem — die paar Tester wissen, dass die Web-App jederzeit kurzzeitig nicht erreichbar sein kann. Sobald die Welt zugucken darf, ist „die Seite zeigt einen 502" nicht akzeptabel.
Diese Woche kamen zwei Gate-Modi:
- Coming-Soon: zeigt eine eigene Landing-Page mit „Wir öffnen am [Datum]", einem Newsletter-Anmelde-Feld und einer kurzen Über-uns-Sektion. Aktiv, solange die Plattform vor dem öffentlichen Start steht.
- Maintenance: zeigt eine eigene Service-Pause-Seite mit „Wir sind kurz weg" und einer geschätzten Wieder-online-Zeit. Aktiv, sobald wir eine Wartungsfenster-Routine fahren (z. B. eine Datenbank-Migration mit Locking).
Beide werden über einen Front-Door-Header gesteuert (X-Gate-Mode: coming-soon oder maintenance), den der Reverse Proxy bei Bedarf setzt — ein 3-Klick-Vorgang im Azure-Portal, kein Code-Deploy notwendig. Der Server entdeckt den Header in seinem GateMiddleware und schreibt die URL transparent auf /coming-soon oder /maintenance um, ohne einen HTTP-302 zu schicken. Das ist die SEO-bewusste Variante: ein 302-Hopp würde Suchmaschinen-Crawler durcheinanderbringen (alle URLs würden auf die Gate-Seite redirecten, die Original-URL würde aus dem Index fallen). Stattdessen rendert der Server die Gate-Seite in-place mit Status-Code 503 (Service Unavailable) und einem Retry-After-Header. Crawler ignorieren den Inhalt unter 503, behalten aber die ursprüngliche URL — wenn die Gate-Seite eines Tages abgeschaltet wird, ist die URL noch da, wo sie war.
GateLayout, GateShell und die Sprach-Umschaltung
Die Gate-Seiten haben ihre eigene visuelle Identität, die sich vom Haupt-Layout absichtlich unterscheidet — einfacher Hintergrund, große Type, mittiges Branding, eine sehr knappe Aufmerksamkeit-im-Wartezimmer-Atmosphäre. Das wird über zwei Komponenten gelöst: GateLayout ersetzt das normale AppLayout (und greift nicht auf die Sidebar zu, die im Wartemodus eh nicht funktionieren würde), und GateShell ist die obere Leiste mit Sprach-Umschaltung (DE/EN) und einem unauffälligen OutaStory-Logo.
Eine architektonische Nuance: der Server muss vor dem Render entscheiden, welches Layout zu verwenden ist. Das passiert über PersistentComponentState — das GateMode wird vom Server an den Client gehängt, sodass die Blazor-Server-Komponente und ihr Wasm-Pendant beide das richtige Layout finden. Routes.razor pickt das Layout über diesen Wert; AppLayout bleibt Single-Purpose (nicht mehr „Layout, das selber entscheidet, was es ist").
Standalone Legal Pages
Die Datenschutzerklärung, die Nutzungsbedingungen, das Impressum und die Revenue-Share-Bedingungen — alle Pages, die unter /legal/* liegen — sind jetzt Standalone-fähig. Im Gate-Modus erscheinen sie ohne den Rest der Plattform (mit dem GateLayout), aber mit funktionierender Sprach-Umschaltung, einem Zurück-Knopf, der zum Gate führt, und vollständigem Inhalt. Das ist eine echte juristische Anforderung: die Impressum-Pflicht endet nicht, wenn die Plattform in Wartung geht.
Front Door, Canonical Host und PR #389
Eine zweite Welle räumte die Hosts auf: vorher konnte OutaStory unter zwei URLs erreicht werden — der Container-App-Standardname (outastory-app-web.delightfulmoss-e8d84f4f.westeurope.azurecontainerapps.io) und der Custom-Domain-Name (www.outastory.com). Das ist nicht nur SEO-unschön (zwei URLs mit demselben Inhalt), es ist auch ein Auth-Risiko (Cookies sind pro Domain). PR #389 setzt einen Canonical-Host-Redirect: jeder Request auf den Container-Hostname wird mit 301 auf die Custom-Domain umgeleitet. Im Gate-Modus passiert der Redirect vor der Gate-Middleware, damit die Gate-Seite auch unter dem Custom-Hostname läuft.
Signierter Windows-MSIX-Installer aus der Pipeline
OutaStory liefert sich als MAUI-App auf Android, iOS, macOS und Windows. Drei dieser Plattformen haben einen Store-Pfad (Google Play, App Store, Microsoft Store) — Windows ist die Ausnahme, weil der Microsoft Store für Heim-Anwender eine geringere Reichweite hat. Statt eines Store-Pfads liefern wir auf Windows einen direkten MSIX-Installer aus, und dafür brauchte es ein paar bewegliche Teile.
Foto: Jakub Żerdzicki auf Unsplash
Was diese Woche dazukam
Die Pipeline (PR #402): ein neues GitHub-Actions-Job im Release-Workflow, das die MAUI-Windows-Targets baut (x64, x86, arm64), die .msix-Artefakte sammelt, sie über Azure Trusted Signing signiert (PRs #305, #427 setzen das Service-Konto auf), in einen Azure-Storage-Container hochlädt und eine release-manifest.json mit Download-URLs pro Architektur schreibt.
Die Bicep-Sidecar (#403): weil die generierten azd-Bicep-Dateien (infra/*.bicep) bei jedem Provision-Lauf überschrieben werden, kommt die zusätzliche Storage-Konto-Definition für die Releases-Storage als Sidecar releases.bicep daher. Der azd-Hauptpfad ruft das Sidecar im Release-Pipeline-Schritt auf. Der Storage-Prefix wurde auf 11 Zeichen verkürzt, weil Bicep einen maxLength(11)-Constraint auf Storage-Konto-Namen pro Resource Group hat (Lesson learned aus einem ersten Lauf, der mit Limit-überschritten-Fehler abgebrochen ist).
Die UI (PR #402): /about/apps zeigt jetzt drei Download-Kacheln (Android-APK, Windows-MSIX, iOS via TestFlight) plus einen Status pro Plattform — „Signiert und veröffentlicht", „Vorschau (unsigniert)" oder „TestFlight: Anmeldung erforderlich". Solange Trusted Signing noch in der KYC-Phase steht, zeigt Windows den Vorschau-Status (PR #483 macht das explizit). Sobald die Signaturen aktiv sind, fällt der Vorschau-Banner weg.
Was an Stolpersteinen kam
- MSIX-Ausgabe-Pfad (PR #487): MAUI legt das
.msixnicht indotnet publish -o's Ausgabe-Verzeichnis, sondern in<ProjectDir>/AppPackages/<App>_<ver>_<arch>_Test/. Die Pipeline musste das wissen und das Glob anpassen. - Releases-Storage-Creds (PR #489): die Pipeline schrieb die Storage-Zugangsdaten initial in den Repo-Root, was Git-Status verwirrte und einen Lint-Fehler verursachte. Korrigiert auf
$RUNNER_TEMP/, das nach dem Lauf weg ist. - azure/artifact-signing-action-Umbenennung (PR #427): die GitHub Action wurde von
Microsoft/trusted-signing-actionaufazure/artifact-signing-actionumbenannt; der Workflow musste folgen. - OIDC-Token-Drift im seriellen Deploy (PR #471):
azd deploybrauchte mit 15 Container-Apps mittlerweile so lange, dass der OIDC-Token zwischen erstem und letztem Service ablief. Lösung: Token vor jedemazd deployneu erfragen, plus serieller statt paralleler Deploy-Pfad (auch eine Speicher-Schonung — der parallele Pfad ließ den Build-Runner OOM-kicken). - Parser-Bug im PowerShell-Skript (PR #483): die signierten/unsignierten Status werden über eine
manifest.jsonan die UI weitergereicht; das ursprüngliche Skript benutzte einen falschen JSON-Parser-Pfad, der dennull-Wert für unsignierte MSIX als Fehler interpretierte.
Die offene Sache ist die Trusted-Signing-Freigabe selbst — Microsoft hat unsere KYC eingereicht, die Antwortzeit ist laut Dokumentation 1–4 Wochen. Bis dahin laufen die Windows-MSIX-Artefakte als unsignierte Previews auf /about/apps mit einem entsprechenden Banner.
Tester-Panel auf /profile/tester
Mit der wachsenden Tester-Liste wurde der Bedarf an strukturiertem Feedback offensichtlich. Ein einzelner Tester schreibt drei oder vier konkrete Bug-Reports pro Woche; sieben Tester schreiben in derselben Woche 21–28 Reports, und manche Dinge sind in 12 davon zu finden, andere in keinem. Wir brauchten eine Form, in der Tester durch eine Checkliste gehen und ein konkretes „funktioniert / funktioniert nicht / Anmerkung"-Signal abgeben können.
Unter /profile/tester (PRs #341, #485) läuft jetzt das Tester-Panel mit derzeit rund 41 Test-Herausforderungen in 6 Kategorien:
- Onboarding — Konto-Erstellung, E-Mail-Verifizierung, Premium-Anmeldung.
- Lesen & Anhören — Geschichten finden, im Reader navigieren, Audio starten, Offline-Modus testen.
- Schreiben & Veröffentlichen — neue Geschichte starten, Kapitel schreiben, Audio generieren, KI-Cover anfordern, Vorschau prüfen, veröffentlichen.
- Profil & Datenschutz — Profil bearbeiten, Avatar hochladen, DSAR auslösen, Konto-Löschung anstoßen-und-stornieren.
- Support & Moderation — Hilfeartikel suchen, FAQ aufklappen, anonymes Test-Ticket eröffnen, eingeloggte Ticket-Konversation, einen Kommentar als Verstoß melden.
- Admin (Rollen-gegated für Moderator-/Admin-/Developer-Konten) — Kategorien-Editor, Benutzer-Verwaltung, Moderator-Warteschlange durchgehen.
Jede Herausforderung hat einen Titel, eine Beschreibung mit Schritten und ein „Bestanden / Fehlgeschlagen / Übersprungen"-Trio. Bei „Fehlgeschlagen" öffnet sich ein Notiz-Feld, in dem die Tester:in den Bug beschreiben kann. Jeder Submit landet als TestChallengeAttempt-Zeile mit der App-Version, dem User-Agent, der Plattform (Web/Android/iOS), der laufenden Locale und einem optionalen Extra-JSON-Block.
Unter /developer/test-feedback (Developer-gegated) läuft die andere Seite: eine Übersicht aller Abgaben, gruppiert nach Version (sodass eine Re-Submission auf einer neueren Version nicht das alte Bestanden-Marker überschreibt — beide Zeilen bleiben in der Historie), filterbar nach Status, Kategorie und Plattform. Eine grüne Zelle bedeutet „bestanden auf dieser Version"; eine rote bedeutet „fehlgeschlagen, hier ist die Notiz".
Eine Lokalisierungs-Korrektur in derselben Woche (PR #485): die Challenge-Titel und -Beschreibungen wurden vom API immer in Englisch zurückgegeben, auch wenn der Web-Client den Accept-Language: de-DE-Header schickte. Ursache: der TestChallengesController las CultureInfo.CurrentUICulture statt des Accept-Language-Headers; und der API-Host registriert kein UseRequestLocalization. Behoben durch direktes Header-Lesen mit Fallback auf en-US. Der gleiche PR brachte 11 zusätzliche Challenges und eine ausklappbare Kategorie-Ansicht — die Liste ist sonst zu lang für einen einzelnen Bildschirm.
Tägliche Social-Media-Posts auf Facebook, Instagram und Bluesky
Heute Mittwoch noch frisch reingekommen (PR #493): ein automatisierter Posting-Mechanismus, der täglich eine ausgewählte „Geschichte des Tages" auf Facebook, Instagram und Bluesky pro Sprache (EN + DE) veröffentlicht. Sechs Posting-Ziele insgesamt (3 Netzwerke × 2 Locales). Das ist mehr Infrastruktur als Anwender-Feature — sobald die Plattform öffentlich ist, sollten unsere Geschichten regelmäßig im Social-Stream sichtbar werden, und der Aufwand pro Tag soll Null sein.
Sechs Adapter (FacebookPoster, InstagramPoster, BlueskyPoster, je einer pro Netzwerk × Locale) hängen am gleichen INetworkPoster-Interface und an einem gemeinsamen IStoryOfTheDayPicker, der die Auswahl trifft. Die Auswahl-Logik ist zwei Stufen: zuerst nach Beliebtheit (die IRecommendationEngine-Top-50-Liste), dann nach zufälligem-aus-Pool, wenn die Beliebten alle schon vor kurzem gepostet wurden (Dedup-Fenster: 14 Tage pro Locale). Beide Stufen filtern auf veröffentlichte Geschichten und passende Locale.
Die OAuth-Anmeldung läuft direkt im Admin-Bildschirm unter /management/social: ein Connect-Knopf pro Netzwerk-Locale-Zeile, der die OAuth-Dance für Meta startet oder ein App-Passwort-Modal für Bluesky öffnet. Die Tokens werden via IDataProtector verschlüsselt in der Datenbank gehalten. Ein SocialConnectionProbeService läuft täglich um 04:00 UTC und prüft die Health jeder Verbindung; bei dauerhaftem Fehler wird die Verbindung automatisch deaktiviert und eine E-Mail an den Admin-Alert-Adressaten geschickt (mit 24h-Dedup, damit ein Ausfall keine Mail-Flut erzeugt).
Die Funktion ist diese Woche technisch komplett, läuft aber noch nicht live: Meta verlangt eine App-Review für die instagram_content_publish-Berechtigung (1–3 Wochen Antwortzeit), die Bluesky-Accounts müssen noch angelegt werden, und die Geheimnisse über tools/social-keys/Set-SocialKeys.ps1 einmal in die Geheimnis-Pipeline geschoben werden. Bis dahin: leere Geheimnisse fließen harmlos durch — der Daily-Job läuft, findet keine aktive Verbindung und loggt entsprechend.
In derselben Woche hat sich der ursprüngliche PR-Merge auf Release-Seite gerächt: die Api referenzierte den OutaStory.SocialPoster-Functions-Host, der wiederum OutaStory.Newsletters referenzierte — beide Functions-Hosts produzieren host.json / worker.config.json / functions.metadata an denselben relativen Pfaden, was beim Publish in NETSDK1152: Found multiple publish output files landete und das Release blockierte. Folge-PR #495 extrahierte die geteilten, nicht-Functions-Teile in zwei plain-Bibliotheken (OutaStory.SocialPoster.Core und OutaStory.Newsletters.Picking) — die Api referenziert jetzt nur noch die Bibliotheken, die Functions-Hosts referenzieren sie ebenfalls, und die host.json-Kollision ist weg. Eine Lektion fürs Repository-Gedächtnis (siehe [[reference-netsdk1152-functions-isolation]]): Functions-Hosts werden niemals von Nicht-Functions-Konsumenten direkt referenziert.
Wettbewerbs-Funktion: admin-verwaltete Contests
PR #343 brachte einen verwaltbaren Wettbewerbs-Bereich. Admins können unter /admin/contests einen neuen Contest anlegen mit Titel, Beschreibung, Zeitraum, Einsendebedingungen, Jury-Liste und einem Preisgeld. Die Contest-Landingpage erscheint dann unter /contests/{slug} und ist öffentlich. Für die Closed Alpha ist das vor allem eine Vorbereitung — wir haben drei Sample-Contests im Seed (Mai-Romance-Challenge, Frühjahr-Fan-Fiction, Familien-Tagebuch-Wettbewerb), die als Beispiele und als Test-Daten dienen.
Zwei Sicherheits-Mauern: ein typisierter Slug-Confirm-Knopf beim Admin-Delete (PR #350) — das Löschen eines Contests verlangt, dass der Admin den exakten Contest-Slug abtippt, bevor der Knopf aktiv wird; und der Seeder löscht keine laufzeit-hinzugefügten Juroren mehr (PR #351), sondern legt sie additiv an, sodass das Re-Seeding manuelle Änderungen nicht überschreibt.
/about/partners und Team-Erweiterung
Zwei kleinere Inhalts-Updates auf der Über-uns-Seite:
- /about/partners (PR #314): eine neue Landing-Page für Partner — Lizenz-Partner für Audio-Vertonung, Bildungs-Partner für Schul-Content-Pakete, Werbe-Anbieter — mit einer Liste der bisherigen Partner und einem Anfrage-Formular. Die Liste ist über
assets/team/partners.yamladmin-pflegbar. - Team-Erweiterung (PR #303): drei neue Rollen auf der
/about/team-Seite — CFO, Investor Relations, Social Media Manager. Jede Rolle bekam einen Sample-Eintrag plus die zugehörigen Social-Link-Symbole. - LegalFooter (PR #304): site-weit auf dem Web-Host, mit Links auf Impressum, Datenschutz, Nutzungsbedingungen, Revenue-Share-Bedingungen plus einer kleinen Versions-Anzeige. Ersetzt die bisher noch ad-hoc per Page eingestreuten Footer-Varianten.
Seed-Geschichten neu geschrieben
Eine ältere Schmerz-Stelle wurde diese Woche gelöst: 252 der eingeseed'eten Fan-Fiction-Geschichten waren Boilerplate (überladene Templates, Wiederholungen, generische Plots) — sie sollten den Katalog füllen, wirkten aber im Reader wie automatisch generiert. PR #308 schrieb sie neu, mit einer Härtung der Author-Cap (max 10 Geschichten pro Author, damit die Verteilung natürlich aussieht). PR #315 rettete dazu noch 13 menschen-geschriebene Geschichten und 4 reale Autoren aus der OutaStory-v1-Datenbank (vor dem Relaunch — Genehmigung der Originalautoren liegt vor), die ihren Weg zurück in den Seed gefunden haben.
Repository-Hygiene: /bicep/ als kanonische Bypass-Spur
Eine kleine Aufräum-Arbeit am Schluss, die mehr verspricht als sie klingt: das Repository hatte bisher zwei parallele Ordner, in denen Hand-geschriebene Infrastruktur-Bausteine landeten — einmal /bicep/ (für outastoryreleases.bicep, das die Windows-MSIX-Storage-Konto-Pipeline aus PR #402 versorgt) und einmal /infra/extensions/ (für blob-lifecycle-policy.json, die 10-Jahres-Retention auf den Revenue-Share-Containern aus Phase 6). Beide existierten aus dem gleichen Grund — sie sind das, was azd infra gen nicht erzeugt, weil sich die Sache nicht über die Aspire-AppHost-Manifest-APIs ausdrücken lässt. Zwei Ordner für denselben Zweck sind ein Geruch.
Diese Woche wurde konsolidiert: alles wandert auf /bicep/. Die README dort dokumentiert jetzt die Konvention explizit:
/infra/ist ausschließlichazd infra gen-Output. Niemals ein Datei hier von Hand anlegen oder ändern; jede Bearbeitung geht beim nächsten Re-Generieren verloren./bicep/ist die kanonische Bypass-Spur für Hand-geschriebene Bicep-Module und Policy-JSON-Dateien, die nicht durch AppHost laufen können. Anwendet werden sie ausrelease.ymlheraus, nachazd provisiondie AppHost-eigenen Ressourcen materialisiert hat.
Das wirkt nach trockener Buchhaltung, ist aber präventiv: in den nächsten Phasen kommen mit hoher Wahrscheinlichkeit weitere Bypass-Stücke dazu (Front-Door-Regeln, custom RBAC für externe Principals, vielleicht eine Diagnostic-Settings-Anlage auf einen separaten Log-Analytics-Workspace), und der nächste Mit-Entwickelnde sollte nicht raten müssen, wo das landet. Eine Stelle, eine README, eine Konvention.
Zahlen zum Stand
- Migrationen: drei neue Produktions-Migrationen diese Woche (
AddContentSafetyPerSurfaceThresholdsfür die Schwellen-Tabelle,AddTestChallengesfür die Tester-Panel-Tabellen,AddSocialPostingEntitiesfür die drei Social-Posting-Tabellen). Alle auf jedem Aspire-Boot automatisch angewendet. - Seed-Inhalte: 41 Test-Herausforderungen (mit deutscher Übersetzung), 3 Sample-Contests, 13 gerettete v1-Geschichten + 4 Autoren, 252 neu geschriebene Fan-Fictions, 6 Social-Posting-Verbindungs-Platzhalter. Beim Boot auf jeder Umgebung neu eingespielt.
- Pull-Requests: rund 50 PRs, gefühlt zu viele zum Aufzählen. Highlights: #464 + #477 (Content Safety), #391–#401 (GDPR Phasen 1–3, 11 PRs), #305 + #389 + #405 + #446 + #481 + #486 + #488 (Coming-Soon / Maintenance Gates), #402 + #403 + #427 + #483 + #487 + #489 (Windows MSIX), #341 + #485 (Tester-Panel), #493 + #495 (Social Posting + Folge-Fix), #343 + #350 + #351 (Contests). Insgesamt rund 70 Commits.
- Unit-Tests: jetzt rund 2.940 grün (zu Wochenbeginn 2.580; rund 360 neue Tests, davon allein 250+ für die Content-Safety-Erweiterung — jede Oberfläche bekam eigene Service- und Report-Publisher-Tests).
- Sentry-Lärm-Reduzierung: PRs #356, #357, #383, #384, #385, #386, #492 — sieben PRs, die ungefähr 40 erwartete Nicht-Bugs (abgelaufene JWTs, client-gecancelte Requests, EF-Design-Time-Warnings, Development-Environment-Events, Prerender-401s, Manifest-404s) aus dem Sentry-Event-Stream entfernten. Der Stream zeigt jetzt eigentliche Bugs.
Was als nächstes
Die Sicherheits-Welle ist mit dieser Woche durch. Der nächste Schwerpunkt verlagert sich Richtung Live-Vorbereitung:
- Trusted-Signing-Freigabe abwarten — sobald Microsoft die KYC durchgewunken hat, fallen die unsigniert-Banner auf
/about/appsweg und die Windows-MSIX-Artefakte gehen als signierte Releases raus. - Meta-App-Review absenden — die
instagram_content_publish-Berechtigung ist die einzige offene Sperre fürs tägliche Posten auf Instagram. Facebook und Bluesky laufen schon mit den vorhandenen Berechtigungen. - Beta-Tester-Onboarding-Welle — wir wollen vor der öffentlichen Eröffnung noch eine breitere (aber immer noch eingeladene) Beta-Phase laufen lassen. Das Tester-Panel ist der Ankerpunkt; der Onboarding-Flow steht noch nicht ganz, und die Welcome-E-Mail braucht einen letzten Schliff.
- Inhalte-Sweep — die 252 neu geschriebenen Seed-Geschichten sind ein gutes Fundament, brauchen aber pro Genre noch einen Editorial-Durchgang. Das ist Hand-Arbeit, kein Code.
Bis dahin: morgen ist Feiertag, Freitag ist Brückentag — wenn dir auf einem der neuen Bildschirme etwas seltsam vorkommt, schreib's. Die kleinen Sachen sind oft die, die das Gefühl der App prägen.
