20 KiB
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:
- Visit
http://localhost:8000/, see the home page, and click "New scenario". - 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
localStorageunderscenario:{id}. - 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
localStoragekey is updated with the assembled scenario. - Click "Start match" and have the initial
MatchStateround-trip through the server'sPostStartMatchendpoint, land inlocalStorageundermatch:current, and render the "match loaded" stub. - Refresh the page, navigate around, and find their scenarios still in their browser.
- 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 thelocalStoragewrite helpers. - Bundled placeholder images under
public/assets/placeholders/. - The
archetypes.jsonasset. - 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:
- Verifies the CSRF token at the top (form POSTs read
_csrffrom$request->post; fetch POSTs readX-CSRF-Tokenfrom$request->server; the secret comes from the front controller via a request attribute__csrf_secret). Mismatch returnsResponse::html(403, '<h1>Forbidden</h1>')for form POSTs orResponse::json(403, ['error' => 'csrf']). - Parses the form or JSON body.
- Calls the relevant
Applicationservice (ScenarioDraft::fromPost,ScenarioSerializer::scenarioFromArray,ImageUploadService::store,Scenario::startMatch). - Runs the validator where applicable.
- Returns a
Responsewith the right status, headers, and body.
The handlers:
GetTeamEditor::handle— ReturnsResponse::html(200, …)rendering the team editor template. The form is rendered empty; Plan 3c'sstorage.jspopulates it fromlocalStorageon load.PostTeamEditor::handle— Reads form fields, builds aScenarioDraft, callstoScenario(), runsScenarioValidator::validate(). On success, renders a "Saved" template that contains a<script type="module">block that callslocalStorage.setItem('scenario:' + id, JSON.stringify(scenarioJson)). On failure, re-renders the editor with errors and original form values.GetBattlefieldEditor::handle— ReturnsResponse::html(200, …)rendering the battlefield editor with an empty<table class="bf-grid">(Plan 3c populates it fromlocalStorage).PostBattlefieldEditor::handle— ReadsContent-Type: application/jsonbody via$request->rawBody, decodes aScenarioDraft-shaped array, runs the validator, returnsResponse::json(200, ['ok' => true, 'scenario' => $array])on success orResponse::json(400, ['ok' => false, 'errors' => [...]])on failure.PostImageUpload::handle— Reads$_FILES['image'], computes theuserToken(derived from the CSRF secret — see CSRF Model below), constructsImageUploadService($userToken, $uploadsRoot), callsstore($tempPath, $declaredMime), returnsResponse::json(200, ['url' => $url])on success orResponse::json(400, ['error' => $message])on failure.PostStartMatch::handle— Reads the JSON body, decodes aScenarioviaScenarioSerializer::scenarioFromArray, runsScenarioValidator::validate()(defense in depth), callsScenario::startMatch('alpha'), returnsResponse::json(200, ['match' => $array])on success orResponse::json(400, ['errors' => [...]])on failure.
View Templates (six new files in src/Views/)
All templates are plain PHP files. They:
- Begin with
<?php declare(strict_types=1); ?>. - 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 viaheader()calls in the front controller, and the document body). - Print every dynamic value via
Escape::html,Escape::attr, orEscape::url. - Do not call domain code directly. They receive pre-built data from the handler.
The templates:
layout.php— Shared chrome. SetsContent-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 fromlocalStorage).team-editor.php— The team editor form. Sections for meta (id, name), team A, team B, victory condition, hidden_csrffield, 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 fromArchetypeCatalog::templates()and inlined in the HTML so the form is fully functional without JS. Plan 3c adds the JS that pre-fills the form fromlocalStorage.battlefield-editor.php— The battlefield editor: width/height number inputs, terrain palette,<table class="bf-grid">rendered empty (Plan 3c populates it fromlocalStorage), two deployment-zone fieldsets, an objective fieldset (only whenHoldObjectiveis the victory condition), hidden_csrffield, "Save" submit.match-stub.php— The Plan-3a/3c "Battle interface coming in Plan 4" placeholder. Readsmatch:currentfrom the request'slocalStoragesimulation (the integration test sets it via the__csrfcookie pattern; the front controller's real version hands the JS amatch:currentkey 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 callswindow.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:
- Reads
$_GET,$_POST,$_FILES,$_COOKIE,$_SERVER. - Derives the request method, path, query string, and content type.
- Reads the app secret from
BATTLEFORGE_SECRET(env var) or, if absent, fromvar/secret.key(a 32-byte random file created on first run and git-ignored). - Issues a CSRF token on first visit (when the
__csrfcookie is absent) by setting the cookie toCsrfToken::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 withHttpOnly,SameSite=Lax,Secure(production),Path=/,Expires=time()+86400. - Computes the
__uploads_tokencookie ashash_hmac('sha256', $secret, 'bf-uploads')(or reads it from the request) and threads it toGetAssetsvia a request attribute__uploads_token(read inside the handler via$request->server['__uploads_token']). - Configures the
Routerwith the eight routes:GET /→GetHomePageGET /scenarios/{id}/edit/team→GetTeamEditorPOST /scenarios/{id}/edit/team→PostTeamEditorGET /scenarios/{id}/edit/battlefield→GetBattlefieldEditorPOST /scenarios/{id}/edit/battlefield→PostBattlefieldEditorPOST /scenarios/{id}/start→PostStartMatchPOST /assets/upload→PostImageUploadGET /assets/{kind}/{filename}→GetAssets(the router pattern handles{kind}and{filename})
- Builds a
Requestvalue 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. - 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
_csrffield with$tokenis matched againstCsrfToken::verify($token, $secret, $request->cookies['__csrf'] ?? ''). - Fetch:
X-CSRF-Tokenheader with$tokenis matched the same way.
The upload-endpoint user token is derived from the same secret:
$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:
- Content library (Domain, unchanged).
- Scenario editor (web, expanding): now includes the team editor, battlefield editor, and image upload forms.
- Rules engine (Domain, unchanged).
- 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
- User edits the form, presses Save.
- Browser POSTs the form to
POST /scenarios/{id}/edit/teamwithContent-Type: application/x-www-form-urlencodedand a hidden_csrffield. - Server's
PostTeamEditor: a. Verifies the CSRF token. b. Reads form fields into aScenarioDraft. c. CallsScenarioDraft::toScenario()andScenarioValidator::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 callslocalStorage.setItem('scenario:' + id, JSON.stringify(scenarioJson)).
Battlefield editor save flow
- User clicks tiles; the small JS helper maintains a
tileMapobject. - User presses Save; the JS helper POSTs JSON to
POST /scenarios/{id}/edit/battlefieldwithContent-Type: application/jsonandX-CSRF-Tokenheader. - Server's
PostBattlefieldEditor: a. Verifies the CSRF token from the header. b. Decodes the JSON body. c. RunsScenarioValidator::validate(). d. On failure: returnsResponse::json(400, ['ok' => false, 'errors' => [...]]). e. On success: returnsResponse::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
- User picks an image, presses Upload.
- Browser submits the file to
POST /assets/uploadasmultipart/form-datawith a hidden_csrffield. - Server's
PostImageUpload: a. Verifies the CSRF token. b. Reads$_FILES['image']. c. ConstructsImageUploadService($userToken, $uploadsRoot), callsstore($tempPath, $declaredMime). d. ReturnsResponse::json(200, ['url' => $url])on success orResponse::json(400, ['error' => $message])on failure.
Start-match flow
- User clicks "Start match" on either editor.
- JS reads the assembled
ScenariofromlocalStorage, POSTs JSON toPOST /scenarios/{id}/startwithX-CSRF-Tokenheader. - Server's
PostStartMatch: a. Verifies the CSRF token. b. Decodes aScenarioviaScenarioSerializer::scenarioFromArray. c. RunsScenarioValidator::validate()(defense in depth). d. CallsScenario::startMatch('alpha'). e. ReturnsResponse::json(200, ['match' => $array])on success orResponse::json(400, ['errors' => [...]])on failure.
Validation and Failure Handling
ScenarioValidatoris 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.
localStoragewrites 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 inlocalStorage(Plan 3c's responsibility). - A failed image upload returns 400 JSON with the error message. The unit row keeps whatever
imagefield it had. - Corrupt or incompatible JSON in
localStorageis 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
_csrffield; fetch uses anX-CSRF-Tokenheader. - All rendered output is escaped via
Escape::html,Escape::attr, andEscape::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) andindex.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 theContent-Security-Policyheader.
Plan 3b-specific:
- The front controller's
userTokenvalidation inGetAssetsuses a strict format regex (/^[a-f0-9]{32,}$/) to harden the path-traversal surface. - The
BATTLEFORGE_SECRETenv var takes precedence over the per-install file. The per-install file isvar/secret.key, a 32-byte binary file created on first run withrandom_bytes(32)andchmod 0600. The file is git-ignored. - The CSRF secret in the
__csrfcookie is bound to the browser via the cookie'sHttpOnly+SameSite=Laxattributes, 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
Requestwith a valid CSRF token and a valid form/JSON body, asserts on the response status, headers, and body. - One validation-error test: synthetic
Requestwith 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 withlocalStorage.setItemsnippet; 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: ...}containingactiveTeamId: 'alpha'andround: 1.- Forged CSRF (no
_csrffield) 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:
- Bootstraps the front controller in-process (no
php -Sboot) by includingpublic/index.phpwith synthetic superglobals. - Issues a CSRF token.
- GET home page.
- POST team editor with a valid form body, asserts the response says "Saved" and contains the
localStorage.setItemsnippet. - POST battlefield editor with a valid JSON body, asserts
{ok: true, ...}. - 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 byphpstan.neonbut still checked by PHPCS),public/index.php. Already covered by the Task 1phpcs.xml. - PHPStan level 6 stays.
src/Views/*is excluded (templates are not statically analyzed).public/index.phpis 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.