# Persistence and Scenario Editors (Backend) Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Build the plain-PHP backend that lets an anonymous browser visitor author, validate, save, and start a scenario, with secure image uploads and a CSRF-protected, escape-everything web stack. **Architecture:** A front controller (`public/index.php`) dispatches every request to a small `Http\Router` that routes by HTTP method + path to a `Http\Handlers\*` class. Handlers parse the request through a `Http\Request` value object, do their work through `Application\*` services (which know the `Domain`), and return a `Http\Response` rendered by `Views\*` PHP templates. The browser owns persistence via `localStorage`; the server only ever sees form POSTs and image uploads, never the assembled `Scenario` JSON. **Tech Stack:** PHP 8.3, PHPUnit 11, PHPStan level 6, PHP_CodeSniffer PSR-12, plain `php -S` for local dev. The frontend toolchain (ESLint airbnb/base + Prettier) and JS files land in Plan 3b. ## Global Constraints These apply to every task and are copied verbatim from the design spec. - Anonymous browser persistence: scenarios and matches live in the browser's `localStorage` (`scenario:{id}` and `match:current`); the server never stores game state. - No user accounts, no cross-device, no server-side storage of scenarios or matches. The server only keeps per-session CSRF state and uploaded image files. - Built-in dev server only: `php -S 0.0.0.0:8000 -t public` (documented in README). No Apache-only assumptions; the `var/uploads/.htaccess` and `var/uploads/index.php` sentinels cover both Apache deployments and the built-in server. - Every state-changing form or request uses and verifies a CSRF token before mutation. Tokens are per-session, signed with the app's secret using `hash_hmac('sha256', …)`. 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` helpers. Code review enforces the pattern; PHPStan has no rule for it. - All uploaded images are content-validated by reading the first 12 bytes and checking the magic number against the allowlist (PNG, JPEG, WebP, GIF), then `getimagesizefromstring()` confirms dimensions ≤ 512×512, then size is checked at ≤ 2 MB. The original filename and declared MIME are discarded after validation. - `Content-Security-Policy: default-src 'self'; img-src 'self' data:; style-src 'self'` on every page. `X-Content-Type-Options: nosniff` and `Referrer-Policy: same-origin` on every page. - Bundled placeholders are committed under `public/assets/placeholders/`; user uploads live in `var/uploads/` (git-ignored). - PHP follows the repository PHPCS rules and passes PHPStan at level 6. - The small JS surface passes ESLint `airbnb/base` and Prettier. - Composer configuration passes `composer validate --strict`. - Lint, static analysis, unit and integration tests, the end-to-end smoke test, and Composer validation are required CI checks. The MVP is releasable only when all checks pass. - Domain code has no dependency on HTTP, sessions, storage, or browser code (unchanged from Plans 1 and 2). ## File Structure ```text src/ ├── Application/ │ ├── ScenarioSerializer.php JSON ↔ Scenario (used by both editors and the start-match endpoint) │ ├── ScenarioDraft.php mutable draft state used to build a Scenario from form input │ ├── ImageValidator.php content-sniff + size + dimension checks │ └── ImageUploadService.php validates + stores a user-uploaded image; returns a stable URL ├── Http/ │ ├── Request.php value object wrapping $_GET, $_POST, $_FILES, $_SERVER │ ├── Response.php value object with status, headers, body │ ├── Router.php tiny path → handler dispatcher │ ├── CsrfToken.php per-session token store + verify helper │ ├── Escape.php html(), attr(), url() helpers │ └── Handlers/ │ ├── GetHomePage.php │ ├── GetTeamEditor.php │ ├── PostTeamEditor.php │ ├── GetBattlefieldEditor.php │ ├── PostBattlefieldEditor.php │ ├── GetAssets.php │ ├── PostImageUpload.php │ └── PostStartMatch.php ├── Views/ │ ├── layout.php │ ├── home.php │ ├── team-editor.php │ ├── battlefield-editor.php │ ├── match-stub.php │ └── upload-result.php └── public/ ├── index.php front controller └── assets/ └── styles.css ├── storage.js └── grid-editor.js var/ git-ignored, runtime-writable ├── phpunit/ ├── phpstan/ └── uploads/ ├── .htaccess Deny from all (Apache) ├── index.php Sentinel that returns 403 (built-in dev server) └── {userToken}/ Per-browser namespace; created on first upload tests/ ├── Unit/ │ ├── Application/ │ │ ├── ScenarioSerializerTest.php │ │ ├── ScenarioDraftTest.php │ │ ├── ImageValidatorTest.php │ │ └── ImageUploadServiceTest.php │ └── Http/ │ ├── CsrfTokenTest.php │ ├── EscapeTest.php │ ├── RequestTest.php │ └── RouterTest.php └── Integration/ ├── GetHomePageTest.php ├── GetTeamEditorTest.php ├── PostTeamEditorTest.php ├── GetBattlefieldEditorTest.php ├── PostBattlefieldEditorTest.php ├── GetAssetsTest.php ├── PostImageUploadTest.php └── PostStartMatchTest.php ``` ## Delivery Sequence Plan 3 is the third of four plans. It is split into: - **Plan 3a (this plan, the PHP backend):** Tasks 1-14. The web app is fully working and `curl`-able; tests cover all backend behavior. - **Plan 3b (the frontend, separate plan):** the small JS surface (`public/js/storage.js`, `public/js/grid-editor.js`), the placeholder images, and the ESLint/Prettier toolchain. The editor pages render with HTML-only submit and `localStorage` write today; Plan 3b adds the fetch-based battlefield grid and the JS UX. This plan covers 1-14 below. Plan 3b is drafted and executed after this one lands. 1. Toolchain and infrastructure (Task 1) 2. Application layer (Tasks 2-4) 3. Http layer (Tasks 5-7) 4. Editor pages and the front controller (Tasks 8-12) 5. Integration tests and end-to-end plumbing (Task 13) 6. CI updates (Task 14) 7. Final whole-branch review ## Execution Preflight Execute this plan in an isolated worktree on `feature/persistence-backend`, branched from `develop`. The implementation branch will be submitted as a pull request into `develop` only after every completion check is green. ### Task 1: Toolchain and infrastructure **Files:** - Create: `var/uploads/.htaccess` - Create: `var/uploads/index.php` - Create: `public/index.php` - Create: `public/assets/styles.css` - Modify: `.gitignore` - Modify: `composer.json` - Modify: `phpcs.xml` - Modify: `phpstan.neon` - Modify: `phpunit.xml` **Interfaces:** - Produces: a public document root that the built-in dev server can serve; an expanded PHPCS/PHPStan/PHPUnit scope that covers the new layers; an updated `.gitignore` that excludes `var/uploads/*` but commits `var/uploads/.htaccess` and `var/uploads/index.php`. The Node toolchain (ESLint + Prettier) and JS files land in Plan 3b. - [ ] **Step 1: Update `.gitignore`** Append a force-include rule for the upload sentinels so the `var/uploads/.htaccess` and `var/uploads/index.php` files are committed even though the rest of `var/uploads/` is ignored. Replace `.gitignore` with: ``` .worktrees/ /vendor/ /var/cache/ /var/phpunit/ /var/phpstan/ /var/logs/ /var/uploads/* !/var/uploads/.htaccess !/var/uploads/index.php /node_modules/ /public/js/*.map .phpunit.result.cache .phpunit.cache ``` - [ ] **Step 2: Create `var/uploads/.htaccess`** Create `var/uploads/.htaccess`: ``` Require all denied Deny from all ``` - [ ] **Step 3: Create `var/uploads/index.php`** Create `var/uploads/index.php`: ```php BattleForge coding standards. src tests public/index.php ``` - [ ] **Step 6: Update `phpstan.neon` to include the new layers and exclude templates** Replace `phpstan.neon` with: ```neon parameters: level: 6 paths: - src - tests - public/index.php excludePaths: - src/Views/* tmpDir: var/phpstan ``` - [ ] **Step 7: Update `phpunit.xml` to add an integration test suite** Replace `phpunit.xml` with: ```xml tests/Unit tests/Integration src ``` - [ ] **Step 8: Create `public/index.php` (front controller stub)** The full front controller lands in Task 13. This step creates the file with a placeholder so the document root exists and `php -S` can serve it. Create `public/index.php`: ```php */ public static function htmlProvider(): iterable { yield 'plain' => ['hello', 'hello']; yield 'less-than' => [' HTML; // The CSRF token placeholder is filled by the front controller (Task 16) // before the body is sent. The handler does not see the cookie or the // secret; it just receives a pre-issued token from the front controller. $token = $request->cookies['__csrf'] ?? ''; $body = str_replace('{{ csrf }}', htmlspecialchars($token, ENT_QUOTES, 'UTF-8'), $body); return Response::html(200, $body); } } ``` - [ ] **Step 4: Run the test to verify it passes** Run: `vendor/bin/phpunit tests/Integration/GetHomePageTest.php` Expected: PASS. - [ ] **Step 5: Write the failing integration test for `GetAssets`** Create `tests/Integration/GetAssetsTest.php`: ```php placeholderDir = __DIR__ . '/../fixtures/placeholders'; if (!is_dir($this->placeholderDir)) { mkdir($this->placeholderDir . '/..', 0755, true); mkdir($this->placeholderDir, 0755, true); } // 1x1 red PNG $png = base64_decode( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGBgAAAABQABh6FO1AAAAABJRU5ErkJggg==', true, ); file_put_contents($this->placeholderDir . '/defender.png', $png); $this->uploadsDir = sys_get_temp_dir() . '/bf-assets-' . bin2hex(random_bytes(4)); mkdir($this->uploadsDir, 0700, true); } protected function tearDown(): void { if (is_dir($this->uploadsDir)) { foreach (glob($this->uploadsDir . '/*/*') as $file) { unlink($file); } foreach (glob($this->uploadsDir . '/*') as $dir) { rmdir($dir); } rmdir($this->uploadsDir); } } public function testItServesAPlaceholderImageWithoutAuth(): void { $handler = new GetAssets($this->placeholderDir, $this->uploadsDir); $request = new Request([], [], [], [], [], '', 'GET', '/assets/placeholders/defender.png', null, ''); $response = $handler->handle($request, ['kind' => 'placeholders', 'filename' => 'defender.png']); self::assertSame(200, $response->status); self::assertSame('image/png', $response->headers['Content-Type'] ?? ''); self::assertGreaterThan(0, strlen($response->body)); } public function testItReturns404ForAMissingFile(): void { $handler = new GetAssets($this->placeholderDir, $this->uploadsDir); $request = new Request([], [], [], [], [], '', 'GET', '/assets/placeholders/missing.png', null, ''); $response = $handler->handle($request, ['kind' => 'placeholders', 'filename' => 'missing.png']); self::assertSame(404, $response->status); } } ``` - [ ] **Step 6: Run the test to verify it fails** Run: `vendor/bin/phpunit tests/Integration/GetAssetsTest.php` Expected: FAIL because `BattleForge\Http\Handlers\GetAssets` does not exist. - [ ] **Step 7: Implement `GetAssets`** Create `src/Http/Handlers/GetAssets.php`: ```php serveFrom($this->placeholderDir . '/' . $filename); } if ($kind === 'uploads') { $userToken = $params['userToken'] ?? ''; $requestToken = $request->cookies['__uploads_token'] ?? ''; if ($userToken === '' || $userToken !== $requestToken) { return Response::html(404, '

Not found

'); } return $this->serveFrom($this->uploadsRoot . '/' . $userToken . '/' . $filename); } return Response::html(404, '

Not found

'); } private function serveFrom(string $path): Response { if (!is_file($path)) { return Response::html(404, '

Not found

'); } $body = file_get_contents($path); if ($body === false) { return Response::html(500, '

Read error

'); } $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION)); $contentType = match ($ext) { 'png' => 'image/png', 'jpg', 'jpeg' => 'image/jpeg', 'webp' => 'image/webp', 'gif' => 'image/gif', default => 'application/octet-stream', }; return new Response(200, ['Content-Type' => $contentType] + self::securityHeaders(), $body); } /** @return array */ private static function securityHeaders(): array { return [ 'X-Content-Type-Options' => 'nosniff', 'Referrer-Policy' => 'same-origin', 'Content-Security-Policy' => "default-src 'self'; img-src 'self' data:; style-src 'self'", ]; } } ``` - [ ] **Step 8: Run the test to verify it passes** Run: `vendor/bin/phpunit tests/Integration/GetAssetsTest.php` Expected: PASS. - [ ] **Step 9: Run the full quality suite and commit** Run: `composer check` Expected: all checks green. ```powershell git add src/Http/Handlers/GetHomePage.php src/Http/Handlers/GetAssets.php tests/Integration/GetHomePageTest.php tests/Integration/GetAssetsTest.php git commit -m "feat: add home page and asset-serving handlers" ```