seedproject-web/api/.memory/foundation-plan.md
Carlos Arias 1559ce017d chore: scaffold SeedProject base (Phase 1)
Clean-room copy of the reusable engines from comiida, with all
instance data, secrets, dependencies, and build output excluded:
- app/         Astro theme skeleton (no comiida blog posts; hero image -> placeholder)
- api/         SeedProject PHP framework (no vendor/.env/config.php)
- content-pipeline/  engine only (scripts/admin/prompts; empty runtime state)
- astroagent.config.json + app/.astroagent/skills

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SYHWLHihq3v9nxNwoPCKSn
2026-07-04 22:53:10 +00:00

31 KiB

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, 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 requires them) and use the namespaced bases. Core files (core/*) are never modified.


File structure

Create

  • app/Services/Installer.phpApp\Services\Installer: test DB, import dump.sql, generate keys, write config.php, write install lock.
  • app/Services/Migrator.phpApp\Services\Migrator: ensure migrations table, list/apply db/migrations/*.sql.
  • commands/InstallCommand.phpphp console app:install.
  • commands/MigrateCommand.phpphp console db:migrate [--status].
  • app/Controllers/JsonController.phpApp\Controllers\JsonController extends \Controller: json() envelope + throttle().
  • app/Controllers/PublicController.phpApp\Controllers\PublicController extends JsonController: checkOrigin().
  • app/Controllers/ApiController.phpApp\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:

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
namespace App\Services;

use PDO;
use PDOException;
use RuntimeException;

/**
 * Installer — provisions config.php + imports the skeleton schema.
 * Shared by the CLI (app:install) and the (locked) web wizard.
 * Uses filesystem-relative paths, never $_SERVER['DOCUMENT_ROOT'].
 */
class Installer
{
    private string $baseDir; // == api/

    public function __construct()
    {
        // app/Services/Installer.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 = "<?php\n"
            . "// Generated by SeedProject installer. DO NOT COMMIT (gitignored).\n"
            . "define('URL', '" . $e($c['url']) . "');\n"
            . "define('SITE_BASE', '/api');\n"
            . "define('ASSETS', '/api/public/assets/');\n"
            . "define('LIBS', 'core/');\n"
            . "define('PROJECT_NAME', '" . $e($c['name']) . "');\n"
            . "define('PROJECT_LOGO', '/api/public/assets/imgs/SeedProject.png');\n"
            . "define('DEBUG', false);\n"
            . "define('SECUREAPI', false);\n\n"
            . "define('ADMIN_TOKEN', '" . $c['admin_token'] . "');\n"
            . "define('ALLOWED_ORIGINS', '" . $e($c['url']) . "');\n\n"
            . "define('EMAILUSER', 'ADDEMAILUSER');\n"
            . "define('EMAILPASSWORD', 'ADDEMAILPASSWORD');\n"
            . "define('EMAILHOST', 'ADDEMAILHOST');\n\n"
            . "define('DB_TYPE', 'mysql');\n"
            . "define('DB_HOST', '" . $e($c['db']['host']) . "');\n"
            . "define('DB_NAME', '" . $e($c['db']['name']) . "');\n"
            . "define('DB_USER', '" . $e($c['db']['user']) . "');\n"
            . "define('DB_PASS', '" . $e($c['db']['pass']) . "');\n\n"
            . "define('HASH_PASSWORD_KEY', '" . $c['hash_password_key'] . "');\n"
            . "define('HASH_API_KEY', '" . $c['hash_api_key'] . "');\n"
            . "define('TIMESTAMP', date('Y-m-d H:i:s'));\n"
            . "date_default_timezone_set('America/New_York');\n\n"
            . "\\Db::setConnectionInfo(DB_TYPE, DB_NAME, DB_USER, DB_PASS, DB_HOST);\n";
        file_put_contents($this->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
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use App\Services\Installer;

class InstallCommand extends Command
{
    protected function configure()
    {
        $this->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('<error>Already installed. Use --force to re-run.</error>');
            return Command::FAILURE;
        }
        foreach (['db-name', 'db-user', 'db-pass'] as $req) {
            if (!$input->getOption($req)) {
                $output->writeln("<error>--{$req} is required.</error>");
                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('<error>Install failed: ' . $e->getMessage() . '</error>');
            return Command::FAILURE;
        }
        $output->writeln('<info>Install complete. config.php written, schema imported, lock set.</info>');
        return Command::SUCCESS;
    }
}
  • Step 3: Register the command in console

In console, add the use and registration (below the existing GreetCommand line):

$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):

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:

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
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
namespace App\Services;

use PDO;

/** Applies ordered *.sql files from a migrations dir, tracked in a `migrations` table. */
class Migrator
{
    private PDO $pdo;
    private string $dir;

    public function __construct(PDO $pdo, string $dir)
    {
        $this->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
-- 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
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use App\Services\Migrator;

class MigrateCommand extends Command
{
    protected function configure()
    {
        $this->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('<error>config.php missing — run app:install first.</error>');
            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
$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:

        // 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:

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
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
namespace App\Controllers;

/** Base for JSON endpoints: response envelope + IP throttle. Extends core \Controller. */
class JsonController extends \Controller
{
    /** Emit { ok, data, error } with the right HTTP status, then stop. */
    protected function json($data = null, int $status = 200, ?array $error = null): void
    {
        http_response_code($status);
        header('Content-Type: application/json; charset=UTF-8');
        echo json_encode([
            'ok'    => $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
namespace App\Controllers;

/** Base for public (browser-callable) endpoints: origin allowlist + rate limit. */
class PublicController extends JsonController
{
    /**
     * Cross-origin browser requests send Origin; if present and not allowlisted -> 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
use App\Controllers\PublicController;

/** GET /api/health — public health round-trip (proves DB connectivity). */
class Health extends PublicController
{
    public function index()
    {
        $this->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":<n>,"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
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
namespace App\Controllers;

/** Base for privileged endpoints: bearer ADMIN_TOKEN or api_auth API key. */
class ApiController extends JsonController
{
    protected ?array $apiUser = null; // set when an api_auth key authenticates

    /** Extract a token from Authorization: Bearer, ?apikey=, or X-Api-Key. */
    private function bearer(): string
    {
        $h = $_SERVER['HTTP_AUTHORIZATION'] ?? ($_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ?? '');
        if (stripos($h, 'Bearer ') === 0) {
            return trim(substr($h, 7));
        }
        return $_SERVER['HTTP_X_API_KEY'] ?? ($_GET['apikey'] ?? '');
    }

    /** Enforce authentication; emits 401 and stops on failure. Also basic per-minute throttle. */
    protected function requireAuth(int $max = 120, int $window = 60): void
    {
        $token = $this->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
use App\Controllers\ApiController;

/** GET /api/admin/ping — privileged round-trip. */
class Admin extends ApiController
{
    public function ping()
    {
        $this->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:

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
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:

        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:

    # Deny web access to framework internals (loaded server-side only)
    <DirectoryMatch "^/www/wwwroot/comiida\.com/api/(vendor|core|app|system|commands|db|install|\.memory|\.reference_files)(/|$)">
        Require all denied
    </DirectoryMatch>
    <Files ".installed">
        Require all denied
    </Files>
  • 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
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

---
// Static page; the fetch runs client-side, same-origin, against the PHP API.
---
<html lang="en">
  <head><meta charset="utf-8" /><title>API health test</title></head>
  <body style="font-family: system-ui; padding: 2rem;">
    <h1>SeedProject /api health</h1>
    <pre id="out">loading…</pre>
    <script>
      fetch("/api/health", { credentials: "same-origin" })
        .then((r) => r.json())
        .then((j) => { document.getElementById("out").textContent = JSON.stringify(j, null, 2); })
        .catch((e) => { document.getElementById("out").textContent = "ERROR: " + e; });
    </script>
  </body>
</html>
  • 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
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.