feat: change log table with retention cron and limits option

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
This commit is contained in:
2026-09-25 21:58:15 +02:00
co-authored by Claude Opus 5.5
parent 1df655a74e
commit 261972751f
14 changed files with 593 additions and 0 deletions
+95
View File
@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\ChangeLog;
use LIT\MCP\Server\RequestContext;
/**
* Change log table {prefix}lit_mcp_log (spec §8.3). Stores references only,
* never content. Deliberately not final so abilities can be unit-tested with a mock.
*/
class ChangeLog
{
public const TABLE = 'lit_mcp_log';
public function __construct(private ?\wpdb $db = null)
{
}
public function tableName(): string
{
return $this->db()->prefix . self::TABLE;
}
public function charsetCollate(): string
{
return (string) $this->db()->get_charset_collate();
}
public function record(
string $ability,
string $objectType,
string $objectId,
string $summary,
?int $revisionId = null,
?string $snapshotId = null,
string $result = 'ok'
): ?int {
$wpdb = $this->db();
$inserted = $wpdb->insert(
$this->tableName(),
[
'created_at' => current_time('mysql', true),
'user_id' => get_current_user_id(),
'client' => $this->sanitizedClientName(),
'ability' => $ability,
'object_type' => $objectType,
'object_id' => $objectId,
'summary' => mb_substr($summary, 0, 500),
'revision_id' => $revisionId,
'snapshot_id' => $snapshotId,
'result' => $result,
],
['%s', '%d', '%s', '%s', '%s', '%s', '%s', '%d', '%s', '%s']
);
if ($inserted === false) {
$message = sprintf('[wp-lit-mcp] change log insert failed for %s %s:%s', $ability, $objectType, $objectId);
error_log($message); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
return null;
}
return (int) $wpdb->insert_id;
}
public function purgeOlderThan(int $days): int
{
$wpdb = $this->db();
$table = $this->tableName();
$cutoff = gmdate('Y-m-d H:i:s', time() - max(1, $days) * 86400);
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is our own constant.
return (int) $wpdb->query($wpdb->prepare("DELETE FROM {$table} WHERE created_at < %s", $cutoff));
}
/**
* RequestContext::clientName() is a client-chosen string (app-password name or
* OAuth client_id) – treat it as untrusted input before it lands in the DB.
*/
private function sanitizedClientName(): string
{
return mb_substr(sanitize_text_field(RequestContext::clientName()), 0, 191);
}
protected function db(): \wpdb
{
if ($this->db === null) {
global $wpdb;
$this->db = $wpdb;
}
return $this->db;
}
}
+37
View File
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\ChangeLog;
use LIT\MCP\Limits;
/**
* Daily WP-Cron job that removes log rows older than the configured retention.
*/
final class ChangeLogCleanup
{
public const HOOK = 'lit_mcp_log_cleanup';
public function __construct(private ChangeLog $log, private Limits $limits)
{
}
public function register(): void
{
add_action(self::HOOK, [$this, 'run']);
add_action('init', [$this, 'ensureScheduled']);
}
public function ensureScheduled(): void
{
if (wp_next_scheduled(self::HOOK) === false) {
wp_schedule_event(time() + 3600, 'daily', self::HOOK);
}
}
public function run(): void
{
$this->log->purgeOlderThan($this->limits->logRetentionDays());
}
}
+57
View File
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\ChangeLog;
/**
* Creates/updates the change log table via dbDelta. Runs on activation and –
* because plugin updates never fire the activation hook – whenever the stored
* schema version differs.
*/
final class ChangeLogInstaller
{
public const VERSION_OPTION = 'lit_mcp_db_version';
public const VERSION = '1';
public function __construct(private ChangeLog $log)
{
}
public function maybeInstall(): void
{
if (get_option(self::VERSION_OPTION) !== self::VERSION) {
$this->install();
}
}
public function install(): void
{
if (! function_exists('dbDelta')) {
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
}
$table = $this->log->tableName();
$charset = $this->log->charsetCollate();
dbDelta("CREATE TABLE {$table} (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
created_at datetime NOT NULL,
user_id bigint(20) unsigned NOT NULL DEFAULT 0,
client varchar(191) NOT NULL DEFAULT '',
ability varchar(100) NOT NULL,
object_type varchar(50) NOT NULL DEFAULT '',
object_id varchar(191) NOT NULL DEFAULT '',
summary text NOT NULL,
revision_id bigint(20) unsigned DEFAULT NULL,
snapshot_id varchar(64) DEFAULT NULL,
result varchar(20) NOT NULL DEFAULT 'ok',
PRIMARY KEY (id),
KEY created_at (created_at),
KEY ability (ability),
KEY user_id (user_id)
) {$charset};");
update_option(self::VERSION_OPTION, self::VERSION);
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace LIT\MCP;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\ChangeLog\ChangeLogCleanup;
use LIT\MCP\ChangeLog\ChangeLogInstaller;
/**
* Activation and deactivation hooks (registered in wp-lit-mcp.php).
*/
final class Lifecycle
{
public static function activate(): void
{
$log = new ChangeLog();
(new ChangeLogInstaller($log))->install();
(new ChangeLogCleanup($log, new Limits()))->ensureScheduled();
}
public static function deactivate(): void
{
wp_clear_scheduled_hook(ChangeLogCleanup::HOOK);
}
}
+59
View File
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace LIT\MCP;
/**
* Admin-configurable limits (settings tab "Limits", spec §8.4).
*/
final class Limits
{
public const OPTION = 'lit_mcp_limits';
public const DEFAULTS = ['upload_mb' => 5, 'log_retention_days' => 90, 'snapshot_count' => 20];
private const BOUNDS = ['upload_mb' => [1, 100], 'log_retention_days' => [1, 3650], 'snapshot_count' => [1, 100]];
public function uploadBytes(): int
{
return $this->uploadMegabytes() * 1024 * 1024;
}
public function uploadMegabytes(): int
{
return $this->all()['upload_mb'];
}
public function logRetentionDays(): int
{
return $this->all()['log_retention_days'];
}
public function snapshotCount(): int
{
return $this->all()['snapshot_count'];
}
/** @return array{upload_mb: int, log_retention_days: int, snapshot_count: int} */
public function all(): array
{
return self::sanitize(get_option(self::OPTION, []));
}
/**
* Settings API sanitize callback: always returns all keys, clamped to their bounds.
*
* @return array{upload_mb: int, log_retention_days: int, snapshot_count: int}
*/
public static function sanitize(mixed $input): array
{
$input = is_array($input) ? $input : [];
$clean = self::DEFAULTS;
foreach (self::BOUNDS as $key => [$min, $max]) {
if (isset($input[$key]) && is_numeric($input[$key])) {
$clean[$key] = max($min, min($max, (int) $input[$key]));
}
}
return $clean;
}
}
+7
View File
@@ -5,6 +5,9 @@ declare(strict_types=1);
namespace LIT\MCP;
use LIT\MCP\Abilities\AbilityRegistrar;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\ChangeLog\ChangeLogCleanup;
use LIT\MCP\ChangeLog\ChangeLogInstaller;
use LIT\MCP\Guides\InstructionsBuilder;
use LIT\MCP\Integrations\Core\CoreIntegration;
use LIT\MCP\Integrations\IntegrationRegistry;
@@ -33,6 +36,10 @@ final class Plugin
(new Access())->register();
(new RequestContext())->register();
$changeLog = new ChangeLog();
(new ChangeLogInstaller($changeLog))->maybeInstall();
(new ChangeLogCleanup($changeLog, new Limits()))->register();
$registry = new IntegrationRegistry(
new CoreIntegration(self::pluginDir() . '/docs/guides')
);