Content moderation on every piece of content, the GDPR package complete, a Windows installer out of the pipeline

Content moderation on every piece of content, the GDPR package complete, a Windows installer out of the pipeline

content-safetygdprdatenschutzwindowsmsixgatestesteralpha
Fiction

Content moderation on every piece of content, the GDPR package complete, a Windows installer out of the pipeline

Moss-covered stone guardian lion peering out from foliage — a symbol of quiet vigilance and protection Photo: MChe Lee on Unsplash

After last week's tooling wave, this week had a clear common thread: security. Not in the IT-audit sense, but in the sense of a platform preparing for its gates to open. What happens when the first outside user writes a comment with offensive content? When someone wants to delete their account, but has already published stories that others are reading and commenting on? When the platform needs to go into planned maintenance mode without search engines indexing the placeholder page instead of the real one? When a tester reports "I can't install on Windows"?

Seven of these questions had no clean answers at the start of the week. By Wednesday, all of them were answered. On top of that, with a bit of momentum at the end, came the daily social media integration with Facebook, Instagram, and Bluesky, a contest feature, the tester panel with a checklist, a few team additions on the about page, and Thursday is a public holiday.

Content Safety now on every piece of content

Until earlier this week, OutaStory moderated nothing. Comments were accepted as they came in; chapter text likewise; AI-generated covers went through the publish wizard unchecked. That held up through the closed-alpha phase because the tester list is hand-picked — the moment a single unknown outside account is allowed to publish, that becomes untenable. This week, moderation was pulled through the entire stack in one pass.

The architecture follows a single pattern that now works identically across four surfaces: comments, page comments, story chapters, and book covers. Azure Content Safety is the provider; four categories (Hate, Sexual, Violence, Selfharm) plus a configurable threshold per category and per surface decide whether content passes through, gets auto-flagged, or is rejected outright. The read/write pattern is fail-open: if the Azure service is unreachable, the content passes through but is marked Failed=true and automatically lands in the moderator queue. The platform stays usable — no single service outage locks down the write side.

Comments and page comments (PR #464)

The two shortest content paths kicked things off: story comments under /stories/{slug} and page comments on the chapter detail pages. Both now run through the same CommentModerationService, which calls AnalyzeTextAsync on every new submission, checks the result against the ContentSafetyThreshold table, and sets the status to Approved, Flagged, or Rejected depending on the hit. Flagged comments stay visible to their authors (you don't want your own comment to suddenly vanish from the thread from your own point of view), but they show up in the /moderation/comments moderator queue and are hidden from other readers until a moderator approves or rejects them.

The thresholds are administrable at /admin/content-safety-thresholds — a single form per surface with four sliders (Allow if severity ≤ X, Flag if severity > X and ≤ Y, Reject if severity > Y). The values differ by surface. On comments, Sexual is cut more strictly than on story chapters (chapter content can be considerably more explicit than a comment underneath it, depending on genre and age rating); Hate is hard-capped to Reject from severity 4 everywhere. The thresholds are pre-populated with defaults via IConfiguration, so an empty ContentSafetyThreshold table still yields a working configuration.

The secrets for the Azure resource (Endpoint, Key, Location, SubscriptionId) follow the usual path: tools/content-safety-keys/Set-ContentSafetyKeys.ps1 writes them in this order to AppHost user secrets → Key Vault → GitHub Actions secrets. Same pattern as Stripe, SendGrid, Sentry, BZSt, and all the other vendor tools.

Story chapters (PR #477)

Four days later, the same architecture was extended to the writing side. Every chapter goes through ChapterModerationService on publish or update, which splits the text into 9,500-character chunks (Azure Content Safety's hard input limit is 10,000), analyzes each chunk individually, and aggregates the result by worst-case severity. The chunk-by-chunk approach isn't just a technical constraint — it also solves a practical problem: a single harsh hit at the start would otherwise get diluted by a cozy romance middle section. Worst-case means: if one section hits Sexual=Severity 6, the entire chapter is Sexual=Severity 6, regardless of what chunk 4 of 12 says.

Flagged chapters are published-but-marked: visible only to the author plus the moderator list, hidden on listing pages, with a green-orange-red status chip in the editor. Rejected chapters aren't turned away — we leave the author room to revise the draft — but they're visible to no one except the author and carry a prominent red notice on the editor page.

The ChapterModerationReportPublisher additionally writes a structured row per hit into the moderation table — severity per category, timestamp, worst-case chunk with the first 200 characters of the trigger. That's the audit trail a moderator can use to trace exactly why the system reacted. Severity-0 content leaves nothing in the table; it stays unremarkable.

AI-generated book covers (PR #477)

The third surface is the most unusual. For the others, Content Safety checks existing, user-entered text or an existing image — for covers, the problem sits upstream: covers are generated from an AI prompt our cover generator sends to gpt-image-1.5, and we want to prevent the ImageProcessor from even generating a cover whose prompt is already problematic.

The solution runs in two steps and pushes the content-safety wall one stage forward. Step one: CoverGenerationModerator checks the prompt text with AnalyzeTextAsync before anything is sent to Azure OpenAI. If the prompt gets flagged or rejected, the request never goes out — no OpenAI call, no generated image, no storage cost. Step two: after a cover has been generated, it runs through AnalyzeImageAsync (the new image lane in IContentSafetyClient). That's belt-and-suspenders — the prompt is clean, OpenAI's style filter is turned on, and yet Content Safety checks the output image once more anyway. If the image check trips, the image doesn't get sent to storage, and the generator retries with a neutralized prompt.

AnalyzeImageAsync has its own pre-check for the Azure input limit (4 MB for images) and delivers the same fail-open semantics as the text path: on Azure errors, the cover passes through but is marked Failed=true and put up for manual review in /moderation/covers.

In total, the wall now covers four surfaces and introduces a fifth column into the database schema: ContentSafetyThreshold with a (SurfaceType, CategoryName) index. On the UI side, the existing /admin/content-safety-thresholds screen was expanded into a complete overview — one card per surface with the four category thresholds, all visible at once on a single page. If a threshold is off, it's not hidden.

GDPR phases 1–3: full data export, grace-windowed account deletion, and an Auth0 reaper

Before this week, the /profile/privacy panel had a DSAR button (Data Subject Access Request, the data export required under GDPR) that returned a sparse JSON with the Auth0 profile and a few Author columns. That was a bare minimum — what GDPR actually requires is all personal data plus a clear picture of what happens on account deletion. This week, phases 1, 2, and 3 were completed.

Person typing on an HP laptop running Windows 11 — a symbol of the new MSIX installer on desktop devices Photo: Windows on Unsplash

Phase 1: full data export (PR #391, #392)

The DSAR endpoint now produces a structured JSON with everything OutaStory has stored about a person:

  • Auth0 profile: email, name, login count, last login, all custom app metadata.
  • Author record: bio, avatar URL, all profile columns.
  • Stories: title, slugs, chapter count, publication status, estimated reading time.
  • Comments: every story and page comment with timestamp and target slug.
  • Reading progress: per story, with chapter and page position.
  • Reactions / likes / bookmarks: all interactions.
  • Newsletter subscriptions: status per list (daily / weekly), locale.
  • Support tickets: every opened ticket with status and content.
  • Push devices: push token hashes (no plaintext tokens), platform, last activity.
  • Moderation history: all content that was flagged or rejected, with the reason.

An open bug from the first attempt: the DSAR handler inside /profile/privacy didn't prime AuthState correctly, causing the first click on "Download data" to occasionally 401 (PR #391, a classic race condition between Blazor Server rendering and cookie-auth hand-off — the same class of issue as the admin-page 401s from the previous week).

Phase 2: revenue-share block in the data export (PR #393)

The DSAR file now also carries a revenue-share block: every amount attributed in RevenueShareLedger, the monthly aggregates, the amounts paid out, the Stripe Connect account ID, and the tax form records held in the database (1099-K for US authors, the DPI record for German authors). This is deliberately separated from the Auth0 personal information — it's compensation information, stored separately and subject to its own retention period. The block documents this directly in the JSON as well: "retentionNote": "kept for 10 years per § 147 AO and § 257 HGB".

Phase 3: account deletion with a grace period (PR #395, #396, #397, #398)

This is where most of the substance sits. Until now there was no account-deletion feature at all — only an email-to-support process. Now there's a delete button on /profile/privacy that triggers a multi-stage workflow:

  1. An entry in AccountDeletionRequest, with RequestedAt, GracePeriodEnds = RequestedAt + 30 days, and a choice per content type: what happens to your stories? To your comments? To your reactions? Three options per type: delete along with the account, anonymize (the content stays visible, but "Anonymous Author" / "Anonymous Comment" replaces the name), or donate to the system (for stories: the story stays in the catalog under the name of a "Community" pseudo-identity subject to an open license; for comments: the same anonymization as option 2). This is documented in detail in assets/Announcements/2026-05-15-… (= this page) and explained on the /profile/privacy page with examples.
  2. A status banner on every platform page during the grace period: "Your account will be deleted on [date]. [Cancel]." (PR #395, later adjusted in #400 because the cancel link initially pointed to a 404). The banner respects plurality — a click opens a dialog with the choice options, so the person can review or change their decision again.
  3. A daily background job (AccountPurgeService, PR #396) that carries out the account deletion once the 30-day window has elapsed. The service checks a "service is enabled" flag, an "a lease is held" flag (prevents parallel runs on two container replicas), and a batching cap (max 50 accounts per tick) before it executes the deletions.
  4. Auth0 hook (PR #397): after a successful deletion in our own database, the Auth0 user is removed via Auth0.ManagementApi.Clients.Users.DeleteAsync. This was the trickiest part — if the Auth0 call fails, an entry is written to an AccountDeletionFailure table instead of rolling back the whole transaction. Manual cleanup is possible, but the database deletion stays in effect — otherwise the person would be left with a still-active email login but an empty account.
  5. Auth0 orphan reaper (PR #401, the final phase-3 follow-up): a nightly job that looks for Auth0 users who no longer have an Author record in our database. They're checked against the AccountDeletionRequest table and, if there's a match, removed with the same DeleteAsync call. That closes the loop: no Auth0 account survives a successful OutaStory deletion.

The choice per content type (PR #398) is where we put in the most hours. There's a real tension between the GDPR right to erasure (everything gone) and the community's interest in continuity (a story with 200 comments isn't only the author's — the discussion doesn't belong exclusively to them). The German Data Protection Conference (Datenschutzkonferenz) published a statement in 2020 that recommends exactly this anonymization option as a compromise, and our lawyer pointed us to it. The UI presents the choice as a deliberate decision: each option shows exactly what will happen, with a small example block. "Anonymize: Your story 'The Forgotten War' remains readable to others, but the author name is set to 'Anonymous Author'; your comments and reactions are anonymized in the same way."

Coming-soon and maintenance gates

Until now, the platform had no way to be switched into a planned maintenance mode. That wasn't a problem during the closed alpha — the handful of testers know the web app can briefly become unreachable at any time. Once the world is allowed to watch, "the site shows a 502" isn't acceptable.

Two gate modes arrived this week:

  • Coming-soon: shows a dedicated landing page with "We open on [date]," a newsletter sign-up field, and a short about section. Active while the platform is ahead of its public launch.
  • Maintenance: shows a dedicated service-paused page with "We'll be right back" and an estimated time back online. Active whenever we're running a maintenance-window routine (e.g., a database migration with locking).

Both are controlled via a Front Door header (X-Gate-Mode: coming-soon or maintenance) that the reverse proxy sets as needed — a 3-click operation in the Azure portal, no code deploy required. The server picks up the header in its GateMiddleware and transparently rewrites the URL to /coming-soon or /maintenance without issuing an HTTP 302. That's the SEO-conscious variant: a 302 hop would confuse search-engine crawlers (every URL would redirect to the gate page, and the original URL would fall out of the index). Instead, the server renders the gate page in-place with status code 503 (Service Unavailable) and a Retry-After header. Crawlers ignore the content under a 503 but keep the original URL — if the gate page is ever switched off, the URL is still right where it was.

GateLayout, GateShell, and the language switch

The gate pages have their own visual identity, deliberately distinct from the main layout — a simple background, large type, centered branding, a very quiet waiting-room feel. That's solved with two components: GateLayout replaces the normal AppLayout (and doesn't reach for the sidebar, which wouldn't work in maintenance mode anyway), and GateShell is the top bar with a language switch (DE/EN) and an understated OutaStory logo.

An architectural nuance: the server has to decide which layout to use before rendering. That happens via PersistentComponentState — the GateMode is attached by the server to the client, so both the Blazor Server component and its Wasm counterpart find the correct layout. Routes.razor picks the layout based on this value; AppLayout stays single-purpose (no longer "the layout that decides for itself what it is").

The privacy policy, the terms of service, the imprint, and the revenue-share terms — all the pages under /legal/* — are now standalone-capable. In gate mode they appear without the rest of the platform (using GateLayout), but with a working language switch, a back button leading to the gate, and full content. This is a real legal requirement: the imprint obligation doesn't end when the platform goes into maintenance.

Front Door, canonical host, and PR #389

A second wave cleaned up the hosts: previously, OutaStory was reachable under two URLs — the container app's default name (outastory-app-web.delightfulmoss-e8d84f4f.westeurope.azurecontainerapps.io) and the custom domain name (www.outastory.com). That's not just unattractive for SEO (two URLs with identical content), it's also an auth risk (cookies are per domain). PR #389 sets up a canonical-host redirect: every request to the container hostname is redirected with a 301 to the custom domain. In gate mode, the redirect happens before the gate middleware, so the gate page also works under the custom hostname.

Signed Windows MSIX installer out of the pipeline

OutaStory ships as a MAUI app on Android, iOS, macOS, and Windows. Three of those platforms have a store path (Google Play, App Store, Microsoft Store) — Windows is the exception, because the Microsoft Store has lower reach for home users. Instead of a store path, we ship a direct MSIX installer on Windows, and that took a few moving parts.

Hand ticking off a checklist with checkboxes in a notebook, with a mechanical keyboard and a monitor in the background Photo: Jakub Żerdzicki on Unsplash

What was added this week

The pipeline (PR #402): a new GitHub Actions job in the release workflow that builds the MAUI Windows targets (x64, x86, arm64), collects the .msix artifacts, signs them via Azure Trusted Signing (PRs #305, #427 set up the service account), uploads them to an Azure Storage container, and writes a release-manifest.json with download URLs per architecture.

The Bicep sidecar (#403): because the generated azd Bicep files (infra/*.bicep) get overwritten on every provision run, the additional storage account definition for the releases storage lives in a sidecar releases.bicep instead. The main azd path invokes the sidecar in the release-pipeline step. The storage prefix was shortened to 11 characters because Bicep has a maxLength(11) constraint on storage account names per resource group (a lesson learned from a first run that aborted with a limit-exceeded error).

The UI (PR #402): /about/apps now shows three download tiles (Android APK, Windows MSIX, iOS via TestFlight) plus a status per platform — "Signed and published," "Preview (unsigned)," or "TestFlight: sign-in required." As long as Trusted Signing is still in the KYC phase, Windows shows the preview status (PR #483 makes this explicit). Once signing goes live, the preview banner disappears.

What went wrong along the way

  • MSIX output path (PR #487): MAUI doesn't drop the .msix into dotnet publish -o's output directory, but into <ProjectDir>/AppPackages/<App>_<ver>_<arch>_Test/. The pipeline needed to know this and adjust the glob.
  • Releases storage credentials (PR #489): the pipeline initially wrote the storage credentials into the repo root, which confused git status and triggered a lint error. Fixed to write into $RUNNER_TEMP/, which disappears after the run.
  • azure/artifact-signing-action rename (PR #427): the GitHub Action was renamed from Microsoft/trusted-signing-action to azure/artifact-signing-action; the workflow had to follow.
  • OIDC token drift in the serial deploy (PR #471): azd deploy had grown to take long enough with 15 container apps that the OIDC token expired between the first and last service. Fix: re-request the token before every azd deploy, plus a serial rather than parallel deploy path (also a memory saver — the parallel path had been OOM-killing the build runner).
  • Parser bug in the PowerShell script (PR #483): the signed/unsigned status is passed to the UI via a manifest.json; the original script used the wrong JSON parser path, which interpreted the null value for an unsigned MSIX as an error.

The one open item is the Trusted Signing approval itself — Microsoft has our KYC submitted, and the documented response time is 1–4 weeks. Until then, the Windows MSIX artifacts run as unsigned previews on /about/apps with a corresponding banner.

Tester panel at /profile/tester

With the growing tester list, the need for structured feedback became obvious. A single tester writes three or four concrete bug reports a week; seven testers write 21–28 reports in the same week, and some things show up in 12 of them, others in none. We needed a format where testers can work through a checklist and give a concrete "works / doesn't work / note" signal.

/profile/tester (PRs #341, #485) now runs the tester panel with currently around 41 test challenges across 6 categories:

  • Onboarding — account creation, email verification, premium sign-up.
  • Reading & listening — finding stories, navigating the reader, starting audio, testing offline mode.
  • Writing & publishing — starting a new story, writing chapters, generating audio, requesting an AI cover, checking the preview, publishing.
  • Profile & privacy — editing profile, uploading avatar, triggering DSAR, initiating-and-canceling account deletion.
  • Support & moderation — searching help articles, expanding FAQ entries, opening an anonymous test ticket, a logged-in ticket conversation, reporting a comment as a violation.
  • Admin (role-gated for moderator/admin/developer accounts) — the category editor, user management, working through the moderator queue.

Each challenge has a title, a description with steps, and a "Passed / Failed / Skipped" trio. On "Failed," a note field opens where the tester can describe the bug. Every submission lands as a TestChallengeAttempt row with the app version, the User-Agent, the platform (web/Android/iOS), the current locale, and an optional extra JSON block.

/developer/test-feedback (developer-gated) runs the other side: an overview of all submissions, grouped by version (so a resubmission on a newer version doesn't overwrite the old "passed" marker — both rows stay in the history), filterable by status, category, and platform. A green cell means "passed on this version"; a red one means "failed, here's the note."

A localization fix in the same week (PR #485): the challenge titles and descriptions were always returned in English by the API, even when the web client sent the Accept-Language: de-DE header. Cause: TestChallengesController was reading CultureInfo.CurrentUICulture instead of the Accept-Language header, and the API host doesn't register UseRequestLocalization. Fixed by reading the header directly, with a fallback to en-US. The same PR added 11 additional challenges and a collapsible category view — the list is otherwise too long for a single screen.

Daily social media posts on Facebook, Instagram, and Bluesky

Landed fresh this Wednesday (PR #493): an automated posting mechanism that publishes a selected "story of the day" to Facebook, Instagram, and Bluesky daily, per language (EN + DE). Six posting targets in total (3 networks × 2 locales). This is more infrastructure than a user-facing feature — once the platform is public, our stories should show up regularly in the social stream, and the effort per day should be zero.

Six adapters (FacebookPoster, InstagramPoster, BlueskyPoster, one per network × locale) hang off the same INetworkPoster interface and a shared IStoryOfTheDayPicker that makes the selection. The selection logic is two-tier: first by popularity (the IRecommendationEngine top-50 list), then a random pick from the pool if the popular ones have already all been posted recently (dedup window: 14 days per locale). Both tiers filter down to published stories in the matching locale.

OAuth sign-in runs directly in the admin screen at /management/social: a connect button per network-locale row that either starts the OAuth dance for Meta or opens an app-password modal for Bluesky. Tokens are encrypted via IDataProtector and held in the database. A SocialConnectionProbeService runs daily at 04:00 UTC and checks the health of each connection; on a persistent failure the connection is automatically disabled and an email goes out to the admin alert recipient (with a 24h dedup so an outage doesn't produce an email flood).

The feature is technically complete this week but not yet live: Meta requires an app review for the instagram_content_publish permission (1–3 weeks response time), the Bluesky accounts still need to be created, and the secrets need to be pushed through the secrets pipeline once via tools/social-keys/Set-SocialKeys.ps1. Until then, empty secrets flow through harmlessly — the daily job runs, finds no active connection, and logs accordingly.

The same week, an earlier PR merge came back to bite us on the release side: the API referenced the OutaStory.SocialPoster Functions host, which in turn referenced OutaStory.Newsletters — both Functions hosts produce host.json / worker.config.json / functions.metadata at the same relative paths, which caused the publish step to hit NETSDK1152: Found multiple publish output files and blocked the release. Follow-up PR #495 extracted the shared, non-Functions parts into two plain libraries (OutaStory.SocialPoster.Core and OutaStory.Newsletters.Picking) — the API now only references the libraries, the Functions hosts reference them too, and the host.json collision is gone. A lesson for the repository memory (see [[reference-netsdk1152-functions-isolation]]): Functions hosts are never directly referenced by non-Functions consumers.

Contest feature: admin-managed contests

PR #343 brought an admin-managed contest area. Admins can create a new contest at /admin/contests with a title, description, time window, submission conditions, jury list, and a prize amount. The contest landing page then appears at /contests/{slug} and is public. For the closed alpha this is mostly preparation — we have three sample contests in the seed (May Romance Challenge, Spring Fan-Fiction, Family Diary Contest) that serve as examples and as test data.

Two safety guards: a typed slug-confirm button on admin delete (PR #350) — deleting a contest requires the admin to type the exact contest slug before the button becomes active; and the seeder no longer deletes runtime-added jurors (PR #351), instead creating them additively so re-seeding doesn't overwrite manual changes.

/about/partners and team expansion

Two smaller content updates on the about page:

  • /about/partners (PR #314): a new landing page for partners — licensing partners for audio narration, education partners for school content packages, ad providers — with a list of existing partners and a request form. The list is admin-maintainable via assets/team/partners.yaml.
  • Team expansion (PR #303): three new roles on the /about/team page — CFO, Investor Relations, Social Media Manager. Each role got a sample entry plus its associated social-link icons.
  • LegalFooter (PR #304): site-wide on the web host, with links to imprint, privacy, terms of service, revenue-share terms, plus a small version indicator. Replaces the ad-hoc, per-page footer variants that had accumulated until now.

Seed stories rewritten

An older pain point got resolved this week: 252 of the seeded fan-fiction stories were boilerplate (bloated templates, repetition, generic plots) — meant to fill out the catalog, but they read like auto-generated content in the reader. PR #308 rewrote them, along with hardening the author cap (max 10 stories per author, so the distribution looks natural). PR #315 additionally rescued 13 human-written stories and 4 real authors from the OutaStory v1 database (from before the relaunch — original authors' permission is on file) that found their way back into the seed.

Repository hygiene: /bicep/ as the canonical bypass track

A small cleanup at the end that promises more than it sounds: the repository until now had two parallel folders where hand-written infrastructure pieces landed — one, /bicep/ (for outastoryreleases.bicep, which feeds the Windows MSIX storage account pipeline from PR #402), and the other, /infra/extensions/ (for blob-lifecycle-policy.json, the 10-year retention on the revenue-share containers from phase 6). Both existed for the same reason — they're what azd infra gen doesn't produce, because the thing can't be expressed through the Aspire AppHost manifest APIs. Two folders for the same purpose is a smell.

This week it got consolidated: everything moves to /bicep/. The README there now documents the convention explicitly:

  • /infra/ is exclusively azd infra gen output. Never create or modify a file here by hand; any edit is lost on the next regeneration.
  • /bicep/ is the canonical bypass track for hand-written Bicep modules and policy JSON files that can't go through the AppHost. They're applied from release.yml, after azd provision has materialized the AppHost's own resources.

This reads like dry bookkeeping, but it's preventive: the next phases will likely bring more bypass pieces (Front Door rules, custom RBAC for external principals, maybe a diagnostic-settings config on a separate Log Analytics workspace), and the next contributor shouldn't have to guess where that lands. One place, one README, one convention.

Numbers as they stand

  • Migrations: three new production migrations this week (AddContentSafetyPerSurfaceThresholds for the threshold table, AddTestChallenges for the tester-panel tables, AddSocialPostingEntities for the three social-posting tables). All applied automatically on every Aspire boot.
  • Seed content: 41 test challenges (with German translation), 3 sample contests, 13 rescued v1 stories + 4 authors, 252 rewritten fan-fictions, 6 social-posting connection placeholders. Re-seeded on boot in every environment.
  • Pull requests: around 50 PRs, honestly too many to list. Highlights: #464 + #477 (Content Safety), #391–#401 (GDPR phases 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 + follow-up fix), #343 + #350 + #351 (contests). Roughly 70 commits in total.
  • Unit tests: now around 2,940 passing (2,580 at the start of the week; around 360 new tests, more than 250 of them for the Content Safety expansion alone — every surface got its own service and report-publisher tests).
  • Sentry noise reduction: PRs #356, #357, #383, #384, #385, #386, #492 — seven PRs that removed roughly 40 expected non-bugs (expired JWTs, client-canceled requests, EF design-time warnings, development-environment events, prerender 401s, manifest 404s) from the Sentry event stream. The stream now shows actual bugs.

What's next

The security wave wraps up with this week. The next focus shifts toward launch preparation:

  • Waiting on Trusted Signing approval — once Microsoft clears the KYC, the unsigned banners on /about/apps disappear and the Windows MSIX artifacts go out as signed releases.
  • Submitting the Meta app review — the instagram_content_publish permission is the only remaining block on daily posting to Instagram. Facebook and Bluesky already run with the permissions we have.
  • Beta tester onboarding wave — we want to run a broader (but still invite-only) beta phase before the public launch. The tester panel is the anchor point; the onboarding flow isn't quite there yet, and the welcome email needs a final polish.
  • Content sweep — the 252 rewritten seed stories are a good foundation but still need an editorial pass per genre. That's manual work, not code.

Until then: tomorrow is a public holiday, Friday is a bridge day — if something on any of the new screens feels off, let me know. The small things are often the ones that shape how the app feels.


Comments (0)

No comments yet.


Leave a comment

Rejoining the server...

Rejoin failed... trying again in seconds.

Failed to rejoin.
Please retry or reload the page.

The session has been paused by the server.

Failed to resume the session.
Please reload the page.