diff --git a/db/migrations/004_rate_limits.sql b/db/migrations/004_rate_limits.sql
new file mode 100644
index 0000000..2adc7f5
--- /dev/null
+++ b/db/migrations/004_rate_limits.sql
@@ -0,0 +1,8 @@
+-- Migration 004: API rate limiting table
+CREATE TABLE IF NOT EXISTS api_rate_limits (
+ ip VARCHAR(45) NOT NULL,
+ endpoint VARCHAR(64) NOT NULL DEFAULT 'api',
+ hits INT UNSIGNED NOT NULL DEFAULT 1,
+ window_start INT UNSIGNED NOT NULL,
+ PRIMARY KEY (ip, endpoint)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
diff --git a/panel/api/endpoints/sessions.php b/panel/api/endpoints/sessions.php
new file mode 100644
index 0000000..3680e9d
--- /dev/null
+++ b/panel/api/endpoints/sessions.php
@@ -0,0 +1,57 @@
+require('admin');
+$body = json_decode(file_get_contents('php://input'), true) ?? [];
+$db = DB::getInstance();
+$me = Auth::getInstance()->user();
+$method = $_SERVER['REQUEST_METHOD'];
+
+match (true) {
+
+ $action === 'list' && $method === 'GET' => (function() use ($db) {
+ $rows = $db->fetchAll(
+ "SELECT s.id, s.user_id, s.ip_address, s.user_agent, s.created_at, s.expires_at,
+ u.username, u.email, u.role
+ FROM sessions s
+ JOIN users u ON u.id = s.user_id
+ WHERE s.expires_at > NOW()
+ ORDER BY s.created_at DESC
+ LIMIT 200"
+ ) ?: [];
+ Response::json(['success' => true, 'data' => $rows]);
+ })(),
+
+ $action === 'revoke' && $method === 'DELETE' => (function() use ($db, $body) {
+ $sid = trim($body['session_id'] ?? '');
+ if (!$sid) Response::error('session_id required', 400);
+ $db->execute("DELETE FROM sessions WHERE id = ?", [$sid]);
+ Response::json(['success' => true]);
+ })(),
+
+ $action === 'revoke-user' && $method === 'DELETE' => (function() use ($db, $body) {
+ $uid = (int)($body['user_id'] ?? 0);
+ if (!$uid) Response::error('user_id required', 400);
+ $count = $db->execute("DELETE FROM sessions WHERE user_id = ?", [$uid]);
+ Response::json(['success' => true, 'data' => ['revoked' => $count]]);
+ })(),
+
+ $action === 'revoke-all' && $method === 'DELETE' => (function() use ($db, $me, $body) {
+ // Keep current session if provided
+ $keepId = $body['keep_session'] ?? null;
+ if ($keepId) {
+ $db->execute("DELETE FROM sessions WHERE id != ?", [hash('sha256', $keepId)]);
+ } else {
+ $db->execute("DELETE FROM sessions");
+ }
+ Response::json(['success' => true]);
+ })(),
+
+ default => Response::error('Not found', 404),
+};
diff --git a/panel/api/index.php b/panel/api/index.php
index 1b7839e..7b84ec6 100644
--- a/panel/api/index.php
+++ b/panel/api/index.php
@@ -56,4 +56,38 @@ if (!file_exists($endpointFile)) {
Response::error("Unknown endpoint: $endpoint", 404);
}
+
+
+// #28 Rate limiting — per-IP, per-endpoint bucket
+(function() use ($endpoint) {
+ $db = DB::getInstance();
+ $ip = $_SERVER["REMOTE_ADDR"] ?? "0.0.0.0";
+ $now = time();
+ $window = 60;
+ $limit = $endpoint === "auth" ? 10 : 120;
+ $bucket = $endpoint === "auth" ? "auth" : "api";
+ try {
+ $row = $db->fetchOne("SELECT hits, window_start FROM api_rate_limits WHERE ip=? AND endpoint=?", [$ip, $bucket]);
+ if ($row && ($now - (int)$row["window_start"]) < $window) {
+ $hits = (int)$row["hits"] + 1;
+ $db->execute("UPDATE api_rate_limits SET hits=? WHERE ip=? AND endpoint=?", [$hits, $ip, $bucket]);
+ } else {
+ $hits = 1;
+ $db->execute("INSERT INTO api_rate_limits (ip, endpoint, hits, window_start) VALUES (?,?,1,?) ON DUPLICATE KEY UPDATE hits=1, window_start=VALUES(window_start)", [$ip, $bucket, $now]);
+ }
+ $reset = ($row ? (int)$row["window_start"] : $now) + $window;
+ $remaining = max(0, $limit - $hits);
+ header("X-RateLimit-Limit: {$limit}");
+ header("X-RateLimit-Remaining: {$remaining}");
+ header("X-RateLimit-Reset: {$reset}");
+ if ($hits > $limit) {
+ http_response_code(429);
+ echo json_encode(["success"=>false,"message"=>"Too many requests. Try again in " . ($reset - $now) . " seconds.","errors"=>[]]);
+ exit;
+ }
+ } catch (Throwable $e) {
+ novacpx_log("warn", "rate limit error: " . $e->getMessage());
+ }
+})();
+
require $endpointFile;
diff --git a/panel/public/admin/index.php b/panel/public/admin/index.php
index c16e457..504d9df 100644
--- a/panel/public/admin/index.php
+++ b/panel/public/admin/index.php
@@ -14,6 +14,7 @@
+