Files
RpgRoller/TASKS.md

24 KiB

Rewrite The Web App Into A Route-First Authenticated Shell

This ExecPlan is a living document. The sections Progress, Surprises & Discoveries, Decision Log, and Outcomes & Retrospective must be kept up to date as work proceeds.

PLANS.md is checked into the repository root. This document must be maintained in accordance with PLANS.md.

Purpose / Big Picture

After this change, the browser URL will match the authenticated screen the user is actually using. Anonymous users who open / will be redirected to /login. Authenticated users who open / will be redirected to /play. The hamburger menu will navigate to real routes such as /play, /campaigns, and /admin instead of toggling large conditional branches inside one component at /.

This matters because the current authenticated workspace is still one large, structurally dynamic Blazor Server surface. POSTMORTEM.md shows that this architecture is fragile when browser extensions mutate form-related DOM during startup. The route-first rewrite reduces the amount of UI that wakes up at once, removes the dual-purpose / shell, and makes the authenticated shell easier to reason about, test, and evolve.

The change is complete when a human can run the app, open /, observe the correct redirect based on auth state, log in at /login, land on /play, navigate to /campaigns and /admin with real URLs, refresh any of those routes without being thrown back to /, and run the automated host and Playwright tests that prove the new behavior.

Progress

  • (2026-05-04 17:52Z) Reviewed POSTMORTEM.md, the current app shell, workspace routing behavior, and the existing host and Playwright tests to define the rewrite around real routes instead of sessionStorage screen switching.
  • (2026-05-04 17:52Z) Updated README.md so it accurately describes the current architecture and the approved rewrite direction.
  • Implement a server-side entry redirect for / and move the anonymous auth experience to /login.
  • Introduce real authenticated routes for /play, /campaigns, and /admin while preserving current behavior.
  • Remove screen as a sessionStorage routing mechanism and replace menu actions with URL navigation.
  • Split the large Workspace render tree so play, campaign management, and admin each own a smaller subtree.
  • Reduce OnAfterRenderAsync to the smallest practical scope and keep staged startup out of the authenticated shell root.
  • Update host tests, Playwright smoke tests, and docs so the new route model is the only documented and verified behavior.

Surprises & Discoveries

  • Observation: the current browser API client is still implemented through JavaScript interop, so the authenticated UI cannot simply move all startup work into OnInitializedAsync. Evidence: RpgRoller/Components/RpgRollerApiClient.cs calls js.InvokeAsync("rpgRollerApi.request", ...), which means authenticated data fetches currently depend on an interactive render before they can run.

  • Observation: the current smoke suite encodes the old dual-purpose / behavior and will fail as soon as / becomes a redirect entry point. Evidence: tests/e2e/smoke.spec.js currently expects anonymous GET / to render static auth markup and authenticated GET / to render the Blazor workspace shell.

  • Observation: the current host test also encodes an outdated assumption about /. Evidence: RpgRoller.Tests/Api/FrontendHostTests.cs currently asserts that GET / returns HTTP 200 and a Blazor shell containing _framework/blazor.web.js.

Decision Log

  • Decision: implement the approved route-first approach rather than continuing to add localized mitigations inside the current / workspace shell. Rationale: the user approved this direction after reviewing three refactor options, and POSTMORTEM.md concludes that the problem is architectural rather than a single bug. Date/Author: 2026-05-04 / Codex and user

  • Decision: keep the anonymous auth page as plain HTML and JavaScript, but move it to /login instead of restoring it as an interactive Blazor form. Rationale: the anonymous path was intentionally isolated from Blazor in commit 2d2ed56, and the postmortem treats that isolation as a valid mitigation for the login surface. The rewrite should not reintroduce a form-heavy Blazor login page unless there is a compelling reason later. Date/Author: 2026-05-04 / Codex

  • Decision: make / a server-side redirect entry point instead of continuing to let App.razor choose between auth and workspace content based on request-time auth state. Rationale: App.razor is currently a hidden architecture boundary. Moving auth-based entry selection to an HTTP redirect makes the boundary explicit, testable, and smaller. Date/Author: 2026-05-04 / Codex

  • Decision: use the URL path as the source of truth for the current authenticated screen. Rationale: the current screen preference in sessionStorage causes the app state and the browser URL to disagree. Real routes remove that mismatch and make refresh, deep-linking, and testing simpler. Date/Author: 2026-05-04 / Codex

  • Decision: stage the rewrite in two layers: first introduce real routes while preserving existing feature behavior, then split the large workspace tree into route-owned subtrees. Rationale: the current workspace is dense and risk-prone. A staged rewrite keeps the app working while the route model changes, and it gives the test suite meaningful checkpoints. Date/Author: 2026-05-04 / Codex

Outcomes & Retrospective

At plan creation time, the repository has an updated README and a concrete implementation plan, but no code for the route-first rewrite has been started yet. The immediate risk is not uncertainty about direction; it is carrying old assumptions about /, Home.razor, and sessionStorage-based screen switching into the first code changes. The milestones below are written to make those assumptions explicit and retire them in an observable order.

This section must be updated after each major milestone. When the implementation is complete, summarize which parts of the old workspace architecture were fully removed, which compatibility constraints remain, and whether the final startup path still depends on any multi-batch structural rendering.

Context and Orientation

The current app serves both anonymous and authenticated experiences from the same root request path. In RpgRoller/Components/App.razor, the HTML shell checks the current request path and session cookie through HttpContext. If the request is for / and no valid session exists, it renders RpgRoller/Components/Pages/HomeControls/StaticAuthPage.razor as plain HTML and loads RpgRoller/wwwroot/js/rpgroller-api.js. That JavaScript file binds the login and registration forms and sends fetch requests to /api/auth/register and /api/auth/login.

If a valid session cookie exists, App.razor instead renders the interactive Blazor router. The only current component route for the authenticated shell is RpgRoller/Components/Pages/Home.razor, which maps @page "/" and immediately renders Workspace. RpgRoller/Components/Pages/Home.razor.cs is only a logout redirect helper; it is not a real page controller anymore.

The authenticated workspace lives in RpgRoller/Components/Pages/Workspace.razor and Workspace.razor.cs. The Razor file contains the header, play screen, campaign management screen, admin screen, toasts, and modals. The code-behind wires several coordinator classes, and OnAfterRenderAsync drives session initialization and staged control enablement. The currently selected screen is stored in WorkspaceState.CurrentScreen, and WorkspaceSessionCoordinator.cs persists that screen name in browser sessionStorage under the key rpgroller.screen.

In plain language, a “route-first authenticated shell” means that the browser path decides which authenticated page is being rendered. /play means the play page. /campaigns means the campaign management page. /admin means the admin page. The URL is not a decorative detail; it is the primary way the app chooses the screen. Menu clicks change the URL. Reloading the page preserves the same screen because the URL already says what the screen is.

In this repository, “server-side redirect” means an HTTP redirect response such as 302 Found returned before any Blazor UI is rendered. For example, GET / should answer with a redirect to /login or /play based on whether the session cookie maps to a real user through IGameService.GetUserBySession.

The API surface is already session-cookie-based. RpgRoller/Api/AuthEndpoints.cs sets the session cookie on login, RpgRoller/Api/MeEndpoints.cs returns the authenticated user model, and the rest of the authenticated /api routes are behind RequireSessionTokenFilter. This means the routing rewrite does not need a new auth system. It needs a clearer frontend entry structure and smaller authenticated page ownership boundaries.

One constraint must be kept in mind from the start: RpgRoller/Components/RpgRollerApiClient.cs performs requests through JavaScript interop. That means the authenticated UI still needs an interactive render before it can make its first data request. The rewrite must therefore reduce the amount of structure that changes after interactivity begins, not pretend that interactivity can be avoided entirely with the current client stack.

Plan of Work

Begin by separating the entry route from the anonymous auth page. Add a small host-level endpoint module, for example RpgRoller/Api/FrontendEntryEndpoints.cs, or an equivalent hosting extension, and map GET / before the Razor component host is mapped in RpgRoller/Program.cs. This endpoint must read the session cookie using the same cookie name defined in RpgRoller/Api/SessionCookie.cs, ask IGameService whether the cookie belongs to a real user, and return a redirect to /play for authenticated users or /login for anonymous users. This removes the dual-purpose / behavior.

Next, simplify RpgRoller/Components/App.razor so it no longer chooses between anonymous and authenticated content based on auth state. It may still choose between a static /login document and the interactive authenticated router based on the request path, because the anonymous page is intentionally plain HTML. The important change is that auth-state branching moves out of the component tree. App.razor should become a stable host for either the static login document at /login or the interactive authenticated route set everywhere else.

After that, introduce real component routes for the authenticated pages. Create RpgRoller/Components/Pages/PlayPage.razor, CampaignsPage.razor, and AdminPage.razor, each with an explicit @page directive. In the first implementation pass, it is acceptable to keep much of the existing state and coordinator logic by adapting Workspace so each route uses only the subtree it needs. The key result of this milestone is that the URL changes from /play to /campaigns to /admin and each route refreshes correctly.

Once the routes exist, remove screen as a persistence and navigation concept. Delete the CurrentScreen routing responsibility from WorkspaceState.cs and remove the screen sessionStorage reads and writes from WorkspaceSessionCoordinator.cs. Replace menu items in Workspace.razor.cs and AppHeader.razor wiring so they navigate through NavigationManager.NavigateTo(...) or plain links to /play, /campaigns, and /admin. Keep sessionStorage only for true view preferences such as mobile panel state, selected campaign when appropriate, and roll visibility if those still earn their complexity.

The next pass is the structural split. Extract the common authenticated chrome into a dedicated component such as RpgRoller/Components/Pages/AuthenticatedShell.razor. This shared shell should own the header, logout action, health banner, and toast stack. Then move the play-only DOM, campaign management DOM, and admin DOM out of the monolithic conditional branches in Workspace.razor and into page-specific route components. PlayPage should own SSE startup and the play-specific panels. CampaignsPage should own character create and edit workflows. AdminPage should own admin-only data loading and buttons. The goal is that each route owns a smaller and more stable subtree, rather than all authenticated screens living under one branching root.

Finally, revisit startup sequencing. Because API reads still depend on JS interop, some post-render initialization may remain necessary, but it should be limited to the page that actually needs it. Remove the pattern where the authenticated shell root performs several structural follow-up renders merely to decide which screen to show. If staged initialization remains on /play, it should be contained to the play page and should reveal a stable page-local loading shell rather than reshaping the entire authenticated app. Record the exact remaining OnAfterRenderAsync responsibilities in the code and in README.md.

Throughout the rewrite, keep the documentation and tests aligned. README.md must stop describing the rewrite as planned once the code lands, and the host and smoke tests must verify the new route-first behavior rather than preserve the old root-path assumptions.

Milestones

Milestone 1: Make / An Explicit Entry Redirect

At the end of this milestone, a browser request to / no longer renders either the auth page or the workspace directly. Instead, the server returns a redirect to /login or /play based on the session cookie. The anonymous auth page is reachable at /login, and logging in transitions the user to /play.

Implement this by adding the new entry endpoint mapping, updating App.razor to host /login without auth-state branching, and changing rpgroller-api.js so successful login goes to /play rather than /. Also update Home.razor.cs or its replacement logout helper so logout navigates to /login with the existing status message query behavior.

Proof for this milestone is simple and observable. Anonymous GET / returns a redirect to /login. Authenticated GET / returns a redirect to /play. Opening /login renders the current static auth markup without loading _framework/blazor.web.js. Logging in from /login lands on the play workspace.

Milestone 2: Add Real Authenticated Routes Without Breaking Features

At the end of this milestone, /play, /campaigns, and /admin all exist as first-class routes, and the hamburger menu moves between them using URLs rather than sessionStorage. Feature behavior may still be backed by some shared workspace code, but the route model is now real.

Implement this by creating the new page components and adapting the current workspace logic so the correct route renders the correct content. During this milestone it is acceptable to keep a shared backing component or service if that reduces churn, but the URL must be the authoritative screen selection mechanism. Direct navigation to /campaigns should show campaign management, not the play screen followed by an in-memory switch. Direct navigation to /admin should either show the admin page for admins or redirect non-admin users to /play.

Acceptance for this milestone is that refreshing /campaigns leaves the user on /campaigns, refreshing /play leaves the user on /play, and opening /admin as a non-admin does not expose admin controls.

Milestone 3: Split The Monolithic Workspace Tree

At the end of this milestone, there is no longer a single authenticated component that conditionally renders all major screens under one branch-heavy root. Shared authenticated chrome is extracted, and each route owns its own main content subtree.

Implement this by introducing a shared authenticated shell component and moving the play, campaign management, and admin markup and page-specific coordination into route-owned components. Keep shared models and helper methods where they still make sense, but stop letting the root workspace decide which major screen exists in the DOM. If common state still exists, narrow it to user identity, selected campaign context, and shared feedback only.

Acceptance for this milestone is partly structural and partly behavioral. Structurally, Workspace.razor should no longer contain mutually exclusive branches for play, management, and admin screens. Behaviorally, the DOM-wrap smoke test or its replacement should still pass while each route loads only the controls it needs.

Milestone 4: Reduce Startup Churn And Finalize Docs

At the end of this milestone, the authenticated shell no longer uses OnAfterRenderAsync as the orchestration point for screen selection and broad structural staging. Any remaining post-render work is page-local, justified, and documented.

Implement this by moving any remaining screen-routing or shell-bootstrap logic out of Workspace.razor.cs, narrowing OnAfterRenderAsync responsibilities, and updating README.md to describe the completed route-first architecture rather than a planned rewrite. Also update POSTMORTEM.md only if a concise follow-up note is warranted; do not rewrite its historical analysis.

Acceptance for this milestone is a passing automated suite plus a manual browser run where /, /login, /play, /campaigns, and /admin all behave consistently with the final route model.

Concrete Steps

Run all commands from the repository root, which is /home/frank/Code/RpgRoller.

Start by inspecting the current route and auth files before editing:

sed -n '1,220p' RpgRoller/Components/App.razor
sed -n '1,220p' RpgRoller/Components/Pages/Home.razor
sed -n '1,260p' RpgRoller/Components/Pages/Workspace.razor.cs
sed -n '1,260p' RpgRoller/Components/Pages/WorkspaceSessionCoordinator.cs
sed -n '1,260p' RpgRoller/wwwroot/js/rpgroller-api.js
sed -n '1,220p' RpgRoller.Tests/Api/FrontendHostTests.cs
sed -n '1,260p' tests/e2e/smoke.spec.js

When implementing Milestone 1, update the host test first so the intended redirect behavior is explicit:

dotnet test RpgRoller.Tests/RpgRoller.Tests.csproj --filter FrontendHostTests

Expected direction after the edits:

RootPath_RedirectsToLogin_WhenAnonymous
RootPath_RedirectsToPlay_WhenAuthenticated
LoginPath_ServesStaticAuthMarkup

After wiring /login and the root redirect, run the app locally:

dotnet run --project RpgRoller/RpgRoller.csproj

Then verify in a browser:

open http://localhost:5000/
observe: anonymous request lands on /login
submit valid credentials
observe: browser lands on /play

When implementing route pages and navigation, prefer running the focused smoke suite against a temporary database:

pwsh ./scripts/run-playwright.ps1

If the app is already running and a faster inner loop is needed, run the checked-in smoke file directly:

npm run e2e:smoke

After each milestone that touches C# files, run the relevant test suite and then the full backend suite before moving on:

dotnet test RpgRoller.Tests/RpgRoller.Tests.csproj --collect:"XPlat Code Coverage" --settings RpgRoller.Tests/coverlet.runsettings

After major frontend milestones, repeat browser verification in Chromium and Firefox. If a Firefox profile with RoboForm is available, include that manual check and record the result in Surprises & Discoveries or Outcomes & Retrospective.

Validation and Acceptance

The final implementation is acceptable only if all of the following behaviors are true and visible.

Anonymous navigation:

GET / returns an HTTP redirect to /login. Opening /login shows the static auth document with the current register and login forms. The /login document must not load _framework/blazor.web.js, and it must still include the existing auth page hooks used by rpgroller-api.js.

Authenticated navigation:

After a successful login, the browser lands on /play. Opening / with an already valid session cookie redirects to /play. Refreshing /play, /campaigns, or /admin preserves the same route instead of rebuilding everything behind /.

Menu behavior:

The header menu items navigate to real routes. The active state matches the current route. Non-admin users cannot remain on /admin; they are redirected to /play or shown a deliberate authorization result defined by the implementation, but not an exposed admin UI.

Workspace stability:

The authenticated play route continues to support the existing play workflow, including campaign log rendering, character controls, and custom roll actions. The DOM-wrap smoke coverage for extension-like mutations must still pass, either through the existing test or an updated equivalent that targets /play.

Automated coverage:

dotnet test RpgRoller.Tests/RpgRoller.Tests.csproj --collect:"XPlat Code Coverage" --settings RpgRoller.Tests/coverlet.runsettings passes.

pwsh ./scripts/run-playwright.ps1 passes against a temporary SQLite database.

If any previous tests are deleted or renamed because they encoded the old / behavior, replace them with tests that prove the new route model instead of simply removing coverage.

Idempotence and Recovery

This rewrite should be implemented as a sequence of additive, testable steps. Each milestone must leave the app runnable and the tests meaningful. Avoid a large flag day where / is changed, the route pages half-exist, and the smoke suite is left broken for an extended period.

The safest recovery strategy is to keep the current workspace internals temporarily while introducing the new route model. That means it is acceptable to reuse Workspace behind the new page routes during Milestone 2, as long as the route behavior is correct and clearly transitional. After that, extract route-specific subtrees in Milestone 3.

When changing redirects or login targets, update the host and Playwright assertions in the same commit as the code change so the repository never has code and tests describing different route contracts.

Use a temporary SQLite database for Playwright verification, as required by the repo instructions, so browser tests do not mutate the canonical development database.

Artifacts and Notes

Current evidence that must be retired by this rewrite:

RpgRoller.Tests/Api/FrontendHostTests.cs
    Assert.Equal(HttpStatusCode.OK, response.StatusCode);
    Assert.Contains("_framework/blazor.web.js", html);

tests/e2e/smoke.spec.js
    test("home page loads auth entry points", ...)
    test("home document renders static auth markup without bootstrapping blazor", ...)
    test("authenticated home document avoids prerendered workspace shell", ...)

Current evidence that explains the bootstrap constraint:

RpgRoller/Components/RpgRollerApiClient.cs
    var response = await js.InvokeAsync<JsApiResponse>("rpgRollerApi.request", method, path, payload);

Current evidence that explains why route navigation must replace screen persistence:

RpgRoller/Components/Pages/WorkspaceSessionCoordinator.cs
    private const string ScreenSessionKey = "screen";
    state.CurrentScreen = NormalizeRequestedScreen(storedScreen) ?? ScreenPlay;
    await js.InvokeVoidAsync("rpgRollerApi.setSessionValue", ScreenSessionKey, state.CurrentScreen);

Interfaces and Dependencies

The implementation must continue to use the existing ASP.NET Core hosting model in RpgRoller/Program.cs, the minimal API auth surface in RpgRoller/Api, and the existing IGameService session lookup methods. Do not introduce a second auth mechanism.

At the end of Milestone 1, the codebase must contain a host-level entry point with behavior equivalent to:

GET /
  if session cookie maps to a valid user: redirect to /play
  otherwise: redirect to /login

At the end of Milestone 2, the codebase must contain route components equivalent to:

/play
/campaigns
/admin

Each of those routes must be directly navigable and refreshable.

At the end of Milestone 3, the codebase must contain a shared authenticated shell component or layout that owns common header and feedback concerns, while route pages own their feature-specific DOM. Stable names are recommended:

RpgRoller/Components/Pages/AuthenticatedShell.razor
RpgRoller/Components/Pages/PlayPage.razor
RpgRoller/Components/Pages/CampaignsPage.razor
RpgRoller/Components/Pages/AdminPage.razor

These exact filenames are recommended because they make the route split obvious to a new contributor, but equivalent names are acceptable if the same ownership boundaries are preserved and the README is updated accordingly.

Revision Note

2026-05-04 17:52Z: Initial ExecPlan created after the route-first rewrite direction was approved and the README was overhauled. The main reason for the plan is to replace the dual-purpose / shell with explicit routes while keeping the repository testable at every step.