diff --git a/api/app/Controllers/ApiController.php b/api/app/Controllers/ApiController.php
new file mode 100644
index 0000000..5c2bbd7
--- /dev/null
+++ b/api/app/Controllers/ApiController.php
@@ -0,0 +1,47 @@
+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.
+ }
+}
diff --git a/api/app/Controllers/JsonController.php b/api/app/Controllers/JsonController.php
new file mode 100644
index 0000000..f5b74d7
--- /dev/null
+++ b/api/app/Controllers/JsonController.php
@@ -0,0 +1,42 @@
+ $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;
+ }
+}
diff --git a/api/app/Controllers/PublicController.php b/api/app/Controllers/PublicController.php
new file mode 100644
index 0000000..3df172b
--- /dev/null
+++ b/api/app/Controllers/PublicController.php
@@ -0,0 +1,42 @@
+ 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']);
+ }
+ }
+}
diff --git a/api/app/Services/Installer.php b/api/app/Services/Installer.php
new file mode 100644
index 0000000..5ce354e
--- /dev/null
+++ b/api/app/Services/Installer.php
@@ -0,0 +1,132 @@
+ 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();
+
+ // 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();
+ }
+}
diff --git a/api/app/Services/Migrator.php b/api/app/Services/Migrator.php
new file mode 100644
index 0000000..df397e9
--- /dev/null
+++ b/api/app/Services/Migrator.php
@@ -0,0 +1,57 @@
+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;
+ }
+}
diff --git a/api/commands/InstallCommand.php b/api/commands/InstallCommand.php
new file mode 100644
index 0000000..bc87da6
--- /dev/null
+++ b/api/commands/InstallCommand.php
@@ -0,0 +1,54 @@
+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://example.com')
+ ->addOption('name', null, InputOption::VALUE_REQUIRED, 'Project name', 'SeedProject')
+ ->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;
+ }
+}
diff --git a/api/commands/MigrateCommand.php b/api/commands/MigrateCommand.php
new file mode 100644
index 0000000..0da276d
--- /dev/null
+++ b/api/commands/MigrateCommand.php
@@ -0,0 +1,42 @@
+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;
+ }
+}
diff --git a/api/composer.json b/api/composer.json
index cc94fe8..0778a06 100644
--- a/api/composer.json
+++ b/api/composer.json
@@ -28,6 +28,7 @@
},
"autoload": {
"classmap": [
+ "commands",
"core/",
"app/Helpers",
"app/Controllers",
diff --git a/api/console b/api/console
index ee7f910..24b68dd 100644
--- a/api/console
+++ b/api/console
@@ -11,7 +11,9 @@ use Symfony\Component\Console\Application;
$application = new Application();
# add our commands
-$application->add(new GreetCommand());
+$application->add(new GreetCommand());
+$application->add(new InstallCommand());
+$application->add(new MigrateCommand());
//$application->add(new Sentinel());
//$application->add(new Engine());
$application->run();
\ No newline at end of file
diff --git a/api/db/migrations/001_create_metrics_placeholder.sql b/api/db/migrations/001_create_metrics_placeholder.sql
new file mode 100644
index 0000000..246a7a4
--- /dev/null
+++ b/api/db/migrations/001_create_metrics_placeholder.sql
@@ -0,0 +1,7 @@
+-- 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;
diff --git a/api/install/controllers/index.php b/api/install/controllers/index.php
index d3898bb..43f0dcc 100644
--- a/api/install/controllers/index.php
+++ b/api/install/controllers/index.php
@@ -1,139 +1,69 @@
-view->render(__CLASS__ .'/'. __FUNCTION__);
- }
-
- function checkDB(){
- // \Helper::print_array($_POST);
-
- $host = $_POST['dbloca'];
- $user = $_POST['dbuser'];
- $pass = $_POST['dbpass'];
- $db = $_POST['dbname'];
- $charset = 'utf8';
-
-
- $dsn = "mysql:host=$host;dbname=$db;charset=$charset";
- $options = [
- PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
- PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_LAZY,
- PDO::ATTR_EMULATE_PREPARES => false,
- ];
-
- try {
- $pdo = new PDO($dsn, $user, $pass, $options);
- echo 1;
- } catch (\PDOException $e) {
- echo 0;
- }
- }
-
- function installation() {
- // \Helper::print_array($_POST);
- if(!$_POST) {
- header("Location: /install/");
- die();
- };
-
- $dbLoca = $_POST['dbloca'];
- $dbName = $_POST['dbname'];
- $dbUser = $_POST['dbuser'];
- $dbPass = $_POST['dbpass'];
-
-
- # Step 1: building out database structure
- $SQLFile = $_SERVER['DOCUMENT_ROOT'] . '/install/dump.sql';
- $ConfigFile = $_SERVER['DOCUMENT_ROOT'] . '/config.php';
-
-
- $HashKey = $this->getName(50);
- $HashAPIKey = $this->getName(50);
-
-
-
- if(!file_exists($SQLFile) ){
- die('Error: Failed to Load SQL Dump File. ');
- }
-
- $sql = file_get_contents($SQLFile);
- $mysqli = new mysqli($dbLoca, $dbUser, $dbPass, $dbName);
-
- /* check connection */
- if ($mysqli->connect_errno) {
- printf("Connect failed: %s\n", $mysqli->connect_error);
- exit();
- }
-
- if (!$mysqli->multi_query($sql)) {
- printf("Error message: %s\n", $mysqli->error);
- };
-
- /* close connection */
- $mysqli->close();
-
- # Step 2: Create the Config file.
- // if(file_exists($ConfigFile)) { die ('File Currently Exist. Please Delete config.php; if you are trying to do a new install.'); }
-
- $myfile = fopen($ConfigFile, "w") or die("Unable to Write or Open file, Please check your permissions!");
- fwrite($myfile, '');
- fclose($myfile);
-
-
- $config_content =
- <<?;:\/';
- $randomString = '';
-
- for ($i = 0; $i < $n; $i++) {
- $index = rand(0, strlen($characters) - 1);
- $randomString .= $characters[$index];
- }
-
- return $randomString;
- }
-
-
-}
\ No newline at end of file
+view->render(__CLASS__ .'/'. __FUNCTION__);
+ }
+
+ // AJAX: test the supplied DB credentials (echo 1 = ok, 0 = fail).
+ function checkDB(){
+ $host = $_POST['dbloca'];
+ $user = $_POST['dbuser'];
+ $pass = $_POST['dbpass'];
+ $db = $_POST['dbname'];
+
+ $dsn = "mysql:host=$host;dbname=$db;charset=utf8mb4";
+ $options = [
+ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
+ PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_LAZY,
+ PDO::ATTR_EMULATE_PREPARES => false,
+ ];
+
+ try {
+ new PDO($dsn, $user, $pass, $options);
+ echo 1;
+ } catch (\PDOException $e) {
+ echo 0;
+ }
+ }
+
+ // Run the install by delegating to the shared Installer service.
+ // Refuses once installed; writes the correct /api config (no DOCUMENT_ROOT path bugs).
+ function installation() {
+ require_once dirname(__DIR__, 2) . '/vendor/autoload.php';
+
+ $installer = new \App\Services\Installer();
+ if ($installer->isInstalled()) {
+ http_response_code(403);
+ die('Already installed. Remove api/system/.installed to reinstall.');
+ }
+ if (!$_POST) {
+ header('Location: /api/install/');
+ die();
+ }
+
+ try {
+ $installer->run([
+ 'url' => 'https://example.com',
+ 'name' => 'SeedProject',
+ 'db' => [
+ 'host' => $_POST['dbloca'],
+ 'name' => $_POST['dbname'],
+ 'user' => $_POST['dbuser'],
+ 'pass' => $_POST['dbpass'],
+ ],
+ ]);
+ } catch (\Throwable $e) {
+ http_response_code(500);
+ die('Install failed: ' . htmlspecialchars($e->getMessage()));
+ }
+
+ header('Location: /api/install/i/complete');
+ }
+
+}
diff --git a/app/src/pages/api-health-test.astro b/app/src/pages/api-health-test.astro
new file mode 100644
index 0000000..33053aa
--- /dev/null
+++ b/app/src/pages/api-health-test.astro
@@ -0,0 +1,18 @@
+---
+// Static page; the fetch runs client-side, same-origin, against the PHP API.
+// A quick way to confirm the /api backend is wired up on a deployed site.
+---
+
+
+ API health test
+
+ SeedProject /api health
+ loading…
+
+
+