Files
BattleForge/docs/superpowers/specs/2026-07-06-persistence-editors-ui-design.md

255 lines
20 KiB
Markdown

# Plan 3b: Editor Handlers, Views, and Front Controller Design
## Purpose
Plan 3b of the four-plan BattleForge build. Completes the web layer that Plan 3a started: the seven remaining PHP tasks that ship the editor pages, the front controller, the full-flow integration test, and CI verification.
Plan 3a delivered 10 of 17 written tasks: toolchain, output escaping, CSRF, Request/Response, Router, JSON serializer, image validator, image upload service, scenario draft, and the first two HTTP handlers (home page and asset serving). Plan 3b delivers the remaining 7:
- The five remaining HTTP handlers (team editor GET/POST, battlefield editor GET/POST, image upload POST, start-match POST)
- The six view templates
- The full front controller that wires the routes together and issues the CSRF + upload tokens
- A full-flow integration test
- CI verification
- Final whole-branch code review
The design is a delta on `docs/superpowers/specs/2026-07-06-persistence-editors-design.md` (the Plan 3 spec), which is the authoritative design. This document records only the clarifications and decisions that are specific to Plan 3b; everything else is inherited from the Plan 3 spec.
## Success Criteria
Plan 3b succeeds when a new user, without developer assistance, can:
1. Visit `http://localhost:8000/`, see the home page, and click "New scenario".
2. Fill in two teams of 3-6 units each on the team editor, upload a custom image, and save. The browser shows a "Saved" toast and the scenario lands in `localStorage` under `scenario:{id}`.
3. Navigate to the battlefield editor, paint a small grid, place deployment zones, place an objective (when the victory condition requires it), and save. The same `localStorage` key is updated with the assembled scenario.
4. Click "Start match" and have the initial `MatchState` round-trip through the server's `PostStartMatch` endpoint, land in `localStorage` under `match:current`, and render the "match loaded" stub.
5. Refresh the page, navigate around, and find their scenarios still in their browser.
6. Try a forged cross-site POST and see a 403. Try to access another user's uploaded image and see a 404.
## Out of Scope (deferred to Plan 3c or Plan 4)
- The JavaScript surface: `public/js/storage.js`, `public/js/grid-editor.js`, ESLint, Prettier. The current handlers and views render with HTML form submits and a `<script type="module">` placeholder; Plan 3c adds the fetch-based battlefield grid and the `localStorage` write helpers.
- Bundled placeholder images under `public/assets/placeholders/`.
- The `archetypes.json` asset.
- The hot-seat battle interface (Plan 4).
- Bundled scenarios (Plan 4).
- End-to-end smoke test against a real browser (Plan 4).
## In Scope
### HTTP Handlers (six new files in `src/Http/Handlers/`)
All handlers are `final class` with a public `handle(Request $request, array $params): Response` method. Each handler:
1. Verifies the CSRF token at the top (form POSTs read `_csrf` from `$request->post`; fetch POSTs read `X-CSRF-Token` from `$request->server`; the secret comes from the front controller via a request attribute `__csrf_secret`). Mismatch returns `Response::html(403, '<h1>Forbidden</h1>')` for form POSTs or `Response::json(403, ['error' => 'csrf'])`.
2. Parses the form or JSON body.
3. Calls the relevant `Application` service (`ScenarioDraft::fromPost`, `ScenarioSerializer::scenarioFromArray`, `ImageUploadService::store`, `Scenario::startMatch`).
4. Runs the validator where applicable.
5. Returns a `Response` with the right status, headers, and body.
The handlers:
- **`GetTeamEditor::handle`** — Returns `Response::html(200, …)` rendering the team editor template. The form is rendered empty; Plan 3c's `storage.js` populates it from `localStorage` on load.
- **`PostTeamEditor::handle`** — Reads form fields, builds a `ScenarioDraft`, calls `toScenario()`, runs `ScenarioValidator::validate()`. On success, renders a "Saved" template that contains a `<script type="module">` block that calls `localStorage.setItem('scenario:' + id, JSON.stringify(scenarioJson))`. On failure, re-renders the editor with errors and original form values.
- **`GetBattlefieldEditor::handle`** — Returns `Response::html(200, …)` rendering the battlefield editor with an empty `<table class="bf-grid">` (Plan 3c populates it from `localStorage`).
- **`PostBattlefieldEditor::handle`** — Reads `Content-Type: application/json` body via `$request->rawBody`, decodes a `ScenarioDraft`-shaped array, runs the validator, returns `Response::json(200, ['ok' => true, 'scenario' => $array])` on success or `Response::json(400, ['ok' => false, 'errors' => [...]])` on failure.
- **`PostImageUpload::handle`** — Reads `$_FILES['image']`, computes the `userToken` (derived from the CSRF secret — see CSRF Model below), constructs `ImageUploadService($userToken, $uploadsRoot)`, calls `store($tempPath, $declaredMime)`, returns `Response::json(200, ['url' => $url])` on success or `Response::json(400, ['error' => $message])` on failure.
- **`PostStartMatch::handle`** — Reads the JSON body, decodes a `Scenario` via `ScenarioSerializer::scenarioFromArray`, runs `ScenarioValidator::validate()` (defense in depth), calls `Scenario::startMatch('alpha')`, returns `Response::json(200, ['match' => $array])` on success or `Response::json(400, ['errors' => [...]])` on failure.
### View Templates (six new files in `src/Views/`)
All templates are plain PHP files. They:
1. Begin with `<?php declare(strict_types=1); ?>`.
2. Use `require __DIR__ . '/layout.php';` for shared chrome (the layout sets up `<meta name="csrf-token" content="…">`, `<link rel="stylesheet" href="/assets/styles.css">`, security headers via `header()` calls in the front controller, and the document body).
3. Print every dynamic value via `Escape::html`, `Escape::attr`, or `Escape::url`.
4. Do not call domain code directly. They receive pre-built data from the handler.
The templates:
- **`layout.php`** — Shared chrome. Sets `Content-Security-Policy: default-src 'self'; img-src 'self' data:; style-src 'self'` (Plan 3c may tighten this), `X-Content-Type-Options: nosniff`, `Referrer-Policy: same-origin`. Outputs the doctype, `<head>` with the CSRF meta and the stylesheet link, the `<body>` open tag, and `</body></html>`.
- **`home.php`** — The home page: heading, "New scenario" link to `/scenarios/new/edit/team`, `<div id="recent">` placeholder (Plan 3c populates it from `localStorage`).
- **`team-editor.php`** — The team editor form. Sections for meta (id, name), team A, team B, victory condition, hidden `_csrf` field, an `<input type="file" name="unit-N-image">` per unit row for the upload form, and a "Save" submit button. The actual archetype list and stat min/max are derived from `ArchetypeCatalog::templates()` and inlined in the HTML so the form is fully functional without JS. Plan 3c adds the JS that pre-fills the form from `localStorage`.
- **`battlefield-editor.php`** — The battlefield editor: width/height number inputs, terrain palette, `<table class="bf-grid">` rendered empty (Plan 3c populates it from `localStorage`), two deployment-zone fieldsets, an objective fieldset (only when `HoldObjective` is the victory condition), hidden `_csrf` field, "Save" submit.
- **`match-stub.php`** — The Plan-3a/3c "Battle interface coming in Plan 4" placeholder. Reads `match:current` from the request's `localStorage` simulation (the integration test sets it via the `__csrf` cookie pattern; the front controller's real version hands the JS a `match:current` key from the dispatch flow).
- **`upload-result.php`** — A small fragment used by the upload form's iframe-style response (so the parent form can read the returned URL). Renders a `<script type="text/javascript">` block that calls `window.parent.postMessage({url: "..."}, "*")` and a fallback link.
### Front Controller (modifies `public/index.php`)
The Task 1 stub is replaced with a real front controller. The front controller is the only place that touches `$_GET`, `$_POST`, `$_FILES`, `$_COOKIE`, `$_SERVER` directly. It:
1. Reads `$_GET`, `$_POST`, `$_FILES`, `$_COOKIE`, `$_SERVER`.
2. Derives the request method, path, query string, and content type.
3. Reads the app secret from `BATTLEFORGE_SECRET` (env var) or, if absent, from `var/secret.key` (a 32-byte random file created on first run and git-ignored).
4. Issues a CSRF token on first visit (when the `__csrf` cookie is absent) by setting the cookie to `CsrfToken::issue($secret)[1]` — the cookie holds the HMAC, the form/header holds the original token value. On every response, the controller ensures the cookie is set with `HttpOnly`, `SameSite=Lax`, `Secure` (production), `Path=/`, `Expires=time()+86400`.
5. Computes the `__uploads_token` cookie as `hash_hmac('sha256', $secret, 'bf-uploads')` (or reads it from the request) and threads it to `GetAssets` via a request attribute `__uploads_token` (read inside the handler via `$request->server['__uploads_token']`).
6. Configures the `Router` with the eight routes:
- `GET /``GetHomePage`
- `GET /scenarios/{id}/edit/team``GetTeamEditor`
- `POST /scenarios/{id}/edit/team``PostTeamEditor`
- `GET /scenarios/{id}/edit/battlefield``GetBattlefieldEditor`
- `POST /scenarios/{id}/edit/battlefield``PostBattlefieldEditor`
- `POST /scenarios/{id}/start``PostStartMatch`
- `POST /assets/upload``PostImageUpload`
- `GET /assets/{kind}/{filename}``GetAssets` (the router pattern handles `{kind}` and `{filename}`)
7. Builds a `Request` value object with the request data, the CSRF secret (in `__csrf_secret`), the upload token (in `__uploads_token`), and the request method, path, and content type.
8. Dispatches and emits the response (status, headers, body).
### CSRF Model
A single `__csrf` cookie per session. The cookie holds `hash_hmac('sha256', $token, $secret)` (the `issue()` method's second return value). The form/header holds the original token (`$token`, the first return value). On every state-changing request:
- Forms: hidden `_csrf` field with `$token` is matched against `CsrfToken::verify($token, $secret, $request->cookies['__csrf'] ?? '')`.
- Fetch: `X-CSRF-Token` header with `$token` is matched the same way.
The upload-endpoint user token is derived from the same secret:
```php
$userToken = hash_hmac('sha256', $secret, 'bf-uploads');
```
`GetAssets` validates `$request->cookies['__uploads_token'] === $params['userToken']`. The front controller sets the `__uploads_token` cookie on first visit.
**Hardening note for the front controller**: the `userToken` path-param in `GetAssets` should be validated against a strict format (e.g. `/^[a-f0-9]{32,}$/`) to prevent directory traversal. The router's `[^/]+` pattern restricts `$filename` already, but `$userToken` is the path-segment before the file, so a defensive regex there is appropriate.
### System Boundaries (delta on the Plan 3 spec)
Plan 3b introduces no new boundaries. The four module boundaries from the Plan 3 spec still hold:
1. **Content library** (Domain, unchanged).
2. **Scenario editor** (web, expanding): now includes the team editor, battlefield editor, and image upload forms.
3. **Rules engine** (Domain, unchanged).
4. **Battle interface** (Plan 4, stubbed in Plan 3b's `match-stub.php`).
The `Application` and `Http` layers from Plan 3a continue to mediate between the web pages and the `Domain`. The front controller in `public/index.php` is the only place that touches the superglobals.
## Data and Action Flow
### Cold-start flow (no change from Plan 3 spec)
### Team editor save flow
1. User edits the form, presses Save.
2. Browser POSTs the form to `POST /scenarios/{id}/edit/team` with `Content-Type: application/x-www-form-urlencoded` and a hidden `_csrf` field.
3. Server's `PostTeamEditor`:
a. Verifies the CSRF token.
b. Reads form fields into a `ScenarioDraft`.
c. Calls `ScenarioDraft::toScenario()` and `ScenarioValidator::validate($scenario)`.
d. On failure: re-renders the editor with errors and original form values.
e. On success: renders a "Saved" template that includes a `<script type="module">` block that calls `localStorage.setItem('scenario:' + id, JSON.stringify(scenarioJson))`.
### Battlefield editor save flow
1. User clicks tiles; the small JS helper maintains a `tileMap` object.
2. User presses Save; the JS helper POSTs JSON to `POST /scenarios/{id}/edit/battlefield` with `Content-Type: application/json` and `X-CSRF-Token` header.
3. Server's `PostBattlefieldEditor`:
a. Verifies the CSRF token from the header.
b. Decodes the JSON body.
c. Runs `ScenarioValidator::validate()`.
d. On failure: returns `Response::json(400, ['ok' => false, 'errors' => [...]])`.
e. On success: returns `Response::json(200, ['ok' => true, 'scenario' => $array])`.
(Note: Plan 3b ships the form-based team editor; the JS-driven fetch is added in Plan 3c. The handler accepts both for compatibility.)
### Image upload flow
1. User picks an image, presses Upload.
2. Browser submits the file to `POST /assets/upload` as `multipart/form-data` with a hidden `_csrf` field.
3. Server's `PostImageUpload`:
a. Verifies the CSRF token.
b. Reads `$_FILES['image']`.
c. Constructs `ImageUploadService($userToken, $uploadsRoot)`, calls `store($tempPath, $declaredMime)`.
d. Returns `Response::json(200, ['url' => $url])` on success or `Response::json(400, ['error' => $message])` on failure.
### Start-match flow
1. User clicks "Start match" on either editor.
2. JS reads the assembled `Scenario` from `localStorage`, POSTs JSON to `POST /scenarios/{id}/start` with `X-CSRF-Token` header.
3. Server's `PostStartMatch`:
a. Verifies the CSRF token.
b. Decodes a `Scenario` via `ScenarioSerializer::scenarioFromArray`.
c. Runs `ScenarioValidator::validate()` (defense in depth).
d. Calls `Scenario::startMatch('alpha')`.
e. Returns `Response::json(200, ['match' => $array])` on success or `Response::json(400, ['errors' => [...]])` on failure.
## Validation and Failure Handling
- `ScenarioValidator` is the single source of truth for scenario validity. Both the server (on every form POST) and the browser (when Plan 3c lands) call it.
- A rejected action never partially mutates the persisted state. `localStorage` writes happen only on a successful server response.
- Failure to save a scenario leaves the user's in-progress form intact; the editor re-renders the same values with errors inline.
- The CSRF token's invalid-or-missing case returns a generic 403 page (form POSTs) or 403 JSON (fetch POSTs) that links back to `GET /`. The user's in-progress form state is preserved in `localStorage` (Plan 3c's responsibility).
- A failed image upload returns 400 JSON with the error message. The unit row keeps whatever `image` field it had.
- Corrupt or incompatible JSON in `localStorage` is rejected on read: the JS helper skips the row and surfaces an error to the user.
## Security and Quality Requirements
Inherited from the Plan 3 spec:
- Every state-changing form or request uses and verifies a CSRF token before mutation. Forms use a hidden `_csrf` field; fetch uses an `X-CSRF-Token` header.
- All rendered output is escaped via `Escape::html`, `Escape::attr`, and `Escape::url`. Code review enforces the pattern.
- All uploaded images are content-validated and by dimensions; the stored file's name is a server-generated random hex; the original filename and declared MIME are discarded after validation.
- `var/uploads/` is denied by `.htaccess` (Apache) and `index.php` (built-in server).
- The dev server is the built-in `php -S`.
- PHP follows the repository PHPCS rules and passes PHPStan at level 6.
- Composer configuration passes `composer validate --strict`.
- All output includes `X-Content-Type-Options: nosniff`, `Referrer-Policy: same-origin`, and the `Content-Security-Policy` header.
Plan 3b-specific:
- The front controller's `userToken` validation in `GetAssets` uses a strict format regex (`/^[a-f0-9]{32,}$/`) to harden the path-traversal surface.
- The `BATTLEFORGE_SECRET` env var takes precedence over the per-install file. The per-install file is `var/secret.key`, a 32-byte binary file created on first run with `random_bytes(32)` and `chmod 0600`. The file is git-ignored.
- The CSRF secret in the `__csrf` cookie is bound to the browser via the cookie's `HttpOnly` + `SameSite=Lax` attributes, and the secret is HMAC-signed server-side. A cross-site request cannot forge the HMAC.
## Verification Strategy
### Unit tests (in `tests/Integration/` per the established Plan 3a pattern)
The handlers are integration-tested, not unit-tested, because their behavior crosses the HTTP / Application / Domain boundary. Each handler has at least:
- One happy-path test: synthetic `Request` with a valid CSRF token and a valid form/JSON body, asserts on the response status, headers, and body.
- One validation-error test: synthetic `Request` with valid CSRF but out-of-bounds data, asserts the response carries the validator's error messages.
Tests cover (per the Plan 3 spec):
- `GetTeamEditor` — 200 with security headers and a form.
- `PostTeamEditor` — happy path returns 200 with `localStorage.setItem` snippet; out-of-bounds stat returns 200 with the validator's error message inline.
- `GetBattlefieldEditor` — 200 with security headers and an empty grid.
- `PostBattlefieldEditor` — happy path returns `{ok: true, scenario: ...}`; invalid shape returns `{ok: false, errors: [...]}`.
- `PostImageUpload` — 200 with `{url: ...}`; missing file returns 400; invalid file returns 400.
- `PostStartMatch` — 200 with `{match: ...}` containing `activeTeamId: 'alpha'` and `round: 1`.
- Forged CSRF (no `_csrf` field) returns 403 for form POSTs and 403 JSON for fetch POSTs.
- Wrong-user upload (token mismatch) returns 404.
### Full-flow integration test (`FullFlowTest`)
A single integration test that walks the full create-and-save flow:
1. Bootstraps the front controller in-process (no `php -S` boot) by including `public/index.php` with synthetic superglobals.
2. Issues a CSRF token.
3. GET home page.
4. POST team editor with a valid form body, asserts the response says "Saved" and contains the `localStorage.setItem` snippet.
5. POST battlefield editor with a valid JSON body, asserts `{ok: true, ...}`.
6. POST start-match with the assembled scenario, asserts the response contains the initial match state.
This test exercises the actual `public/index.php` front controller end-to-end and is the closest the MVP gets to a true E2E test (a real browser-driven E2E is in Plan 4).
### CI verification (Task 16)
The existing `.github/workflows/ci.yml` already runs `composer check` (PHPCS + PHPStan + PHPUnit). Plan 3b adds no new dependencies. No workflow change is required; just verify the existing workflow is present.
### Static analysis / lint
- PHPCS scope: `src/Http/Handlers/`, `src/Views/` (excluded by `phpstan.neon` but still checked by PHPCS), `public/index.php`. Already covered by the Task 1 `phpcs.xml`.
- PHPStan level 6 stays. `src/Views/*` is excluded (templates are not statically analyzed). `public/index.php` is included.
- The pre-existing line-length warnings from Plan 1+2 are not addressed in Plan 3b. They remain as a known follow-up. Plan 3b's new files should not introduce new warnings.
### Manual usability check
A new tester with no BattleForge context can: open the dev URL, see the home page, click "New scenario", fill in two teams with mixed archetypes, upload a custom image, save the team editor, paint a small grid, save the battlefield editor, click "Start match", and reach the "match loaded" stub — without leaving the browser or seeing any error from the validator that wasn't explained inline.
## Release Boundary
Plan 3b is releasable when every in-scope capability and success criterion is met, the verification suite is green (PHPUnit, PHPStan, PHPCS), and no out-of-scope capability (the JS-driven UX, the battle interface, bundled scenarios) is required to exercise the local creation flow. The local dev server runs the full app on a single port with `php -S`.
The next plan (Plan 3c) will add the JavaScript surface (`storage.js`, `grid-editor.js`, placeholder images) and the `archetypes.json` asset. Plan 4 will replace the `match-stub.php` placeholder with the real battle interface, add the three bundled scenarios, and ship the E2E smoke test that this plan seeds.