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

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:

  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/teamGetTeamEditor
    • POST /scenarios/{id}/edit/teamPostTeamEditor
    • GET /scenarios/{id}/edit/battlefieldGetBattlefieldEditor
    • POST /scenarios/{id}/edit/battlefieldPostBattlefieldEditor
    • POST /scenarios/{id}/startPostStartMatch
    • POST /assets/uploadPostImageUpload
    • 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:

$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.