docs(plan): promote ARCHITECTURE-PROPOSAL decisions; add CMS stealth-routing constraint

This commit is contained in:
Daniel Harvey
2026-05-18 20:49:33 -04:00
parent 65944ed9f5
commit 130f1357ec
2 changed files with 31 additions and 14 deletions
+16 -8
View File
@@ -14,6 +14,14 @@ Recommendation up front, reasoning after.
## 0. Recommendation (TL;DR) ## 0. Recommendation (TL;DR)
**Decision status (Daniel, 2026-05-18):**
- **Confirmed:** Decision #1 (BlazorBlocks data lift on the SQL side) and Decision #3 (rename `*.Services``*.Data`). Both fold into CMS-PLAN Wave 1.0 alongside the Postgres migration.
- **Deferred:** Decision #2 (`DeepDrftApi` shared controller library). Premature with one SQL-backed entity; revisit when the image vault (`PLAN.md §2.1`) lands and brings a second SQL controller.
- **Confirmed holds (no change):** Decision #4 (CMS as RCL mounted into `DeepDrftWeb`) and Decision #5 (two-host split between `DeepDrftWeb` and `DeepDrftContent`).
Original recommendation, retained as the record:
- **Keep** the SQL data layer and the FileDatabase data layer in **separate projects**. They are different storage systems with different invariants, not two repositories over one database. The split is load-bearing, not vestigial. - **Keep** the SQL data layer and the FileDatabase data layer in **separate projects**. They are different storage systems with different invariants, not two repositories over one database. The split is load-bearing, not vestigial.
- **Rename** the two `*.Services` libraries to match their actual role and reduce ambiguity: `DeepDrftWeb.Services``DeepDrftData` (or `DeepDrftMeta.Data`), `DeepDrftContent.Services``DeepDrftContent.Data`. Lift their host-agnostic seams (repository / manager) onto the **BlazorBlocks Data + Models** base types so the SQL side gets `BaseEntity`, `IRepository`, `Manager`, and `ClassifiedDbError` for free. - **Rename** the two `*.Services` libraries to match their actual role and reduce ambiguity: `DeepDrftWeb.Services``DeepDrftData` (or `DeepDrftMeta.Data`), `DeepDrftContent.Services``DeepDrftContent.Data`. Lift their host-agnostic seams (repository / manager) onto the **BlazorBlocks Data + Models** base types so the SQL side gets `BaseEntity`, `IRepository`, `Manager`, and `ClassifiedDbError` for free.
- **Introduce** a new `DeepDrftApi` class library that holds the controller bases and DTOs the two ASP.NET hosts share. Hosts stay as hosts — `DeepDrftWeb` and `DeepDrftContent` — but their controllers thin out and inherit from `ModelController<,,>` (BlazorBlocks) or a DeepDrft-local extension of it. - **Introduce** a new `DeepDrftApi` class library that holds the controller bases and DTOs the two ASP.NET hosts share. Hosts stay as hosts — `DeepDrftWeb` and `DeepDrftContent` — but their controllers thin out and inherit from `ModelController<,,>` (BlazorBlocks) or a DeepDrft-local extension of it.
@@ -193,7 +201,7 @@ DeepDrftHome.sln
### 3.6 What conflicts with CMS-PLAN.md ### 3.6 What conflicts with CMS-PLAN.md
- **`CMS-PLAN.md §2` (Solution structure).** References `DeepDrftWeb.Services` and `DeepDrftContent.Services` by their current names. If this proposal is adopted, the rename happens *before* CMS Wave 1.2 lands (the RCL references the renamed projects). Wave 1.0 (Postgres migration) is the natural moment to do the rename and the BlazorBlocks lift together — same files are already being edited. - **`CMS-PLAN.md §2` (Solution structure).** **Confirmed (2026-05-18):** the rename (`DeepDrftWeb.Services` `DeepDrftData`, `DeepDrftContent.Services` `DeepDrftContent.Data`) and the BlazorBlocks data lift fold into CMS Wave 1.0 alongside the Postgres migration — same files are already being edited. CMS-PLAN §2.1 and §2.2 have been updated to the new project names; Wave 1.2 and downstream references consume the renamed projects from the start.
- **`CMS-PLAN.md §5` (dual-write).** Unchanged. Option B (HTTP proxy through `DeepDrftContent`) still applies. `DeepDrftWeb` still does not reference `DeepDrftContent.Data` directly. (The new `POST api/track/upload` controller on `DeepDrftContent` is the *only* writer that touches `DeepDrftContent.Data`.) - **`CMS-PLAN.md §5` (dual-write).** Unchanged. Option B (HTTP proxy through `DeepDrftContent`) still applies. `DeepDrftWeb` still does not reference `DeepDrftContent.Data` directly. (The new `POST api/track/upload` controller on `DeepDrftContent` is the *only* writer that touches `DeepDrftContent.Data`.)
- **`CMS-PLAN.md §3.2` (CreatedByUserId).** Subsumed by the `BaseEntity` lift if it includes a `CreatedByUserId` field on `BaseEntity` itself, or added separately if `BaseEntity` doesn't have it. Either way, the column lands in the same migration. - **`CMS-PLAN.md §3.2` (CreatedByUserId).** Subsumed by the `BaseEntity` lift if it includes a `CreatedByUserId` field on `BaseEntity` itself, or added separately if `BaseEntity` doesn't have it. Either way, the column lands in the same migration.
- **`CMS-PLAN.md §6 Wave 1`.** Wave 1.0 (Postgres) becomes "Postgres migration **+ BlazorBlocks data lift + project rename**" if Daniel approves this proposal. Wave 1.1 onwards is unaffected in shape, but the references update. - **`CMS-PLAN.md §6 Wave 1`.** Wave 1.0 (Postgres) becomes "Postgres migration **+ BlazorBlocks data lift + project rename**" if Daniel approves this proposal. Wave 1.1 onwards is unaffected in shape, but the references update.
@@ -248,12 +256,12 @@ The CMS surface gets cheaper to build, not more expensive — the upfront cost i
## 6. Decision points for Daniel ## 6. Decision points for Daniel
These are the load-bearing yes/nos. The rest of the proposal flexes around them. These are the load-bearing yes/nos. The rest of the proposal flexes around them. **All resolved (2026-05-18):**
1. **Adopt BlazorBlocks data primitives for the SQL side?** (`BaseEntity`, `Repository`, `Manager`, `ClassifiedDbError`, BlazorBlocks `PagedResult`/`PagedQuery`.) — Recommended yes. The biggest payoff for the least churn. 1. **Adopt BlazorBlocks data primitives for the SQL side?** (`BaseEntity`, `Repository`, `Manager`, `ClassifiedDbError`, BlazorBlocks `PagedResult`/`PagedQuery`.) — **CONFIRMED.** `TrackEntity : BaseEntity`, `TrackRepository : Repository<DeepDrftContext, TrackEntity>`, `TrackService` becomes `TrackManager : Manager<...>`. BlazorBlocks paging contracts replace DeepDrft's parallel `PagingParameters<T>` / `PagedResult<T>`. Folds into CMS-PLAN Wave 1.0.
2. **Add `DeepDrftApi` as a shared controller library?**Recommended yes, but lower priority. Could be deferred until the second SQL-side controller (Image) is on the horizon, since with one controller it's premature. 2. **Add `DeepDrftApi` as a shared controller library?****DEFERRED.** With one SQL-backed entity (`TrackEntity`), the shared controller library is premature. Revisit when image-vault wiring (`PLAN.md §2.1`) lands and brings a second SQL controller. Until then, the SQL-side `TrackController` stays in `DeepDrftWeb`.
3. **Rename `DeepDrftWeb.Services` and `DeepDrftContent.Services`?**Recommended yes if (1) is approved. The rename is a cheap clarity win at the same migration boundary; doing it standalone is more churn than reward. 3. **Rename `DeepDrftWeb.Services` and `DeepDrftContent.Services`?****CONFIRMED.** `DeepDrftWeb.Services``DeepDrftData`, `DeepDrftContent.Services``DeepDrftContent.Data`. Folds into CMS-PLAN Wave 1.0 alongside (1).
4. **Keep the CMS as an RCL inside `DeepDrftWeb`?**Recommended yes. No change from `CMS-PLAN.md`. Captured the trigger conditions for revisiting in §2. 4. **Keep the CMS as an RCL inside `DeepDrftWeb`?****CONFIRMED hold.** No change from `CMS-PLAN.md`. Trigger conditions for revisiting captured in §2.
5. **Keep the two hosts (`DeepDrftWeb` + `DeepDrftContent`) split?**Recommended yes. The dual-database / dual-host shape is right. 5. **Keep the two hosts (`DeepDrftWeb` + `DeepDrftContent`) split?****CONFIRMED hold.** The dual-database / dual-host shape stays.
If (1) is no, the rest of this proposal mostly evaporates — the existing structure is fine and the discomfort is real but cosmetic. If (1) is yes, (2)(3) follow naturally and (4)(5) are confirmations of decisions already taken. Original recommendation guidance retained for the record: (1)+(3) are the load-bearing pair — they pay off together at the Postgres-migration boundary; (2) waits for a second SQL controller to justify the library; (4)+(5) are confirmations of decisions already taken in `CMS-PLAN.md` and `CONTEXT.md`.
+15 -6
View File
@@ -35,8 +35,8 @@ Daniel said "if possible we will keep the CMS code in an RCL, with mountable pag
``` ```
DeepDrftCms Razor Class Library (RCL). DeepDrftCms Razor Class Library (RCL).
CMS pages, components, view models, page-route registration. CMS pages, components, view models, page-route registration.
References: DeepDrftModels, DeepDrftWeb.Services, References: DeepDrftModels, DeepDrftData,
DeepDrftContent.Services, Cerebellum.AuthBlocks.Web, DeepDrftContent.Data, Cerebellum.AuthBlocks.Web,
Cerebellum.AuthBlocks.Models, MudBlazor. Cerebellum.AuthBlocks.Models, MudBlazor.
Mounted into DeepDrftWeb via project reference + route discovery. Mounted into DeepDrftWeb via project reference + route discovery.
``` ```
@@ -71,7 +71,7 @@ DeepDrftCms Razor Class Library (RCL).
- `DeepDrftWeb` references three AuthBlocks packages (NuGet, published as `Cerebellum.AuthBlocks*` at version 10.3.16+): - `DeepDrftWeb` references three AuthBlocks packages (NuGet, published as `Cerebellum.AuthBlocks*` at version 10.3.16+):
- `Cerebellum.AuthBlocks` — the `AddAuthBlocks`/`UseAuthBlocksStartupAsync`/`MapAuthBlocks` integration surface, JWT services, hierarchical role authorization handler. - `Cerebellum.AuthBlocks` — the `AddAuthBlocks`/`UseAuthBlocksStartupAsync`/`MapAuthBlocks` integration surface, JWT services, hierarchical role authorization handler.
- `Cerebellum.AuthBlocks.Web` — the bundled MudBlazor login/logout/register pages, `JwtAuthenticationStateProvider`, `TokenService` (localStorage), and the `HierarchicalRoleAuthorizeAttribute` / `HierarchicalRoleAuthorizeView` used by the RCL. - `Cerebellum.AuthBlocks.Web` — the bundled MudBlazor login/logout/register pages, `JwtAuthenticationStateProvider`, `TokenService` (localStorage), and the `HierarchicalRoleAuthorizeAttribute` / `HierarchicalRoleAuthorizeView` used by the RCL.
- `Cerebellum.AuthBlocks.Models``ApplicationUser`, `ApplicationRole`, `SystemRole` constants. Transitively pulled by the other two; reference explicitly if `DeepDrftCms` or `DeepDrftWeb.Services` need the entity types. - `Cerebellum.AuthBlocks.Models``ApplicationUser`, `ApplicationRole`, `SystemRole` constants. Transitively pulled by the other two; reference explicitly if `DeepDrftCms` or `DeepDrftData` need the entity types.
- `DeepDrftWeb.Client` references `Cerebellum.AuthBlocks.Web` and calls `AuthBlocksWeb.Client.Startup.ConfigureServices(builder.Services)` to register `AddAuthorizationCore()`, `AddCascadingAuthenticationState()`, and `AddAuthenticationStateDeserialization()`. This is the prerender → WASM bridge for auth state, equivalent to what `DarkModeSettings` does today (see `CONTEXT.md §3.6`). - `DeepDrftWeb.Client` references `Cerebellum.AuthBlocks.Web` and calls `AuthBlocksWeb.Client.Startup.ConfigureServices(builder.Services)` to register `AddAuthorizationCore()`, `AddCascadingAuthenticationState()`, and `AddAuthenticationStateDeserialization()`. This is the prerender → WASM bridge for auth state, equivalent to what `DarkModeSettings` does today (see `CONTEXT.md §3.6`).
- `DeepDrftCms` references `Cerebellum.AuthBlocks.Web` (for the authorize attribute / view) and `Cerebellum.AuthBlocks.Models` (for `SystemRoleConstants.Admin`). - `DeepDrftCms` references `Cerebellum.AuthBlocks.Web` (for the authorize attribute / view) and `Cerebellum.AuthBlocks.Models` (for `SystemRoleConstants.Admin`).
- A new `DeepDrftWeb/environment/authblocks.json` (or appsettings section) holds the JWT secret, issuer, audience, Mailtrap email connection, admin seed credentials, and Postgres connection string. Follows the same pattern as `apikey.json` — not in repo. - A new `DeepDrftWeb/environment/authblocks.json` (or appsettings section) holds the JWT secret, issuer, audience, Mailtrap email connection, admin seed credentials, and Postgres connection string. Follows the same pattern as `apikey.json` — not in repo.
@@ -117,9 +117,18 @@ AuthBlocks's JWT-in-localStorage posture interacts with Blazor's prerender → W
### 3.4 Authorization wiring (concrete) ### 3.4 Authorization wiring (concrete)
- **Razor pages in the RCL:** `@attribute [HierarchicalRoleAuthorize("Admin")]` at the top of every `/cms/*` page. The bundled handler walks the role hierarchy. - **Razor pages in the RCL:** `@attribute [HierarchicalRoleAuthorize("Admin")]` at the top of every `/cms/*` page. The bundled handler walks the role hierarchy.
**Stealth routing (hard constraint).** Non-admin and anonymous requests to any `/cms/*` route must return **404 Not Found**, not a 401 and not a redirect to `/account/login`. The CMS must not acknowledge its own existence to an unauthorized caller. This is a deliberate departure from the bundled `RedirectToLogin` pattern: the login redirect is appropriate for *intentional authenticated-but-wrong-role* access (a signed-in non-admin clicking a CMS link they shouldn't have been shown), but it is wrong for *anonymous discovery* — a redirect to `/account/login` on a hit to `/cms/tracks` reveals that the route exists.
Candidate implementation shapes (decide at build time, not here):
- A custom `IAuthorizationMiddlewareResultHandler` registered for the `/cms/*` route prefix that maps `AuthorizationFailure` to a `404` `StatusCodeResult` instead of the default challenge/forbid behaviour.
- A custom authorization policy attached to the CMS routes whose handler returns failure via `context.Fail(new AuthorizationFailureReason(..., "not found"))` and is paired with a result handler that translates that reason to 404.
- A route-level endpoint filter / middleware on the `/cms` prefix that inspects auth state before the standard authorization middleware runs and short-circuits to 404 when the caller is not in the `Admin` hierarchy.
Whichever shape lands, the `/account/login` page itself stays publicly available (the public site links to it). The login page must not auto-redirect a signed-in non-admin to `/cms/*` either — the CMS surface is invisible from outside the trust boundary.
- **API endpoints in DeepDrftWeb:** `[Authorize(Roles = "Admin")]` on the new `api/cms/track` controller. The hierarchical handler is registered globally so the standard `[Authorize(Roles=...)]` attribute participates in hierarchy walks too. - **API endpoints in DeepDrftWeb:** `[Authorize(Roles = "Admin")]` on the new `api/cms/track` controller. The hierarchical handler is registered globally so the standard `[Authorize(Roles=...)]` attribute participates in hierarchy walks too.
- **Anonymous public surface:** unaffected. `GET api/track/page` and `GET api/track/{id}` remain unauthenticated. The public gallery, player, and home page do not require login. Auth state on the public side is "anonymous or signed-in admin"; signed-in state surfaces only as a "CMS" link in the nav. - **Anonymous public surface:** unaffected. `GET api/track/page` and `GET api/track/{id}` remain unauthenticated. The public gallery, player, and home page do not require login. Auth state on the public side is "anonymous or signed-in admin"; signed-in state surfaces only as a "CMS" link in the nav.
- **Login UI:** consume the bundled pages at `/account/login`, `/account/logout`. Do not author CMS-specific login pages. `RedirectToLogin` handles the "I tried to visit `/cms/tracks` while anonymous" case. - **Login UI:** consume the bundled pages at `/account/login`, `/account/logout`. Do not author CMS-specific login pages. The bundled `RedirectToLogin` component is **not** used on `/cms/*` routes — those return 404 per the stealth-routing constraint above. `/account/login` is reached by direct navigation (the public-site nav, a bookmark, an admin invitation email), not by being redirected there from a CMS URL.
- **First-run experience:** Daniel runs the app, AuthBlocks applies migrations against the Postgres `auth` schema, seeds `Admin` + `UserAdmin` roles, creates the admin user from `authblocks.json`. Daniel visits `/account/login`, authenticates, lands on `/cms/tracks`. - **First-run experience:** Daniel runs the app, AuthBlocks applies migrations against the Postgres `auth` schema, seeds `Admin` + `UserAdmin` roles, creates the admin user from `authblocks.json`. Daniel visits `/account/login`, authenticates, lands on `/cms/tracks`.
### 3.5 Database conflict — AuthBlocks is Postgres-only ### 3.5 Database conflict — AuthBlocks is Postgres-only
@@ -156,7 +165,7 @@ The CMS replaces what the CLI does today (add, list, delete) and grows the small
These are the minimum to retire `DeepDrftCli`. These are the minimum to retire `DeepDrftCli`.
**`/cms/tracks`** — track list. The CMS's mirror of `list`. The `[HierarchicalRoleAuthorize("Admin")]` attribute combined with the bundled `RedirectToLogin` component routes unauthenticated visitors to `/account/login` automatically. **`/cms/tracks`** — track list. The CMS's mirror of `list`. The `[HierarchicalRoleAuthorize("Admin")]` attribute gates the page; per §3.4 stealth routing, anonymous or insufficient-role hits return 404, not a redirect.
- Reads the same `PagedResult<TrackEntity>` that the public gallery reads (`GET api/track/page`). Per `user_one_source_multiple_views`, the CMS list is a different rendering of the same VM — table layout with admin affordances (edit, delete buttons), sort columns, optional row-level selection for bulk operations (see Wave 2). - Reads the same `PagedResult<TrackEntity>` that the public gallery reads (`GET api/track/page`). Per `user_one_source_multiple_views`, the CMS list is a different rendering of the same VM — table layout with admin affordances (edit, delete buttons), sort columns, optional row-level selection for bulk operations (see Wave 2).
- Empty state mirrors the CLI's "No tracks found in database." - Empty state mirrors the CLI's "No tracks found in database."
@@ -244,7 +253,7 @@ Themes, not dates. The order between waves is sequential (each depends on its pr
- **W1.0 `DeepDrftContext` Postgres migration.** Rewrite all existing EF Core migrations from SQLite to PostgreSQL. Update the `DeepDrftWeb` and `DeepDrftCli` connection strings in config. Migrate any existing data from `../Database/deepdrft.db` to Postgres. Verify the existing `api/track/page` and `api/track/{id}` endpoints function against the new backend. This is a prerequisite for W1.2 (which also runs migrations for AuthDbContext against the same Postgres instance). - **W1.0 `DeepDrftContext` Postgres migration.** Rewrite all existing EF Core migrations from SQLite to PostgreSQL. Update the `DeepDrftWeb` and `DeepDrftCli` connection strings in config. Migrate any existing data from `../Database/deepdrft.db` to Postgres. Verify the existing `api/track/page` and `api/track/{id}` endpoints function against the new backend. This is a prerequisite for W1.2 (which also runs migrations for AuthDbContext against the same Postgres instance).
- **W1.1 `DeepDrftCms` RCL skeleton.** Project created, added to solution, referenced from `DeepDrftWeb`. Empty `Pages/Cms/Index.razor` mounted at `/cms` returning a "CMS — under construction" placeholder, proving the mount works. - **W1.1 `DeepDrftCms` RCL skeleton.** Project created, added to solution, referenced from `DeepDrftWeb`. Empty `Pages/Cms/Index.razor` mounted at `/cms` returning a "CMS — under construction" placeholder, proving the mount works.
- **W1.2 AuthBlocks integration + login.** Reference `Cerebellum.AuthBlocks`, `Cerebellum.AuthBlocks.Web`, `Cerebellum.AuthBlocks.Models` from `DeepDrftWeb`; reference `Cerebellum.AuthBlocks.Web` from `DeepDrftWeb.Client`. Call `AddAuthBlocks(...)` in `Program.cs` with JWT secret/issuer/audience, Mailtrap email connection, Postgres connection string, and `AdminUserSettings` from `environment/authblocks.json`. Call `await app.Services.UseAuthBlocksStartupAsync()` post-build. Call `app.MapAuthBlocks()` to mount `/api/auth/*` routes. Add the `AuthBlocksWeb` assembly to `AddAdditionalAssemblies` so the bundled `/account/login` and `/account/logout` pages resolve. In `DeepDrftWeb.Client.Startup`, call `AuthBlocksWeb.Client.Startup.ConfigureServices(builder.Services)` for the prerender→WASM auth-state bridge. Add `CreatedByUserId : long?` column to `TrackEntity` via a nullable migration. Provision local Postgres (docker-compose) and document the dev setup. Verify: anonymous visit to `/cms/anything` redirects to `/account/login`; authenticated `Admin` lands successfully. - **W1.2 AuthBlocks integration + login.** Reference `Cerebellum.AuthBlocks`, `Cerebellum.AuthBlocks.Web`, `Cerebellum.AuthBlocks.Models` from `DeepDrftWeb`; reference `Cerebellum.AuthBlocks.Web` from `DeepDrftWeb.Client`. Call `AddAuthBlocks(...)` in `Program.cs` with JWT secret/issuer/audience, Mailtrap email connection, Postgres connection string, and `AdminUserSettings` from `environment/authblocks.json`. Call `await app.Services.UseAuthBlocksStartupAsync()` post-build. Call `app.MapAuthBlocks()` to mount `/api/auth/*` routes. Add the `AuthBlocksWeb` assembly to `AddAdditionalAssemblies` so the bundled `/account/login` and `/account/logout` pages resolve. In `DeepDrftWeb.Client.Startup`, call `AuthBlocksWeb.Client.Startup.ConfigureServices(builder.Services)` for the prerender→WASM auth-state bridge. Add `CreatedByUserId : long?` column to `TrackEntity` via a nullable migration. Provision local Postgres (docker-compose) and document the dev setup. Acceptance: (a) authenticated `Admin` visiting `/cms/*` lands successfully; (b) **anonymous or insufficient-role access to any `/cms/*` route returns 404** (not a redirect, not a 401 — per §3.4 stealth-routing constraint); (c) `/account/login` remains publicly reachable and does not redirect signed-in non-admins to any `/cms/*` route.
### Wave 2 — Operations the CLI never had ### Wave 2 — Operations the CLI never had