255 lines
20 KiB
Markdown
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.
|