diff --git a/docs/superpowers/plans/2026-07-06-persistence-backend.md b/docs/superpowers/plans/2026-07-06-persistence-backend.md new file mode 100644 index 0000000..8bd812e --- /dev/null +++ b/docs/superpowers/plans/2026-07-06-persistence-backend.md @@ -0,0 +1,2659 @@ +# 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" +```