# Foundation 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:** Turn the SeedProject `api/` into an installable, DB-backed, two-tier-secured backend that a static Astro site calls same-origin, proven by a live health round-trip. **Architecture:** A CLI installer provisions config + schema; a migrations runner evolves it; two namespaced base controllers (`PublicController`, `ApiController`) enforce a public (origin + rate-limit) and privileged (bearer / `api_auth` key) tier over a shared JSON envelope; a `/api/health` endpoint queries MariaDB and returns JSON, called from an Astro page. **Tech Stack:** PHP 8.3, Symfony Console 5.4, MariaDB 10.11 (PDO/`Db` facade), Apache + php-fpm, Astro (static). No PHPUnit in this repo — verification is done with `php console`, `curl`, and `mysql` commands. **Spec:** `api/.memory/foundation.md`. **Conventions:** [documentation.md](documentation.md), [commands.md](../commands/commands.md). **Autoload note:** New classes use PSR-4 — `App\Services\*` → `app/Services/*.php`, `App\Controllers\*` → `app/Controllers/*.php` (composer.json maps `App\` → `app`). No `composer dump-autoload` needed for these. URL controllers stay global in `public/controllers/` (Bootstrap `require`s them) and `use` the namespaced bases. Core files (`core/*`) are never modified. --- ## File structure **Create** - `app/Services/Installer.php` — `App\Services\Installer`: test DB, import `dump.sql`, generate keys, write `config.php`, write install lock. - `app/Services/Migrator.php` — `App\Services\Migrator`: ensure `migrations` table, list/apply `db/migrations/*.sql`. - `commands/InstallCommand.php` — `php console app:install`. - `commands/MigrateCommand.php` — `php console db:migrate [--status]`. - `app/Controllers/JsonController.php` — `App\Controllers\JsonController extends \Controller`: `json()` envelope + `throttle()`. - `app/Controllers/PublicController.php` — `App\Controllers\PublicController extends JsonController`: `checkOrigin()`. - `app/Controllers/ApiController.php` — `App\Controllers\ApiController extends JsonController`: `authenticate()` (bearer/`api_auth`). - `public/controllers/health.php` — global `Health extends App\Controllers\PublicController`. - `public/controllers/admin.php` — global `Admin extends App\Controllers\ApiController`. - `db/migrations/001_create_metrics_placeholder.sql` — a trivial first migration to prove the runner. - `app/src/pages/api-health-test.astro` — client-side round-trip proof page. **Modify** - `console` — register `InstallCommand`, `MigrateCommand`. - `install/controllers/index.php` — delegate to `Installer`; refuse if locked. - Apache vhost `/www/server/panel/vhost/apache/comiida.com.conf` — deny `install/`, `db/`, `.installed`. **Server (outside repo)** - MariaDB `comiida` database + user. --- ## Task 1: Provision the database **Files:** none (server state). - [ ] **Step 1: Create the database and a dedicated user** Operator supplies the MariaDB root password. Choose a strong `APP_DB_PASS`. Run: ```bash mysql -u root -p -e " CREATE DATABASE IF NOT EXISTS comiida CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; CREATE USER IF NOT EXISTS 'comiida'@'localhost' IDENTIFIED BY 'APP_DB_PASS'; GRANT ALL PRIVILEGES ON comiida.* TO 'comiida'@'localhost'; FLUSH PRIVILEGES;" ``` - [ ] **Step 2: Verify the user can connect to the empty DB** Run: `mysql -u comiida -p'APP_DB_PASS' comiida -e "SELECT DATABASE();"` Expected: prints `comiida`, no error. - [ ] **Step 3: No commit** (server state, nothing in repo). --- ## Task 2: Installer service + `app:install` command **Files:** - Create: `app/Services/Installer.php` - Create: `commands/InstallCommand.php` - Modify: `console` - [ ] **Step 1: Write `app/Services/Installer.php`** ```php up two levels -> api/ $this->baseDir = dirname(__DIR__, 2); } public function lockFile(): string { return $this->baseDir . '/system/.installed'; } public function isInstalled(): bool { return is_file($this->lockFile()); } public function testConnection(array $db): bool { try { new PDO( "mysql:host={$db['host']};dbname={$db['name']};charset=utf8mb4", $db['user'], $db['pass'], [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION] ); return true; } catch (PDOException $e) { return false; } } public function importSchema(array $db): void { $sqlFile = $this->baseDir . '/install/dump.sql'; if (!is_file($sqlFile)) { throw new RuntimeException("Schema dump not found: {$sqlFile}"); } $mysqli = new \mysqli($db['host'], $db['user'], $db['pass'], $db['name']); if ($mysqli->connect_errno) { throw new RuntimeException('DB connect failed: ' . $mysqli->connect_error); } if (!$mysqli->multi_query((string) file_get_contents($sqlFile))) { throw new RuntimeException('Schema import failed: ' . $mysqli->error); } // Drain all result sets so the connection finishes cleanly. while ($mysqli->more_results() && $mysqli->next_result()) { /* noop */ } if ($mysqli->errno) { throw new RuntimeException('Schema import error: ' . $mysqli->error); } $mysqli->close(); } public function generateKey(int $bytes = 32): string { return bin2hex(random_bytes($bytes)); // hex only: safe inside single-quoted PHP } public function writeConfig(array $c): void { $e = fn($v) => addslashes((string) $v); // escape operator-provided values $tpl = "baseDir . '/config.php', $tpl); } public function lock(): void { file_put_contents($this->lockFile(), date('c') . "\n"); } /** * Full install. $cfg = ['url','name','db'=>['host','name','user','pass']]. */ public function run(array $cfg): void { if (!$this->testConnection($cfg['db'])) { throw new RuntimeException('Database connection failed. Check credentials.'); } $this->importSchema($cfg['db']); $cfg['admin_token'] = $this->generateKey(24); $cfg['hash_password_key'] = $this->generateKey(32); $cfg['hash_api_key'] = $this->generateKey(32); $this->writeConfig($cfg); $this->lock(); } } ``` - [ ] **Step 2: Write `commands/InstallCommand.php`** ```php setName('app:install') ->setDescription('Install the framework: import schema, write config, lock.') ->addOption('db-host', null, InputOption::VALUE_REQUIRED, 'DB host', 'localhost') ->addOption('db-name', null, InputOption::VALUE_REQUIRED, 'DB name') ->addOption('db-user', null, InputOption::VALUE_REQUIRED, 'DB user') ->addOption('db-pass', null, InputOption::VALUE_REQUIRED, 'DB password') ->addOption('url', null, InputOption::VALUE_REQUIRED, 'Site URL', 'https://www.comiida.com') ->addOption('name', null, InputOption::VALUE_REQUIRED, 'Project name', 'Comiida') ->addOption('force', null, InputOption::VALUE_NONE, 'Re-run even if already installed'); } protected function execute(InputInterface $input, OutputInterface $output) { $installer = new Installer(); if ($installer->isInstalled() && !$input->getOption('force')) { $output->writeln('Already installed. Use --force to re-run.'); return Command::FAILURE; } foreach (['db-name', 'db-user', 'db-pass'] as $req) { if (!$input->getOption($req)) { $output->writeln("--{$req} is required."); return Command::FAILURE; } } try { $installer->run([ 'url' => $input->getOption('url'), 'name' => $input->getOption('name'), 'db' => [ 'host' => $input->getOption('db-host'), 'name' => $input->getOption('db-name'), 'user' => $input->getOption('db-user'), 'pass' => $input->getOption('db-pass'), ], ]); } catch (\Throwable $e) { $output->writeln('Install failed: ' . $e->getMessage() . ''); return Command::FAILURE; } $output->writeln('Install complete. config.php written, schema imported, lock set.'); return Command::SUCCESS; } } ``` - [ ] **Step 3: Register the command in `console`** In `console`, add the `use` and registration (below the existing `GreetCommand` line): ```php $application->add(new GreetCommand()); $application->add(new InstallCommand()); ``` - [ ] **Step 4: Verify the command is discoverable** Run: `php console list | grep app:install` Expected: a line `app:install Install the framework...` - [ ] **Step 5: Run the installer against the comiida DB** Run (operator fills `APP_DB_PASS`): ```bash php console app:install --db-host=localhost --db-name=comiida --db-user=comiida --db-pass='APP_DB_PASS' --url=https://www.comiida.com --name=Comiida ``` Expected: `Install complete...` - [ ] **Step 6: Verify schema, config, and lock** Run: ```bash mysql -u comiida -p'APP_DB_PASS' comiida -e "SHOW TABLES;" | grep -E "api_auth|config|users" php -r "require 'config.php'; echo DB_NAME.PHP_EOL; echo (strlen(ADMIN_TOKEN)>=48?'token-ok':'token-bad').PHP_EOL;" test -f system/.installed && echo "locked" ``` Expected: tables listed; `comiida`, `token-ok`, `locked`. - [ ] **Step 7: Commit** ```bash git add app/Services/Installer.php commands/InstallCommand.php console git commit -m "feat(api): CLI installer (app:install) — schema import, config, lock" ``` (Note: `config.php` and `system/.installed` are gitignored / not tracked.) --- ## Task 3: Migrations runner + `db:migrate` **Files:** - Create: `app/Services/Migrator.php` - Create: `commands/MigrateCommand.php` - Create: `db/migrations/001_create_metrics_placeholder.sql` - Modify: `console`, `app/Services/Installer.php` - [ ] **Step 1: Write `app/Services/Migrator.php`** ```php pdo = $pdo; $this->dir = $dir; } public function ensureTable(): void { $this->pdo->exec( "CREATE TABLE IF NOT EXISTS `migrations` ( `id` INT AUTO_INCREMENT PRIMARY KEY, `filename` VARCHAR(255) NOT NULL UNIQUE, `applied_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" ); } /** @return string[] filenames already applied */ public function applied(): array { return $this->pdo->query("SELECT filename FROM `migrations`")->fetchAll(PDO::FETCH_COLUMN) ?: []; } /** @return string[] absolute paths of pending migrations, in order */ public function pending(): array { $all = glob($this->dir . '/*.sql') ?: []; sort($all); $applied = $this->applied(); return array_values(array_filter($all, fn($p) => !in_array(basename($p), $applied, true))); } /** @return string[] filenames applied this run */ public function migrate(): array { $this->ensureTable(); $done = []; foreach ($this->pending() as $path) { $this->pdo->exec((string) file_get_contents($path)); $stmt = $this->pdo->prepare("INSERT IGNORE INTO `migrations` (filename) VALUES (?)"); $stmt->execute([basename($path)]); $done[] = basename($path); } return $done; } } ``` - [ ] **Step 2: Write the first migration `db/migrations/001_create_metrics_placeholder.sql`** ```sql -- Foundation smoke migration: proves the runner end-to-end. -- (Real metrics tables land in the Metrics sub-project.) CREATE TABLE IF NOT EXISTS `_foundation_check` ( `id` INT AUTO_INCREMENT PRIMARY KEY, `note` VARCHAR(64) NOT NULL, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ``` - [ ] **Step 3: Write `commands/MigrateCommand.php`** ```php setName('db:migrate') ->setDescription('Apply pending SQL migrations from db/migrations/') ->addOption('status', null, InputOption::VALUE_NONE, 'Show applied/pending without applying'); } protected function execute(InputInterface $input, OutputInterface $output) { $base = dirname(__DIR__); // api/ if (!is_file($base . '/config.php')) { $output->writeln('config.php missing — run app:install first.'); return Command::FAILURE; } require_once $base . '/config.php'; // defines DB_* constants $pdo = new PDO( 'mysql:host=' . DB_HOST . ';dbname=' . DB_NAME . ';charset=utf8mb4', DB_USER, DB_PASS, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION] ); $migrator = new Migrator($pdo, $base . '/db/migrations'); $migrator->ensureTable(); if ($input->getOption('status')) { $output->writeln('Applied: ' . (implode(', ', $migrator->applied()) ?: '(none)')); $output->writeln('Pending: ' . (implode(', ', array_map('basename', $migrator->pending())) ?: '(none)')); return Command::SUCCESS; } $done = $migrator->migrate(); $output->writeln($done ? 'Applied: ' . implode(', ', $done) : 'Nothing to migrate.'); return Command::SUCCESS; } } ``` - [ ] **Step 4: Register in `console`** ```php $application->add(new InstallCommand()); $application->add(new MigrateCommand()); ``` - [ ] **Step 5: Wire migrations into the installer** In `app/Services/Installer.php`, at the end of `run()` (after `$this->lock();`), append: ```php // Apply migrations on top of the freshly imported baseline. $pdo = new PDO( "mysql:host={$cfg['db']['host']};dbname={$cfg['db']['name']};charset=utf8mb4", $cfg['db']['user'], $cfg['db']['pass'], [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION] ); (new Migrator($pdo, $this->baseDir . '/db/migrations'))->migrate(); ``` Add `use PDO;` is already present; no new import needed (`Migrator` is same namespace). - [ ] **Step 6: Verify migration status then apply** Run: ```bash php console db:migrate --status php console db:migrate mysql -u comiida -p'APP_DB_PASS' comiida -e "SELECT filename FROM migrations;" ``` Expected: status shows `001_...` pending → apply prints `Applied: 001_create_metrics_placeholder.sql` → the `migrations` table lists it. (`_foundation_check` table now exists.) - [ ] **Step 7: Commit** ```bash git add app/Services/Migrator.php commands/MigrateCommand.php db/migrations/001_create_metrics_placeholder.sql app/Services/Installer.php console git commit -m "feat(api): SQL migrations runner (db:migrate) + wire into installer" ``` --- ## Task 4: JSON base + public tier + `/api/health` **Files:** - Create: `app/Controllers/JsonController.php` - Create: `app/Controllers/PublicController.php` - Create: `public/controllers/health.php` - [ ] **Step 1: Write `app/Controllers/JsonController.php`** ```php $error === null, 'data' => $data, 'error' => $error, // ['code' => ..., 'message' => ...] or null ]); exit; } /** * Returns true when the caller has EXCEEDED $max hits on $key within $window seconds. * Reuses the api_requests table (no new table needed). */ protected function throttle(string $key, int $max, int $window): bool { $ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'; $count = (int) \Db::getValue( "SELECT COUNT(*) FROM `api_requests` WHERE `requesting_ip` = ? AND `request` = ? AND `created_date` > (NOW() - INTERVAL ? SECOND)", [$ip, $key, $window] ); \Db::insert('api_requests', [ 'requesting_ip' => $ip, 'request' => $key, 'service' => 'foundation', 'domainURI' => $_SERVER['HTTP_HOST'] ?? '', 'created_date' => date('Y-m-d H:i:s'), ]); return $count >= $max; } } ``` - [ ] **Step 2: Write `app/Controllers/PublicController.php`** ```php false. * Same-origin GET / non-browser callers omit Origin -> allowed. */ protected function checkOrigin(): bool { $origin = $_SERVER['HTTP_ORIGIN'] ?? ''; if ($origin === '') { return true; // same-origin GET or server-side caller } $allowed = array_filter(array_map('trim', explode(',', defined('ALLOWED_ORIGINS') ? ALLOWED_ORIGINS : ''))); if (empty($allowed)) { return true; // not configured (dev) } $originHost = parse_url($origin, PHP_URL_HOST); foreach ($allowed as $a) { $host = parse_url($a, PHP_URL_HOST) ?: $a; if ($originHost && strcasecmp($originHost, $host) === 0) { header('Access-Control-Allow-Origin: ' . $origin); return true; } } return false; } /** Guard helper: enforce origin + rate limit, or emit the error envelope and stop. */ protected function guardPublic(string $key, int $max = 60, int $window = 60): void { if (!$this->checkOrigin()) { $this->json(null, 403, ['code' => 'forbidden_origin', 'message' => 'Origin not allowed']); } if ($this->throttle($key, $max, $window)) { $this->json(null, 429, ['code' => 'rate_limited', 'message' => 'Too many requests']); } } } ``` - [ ] **Step 3: Write `public/controllers/health.php`** ```php guardPublic('health', 120, 60); try { $up = (int) \Db::getValue("SELECT 1"); $rows = (int) \Db::getValue("SELECT COUNT(*) FROM `config`"); } catch (\Throwable $e) { $this->json(null, 500, ['code' => 'db_error', 'message' => DEBUG ? $e->getMessage() : 'Database unavailable']); } $this->json([ 'db' => $up === 1 ? 'connected' : 'unknown', 'app' => defined('PROJECT_NAME') ? PROJECT_NAME : '', 'config_rows' => $rows, 'time' => date('c'), ]); } } ``` - [ ] **Step 4: Verify the health endpoint returns live DB JSON** Run: `curl -sk https://127.0.0.1/api/health -H "Host: www.comiida.com"` Expected: `{"ok":true,"data":{"db":"connected","app":"Comiida","config_rows":,"time":"..."},"error":null}` - [ ] **Step 5: Verify rate limiting** Run: `for i in $(seq 1 130); do curl -s -o /dev/null -w "%{http_code} " -k https://127.0.0.1/api/health -H "Host: www.comiida.com"; done; echo` Expected: `200` responses turning into `429` after ~120 within the minute. - [ ] **Step 6: Commit** ```bash git add app/Controllers/JsonController.php app/Controllers/PublicController.php public/controllers/health.php git commit -m "feat(api): JSON base + public tier (origin+rate-limit) + /api/health" ``` --- ## Task 5: Privileged tier + `/api/admin/ping` **Files:** - Create: `app/Controllers/ApiController.php` - Create: `public/controllers/admin.php` - [ ] **Step 1: Write `app/Controllers/ApiController.php`** ```php bearer(); if ($token === '') { $this->json(null, 401, ['code' => 'unauthorized', 'message' => 'Missing bearer token']); } // First-party admin token (constant-time compare). if (defined('ADMIN_TOKEN') && hash_equals(ADMIN_TOKEN, $token)) { if ($this->throttle('admin', $max, $window)) { $this->json(null, 429, ['code' => 'rate_limited', 'message' => 'Too many requests']); } return; } // Programmatic api_auth key. $row = \Db::getRow( "SELECT `id`, `userid`, `active` FROM `api_auth` WHERE `apikey` = ? LIMIT 1", [$token] ); if (!$row || (int) $row['active'] !== 1) { $this->json(null, 401, ['code' => 'unauthorized', 'message' => 'Invalid API key']); } $this->apiUser = $row; if ($this->throttle('apikey:' . $row['id'], $max, $window)) { $this->json(null, 429, ['code' => 'rate_limited', 'message' => 'Too many requests']); } // NOTE: monthly/plan quota enforcement (api_plans/api_usage) is deferred to a later sub-project. } } ``` - [ ] **Step 2: Write `public/controllers/admin.php`** ```php requireAuth(); $this->json([ 'pong' => true, 'auth' => $this->apiUser ? 'apikey' : 'admin_token', 'time' => date('c'), ]); } } ``` - [ ] **Step 3: Verify unauthorized is rejected** Run: `curl -sk -o /dev/null -w "%{http_code}\n" https://127.0.0.1/api/admin/ping -H "Host: www.comiida.com"` Expected: `401` - [ ] **Step 4: Verify the admin token authenticates** Run: ```bash TOKEN=$(php -r "require 'config.php'; echo ADMIN_TOKEN;") curl -sk https://127.0.0.1/api/admin/ping -H "Host: www.comiida.com" -H "Authorization: Bearer $TOKEN" ``` Expected: `{"ok":true,"data":{"pong":true,"auth":"admin_token","time":"..."},"error":null}` - [ ] **Step 5: Commit** ```bash git add app/Controllers/ApiController.php public/controllers/admin.php git commit -m "feat(api): privileged tier (bearer/api_auth) + /api/admin/ping" ``` --- ## Task 6: Security hardening — lock the installer **Files:** - Modify: `install/controllers/index.php` - Modify: Apache vhost `/www/server/panel/vhost/apache/comiida.com.conf` - [ ] **Step 1: Make the web installer refuse when locked & delegate to `Installer`** At the top of `installation()` in `install/controllers/index.php`, before any work, insert: ```php require_once dirname(__DIR__, 2) . '/vendor/autoload.php'; $installer = new \App\Services\Installer(); if ($installer->isInstalled()) { http_response_code(403); die('Already installed. Remove system/.installed to reinstall.'); } if (!$_POST) { header('Location: /api/install/'); die(); } $installer->run([ 'url' => 'https://www.comiida.com', 'name' => 'Comiida', 'db' => [ 'host' => $_POST['dbloca'], 'name' => $_POST['dbname'], 'user' => $_POST['dbuser'], 'pass' => $_POST['dbpass'], ], ]); header('Location: /api/install/i/complete'); return; ``` (This replaces the old body that used `$_SERVER['DOCUMENT_ROOT']` and wrote a `seedproject.com` config. The rest of the method below can be removed.) - [ ] **Step 2: Extend the Apache denies** In `/www/server/panel/vhost/apache/comiida.com.conf`, update the existing `DirectoryMatch` (added when `/api` was mounted) to also cover `install`, `db`, and add a `.installed` file deny. Replace the block with: ```apache # Deny web access to framework internals (loaded server-side only) Require all denied Require all denied ``` - [ ] **Step 3: Apply and reload** Run: `/www/server/apache/bin/httpd -t && /www/server/apache/bin/httpd -k graceful` Expected: `Syntax OK`, reload succeeds. - [ ] **Step 4: Verify the installer is no longer web-reachable** Run: `curl -sk -o /dev/null -w "%{http_code}\n" https://127.0.0.1/api/install/ -H "Host: www.comiida.com"` Expected: `403` - [ ] **Step 5: Verify `/api/health` still works (denies didn't over-reach)** Run: `curl -sk -o /dev/null -w "%{http_code}\n" https://127.0.0.1/api/health -H "Host: www.comiida.com"` Expected: `200` - [ ] **Step 6: Commit** ```bash git add install/controllers/index.php git commit -m "feat(api): lock installer, delegate to Installer service, deny install/db over HTTP" ``` (Apache vhost is outside the repo — not committed.) --- ## Task 7: Astro round-trip proof page **Files:** - Create: `app/src/pages/api-health-test.astro` - [ ] **Step 1: Write the test page** ```astro --- // Static page; the fetch runs client-side, same-origin, against the PHP API. --- API health test

SeedProject /api health

loading…
``` - [ ] **Step 2: Build the Astro site** Run: `cd /www/wwwroot/comiida.com/app && npm run build` Expected: build succeeds; `public/api-health-test/index.html` (or `public/api-health-test.html`) produced. - [ ] **Step 3: Verify the page loads and the fetch target resolves** Run: `curl -sk https://127.0.0.1/api-health-test -H "Host: www.comiida.com" | grep -c "/api/health"` Expected: `1` (the page references the endpoint). Loading it in a browser shows the live JSON envelope with `db: "connected"`. - [ ] **Step 4: Commit** ```bash cd /www/wwwroot/comiida.com git add app/src/pages/api-health-test.astro git commit -m "feat(api): Astro page proving browser->PHP->MariaDB health round-trip" ``` --- ## Self-review notes - **Spec coverage:** Installer (§1) → Task 2/6; migrations (§2) → Task 3; two-tier auth + envelope (§3) → Task 4/5; health round-trip (§4) → Task 4/5/7; security hardening (§5) → Task 6; DB provisioning → Task 1. All spec sections mapped. - **Deliberate scope trim (YAGNI):** full `api_plans`/`api_usage` monthly-quota enforcement is deferred (flagged inline in `ApiController::requireAuth`); the foundation ships bearer/api_auth verification + per-minute throttle + request logging, which is enough to prove the privileged tier. - **Type consistency:** `Installer::run(['url','name','db'=>[...]])` shape is used identically in InstallCommand (Task 2), Installer migration wiring (Task 3), and the web wizard (Task 6). `Migrator($pdo, $dir)` signature consistent across Task 3 and the installer. `json()/throttle()/guardPublic()/requireAuth()` names consistent across controllers. - **Open item:** operator provides the MariaDB root + app DB password (Task 1) and should still rotate/delete the vestigial `api/.env`.