From 9384e228f5303b0094b2758525c300c1fbe2ced7 Mon Sep 17 00:00:00 2001 From: Daniel Harvey Date: Tue, 19 May 2026 12:04:03 -0400 Subject: [PATCH] =?UTF-8?q?docs(design):=20two-app=20split=20=E2=80=94=20p?= =?UTF-8?q?ublic=20site=20vs.=20CMS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit design/TWO-APP-SPLIT.md proposes splitting the entangled Blazor host into independent public and CMS apps to escape the layout/cascading-auth deadlock. Ten open questions at §10. PLAN.md gains an in-flight pointer. --- PLAN.md | 6 + design/TWO-APP-SPLIT.md | 514 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 520 insertions(+) create mode 100644 design/TWO-APP-SPLIT.md diff --git a/PLAN.md b/PLAN.md index e60f5e2..e90e260 100644 --- a/PLAN.md +++ b/PLAN.md @@ -6,6 +6,12 @@ Organised by **theme**, not by date. Themes are roughly ordered by current produ --- +## In-flight — Two-app architectural split + +The public site and the CMS are being split into two independent Blazor applications. Design doc: `design/TWO-APP-SPLIT.md`. Open questions pending Daniel's answers at `design/TWO-APP-SPLIT.md §10`. Until those resolve, no implementation phases are scheduled here — see the design doc's §8 for the phased rollout plan that will populate this section once unblocked. Supersedes the host-shape pieces of `CMS-PLAN.md §2`; the CMS feature waves in `CMS-PLAN.md` survive unchanged and move to the new host. + +--- + ## 0. Baseline — what just landed A two-part audit (design + streaming) ran on 2026-05-17 and the fixes for Critical, Major, and Minor findings are now on `dev`. The remainder of this plan assumes that baseline. In summary the audit-pass fixed: diff --git a/design/TWO-APP-SPLIT.md b/design/TWO-APP-SPLIT.md new file mode 100644 index 0000000..744b313 --- /dev/null +++ b/design/TWO-APP-SPLIT.md @@ -0,0 +1,514 @@ +# TWO-APP-SPLIT.md — Public site / CMS architectural split + +**Status:** Design draft. Daniel has committed to the split; this document is the shape, not the decision. Implementation is for staff-engineer once Daniel signs off on the open questions at §10. + +**Companions:** `CONTEXT.md` (current architecture orientation), `PLAN.md` (forward roadmap), `CMS-PLAN.md` (CMS roadmap — most of its shape survives; the host-of-record changes). + +**Cross-link from `PLAN.md`:** add a top-level entry "**Two-app split**" pointing here once Daniel signs off; the items below supersede pieces of `CMS-PLAN.md §2` and `§3`. + +--- + +## 1. Why this design exists + +### 1.1 The proximate problem + +The current `DeepDrftWeb` host is **one** ASP.NET Core Blazor Web App serving both: + +- The **public site** (`Home`, `TracksView`, the audio player stack) — anonymous, must prerender for first-paint quality, lives in the `DeepDrftWeb.Client` WASM assembly. +- The **CMS** (`/cms/*`, track admin) — `Admin`-gated via AuthBlocks, runs `InteractiveServer`, lives in the `DeepDrftCms` RCL referenced by `DeepDrftWeb`. + +Both surfaces share: + +- The same root `App.razor` → `Routes.razor` → `MainLayout.razor` chain. +- The same global `AddCascadingAuthenticationState` registration (server-side, from `AuthBlocksWeb.Startup.ConfigureAuthServices`). +- The same DI scope during prerender. + +Empirically (verified this session): the moment **any** `[Inject]` property is added to `MainLayout.razor` while cascading-auth-state is registered globally, the public Home request hangs forever — `LayoutView` initialisation never completes. Strip every `[Inject]` and the page renders. The reduction in tree state today (`MainLayout.razor` is one line of `
@Body
` plus a single `IJSRuntime` injection — and the page still hangs with that one inject) is the surviving evidence: the layout has been gutted as a band-aid and the chrome is currently gone from the public site. + +Root cause: the public layout takes a cascading auth state it has no business consuming, and the prerender DI scope for an anonymous, presentation-only page is now coupled to the auth subsystem's scoped services. That coupling is the deadlock. + +### 1.2 Why band-aids stopped working + +Every patch attempted this session has been a rendermode reshuffle around the same coupling: + +- `@rendermode` migrating across `App` → `Routes` → `MainLayout` → individual pages. +- `[AllowAnonymous]` sprinkled across public pages to suppress the auth funnel. +- The `JwtAuthenticationStateProvider` rewritten to be prerender-safe. +- `Routes.razor` moved between the server assembly and the WASM assembly (commit `d31a08b` moved it server-side so the router could discover `AuthBlocksWeb` types; that move is what re-crossed the wires). +- Bumping `Cerebellum.AuthBlocks*` from 10.3.31 → 10.3.32 chasing prerender-safety fixes upstream. + +None of these address the architectural shape: **two products, one host, one DI scope, one cascading-auth tree**. The fix is structural. + +### 1.3 The fix Daniel chose + +Two independent ASP.NET Core applications: + +- **Public site** — customer-facing, anonymous, prerender-first, MudBlazor + (optionally) BlazorBlocks. No AuthBlocks. No cascading auth. No `@rendermode` chaos. +- **CMS** — staff-facing, fully authenticated via AuthBlocks, built on BlazorBlocks scaffolding. `InteractiveServer` end-to-end. Stealth-routed (`404` to unauthorized callers). + +Each app gets its own host project, its own DI graph, its own deploy unit, its own URL surface. The two share storage and shared models, not Blazor plumbing. + +> "Cramming these two usecases into the same blazor server host is the source of YOUR and MY confusion and all this wasted time." — Daniel, this session. + +The hard constraints flowing into the design: + +1. **Public site MUST prerender.** "Abandoning the pre-rendering benefits for the public portion of the site is exactly the WRONG decision." (Daniel.) First paint speed and SEO/share-card quality are first-class. This rules out a SPA-only or `InteractiveServer`-only public site. +2. **CMS gets AuthBlocks + BlazorBlocks.** The substrate is already chosen — design the host around it, not around what would be theoretically prettier. +3. **The dual-database storage layer does not change.** `DeepDrftWeb.Services` (EF Core / Postgres), `DeepDrftContent.Services` (FileDatabase), `DeepDrftContent` host, the streaming substrate — all stable, all stay. +4. **The single-source-of-truth rule survives.** The CMS list and the public gallery render the same `PagedResult`. Two hosts is not permission to fork the view model. + +--- + +## 2. Two-app shape + +### 2.1 Recommended project layout + +Working names below. Final naming is at §10 Q1. + +``` +DeepDrftSite NEW. Public-site host (ASP.NET Core, Blazor Web App). + Anonymous. Prerender + InteractiveAuto. MudBlazor. + No AuthBlocks. Owns api/track/page (see §5) and the + TypeScript audio interop. + +DeepDrftSite.Client NEW. Public-site WASM assembly. Promoted from today's + DeepDrftWeb.Client. Contains Home, TracksView, the audio + player stack, dark-mode plumbing, HTTP clients. + +DeepDrftCmsHost NEW. CMS host (ASP.NET Core, Blazor Server-only). + AuthBlocks + BlazorBlocks. InteractiveServer end-to-end. + References the existing DeepDrftCms RCL. + +DeepDrftCms EXISTING (RCL). CMS pages, layouts, components. Becomes + consumed by DeepDrftCmsHost only. Stops being referenced + from the public host. + +DeepDrftShared.Client NEW (RCL). Shared Razor components and client-side helpers + that BOTH apps render: TrackCard, TracksGallery (read-mode), + DDIcons, palette tokens, font loading helpers, audio player + stack (if the CMS plays previews — see §3.4 open question). + See §3 for what does and does not belong here. + +DeepDrftWeb RETIRED. Functionality split across DeepDrftSite and + DeepDrftCmsHost. Project file removed from solution. + +DeepDrftWeb.Client RETIRED. Promoted/renamed to DeepDrftSite.Client. + +DeepDrftData EXISTING. EF Core / Postgres / TrackManager / TrackRepository. + Referenced by whichever host owns the metadata API (see §5) + and by the CMS host for write paths. + +DeepDrftContent EXISTING. Binary content API. Unchanged. +DeepDrftContent.Data EXISTING. FileDatabase + audio processing. Unchanged. + +DeepDrftModels EXISTING. Shared contracts. Unchanged. + +DeepDrftCli EXISTING but retired per CMS-PLAN §8 once split lands. + Not relevant to the split itself. + +DeepDrftTests EXISTING. May gain a few smoke tests around the new hosts + (out of scope here; flag for staff-engineer). +``` + +**One canonical solution file.** Today the repo carries `DeepDrftHome.sln` plus stray `WebAPI.sln`, `WebUI.sln`, `CLI.sln`. The split is the opportunity to delete the strays; keep `DeepDrftHome.sln` as the single solution. + +### 2.2 Naming — flagged as Daniel's call + +- `DeepDrftSite` vs. `DeepDrftPublic` vs. `DeepDrftWeb` (renaming the existing host instead of retiring it). Recommendation: **rename `DeepDrftWeb` → `DeepDrftSite`** rather than retiring it and standing up a new project. The current `DeepDrftWeb` already has the TypeScript pipeline, `wwwroot`, the `App.razor` host, the `MainLayout` history, and the deploy script entries. Renaming preserves that history; standing up a fresh project loses it. **Counter-argument:** the rename is a heavy git-log churn moment and doesn't compose with the band-aid commits already on `dev` — see §8. +- `DeepDrftCmsHost` vs. `DeepDrftCms.Host` vs. just promoting `DeepDrftCms` to be the host directly (changing its Sdk from `Microsoft.NET.Sdk.Razor` to `Microsoft.NET.Sdk.Web`). Recommendation: **new project `DeepDrftCmsHost`**, keep `DeepDrftCms` as an RCL. Reason: an RCL+host pair preserves the option to mount the CMS into a different host later (e.g. embedding CMS into an admin-portal aggregator host); collapsing them forecloses that. +- `DeepDrftShared.Client` vs. `DeepDrftUi.Shared` vs. `DeepDrftWeb.Shared`. Recommendation: **`DeepDrftShared.Client`** — clearly an RCL, clearly client-side. Other names invite ambiguity about whether it can hold server-only code. + +All three are Q1 in §10. + +--- + +## 3. Shared layer policy + +The split is only useful if shared concerns stay shared. The risk is duplication or, worse, two diverging copies of the audio player. + +### 3.1 What stays in `DeepDrftModels` (unchanged) + +`TrackEntity`, `TrackDto`, `PagedResult`, `PagingParameters`, `ApiResultDto`. The split does not touch the contracts layer. + +### 3.2 What stays in `DeepDrftData` / `DeepDrftContent.Data` (unchanged) + +The EF Core domain layer and the FileDatabase implementation are class libraries today; they don't care which host references them. The CMS host references both for writes; whichever host owns the metadata API (§5) references `DeepDrftData` for reads. + +### 3.3 What moves to `DeepDrftShared.Client` (new RCL) + +Things both apps render and want to keep visually consistent: + +- **`TrackCard.razor`** — both the public gallery and the CMS list render tracks. Today's CMS list (CMS Wave 1) renders a table; a future CMS view could embed `TrackCard` previews, and the public-side card must not drift visually. Move it here. +- **`TracksGallery.razor`** — used by the public gallery today. The CMS does not need the gallery layout, but if any CMS preview surface ever wants it (e.g. "preview as listener sees it"), pre-shared is cheaper than retroactive extraction. +- **`DDIcons.cs`** — hand-rolled icons, currently in `DeepDrftWeb.Client/Common/`. Both apps will want the lit/unlit gas-lamp, the brand logo, etc. +- **Palette tokens and CSS variables.** Today `deepdrft-styles.css` lives in `DeepDrftWeb/wwwroot/styles/`. Move the **palette layer** (the `:root` and `.deepdrft-theme-dark` custom properties) into a shared CSS file in `DeepDrftShared.Client/wwwroot/` so MudBlazor themes in both apps map to the same tokens. Page-specific CSS (home page, gallery scoped styles) stays in the public app. +- **MudBlazor palette objects.** Currently inline in `MainLayout.razor`. Promote to a `DeepDrftPalettes.cs` (static `MudTheme Light` / `MudTheme Dark`) in the shared RCL. The CMS host applies the same palettes so the staff side reads as the same product. +- **Font-loading `` helpers.** A `DeepDrftFonts` static or `` component so both apps emit the same Google Fonts request. + +### 3.4 Audio player stack — open question + +The player stack today lives entirely in `DeepDrftWeb.Client/`: + +- `IPlayerService`, `IStreamingPlayerService`, `AudioPlayerService`, `StreamingAudioPlayerService`, `AudioInteropService`, `StreamingErrorHandler`. +- `AudioPlayerProvider.razor`, `AudioPlayerBar.razor`, `PlayerControls.razor`, `TimestampLabel.razor`, `VolumeControls.razor`, `SpectrumVisualizer.razor`. +- `TrackMediaClient` (HTTP client for `DeepDrftContent`). + +The public site needs all of it. Whether the **CMS** needs it is the open question (Q4 in §10): + +- **Option A — Public site only.** Audio player stack moves into `DeepDrftSite.Client`. The CMS never previews audio in-browser. CMS users wanting to verify an upload navigate to the public site in another tab. **Simplest.** +- **Option B — Shared via `DeepDrftShared.Client`.** The audio player stack moves into the shared RCL and is consumed by both apps. The CMS detail page (`/cms/tracks/{id}`) gains an in-place preview player. **More work; modestly more useful.** + +Recommendation: **Option A in Wave 1, leave Option B as a future move.** The audio player carries non-trivial complexity (streaming, seek, spectrum, dark-mode-aware visualiser, `DotNetObjectReference` lifetimes) and consolidating it into a shared RCL for a CMS use case that isn't on the immediate roadmap is unnecessary work today. If a future "preview in CMS" need arises, the player moves to shared then — its surface is already abstracted behind `IPlayerService`. + +### 3.5 TypeScript interop — stays with the public host + +`DeepDrftWeb/Interop/audio/*.ts` and its `Microsoft.TypeScript.MSBuild` pipeline ship with the audio player. Per §3.4 Option A, they ride with `DeepDrftSite` and do not appear in the CMS host. If §3.4 ever flips to Option B, the TS pipeline needs to move to `DeepDrftShared.Client` (or, simpler, the shared RCL serves the compiled JS as a static web asset and the source pipeline stays with the public host as the canonical compiler). + +### 3.6 Theme prerender — stays with public host + +`DarkModeService` (in `DeepDrftWeb/Services/`) reads the cookie during prerender; `DarkModeSettings` lives in `DeepDrftWeb.Client/Common/`. The dark-mode bridge is only meaningful where prerender happens. Since only the public site prerenders (the CMS is `InteractiveServer` over a logged-in circuit and does not need the cookie-prerender bridge), the dark-mode plumbing stays in the public app. + +The CMS still gets dark mode — it just reads the cookie at circuit init the same way any normal Server component does, no `PersistentComponentState` round-trip needed. Implementation detail for staff-engineer. + +--- + +## 4. Render-mode strategy + +This section is the load-bearing one for "why this design avoids the deadlock." + +### 4.1 Public site: prerender-first, `InteractiveAuto` pages + +``` +App.razor static SSR (host markup) +└── Routes.razor static SSR + └── MainLayout.razor static SSR ← key change + └── Page (Home/Tracks) @rendermode InteractiveAuto +``` + +- **No `@rendermode` on `App` or `Routes` or `MainLayout`.** They render statically as the page shell. +- **`@rendermode InteractiveAuto` on each page** (as already attempted in commit `54865e7`, but that fix was incomplete because the auth coupling survived). Pages opt into interactivity individually. +- **MainLayout has no `[Inject]` properties that touch a scoped service participating in cascading auth.** It is presentation chrome only. Anything that needs `IJSRuntime`, `DarkModeSettings`, `IPlayerService` lives **inside** the page (or inside an interactive component the page renders), not in the layout. + - The `DarkModeService.CheckDarkMode()` call currently in `App.razor.OnInitialized` survives — `App` is the right place for prerender-time cookie reads; it does not get a `@rendermode`. + - `AudioPlayerProvider` (the cascading host for `IPlayerService`) moves from `MainLayout` to a wrapper inside each page that needs it (`TracksView`), or into a single interactive shell component that `MainLayout` renders **inside** its `@Body` slot without static-prerender coupling. Staff-engineer call; both shapes work. + +**Why this avoids the deadlock:** there is no AuthBlocks in this app. `AddCascadingAuthenticationState` is **not registered**. `MainLayout` has no auth state to cascade. The `[Inject]` deadlock symptom is bound to the auth-scoped-service initialisation, not to injection in general — remove the auth context and `[Inject]` becomes safe again. + +If staff-engineer wants the additional belt-and-suspenders: `MainLayout` for the public site ships with **zero `[Inject]` properties**. Cosmetic deviation from typical layout patterns; cheap insurance against any future recurrence. + +### 4.2 CMS: `InteractiveServer` end-to-end + +``` +App.razor static SSR (host markup, AuthorizeRouteView in Routes) +└── Routes.razor static SSR + └── CmsLayout.razor @rendermode InteractiveServer + └── Page (/cms/*) inherits InteractiveServer from layout +``` + +- **`AddInteractiveServerComponents()` only.** No WebAssembly render mode in the CMS host. No `InteractiveAuto`, no client assembly. +- **No prerender.** CMS pages are gated by `[HierarchicalRoleAuthorize("Admin")]`; prerendering them in an anonymous context is wrong on two counts (data leakage if the page bypasses the gate during prerender; pointless if it does not). The CMS opts out of prerender via `RenderMode(InteractiveServer, prerender: false)` at the layout or page level. +- **AuthBlocks fully in scope.** `AddAuthBlocks`, `MapAuthBlocks`, `AddCascadingAuthenticationState`, `AddAuthenticationStateDeserialization` — all live here, none of them leak into the public site. +- **`/account/login`, `/account/logout`** (the bundled AuthBlocks UI from `AuthBlocksWeb`) are served by the CMS host. Public site links to them by absolute URL (`https://cms.deepdrft.com/account/login?returnUrl=…` or path on the same domain if §6 picks path-routing). + +**Why this avoids the deadlock:** the CMS deadlock symptom never appeared — the CMS rendered fine. The problem was always the **public** layout being dragged into the auth cascade. Once the public side has no auth at all, the CMS is free to use auth normally. + +### 4.3 Render-mode crosswalk against today's mess + +| Today (`dev` branch) | Two-app design | +|---|---| +| `MainLayout.razor` gutted to one-liner with `[Inject] IJSRuntime` to avoid deadlock | Public `MainLayout.razor` restored to full chrome (nav, theme switcher, footer). No `[Inject]`s of scoped auth services. | +| `Routes.razor` server-side with `AdditionalAssemblies` listing client + CMS + AuthBlocks assemblies | Public `Routes.razor`: only `typeof(DeepDrftSite.Client._Imports).Assembly`. CMS `Routes.razor`: only `typeof(DeepDrftCms._Imports).Assembly` + `typeof(AuthBlocksWeb._Imports).Assembly`. | +| `[AllowAnonymous]` decorating `Home.razor` and `TracksView.razor` to escape the global auth funnel | Removed. No global auth in the public host. | +| `@rendermode InteractiveAuto` on `MainLayout`, then off, then on individual pages | `@rendermode InteractiveAuto` on individual public pages. Layout stays static SSR. | +| `@rendermode InteractiveServer` explicitly declared on AuthBlocks pages | Removed. CMS host's default render mode is `InteractiveServer`; AuthBlocks pages inherit. | +| `JwtAuthenticationStateProvider` prerender-safe rewrite needed because public pages prerendered with the provider in scope | The provider is only registered in the CMS host. Public prerender does not see it. | + +--- + +## 5. API ownership — where does `api/track/page` live? + +`DeepDrftWeb` today owns one controller — `TrackController` — exposing `GET api/track/page` against `DeepDrftContext` (Postgres metadata). It also owns three CMS-internal controllers (`CmsUploadController`, `CmsEditController`, `CmsDeleteController`). + +After the split, the CMS controllers follow the CMS — they require `[Authorize(Roles="Admin")]` and live in `DeepDrftCmsHost`. The question is the public read endpoint. + +### 5.1 Option A — Metadata API stays with the public site (recommended) + +`GET api/track/page` lives in `DeepDrftSite` (the renamed/repurposed `DeepDrftWeb`). The public WASM client calls its own host. The CMS host, when it needs to read paged tracks, calls **across to the public site** via HTTP. + +**Pros:** +- Public site is fully self-contained: it hosts its WASM bundle **and** the API that bundle calls. No cross-host dependency for the listener experience. +- Anonymous endpoint stays anonymous; no auth middleware to step around. +- Same-origin: WASM → `/api/track/page` works with no CORS configuration. +- Mirrors today's shape — minimum surgery. + +**Cons:** +- CMS reads cross a host boundary (extra hop for `/cms/tracks` list). In practice negligible — paged metadata is small, and the CMS list is a low-traffic surface. +- Public site now serves both the WASM bundle and an API. Two responsibilities in one host. Defensible (the API exists to feed the bundle) but not single-purpose. + +### 5.2 Option B — Dedicated metadata-API host (`DeepDrftApi` or `DeepDrftMetadata`) + +A third ASP.NET Core project owns `GET api/track/page`. Both the public site (WASM → cross-host) and the CMS host (server-side → cross-host) call into it. + +**Pros:** +- Each host is single-purpose: public site is presentation, CMS is admin, metadata API is data. Clean separation. +- Future-proofs against "what if a mobile app wants metadata too" — the API exists independently of any UI. + +**Cons:** +- Three hosts instead of two. More deploy units, more nginx routes, more systemd services, more cert wiring. +- The public WASM bundle now does **cross-origin** calls to the metadata API. CORS needs configuration. Either that or nginx-rewrite the public site's `/api/track/page` to the metadata host (which trades the CORS problem for a path-routing problem). +- Justified only if the API is reused outside the public site. Today it isn't. + +### 5.3 Option C — Metadata API lives in the CMS host + +`GET api/track/page` is hosted on `DeepDrftCmsHost`. Public site calls cross-host. + +**Pros:** +- The CMS host already references `DeepDrftData` for writes; adding the read endpoint there is zero new wiring. +- Public site becomes presentation-only (no `DeepDrftData` reference). + +**Cons:** +- The CMS host is auth-gated and `InteractiveServer`. Adding an anonymous controller alongside is fine technically (controllers and Blazor pages share the host but not the auth pipeline) but conceptually muddles the host's role. +- The CMS host becomes a critical-path dependency for **anonymous** public traffic. If the CMS host goes down, the public site's track gallery breaks. +- Same cross-origin / CORS issue as Option B from the WASM client's perspective. + +### 5.4 Recommendation + +**Option A.** Public site owns the metadata read endpoint. It's the lightest surgery, preserves the same-origin contract, and matches the principle that the host that serves the UI also serves the UI's data API. Revisit only if a non-UI client (mobile, third-party) starts consuming metadata. + +The CMS host's `/cms/tracks` list pages call `GET api/track/page` cross-host (a named `IHttpClientFactory` client pointed at the public site, like the existing `DeepDrft.Content.Cms` client points at `DeepDrftContent`). No auth header needed — the endpoint is anonymous. + +**Implication for the CMS host's `DeepDrftData` reference:** it still needs `DeepDrftData` for the write path (Edit/Delete) but could read paged tracks via HTTP. Two choices: +- **Read via HTTP** for consistency with how the CMS reaches `DeepDrftContent`. Cleaner conceptually. +- **Read via direct service call** (CMS host has `DeepDrftData` referenced anyway). Faster path, no HTTP roundtrip for the CMS list page. + +The "one source of truth, multiple views" rule (`user_one_source_multiple_views`) is preserved either way — the VM contract is `PagedResult` either way; only the transport differs. **Recommend: direct service call for the CMS** since the CMS host already has the dependency, and reserve the HTTP path for the public WASM. Flag as Q5 in §10 if Daniel wants the stricter host-boundary discipline. + +--- + +## 6. Auth wiring (CMS only) + +All present in `CMS-PLAN.md` and landed in CMS Wave 1; preserved here for completeness in the new host. **Nothing about the auth model changes in the split.** + +- **AuthBlocks substrate** (`Cerebellum.AuthBlocks*` 10.3.32) — `AddAuthBlocks(...)`, `await app.Services.UseAuthBlocksStartupAsync()`, `app.MapAuthBlocks()` all in `DeepDrftCmsHost/Program.cs`. +- **AuthBlocksWeb pages** (`/account/login`, `/account/logout`) — exposed by adding `typeof(AuthBlocksWeb._Imports).Assembly` to the CMS host's `AddAdditionalAssemblies`. +- **JWT in localStorage** via `JwtAuthenticationStateProvider`. CMS host calls `AuthBlocksWeb.Startup.ConfigureAuthServices` server-side; no WASM client means no `AddAuthenticationStateDeserialization` bridge needed. +- **Auth DB separate from main DB.** `ConnectionStrings:Auth` and `ConnectionStrings:DefaultConnection` are different Postgres databases (or the same instance with different DBs — they share an engine, not a context). The CMS host wires both. +- **`Admin` role gate** via `[HierarchicalRoleAuthorize("Admin")]` on every CMS page and `[Authorize(Roles = "Admin")]` on every CMS API endpoint. +- **Stealth routing.** Unauthorized hits on `/cms/*` return 404, not 401, not redirect. `CmsStealthRoutingHandler` (today in `DeepDrftWeb/Middleware/`) follows into the CMS host. Path scope is now naturally `/cms/*` because the CMS host serves nothing else routable from outside auth. + +**One thing to verify** (Q6 in §10): the stealth-routing 404 behaviour currently applies only to `/cms/*` because that was the only protected surface in a mixed host. In the dedicated CMS host, more of the surface is protected by default — but `/account/login` is anonymous, the auth API endpoints under `/api/auth/*` are anonymous, and the host's static assets are anonymous. The 404 handler must still target only the `[HierarchicalRoleAuthorize]`-failed cases, not all 401s, or `/account/login` discovery breaks (a legitimate anonymous user with no JWT would get 404 on the login page itself, which is wrong). + +**Admin seeding** (`AdminUserSettings` in `authblocks.json`) carries over unchanged. The seed user is created on first boot of the CMS host. + +--- + +## 7. Deployment topology + +The repo currently deploys two systemd services (`deepdrft-content`, `deepdrft-web`) on each host (dch6 beta, prod.cerebellumsoftworks.com prod) via `dch5-publish-deploy.sh`. The split adds a third unit. + +### 7.1 Recommendation — path-based routing on a single domain + +``` + nginx (Let's Encrypt cert for deepdrft.com) + │ + ┌─────────────────┼─────────────────┬───────────────────┐ + │ │ │ │ + location / location /cms location /api/ location /api/ + track/{id} auth, /api/users, + etc. (proxied + from CMS host) + │ │ │ │ + ▼ ▼ ▼ ▼ + DeepDrftSite DeepDrftCmsHost DeepDrftContent DeepDrftCmsHost + systemd unit systemd unit systemd unit (same as /cms) + :5001 :5002 :5003 :5002 +``` + +Three systemd units per host (`deepdrft-site`, `deepdrft-cms`, `deepdrft-content`). nginx terminates TLS once, routes by path: + +- `/` and `/_framework/*` and `/api/track/page` → `DeepDrftSite`. +- `/cms/*` and `/account/*` (login/logout) and `/api/auth/*`, `/api/users/*`, `/api/roles/*` → `DeepDrftCmsHost`. +- `/api/track/{id}` and `/api/track/upload` → `DeepDrftContent`. + +**Pros:** +- One domain, one cert. No CORS between public WASM and the metadata API (same origin). +- Browser sees one site; the architecture is invisible. +- The "CMS is on the same domain at `/cms`" matches the stealth-routing intent (a snooper hitting `https://deepdrft.com/cms/tracks` gets 404, identical to any unmatched path). +- Path routing is what nginx is good at; no new tooling. + +**Cons:** +- Two locations on the CMS host (`/cms/*` and `/account/*`). nginx config has to know about both. +- A misconfigured `/account/login` link from the public site (linking to `/account/login` rather than `/cms/account/login`) needs nginx to route `/account/*` to the CMS host explicitly — easy to forget when the CMS host is mostly known by its `/cms` prefix. + +### 7.2 Alternative — subdomain split + +``` +deepdrft.com → DeepDrftSite (public) +cms.deepdrft.com → DeepDrftCmsHost (staff) +content.deepdrft.com → DeepDrftContent (binary API) +api.deepdrft.com → DeepDrftSite's track/page endpoint (optional) +``` + +**Pros:** +- Each host has one nginx server block. Cleaner config. +- CMS can be IP-restricted at the nginx layer (`allow ; deny all;` on `cms.deepdrft.com`) without affecting the public site. Layered defence on top of the auth gate. +- "cms.deepdrft.com" is unambiguous in browser history and link shares. + +**Cons:** +- Three certs (or one wildcard, which costs more / requires DNS-challenge ACME). +- Public WASM bundle calls **cross-origin** to anything on `content.deepdrft.com` (already cross-origin today, so the CORS config exists). If the metadata API is hosted on `api.deepdrft.com`, that's a new cross-origin path. +- Stealth routing changes flavour — the CMS hostname *does* announce its existence by DNS. Mitigated by binding `cms.deepdrft.com` to a non-routable IP behind a VPN, but that's a bigger lift than the current setup. + +### 7.3 Recommendation summary + +**Recommend path-based routing (§7.1).** The current deploy already nginx-proxies; adding a third location is small. Subdomain split is the right call **if** Daniel wants IP-restrictable CMS access as a hard requirement; until then, the stealth-routing 404 is enough. + +Q7 in §10. + +### 7.4 Deploy-script changes + +`dch5-publish-deploy.sh` publishes two projects today. The split makes it three: + +- Publish `DeepDrftSite` (was `DeepDrftWeb`). +- Publish `DeepDrftCmsHost` (new). +- Publish `DeepDrftContent` (unchanged). +- Three `*.tar.gz` artefacts, three `scp`/`tar`/`restart` cycles. +- Three EF migration paths to consider — `DeepDrftContext` migrations apply against the metadata DB (driven by whichever host owns the read endpoint and the writes), `AuthDbContext` migrations apply against the auth DB on first run of the CMS host (`UseAuthBlocksStartupAsync` handles this automatically). + +The deploy script edit is a staff-engineer task once §10 Q1 (naming) and Q7 (topology) settle. + +--- + +## 8. Migration / rollout plan + +The temptation is to do the split atomically. Don't. The split has too many moving parts (project renames, file moves, deploy-script edits, nginx config) and atomic-everything would mean an unreviewable single change. + +### 8.1 Smallest first slice — "public home page renders correctly" + +**Phase 0: stabilise current `dev`** (small, lands first, can be staff-engineer's first commit). + +Before touching the split, restore the public site to a non-band-aid state on `dev`. The current `MainLayout.razor` (`
@Body
` one-liner) is unshippable — it has no nav, no theme switcher, no footer. Either: + +- **(a)** Roll forward: a tiny restoration commit that puts the chrome back in `MainLayout` with the *minimum* `[Inject]`s needed (likely none — see §4.1), accepting that it still runs in the entangled host. The home page now renders with chrome but the architectural problem isn't fixed. This gives Daniel a presentable site **while** the split work proceeds in a worktree. +- **(b)** Skip Phase 0 and go straight to the split — accept that `dev` is broken-with-chrome-gone until the split lands. + +**Recommend (a).** A demo-grade home page during the split work is valuable; the chrome restoration is a known-shape change. + +### 8.2 Phased split (worktree-friendly) + +**Phase 1: Stand up `DeepDrftCmsHost` as a new project alongside the existing `DeepDrftWeb`.** + +- New `DeepDrftCmsHost` project. `Microsoft.NET.Sdk.Web`. References `DeepDrftCms` (existing RCL), `DeepDrftData`, `DeepDrftModels`, AuthBlocks packages. +- Copy `DeepDrftWeb/Program.cs` + `Startup.cs` + `Middleware/CmsStealthRoutingHandler.cs` into `DeepDrftCmsHost/`, strip out everything that isn't CMS (the public-site MudBlazor host, the `DeepDrftWeb.Client` reference, the `TrackController`, the TypeScript pipeline). +- Wire `app.MapRazorComponents().AddInteractiveServerRenderMode().AddAdditionalAssemblies(typeof(DeepDrftCms._Imports).Assembly, typeof(AuthBlocksWeb._Imports).Assembly)`. **No** `AddInteractiveWebAssemblyRenderMode`. **No** client assembly. +- Spin it up on a third port locally. Verify `/cms/tracks` works against the existing Postgres metadata DB and the existing Auth DB. +- `DeepDrftWeb` is unchanged — both hosts can run concurrently against the same DB during this phase. The CMS lives in both hosts temporarily; that's fine, they share the underlying data. + +**Exit criterion:** CMS host stands up, `/account/login` works against the seeded admin, `/cms/tracks` renders. Public site continues to run from `DeepDrftWeb` exactly as today (still entangled, still using band-aid MainLayout — see Phase 0). + +**Phase 2: Strip AuthBlocks out of `DeepDrftWeb`.** + +- Remove the `AddAuthBlocks(...)`, `MapAuthBlocks()`, `UseAuthBlocksStartupAsync()` from `DeepDrftWeb/Program.cs`. +- Remove `AddCascadingAuthenticationState` (the implicit registration via `AuthBlocksWeb.Startup.ConfigureAuthServices`). +- Remove the `AuthBlocksWeb` assembly from `AddAdditionalAssemblies`. +- Remove the `DeepDrftCms` reference from `DeepDrftWeb` — the CMS now lives only in `DeepDrftCmsHost`. +- Remove the CMS controllers (`CmsUploadController`, `CmsEditController`, `CmsDeleteController`) from `DeepDrftWeb/Controllers/` — they move to the CMS host. +- Remove `[AllowAnonymous]` attributes from `Home.razor` / `TracksView.razor` (no longer needed). +- Remove the `JwtAuthenticationStateProvider` registration from `DeepDrftWeb.Client.Startup` (and from `DeepDrftWeb.Client.Program.cs`'s `AuthBlocksWeb.Client.Startup.ConfigureServices(builder.Services)` call) — only the CMS needs it. +- Restore `MainLayout.razor` to its full chrome state from before the band-aids (the rendition that existed pre-`d31a08b`-era, adapted to current pages). With AuthBlocks gone, `[Inject]`s in the layout work normally. + +**Exit criterion:** Public site (`DeepDrftWeb` still by its old name) renders home page with chrome, prerenders correctly, no hangs. CMS continues to work from `DeepDrftCmsHost`. The hosts run side-by-side. + +**Phase 3: Extract `DeepDrftShared.Client` (optional, can run in parallel with 2).** + +- New RCL `DeepDrftShared.Client`. +- Move `TrackCard.razor`, `TracksGallery.razor`, `DDIcons.cs`, palette objects, font helpers. +- Both `DeepDrftWeb.Client` and (eventually) the CMS RCL reference it. + +**Exit criterion:** Both apps render `TrackCard` from the shared RCL. Visual parity confirmed. + +**Phase 4: Rename `DeepDrftWeb` → `DeepDrftSite`, `DeepDrftWeb.Client` → `DeepDrftSite.Client`.** + +- Project file renames, namespace renames, csproj reference updates, solution file edits. +- Deploy script edits. +- `dch6` directory rename (`/deepdrft/web` → `/deepdrft/site`) + systemd unit rename. + +**Exit criterion:** All references converge on the new names. The rename is a single staff-engineer pass; doing it last means the split has been validated under the old names first. + +**Phase 5: nginx and deploy topology.** + +- Edit `dch5-publish-deploy.sh` to publish three projects. +- Add the `DeepDrftCmsHost` systemd unit. +- nginx config: add the `/cms/*` and `/account/*` location blocks. +- Verify on `dch6` first; promote to prod when stable. + +### 8.3 Can the public site stand up first while CMS stays in the entangled host? + +**Yes — that's exactly Phase 1's shape.** Phase 1 builds the CMS host as a parallel deployment. The public site doesn't move until Phase 2. During Phase 1, the CMS exists in *two* places simultaneously (the old entangled host and the new dedicated host) — that's acceptable because they share storage and the duplication is short-lived. + +The inverse — public site stands up dedicated first, CMS stays in the entangled host — is *not* recommended. The deadlock is in the public path, and ripping out the public bits while leaving the CMS bound to the host that *has* the broken MainLayout creates a worse intermediate state than the current `dev`. + +--- + +## 9. What to throw away + +The band-aid work since the entanglement was discovered exists only to make the entangled host limp along. Once the split lands, these become removable. **None of these are wrong** — they're correct fixes for the wrong-shaped architecture. The work survives in `Cerebellum.AuthBlocks` upstream where relevant; only the DeepDrftHome-side adaptations are scoped here. + +### 9.1 Removable from `DeepDrftWeb` / `DeepDrftSite` once split lands + +- `[AllowAnonymous]` attributes on `Home.razor` and `TracksView.razor`. The public host has no `[Authorize]` policy to escape. +- The `AuthBlocksWeb` assembly entry in `AddAdditionalAssemblies`. The public host does not need to know about `/account/login` — it links to it cross-host (or cross-path via nginx). +- `AuthBlocksWeb.Startup.ConfigureAuthServices(builder.Services, baseUrl)` call. +- `AuthBlocksWeb.Client.Startup.ConfigureServices(builder.Services)` call in `DeepDrftWeb.Client.Program.cs`. +- The `Cerebellum.AuthBlocks*` package references in `DeepDrftWeb.csproj` and `DeepDrftWeb.Client.csproj`. +- The `DeepDrftCms` project reference in `DeepDrftWeb.csproj`. +- The CMS-specific configuration in `DeepDrftWeb/Startup.cs` (the `ContentCmsHttpClientName` client setup — moves to the CMS host). +- The `environment/authblocks.json` config file in `DeepDrftWeb/environment/` — moves to the CMS host's environment dir. +- The `ConnectionStrings:Auth` entry in `DeepDrftWeb/environment/connections.json` — moves. +- The `JwtAuthenticationStateProvider` and related auth-prerender-bridge wiring on the client. +- The `@rendermode InteractiveServer` declarations on AuthBlocks pages (those pages aren't in this host anymore). +- The `` redirect-to-login branch in `Routes.razor` (no auth in this host). + +### 9.2 Removable from the entangled `MainLayout.razor` + +Once restored from the one-liner band-aid: + +- Any defensive try/catch around `[Inject]` properties. +- Any `OnAfterRenderAsync`-only guards around what should be server-renderable. +- The `JwtAuthenticationStateProvider`-prerender-safety detection logic (the provider isn't in scope here anymore). + +### 9.3 What stays (and why) + +- `DarkModeService` + `DarkModeSettings` + `DarkModeCookieService` + the `PersistentComponentState` bridge. The prerender-cookie pattern is good architecture and unrelated to the auth problem. +- `[ApiKeyAuthorize]` on `DeepDrftContent`'s `PUT api/track/{id}` and `POST api/track/upload`. Different auth surface, unaffected. +- The full streaming substrate (`StreamingAudioPlayerService`, `WavOffsetService`, the TS interop). Unrelated to render-mode wiring. +- The CMS itself, in full. Moves wholesale to the new host with no functional change. + +### 9.4 What stays in AuthBlocks upstream + +The prerender-safety rewrites to `JwtAuthenticationStateProvider` in `Cerebellum.AuthBlocks.Web.Client` (versions 10.3.31 and 10.3.32) are real improvements to the library. They stay. The DeepDrftHome-side concession (consuming the newer versions) is what becomes unnecessary in the public host — the CMS host keeps consuming them and benefits from the fixes. + +--- + +## 10. Open questions for Daniel + +These genuinely need decisions before staff-engineer can execute. Numbered for citation. + +1. **Project names.** `DeepDrftSite` vs. `DeepDrftPublic` vs. rename-in-place `DeepDrftWeb`? `DeepDrftCmsHost` vs. `DeepDrftCms.Host` vs. promoting `DeepDrftCms` to a host (collapsing the RCL+host pair)? `DeepDrftShared.Client` for the new shared RCL — acceptable? +2. **Shared RCL — yes/no in Wave 1.** Recommendation: yes, with the minimum surface (palette objects, fonts, `DDIcons`, `TrackCard`). But if Daniel prefers to land the split first and extract shared bits as a follow-up, that's a defensible sequencing. +3. **Single `App.razor` host page or per-app divergence.** Recommendation: per-app. The public site's `App.razor` keeps the dark-mode preconnect, font links, the `DeepDrftAudio` module import. The CMS's `App.razor` is the AuthBlocks-aware version (cascading auth state, MudBlazor + BlazorBlocks chrome). They diverge. +4. **Audio player in CMS — yes/no.** §3.4 Option A (player only in public) vs. Option B (player in shared RCL, CMS gets preview). Recommendation: A for Wave 1. +5. **CMS reads — direct service call vs. HTTP across to public site.** Recommendation: direct service call (CMS host has `DeepDrftData` referenced for writes; reusing the dependency for reads is free). If Daniel wants the stricter host-boundary discipline (no DB access from anything but the host that owns the API), flip to HTTP. The VM contract is the same either way. +6. **Stealth routing scope.** Confirm: only `[HierarchicalRoleAuthorize]`-failed cases return 404. `/account/login` (anonymous-allowed) still serves 200 to unauthenticated users. The middleware predicate must distinguish. +7. **Deployment topology.** Path-based on `deepdrft.com` (recommended) vs. subdomain split (`cms.deepdrft.com`). Subdomain split is required if Daniel wants IP-restricted CMS access; otherwise path-based is lighter. +8. **CMS first-paint experience.** Today the CMS is `InteractiveServer` and that's fine for a logged-in workflow. Confirm prerender is off for `/cms/*` (recommended) — keeps the auth gate honest at the page level rather than relying on the stealth handler to clean up after a prerendered anonymous response. +9. **BlazorBlocks adoption depth.** Daniel said "CMS should be based on BlazorBlocks and AuthBlocks." `Cerebellum.BlazorBlocks.Web` provides "entity management views, modals, and form scaffolding" — does the CMS Wave 1 surface (track list/edit/delete) get rebuilt on BlazorBlocks scaffolding, or does the existing `DeepDrftCms` RCL keep its bespoke pages and BlazorBlocks comes in for Wave 2 (user admin, audit log, etc.)? The split is independent of this, but staff-engineer needs to know whether to refactor or preserve in Phase 1. +10. **Phase 0 chrome restoration on `dev`.** Yes (restore MainLayout to presentable state on `dev` now, before split work begins) or no (live with the one-liner until the split lands)? Recommendation: yes. The home page should be presentable while the structural work happens in a worktree. + +--- + +## 11. Working with this document + +- This doc captures the **shape**, not the implementation. Staff-engineer takes the answers to §10, follows §8's phased plan, executes. +- When phases land, archive their entries here to `COMPLETED.md` with the original "What / Why / Shape" body preserved (per `CONTEXT.md §6`). +- The supersession against `CMS-PLAN.md §2` (which placed the CMS inside `DeepDrftWeb`) takes effect once Daniel signs off — at that point doc-keeper updates `CMS-PLAN.md` to point here for the host shape, and the CMS-PLAN's auth/wave content remains authoritative for the CMS feature roadmap. +- Cross-reference rather than duplicate. If `PLAN.md` adds an item that touches the split (e.g. metadata-API extension), the new item points here for the host context.