Phase 0: admin console foundation

- cja_admin + cja_admin_log tables (dedicated single-admin tier + audit trail)
- AdminAuth controller: /api/adminauth login/logout/me, session-based,
  rate-limited, every attempt logged
- set-admin-password CLI (bcrypt, run manually so the password never enters
  an agent context)
- requireAdmin() guard for future privileged console endpoints
- agent env scaffold (git-ignored)

Foundation only — no agent runner or features yet. Preview/publish, media,
and the dashboard come in phases 1-3 per agents/console/PLAN.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DoFYZY9gkGPNDqZ7NuEa9a
This commit is contained in:
Carlos Arias 2026-07-23 20:23:48 +00:00
parent 43ae1e2015
commit 59019d2f32
3 changed files with 164 additions and 0 deletions

View file

@ -0,0 +1,35 @@
<?php
/**
* Set (or reset) the console admin password. Run it yourself so the password
* never enters an agent's context:
*
* php api/cli/set-admin-password.php <username> <password>
*
* Upserts on username. The plaintext is hashed with bcrypt and never stored.
*/
require __DIR__ . '/../vendor/autoload.php';
require __DIR__ . '/../config.php';
$username = $argv[1] ?? '';
$password = $argv[2] ?? '';
if ($username === '' || $password === '') {
fwrite(STDERR, "usage: php api/cli/set-admin-password.php <username> <password>\n");
exit(1);
}
if (strlen($password) < 12) {
fwrite(STDERR, "Refusing: use at least 12 characters.\n");
exit(1);
}
$hash = password_hash($password, PASSWORD_DEFAULT);
$existing = Db::getValue('SELECT admin_id FROM cja_admin WHERE username = ?', [$username]);
if ($existing) {
Db::update('cja_admin', ['password_hash' => $hash], 'admin_id = ?', [$existing]);
echo " updated password for '{$username}'\n";
} else {
Db::insert('cja_admin', ['username' => $username, 'password_hash' => $hash]);
echo " created admin '{$username}'\n";
}

View file

@ -0,0 +1,36 @@
-- Admin auth + audit for the management console.
--
-- Separate from the seed's two existing tiers on purpose:
-- * account.php — community member sessions (public users)
-- * ADMIN_TOKEN — machine/privileged bearer token
-- The console operator (you) is neither. This is a dedicated single-admin login
-- with its own session flag, so the console's blast radius never overlaps with
-- public accounts or a shared token.
--
-- Passwords are stored as a PHP password_hash() (bcrypt). Set via
-- api/cli/set-admin-password.php — never inline, never committed.
CREATE TABLE IF NOT EXISTS `cja_admin` (
`admin_id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`username` varchar(60) NOT NULL,
`password_hash` varchar(255) NOT NULL,
`created_at` datetime NOT NULL DEFAULT current_timestamp(),
`last_login_at` datetime DEFAULT NULL,
PRIMARY KEY (`admin_id`),
UNIQUE KEY `uq_cja_admin_username` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Every privileged action the console takes, logged. This is the accountability
-- backstop for a code-writing agent: what was asked, what was published,
-- discarded, or rolled back — with who and when.
CREATE TABLE IF NOT EXISTS `cja_admin_log` (
`log_id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`actor` varchar(60) NOT NULL DEFAULT 'admin',
`action` varchar(60) NOT NULL, -- login, run, preview, publish, discard, rollback, upload
`detail` varchar(500) DEFAULT NULL,
`ip` varchar(45) DEFAULT NULL,
`created_at` datetime NOT NULL DEFAULT current_timestamp(),
PRIMARY KEY (`log_id`),
KEY `idx_cja_admin_log_feed` (`created_at`),
KEY `idx_cja_admin_log_action` (`action`, `created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View file

@ -0,0 +1,93 @@
<?php
use App\Controllers\PublicController;
/**
* Admin console authentication.
*
* POST /api/adminauth/login { username, password } -> sets admin session
* POST /api/adminauth/logout -> clears it
* GET /api/adminauth/me -> { admin: bool, username }
*
* A dedicated single-admin tier, separate from community accounts and the
* bearer token. Login sets $_SESSION['admin']; every privileged console
* endpoint (built in later phases) will call requireAdmin() before doing
* anything. Rate-limited hard, and every attempt is written to cja_admin_log.
*
* Extends PublicController for origin + throttle; the admin check is bespoke
* (session flag), not the framework's token/api auth.
*/
class AdminAuth extends PublicController
{
/** Guard for privileged console endpoints. Emits 401 and stops if not admin. */
public static function requireAdmin(): void
{
if (empty($_SESSION['admin']['ok'])) {
http_response_code(401);
header('Content-Type: application/json; charset=UTF-8');
echo json_encode(['ok' => false, 'data' => null, 'error' => [
'code' => 'unauthorized', 'message' => 'Admin login required',
]]);
exit;
}
}
private function logAttempt(string $action, string $detail): void
{
try {
\Db::insert('cja_admin_log', [
'actor' => $_SESSION['admin']['username'] ?? 'anon',
'action' => $action,
'detail' => mb_substr($detail, 0, 500),
'ip' => $_SERVER['REMOTE_ADDR'] ?? null,
]);
} catch (\Throwable $e) {
// logging must never block auth
}
}
public function login(): void
{
// 8 attempts / 5 min / IP — brute-force resistant, human-friendly.
$this->guardPublic('admin_login', 8, 300);
$in = json_decode((string) file_get_contents('php://input'), true) ?: [];
$username = trim((string) ($in['username'] ?? ''));
$password = (string) ($in['password'] ?? '');
$row = \Db::getRow('SELECT admin_id, username, password_hash FROM cja_admin WHERE username = ?', [$username]);
// Constant-ish work whether or not the user exists (avoid enumeration).
$hash = $row['password_hash'] ?? '$2y$10$invalidinvalidinvalidinvalidinvalidinvalidinvalidin';
$ok = $row && password_verify($password, $hash);
if (!$ok) {
$this->logAttempt('login_fail', "username={$username}");
$this->json(null, 401, ['code' => 'bad_credentials', 'message' => 'Incorrect username or password.']);
}
session_regenerate_id(true);
$_SESSION['admin'] = ['ok' => true, 'username' => $row['username'], 'at' => time()];
\Db::update('cja_admin', ['last_login_at' => date('Y-m-d H:i:s')], 'admin_id = ?', [$row['admin_id']]);
$this->logAttempt('login', "username={$row['username']}");
$this->json(['admin' => true, 'username' => $row['username']]);
}
public function logout(): void
{
$this->logAttempt('logout', '');
unset($_SESSION['admin']);
session_regenerate_id(true);
$this->json(['admin' => false]);
}
public function me(): void
{
$admin = $_SESSION['admin']['ok'] ?? false;
$this->json([
'admin' => (bool) $admin,
'username' => $admin ? ($_SESSION['admin']['username'] ?? null) : null,
]);
}
}