register( 'server.status', [ 'class' => 'public', 'required_scopes' => [], 'description' => 'Returns server status, lock state, and active modules.', 'handler' => [ $this, 'handle_server_status' ], ] ); $this->register( 'tools/list', [ 'class' => 'discovery', 'required_scopes' => [], 'description' => 'Lists all available tools for this API key.', 'handler' => [ $this, 'handle_tools_list' ], ] ); $this->register( 'tools/search', [ 'class' => 'discovery', 'required_scopes' => [], 'description' => 'Keyword search over tool names and descriptions, scoped to what your key can call. Use this instead of tools/list when you know what you want to do but not which tool does it, or to avoid loading the full tool list into context. Returns full tool definitions (same shape as tools/list) for the best matches, ranked by relevance.', 'input_schema' => [ 'query' => [ 'type' => 'string', 'required' => true, 'description' => 'Keyword(s) describing what you want to do, e.g. "translate post", "featured image", "woocommerce product price".' ], 'limit' => [ 'type' => 'integer', 'default' => 10, 'description' => 'Max results to return (1-25).' ], ], 'handler' => [ $this, 'handle_tools_search' ], ] ); $this->register( 'tools/describe', [ 'class' => 'discovery', 'required_scopes' => [], 'description' => 'Get the full definition (description + parameter schema) for one tool by exact name. Use this after tools/search or tools/list points you at a tool name but you need its actual parameters — cheaper than re-fetching the whole tool list.', 'input_schema' => [ 'name' => [ 'type' => 'string', 'required' => true, 'description' => 'Exact tool name, e.g. "wp.posts.create".' ], ], 'handler' => [ $this, 'handle_tools_describe' ], ] ); $this->register( 'site.summary', [ 'class' => 'discovery', 'required_scopes' => [], 'description' => 'Returns a summary of this WordPress site: name, URL, version, active theme, active AICOM modules, and post counts. Call this first to understand the site before doing anything else.', 'handler' => [ $this, 'handle_site_summary' ], ] ); $this->register( 'wp.site.info', [ 'class' => 'discovery', 'required_scopes' => [ 'read.wp' ], 'description' => 'Returns site name, URL, WP version, timezone, active theme.', 'handler' => [ $this, 'handle_site_info' ], ] ); $this->register( 'wp.post_types.list', [ 'class' => 'discovery', 'required_scopes' => [ 'read.wp' ], 'description' => 'Lists all registered public post types.', 'handler' => [ $this, 'handle_post_types_list' ], ] ); $this->register( 'wp.taxonomies.list', [ 'class' => 'discovery', 'required_scopes' => [ 'read.wp' ], 'description' => 'Lists all registered taxonomies.', 'handler' => [ $this, 'handle_taxonomies_list' ], ] ); // ── Read ─────────────────────────────────────────────────────────── $this->register( 'wp.posts.list', [ 'class' => 'read', 'required_scopes' => [ 'read.wp' ], 'description' => 'List posts/pages/CPT with filters (post_type, status, search, lang).', 'input_schema' => [ 'post_type' => [ 'type' => 'string', 'default' => 'post' ], 'status' => [ 'type' => 'string', 'default' => 'any' ], 'search' => [ 'type' => 'string' ], 'lang' => [ 'type' => 'string' ], 'posts_per_page' => [ 'type' => 'integer', 'default' => 20 ], 'paged' => [ 'type' => 'integer', 'default' => 1 ], ], 'handler' => [ $this, 'handle_posts_list' ], ] ); $this->register( 'wp.posts.get', [ 'class' => 'read', 'required_scopes' => [ 'read.wp' ], 'description' => 'Get full post data by ID.', 'input_schema' => [ 'id' => [ 'type' => 'integer', 'required' => true ] ], 'handler' => [ $this, 'handle_posts_get' ], ] ); $this->register( 'wp.terms.list', [ 'class' => 'read', 'required_scopes' => [ 'read.wp' ], 'description' => 'List terms for a taxonomy.', 'input_schema' => [ 'taxonomy' => [ 'type' => 'string', 'required' => true ] ], 'handler' => [ $this, 'handle_terms_list' ], ] ); $this->register( 'wp.terms.get', [ 'class' => 'read', 'required_scopes' => [ 'read.wp' ], 'description' => 'Get a term by ID.', 'input_schema' => [ 'term_id' => [ 'type' => 'integer', 'required' => true ], 'taxonomy' => [ 'type' => 'string', 'required' => true ], ], 'handler' => [ $this, 'handle_terms_get' ], ] ); $this->register( 'wp.meta.get', [ 'class' => 'read', 'required_scopes' => [ 'read.wp', 'manage.meta' ], 'description' => 'Get post meta by post_id and optional meta_key.', 'input_schema' => [ 'post_id' => [ 'type' => 'integer', 'required' => true ], 'meta_key' => [ 'type' => 'string' ], ], 'handler' => [ $this, 'handle_meta_get' ], ] ); $this->register( 'wp.options.get', [ 'class' => 'read', 'required_scopes' => [ 'manage.wordpress.settings' ], 'description' => 'Get a WordPress option (allowlist enforced).', 'input_schema' => [ 'option_name' => [ 'type' => 'string', 'required' => true ] ], 'handler' => [ $this, 'handle_options_get' ], ] ); // ── Write ────────────────────────────────────────────────────────── $this->register( 'wp.posts.create', [ 'class' => 'write', 'required_scopes' => [ 'write.wp.posts' ], 'supports_dry_run' => true, 'description' => 'Create a new post. post_author defaults to the user associated with the API key.', 'input_schema' => [ 'post_type' => [ 'type' => 'string', 'default' => 'post' ], 'post_title' => [ 'type' => 'string', 'required' => true ], 'post_content' => [ 'type' => 'string' ], 'post_excerpt' => [ 'type' => 'string' ], 'post_status' => [ 'type' => 'string', 'default' => 'draft', 'enum' => [ 'draft', 'publish', 'private', 'pending', 'future' ], 'description' => 'Post status. Use "draft" first, then "publish" when ready.' ], 'post_name' => [ 'type' => 'string', 'description' => 'URL slug. For drafts WordPress leaves the slug empty unless explicitly set here.' ], 'post_author' => [ 'type' => 'integer', 'description' => 'Author user ID. Defaults to the user who created the API key.' ], 'post_date' => [ 'type' => 'string', 'description' => 'Publish date in YYYY-MM-DD HH:MM:SS or ISO 8601 format. Uses site timezone.' ], ], 'handler' => [ $this, 'handle_posts_create' ], ] ); $this->register( 'wp.posts.update', [ 'class' => 'write', 'required_scopes' => [ 'write.wp.posts' ], 'supports_dry_run' => true, 'description' => 'Update an existing post.', 'input_schema' => [ 'id' => [ 'type' => 'integer', 'required' => true ], 'post_title' => [ 'type' => 'string' ], 'post_content' => [ 'type' => 'string' ], 'post_status' => [ 'type' => 'string', 'enum' => [ 'draft', 'publish', 'private', 'pending', 'future', 'trash' ] ], 'post_excerpt' => [ 'type' => 'string' ], 'post_name' => [ 'type' => 'string', 'description' => 'URL slug.' ], 'post_author' => [ 'type' => 'integer' ], 'post_date' => [ 'type' => 'string', 'description' => 'Publish date in YYYY-MM-DD HH:MM:SS or ISO 8601 format. Uses site timezone.' ], ], 'handler' => [ $this, 'handle_posts_update' ], ] ); $this->register( 'wp.posts.preview_url', [ 'class' => 'read', 'required_scopes' => [ 'read.wp' ], 'description' => 'Get a preview URL for any post or page (works for drafts, private, and published). Returns the WordPress preview link and the admin edit URL.', 'input_schema' => [ 'post_id' => [ 'type' => 'integer', 'required' => true ], ], 'handler' => [ $this, 'handle_posts_preview_url' ], ] ); $this->register( 'wp.terms.create', [ 'class' => 'write', 'required_scopes' => [ 'manage.taxonomies' ], 'description' => 'Create a new term in a taxonomy.', 'input_schema' => [ 'name' => [ 'type' => 'string', 'required' => true ], 'taxonomy' => [ 'type' => 'string', 'required' => true ], 'description' => [ 'type' => 'string' ], 'slug' => [ 'type' => 'string' ], 'parent' => [ 'type' => 'integer' ], ], 'handler' => [ $this, 'handle_terms_create' ], ] ); $this->register( 'wp.terms.update', [ 'class' => 'write', 'required_scopes' => [ 'manage.taxonomies' ], 'description' => 'Update a term.', 'input_schema' => [ 'term_id' => [ 'type' => 'integer', 'required' => true ], 'taxonomy' => [ 'type' => 'string', 'required' => true ], 'name' => [ 'type' => 'string' ], 'slug' => [ 'type' => 'string' ], 'description' => [ 'type' => 'string' ], 'parent' => [ 'type' => 'integer' ], ], 'handler' => [ $this, 'handle_terms_update' ], ] ); $this->register( 'wp.terms.assign_to_post', [ 'class' => 'write', 'required_scopes' => [ 'manage.taxonomies', 'write.wp.posts' ], 'description' => 'Assign terms to a post.', 'input_schema' => [ 'post_id' => [ 'type' => 'integer', 'required' => true ], 'term_ids' => [ 'type' => 'array', 'required' => true, 'description' => 'Array of term IDs to assign.' ], 'taxonomy' => [ 'type' => 'string', 'required' => true ], 'append' => [ 'type' => 'boolean', 'default' => true, 'description' => 'If false, replaces existing terms.' ], ], 'handler' => [ $this, 'handle_terms_assign_to_post' ], ] ); $this->register( 'wp.terms.remove_from_post', [ 'class' => 'write', 'required_scopes' => [ 'manage.taxonomies', 'write.wp.posts' ], 'description' => 'Remove all terms of a taxonomy from a post.', 'input_schema' => [ 'post_id' => [ 'type' => 'integer', 'required' => true ], 'taxonomy' => [ 'type' => 'string', 'required' => true ], ], 'handler' => [ $this, 'handle_terms_remove_from_post' ], ] ); $this->register( 'wp.meta.set', [ 'class' => 'write', 'required_scopes' => [ 'manage.meta' ], 'description' => 'Set post meta value (allowlist enforced).', 'input_schema' => [ 'post_id' => [ 'type' => 'integer', 'required' => true ], 'meta_key' => [ 'type' => 'string', 'required' => true ], 'meta_value' => [ 'description' => 'Value to store (string, number, array).' ], ], 'handler' => [ $this, 'handle_meta_set' ], ] ); $this->register( 'wp.meta.set_many', [ 'class' => 'write', 'required_scopes' => [ 'manage.meta' ], 'description' => 'Set multiple post meta values in one call (allowlist enforced per key).', 'input_schema' => [ 'post_id' => [ 'type' => 'integer', 'required' => true ], 'meta' => [ 'required' => true, 'description' => 'Object of meta_key → meta_value pairs. E.g. {"_yoast_title": "...", "_acf_field": "..."}' ], ], 'handler' => [ $this, 'handle_meta_set_many' ], ] ); $this->register( 'wp.options.set', [ 'class' => 'admin_sensitive', 'required_scopes' => [ 'manage.wordpress.settings' ], 'requires_confirm' => true, 'description' => 'Set a WordPress option (allowlist enforced, confirm=true required).', 'input_schema' => [ 'option_name' => [ 'type' => 'string', 'required' => true ], 'option_value' => [ 'description' => 'New value for the option.' ], 'confirm' => [ 'type' => 'boolean', 'required' => true, 'description' => 'Must be true to execute.' ], ], 'handler' => [ $this, 'handle_options_set' ], ] ); // ── Cache ───────────────────────────────────────────────────────── $this->register( 'wp.cache.purge', [ 'class' => 'write', 'required_scopes' => [ 'write.wp.posts' ], 'supports_dry_run' => true, 'description' => 'Clear the site\'s caching plugin, if one is active (LiteSpeed Cache, WP Fastest Cache, W3 Total Cache, WP Super Cache, WP Rocket, WP-Optimize, Autoptimize). Call this after a change a visitor would otherwise keep seeing a stale cached version of. A no-op if no supported cache plugin is active.', 'input_schema' => [], 'handler' => [ $this, 'handle_cache_purge' ], ] ); // ── Security hardening ──────────────────────────────────────────── $this->register( 'wp.security.xmlrpc.get', [ 'class' => 'read', 'required_scopes' => [ 'read.wp' ], 'description' => 'Whether XML-RPC (xmlrpc.php) is on or off for visitors, and why: the "Disable XML-RPC" setting, AICOM-only mode, and active plugins that need it (Jetpack).', 'input_schema' => [], 'handler' => [ $this, 'handle_xmlrpc_get' ], ] ); $this->register( 'wp.security.xmlrpc.set', [ 'class' => 'write', 'required_scopes' => [ 'manage.security.hardening' ], 'supports_dry_run' => true, 'description' => 'Turn XML-RPC (xmlrpc.php) off, or back on. Off answers every xmlrpc.php request with 403 — a common brute-force target, rarely needed today. Refused while an active plugin needs XML-RPC (Jetpack) unless force=true; the WordPress mobile app needs it too, so ask the site owner if they use it. The previous setting is snapshotted, so Restore session puts it back.', 'input_schema' => [ 'enabled' => [ 'type' => 'boolean', 'required' => true, 'description' => 'false turns XML-RPC off; true turns it back on.' ], 'force' => [ 'type' => 'boolean', 'description' => 'Turn it off even though an active plugin needs it. Only when the site owner confirms.' ], ], 'handler' => [ $this, 'handle_xmlrpc_set' ], ] ); // ── Plugins ─────────────────────────────────────────────────────── $this->register( 'wp.plugins.list', [ 'class' => 'read', 'required_scopes' => [ 'manage.plugins' ], 'description' => 'List all installed plugins with version, status and available update info. Set force_refresh=true to bypass the 12-hour update cache.', 'input_schema' => [ 'force_refresh' => [ 'type' => 'boolean', 'description' => 'Force a fresh check against wordpress.org update API (slower).' ], ], 'handler' => [ $this, 'handle_plugins_list' ], ] ); $this->register( 'wp.plugins.update_all', [ 'class' => 'admin_sensitive', 'required_scopes' => [ 'manage.plugins' ], 'supports_dry_run' => true, 'requires_confirm' => true, 'description' => 'Update all plugins that have available updates. Pass include[] to limit to specific plugin files. Requires direct filesystem access (no FTP). Confirm=true required.', 'input_schema' => [ 'include' => [ 'type' => 'array', 'description' => 'Optional list of plugin file paths (e.g. ["akismet/akismet.php"]) to restrict which plugins are updated. Defaults to all available updates.' ], 'confirm' => [ 'type' => 'boolean', 'required' => true, 'description' => 'Must be true to execute.' ], ], 'handler' => [ $this, 'handle_plugins_update_all' ], ] ); // ── Destructive ──────────────────────────────────────────────────── $this->register( 'wp.posts.trash', [ 'class' => 'destructive', 'required_scopes' => [ 'delete.wp.posts' ], 'description' => 'Move a post to trash.', 'input_schema' => [ 'id' => [ 'type' => 'integer', 'required' => true ], ], 'handler' => [ $this, 'handle_posts_trash' ], ] ); $this->register( 'wp.posts.restore', [ 'class' => 'write', 'required_scopes' => [ 'write.wp.posts' ], 'description' => 'Restore a post from trash.', 'input_schema' => [ 'id' => [ 'type' => 'integer', 'required' => true ], ], 'handler' => [ $this, 'handle_posts_restore' ], ] ); $this->register( 'wp.posts.delete', [ 'class' => 'destructive', 'required_scopes' => [ 'delete.wp.posts' ], 'destructive' => true, 'requires_confirm' => true, 'description' => 'Permanently delete a post (requires confirm=true).', 'input_schema' => [ 'id' => [ 'type' => 'integer', 'required' => true ], 'force' => [ 'type' => 'boolean', 'default' => true ], 'confirm' => [ 'type' => 'boolean', 'required' => true, 'description' => 'Must be true to execute.' ], ], 'handler' => [ $this, 'handle_posts_delete' ], ] ); $this->register( 'wp.terms.delete', [ 'class' => 'destructive', 'required_scopes' => [ 'manage.taxonomies' ], 'destructive' => true, 'requires_confirm' => true, 'description' => 'Permanently delete a term (requires confirm=true).', 'input_schema' => [ 'term_id' => [ 'type' => 'integer', 'required' => true ], 'taxonomy' => [ 'type' => 'string', 'required' => true ], 'confirm' => [ 'type' => 'boolean', 'required' => true, 'description' => 'Must be true to execute.' ], ], 'handler' => [ $this, 'handle_terms_delete' ], ] ); $this->register( 'wp.meta.delete', [ 'class' => 'destructive', 'required_scopes' => [ 'manage.meta' ], 'description' => 'Delete a post meta key.', 'input_schema' => [ 'post_id' => [ 'type' => 'integer', 'required' => true ], 'meta_key' => [ 'type' => 'string', 'required' => true ], ], 'handler' => [ $this, 'handle_meta_delete' ], ] ); } // ── Handlers ────────────────────────────────────────────────────────── public function handle_site_summary( array $args, array $key_record, bool $dry_run ): array { $theme = wp_get_theme(); $post_types = get_post_types( [ 'public' => true ], 'names' ); $counts = []; foreach ( $post_types as $type ) { $c = wp_count_posts( $type ); $counts[ $type ] = [ 'publish' => (int) $c->publish, 'draft' => (int) $c->draft, ]; } return $this->ok( [ 'name' => get_bloginfo( 'name' ), 'description' => get_bloginfo( 'description' ), 'url' => get_site_url(), 'admin_url' => admin_url(), 'wp_version' => get_bloginfo( 'version' ), 'language' => get_bloginfo( 'language' ), 'active_theme' => $theme->get( 'Name' ), 'active_modules' => AICOM_Module_Detector::get_active_modules(), 'post_counts' => $counts, 'user_count' => (int) ( count_users()['total_users'] ?? 0 ), ] ); } public function handle_server_status( array $args, array $key_record, bool $dry_run ): array { return $this->ok( [ 'server' => [ 'name' => 'AICOM - AI Commander for WordPress', 'version' => AICOM_VERSION, 'wordpress_version' => get_bloginfo( 'version' ), 'php_version' => PHP_VERSION, 'site_url' => get_site_url(), ], 'lock' => AICOM_Lock_Manager::get_state(), 'modules' => AICOM_Module_Detector::get_module_status_map(), ] ); } public function handle_tools_list( array $args, array $key_record, bool $dry_run ): array { $active = AICOM_Module_Detector::get_active_modules(); $lock_status = AICOM_Lock_Manager::get_effective_lock(); $active_session = AICOM_Sessions::get_active( (int) $key_record['id'] ); $session_note = $active_session ? 'You have an active session: "' . $active_session['name'] . '" (ID ' . $active_session['id'] . '). You may proceed with write/destructive tools.' : 'IMPORTANT: You do NOT have an active session. Before calling any write, destructive, or admin_sensitive tool you MUST first call session.open. Provide a clear name (e.g. "Update homepage hero text") and a description of what you plan to do and why (e.g. "Rewriting the hero headline and subtext to match the new brand guidelines"). This is required — not optional. Read-only and discovery tools work without a session.'; $lock_notes = [ 'hard_locked' => 'SAFETY: The site is HARD LOCKED — only public/discovery tools are allowed.', 'soft_locked' => 'SAFETY: The site is SOFT LOCKED — read and discovery tools only; all write operations are blocked.', ]; $lock_note = $lock_notes[ $lock_status ] ?? ''; $compact = AICOM_Policy_Engine::compact_tools( $key_record ); $compact_note = $compact ? 'NOTE: To save context, each tool\'s parameter descriptions are omitted below — names, types, and required fields are still shown for every tool (read AND write), so you can build a correct call without them. The tool\'s own top-level description (including any worked example) is never shortened. If you need a parameter\'s full description, call tools/describe(name:"tool.name") — or use tools/search(query:"...") when you don\'t know the exact name.' . "\n\nTo call any tool, send exactly this shape: {\"name\": \"\", \"arguments\": {: , ...}}. Example: {\"name\": \"wp.posts.create\", \"arguments\": {\"post_title\": \"Hello\", \"post_status\": \"draft\"}}." : ''; $instructions = trim( implode( "\n\n", array_filter( [ $session_note, $lock_note, $compact_note ] ) ) ); return $this->ok( [ 'instructions' => $instructions, 'tools' => AICOM_Tool_Registry::to_mcp_list( $active, $key_record, $compact ), ] ); } public function handle_tools_search( array $args, array $key_record, bool $dry_run ): array { $query = trim( (string) ( $args['query'] ?? '' ) ); if ( $query === '' ) { return $this->err( 'MISSING_PARAM', 'query is required.' ); } $limit = (int) ( $args['limit'] ?? 10 ); $active = AICOM_Module_Detector::get_active_modules(); $tools = AICOM_Tool_Search::search( $query, $active, $key_record, $limit ); if ( empty( $tools ) ) { return $this->ok( [ 'tools' => [], 'hint' => 'No tool matched that query for your key\'s permissions. Try broader keywords, or call tools/list to see everything you can access.', ] ); } return $this->ok( [ 'tools' => $tools ] ); } public function handle_tools_describe( array $args, array $key_record, bool $dry_run ): array { $name = trim( (string) ( $args['name'] ?? '' ) ); if ( $name === '' ) { return $this->err( 'MISSING_PARAM', 'name is required.' ); } $active = AICOM_Module_Detector::get_active_modules(); $tool = AICOM_Tool_Registry::describe( $name, $active, $key_record ); if ( $tool === null ) { return $this->err( 'TOOL_NOT_FOUND', "No tool named \"$name\" is available to this key — it may not exist, its module may be inactive, or your key lacks the required scope. Try tools/search(query:\"...\") to find the right name." ); } return $this->ok( [ 'tool' => $tool ] ); } public function handle_site_info( array $args, array $key_record, bool $dry_run ): array { $theme = wp_get_theme(); return $this->ok( [ 'name' => get_bloginfo( 'name' ), 'description' => get_bloginfo( 'description' ), 'url' => get_site_url(), 'admin_email' => get_bloginfo( 'admin_email' ), 'wordpress_version'=> get_bloginfo( 'version' ), 'timezone' => get_option( 'timezone_string' ) ?: 'UTC', 'language' => get_bloginfo( 'language' ), 'active_theme' => [ 'name' => $theme->get( 'Name' ), 'version' => $theme->get( 'Version' ), ], ] ); } public function handle_post_types_list( array $args, array $key_record, bool $dry_run ): array { $post_types = get_post_types( [ 'public' => true ], 'objects' ); $result = []; foreach ( $post_types as $slug => $pt ) { $result[] = [ 'slug' => $slug, 'label' => $pt->label, 'hierarchical'=> $pt->hierarchical, 'has_archive' => $pt->has_archive, ]; } return $this->ok( [ 'post_types' => $result ] ); } public function handle_taxonomies_list( array $args, array $key_record, bool $dry_run ): array { $taxonomies = get_taxonomies( [ 'public' => true ], 'objects' ); $result = []; foreach ( $taxonomies as $slug => $tax ) { $result[] = [ 'slug' => $slug, 'label' => $tax->label, 'hierarchical'=> $tax->hierarchical, 'object_type' => $tax->object_type, ]; } return $this->ok( [ 'taxonomies' => $result ] ); } public function handle_posts_list( array $args, array $key_record, bool $dry_run ): array { $post_type = sanitize_key( $args['post_type'] ?? 'post' ); if ( ! AICOM_Policy_Engine::check_post_type_allowlist( $key_record, $post_type ) ) { return $this->err( 'DENIED_ALLOWLIST', "Post type not in allowlist: $post_type", 'denied_allowlist', 403 ); } $query_args = [ 'post_type' => $post_type, 'post_status' => sanitize_text_field( $args['status'] ?? 'any' ), 'posts_per_page' => min( (int) ( $args['posts_per_page'] ?? 20 ), 100 ), 'paged' => max( 1, (int) ( $args['paged'] ?? 1 ) ), ]; if ( ! empty( $args['search'] ) ) { $query_args['s'] = sanitize_text_field( $args['search'] ); } // Polylang language filter if ( ! empty( $args['lang'] ) && AICOM_Module_Detector::is_polylang_active() ) { $query_args['lang'] = sanitize_key( $args['lang'] ); } $query = new WP_Query( $query_args ); $posts = []; foreach ( $query->posts as $post ) { $posts[] = [ 'id' => $post->ID, 'title' => $post->post_title, 'status' => $post->post_status, 'date' => $post->post_date, 'modified' => $post->post_modified, 'slug' => $post->post_name, 'post_type' => $post->post_type, 'author' => (int) $post->post_author, 'comment_count'=> (int) $post->comment_count, ]; } return $this->ok( [ 'posts' => $posts, 'total' => (int) $query->found_posts, 'total_pages' => (int) $query->max_num_pages, ] ); } public function handle_posts_get( array $args, array $key_record, bool $dry_run ): array { $id = $this->require_int( $args, 'id' ); if ( ! $id ) { return $this->err( 'MISSING_PARAM', 'Parameter id is required', 'validation_failed' ); } $post = get_post( $id ); if ( ! $post ) { return $this->err( 'NOT_FOUND', "Post $id not found", 'error', 404 ); } if ( ! AICOM_Policy_Engine::check_post_type_allowlist( $key_record, $post->post_type ) ) { return $this->err( 'DENIED_ALLOWLIST', 'Post type not in allowlist', 'denied_allowlist', 403 ); } $taxonomies = get_object_taxonomies( $post->post_type ); $terms_map = []; foreach ( $taxonomies as $tax ) { $tax_terms = get_the_terms( $post->ID, $tax ); if ( $tax_terms && ! is_wp_error( $tax_terms ) ) { $terms_map[ $tax ] = array_values( array_map( fn( $t ) => [ 'id' => $t->term_id, 'name' => $t->name, 'slug' => $t->slug, ], $tax_terms ) ); } } return $this->ok( [ 'id' => $post->ID, 'title' => $post->post_title, 'content' => $post->post_content, 'excerpt' => $post->post_excerpt, 'status' => $post->post_status, 'post_type' => $post->post_type, 'slug' => $post->post_name, 'date' => $post->post_date, 'modified' => $post->post_modified, 'author' => (int) $post->post_author, 'parent' => (int) $post->post_parent, 'menu_order' => (int) $post->menu_order, 'comment_status' => $post->comment_status, 'terms' => AICOM_Json::obj( $terms_map ), 'edit_url' => get_edit_post_link( $post->ID, 'raw' ), 'view_url' => get_permalink( $post->ID ), ], [ 'target_type' => 'post', 'target_id' => $id ] ); } public function handle_posts_create( array $args, array $key_record, bool $dry_run ): array { $title = $this->require_string( $args, 'post_title' ); if ( ! $title ) { return $this->err( 'MISSING_PARAM', 'Parameter post_title is required', 'validation_failed' ); } $post_type = sanitize_key( $args['post_type'] ?? 'post' ); if ( ! AICOM_Policy_Engine::check_post_type_allowlist( $key_record, $post_type ) ) { return $this->err( 'DENIED_ALLOWLIST', "Post type not in allowlist: $post_type", 'denied_allowlist', 403 ); } if ( $dry_run ) { return $this->ok( [ 'dry_run' => true, 'would_create' => [ 'post_type' => $post_type, 'post_title' => $title ], ] ); } $data = [ 'post_title' => sanitize_text_field( $title ), 'post_content' => wp_kses_post( $args['post_content'] ?? '' ), 'post_status' => sanitize_key( $args['post_status'] ?? 'draft' ), 'post_type' => $post_type, ]; if ( isset( $args['post_excerpt'] ) ) { $data['post_excerpt'] = sanitize_text_field( $args['post_excerpt'] ); } if ( isset( $args['post_name'] ) ) { $data['post_name'] = sanitize_title( $args['post_name'] ); } // Default author to the user who owns the API key; avoids author=0 on REST requests if ( isset( $args['post_author'] ) ) { $author_id = (int) $args['post_author']; if ( $author_id > 0 && ! get_user_by( 'ID', $author_id ) ) { return $this->err( 'INVALID_PARAM', "No user with ID $author_id", 'validation_failed' ); } $data['post_author'] = $author_id; } else { $data['post_author'] = (int) ( $key_record['created_by_user_id'] ?? 0 ); } if ( isset( $args['post_date'] ) ) { $date = $this->normalize_post_date( $args['post_date'] ); if ( $date === null ) { return $this->err( 'INVALID_PARAM', 'post_date must be YYYY-MM-DD HH:MM:SS or ISO 8601 (e.g. 2026-04-28T10:00:00)', 'validation_failed' ); } $data['post_date'] = $date; } $id = wp_insert_post( $data, true ); if ( is_wp_error( $id ) ) { return $this->err( 'WP_ERROR', $id->get_error_message(), 'error', 500 ); } // Read-after-write: WordPress can silently transform or drop fields (a // duplicate slug gets a "-2" suffix, a post type that doesn't support // excerpts ignores it, sanitizers can alter title/content) — compare // what was actually requested against what's actually stored rather // than assuming wp_insert_post's success means every field landed as sent. $persisted = get_post( $id ); $requested = [ 'post_title' => $data['post_title'], 'post_status' => $data['post_status'] ]; $stored = [ 'post_title' => $persisted->post_title, 'post_status' => $persisted->post_status ]; foreach ( [ 'post_excerpt', 'post_name' ] as $field ) { if ( isset( $data[ $field ] ) ) { $requested[ $field ] = $data[ $field ]; $stored[ $field ] = $persisted->$field; } } $mismatched = array_keys( array_diff_assoc( $requested, $stored ) ); $verified = empty( $mismatched ); $result = [ 'id' => $id, 'post_type' => $post_type, 'title' => get_the_title( $id ), 'status' => get_post_status( $id ), 'edit_url' => get_edit_post_link( $id, 'raw' ), 'view_url' => get_permalink( $id ), 'requested' => $requested, 'persisted' => $stored, 'verified' => $verified, ]; if ( ! $verified ) { $result['warning'] = 'Field(s) ' . implode( ', ', $mismatched ) . ' were not stored exactly as requested — check for a duplicate slug suffix, a post-type field restriction, or sanitization.'; } return $this->ok( $result, [ 'target_type' => 'post', 'target_id' => $id, 'summary' => [ 'created' => true ] ] ); } public function handle_posts_update( array $args, array $key_record, bool $dry_run ): array { $id = $this->require_int( $args, 'id' ); if ( ! $id ) { return $this->err( 'MISSING_PARAM', 'Parameter id is required', 'validation_failed' ); } $post = get_post( $id ); if ( ! $post ) { return $this->err( 'NOT_FOUND', "Post $id not found", 'error', 404 ); } if ( ! AICOM_Policy_Engine::check_post_type_allowlist( $key_record, $post->post_type ) ) { return $this->err( 'DENIED_ALLOWLIST', 'Post type not in allowlist', 'denied_allowlist', 403 ); } if ( $dry_run ) { return $this->ok( [ 'dry_run' => true, 'would_update_id' => $id ] ); } $data = [ 'ID' => $id ]; if ( isset( $args['post_title'] ) ) $data['post_title'] = sanitize_text_field( $args['post_title'] ); if ( isset( $args['post_content'] ) ) $data['post_content'] = wp_kses_post( $args['post_content'] ); if ( isset( $args['post_status'] ) ) $data['post_status'] = sanitize_key( $args['post_status'] ); if ( isset( $args['post_excerpt'] ) ) $data['post_excerpt'] = sanitize_text_field( $args['post_excerpt'] ); if ( isset( $args['post_name'] ) ) $data['post_name'] = sanitize_title( $args['post_name'] ); if ( isset( $args['post_author'] ) ) { $author_id = (int) $args['post_author']; if ( $author_id > 0 && ! get_user_by( 'ID', $author_id ) ) { return $this->err( 'INVALID_PARAM', "No user with ID $author_id", 'validation_failed' ); } $data['post_author'] = $author_id; } if ( isset( $args['post_date'] ) ) { $date = $this->normalize_post_date( $args['post_date'] ); if ( $date === null ) { return $this->err( 'INVALID_PARAM', 'post_date must be YYYY-MM-DD HH:MM:SS or ISO 8601 (e.g. 2026-04-28T10:00:00)', 'validation_failed' ); } $data['post_date'] = $date; $data['post_date_gmt'] = get_gmt_from_date( $date ); } // ID is always present; anything beyond it is a real requested change. $changed_fields = array_values( array_diff( array_keys( $data ), [ 'ID', 'post_date_gmt' ] ) ); if ( empty( $changed_fields ) ) { return $this->ok( [ 'id' => $id, 'updated' => false, 'changed_fields' => [], 'warning' => 'No update fields were supplied — nothing was changed.', ] ); } $result = wp_update_post( $data, true ); if ( is_wp_error( $result ) ) { return $this->err( 'WP_ERROR', $result->get_error_message(), 'error', 500 ); } // Read-after-write: compare each requested field against what's actually // stored now — a duplicate slug gets a "-2" suffix, sanitizers can alter // title/content, a post type may not support every field. $persisted = get_post( $id ); $requested = []; $stored = []; foreach ( $changed_fields as $field ) { $requested[ $field ] = $data[ $field ]; $stored[ $field ] = $persisted->$field; } $mismatched = array_keys( array_diff_assoc( $requested, $stored ) ); $verified = empty( $mismatched ); $response = [ 'id' => $id, 'updated' => true, 'changed_fields' => $changed_fields, 'status' => get_post_status( $id ), 'modified' => get_post_field( 'post_modified', $id ), 'edit_url' => get_edit_post_link( $id, 'raw' ), 'view_url' => get_permalink( $id ), 'requested' => $requested, 'persisted' => $stored, 'verified' => $verified, ]; if ( ! $verified ) { $response['warning'] = 'Field(s) ' . implode( ', ', $mismatched ) . ' were not stored exactly as requested — check for a duplicate slug suffix, a post-type field restriction, or sanitization.'; } return $this->ok( $response, [ 'target_type' => 'post', 'target_id' => $id, 'summary' => $changed_fields ] ); } public function handle_posts_preview_url( array $args, array $key_record, bool $dry_run ): array { $post_id = $this->require_int( $args, 'post_id' ); if ( ! $post_id ) { return $this->err( 'MISSING_PARAM', 'Parameter post_id is required', 'validation_failed' ); } $post = get_post( $post_id ); if ( ! $post ) { return $this->err( 'NOT_FOUND', "Post $post_id not found", 'error', 404 ); } $is_published = $post->post_status === 'publish'; $preview_url = $is_published ? get_permalink( $post_id ) : get_preview_post_link( $post_id ); return $this->ok( [ 'post_id' => $post_id, 'post_status' => $post->post_status, 'preview_url' => $preview_url, 'admin_edit_url' => admin_url( "post.php?post=$post_id&action=edit" ), ], [ 'target_type' => 'post', 'target_id' => $post_id ] ); } public function handle_posts_trash( array $args, array $key_record, bool $dry_run ): array { $id = $this->require_int( $args, 'id' ); if ( ! $id ) { return $this->err( 'MISSING_PARAM', 'Parameter id is required', 'validation_failed' ); } $post = get_post( $id ); if ( ! $post ) { return $this->err( 'NOT_FOUND', "Post $id not found", 'error', 404 ); } if ( $dry_run ) { return $this->ok( [ 'dry_run' => true, 'would_trash_id' => $id ] ); } // Already there is what the caller wants, not a failure — e.g. AICOMBase's Undo trashes the posts a session // created, and Restore session may have trashed them first (wp_trash_post() returns false for those). if ( $post->post_status === 'trash' ) { return $this->ok( [ 'id' => $id, 'trashed' => true, 'already_in_trash' => true ], [ 'target_type' => 'post', 'target_id' => $id ] ); } $result = wp_trash_post( $id ); if ( ! $result ) { return $this->err( 'WP_ERROR', 'Failed to trash post', 'error', 500 ); } return $this->ok( [ 'id' => $id, 'trashed' => true ], [ 'target_type' => 'post', 'target_id' => $id ] ); } public function handle_posts_restore( array $args, array $key_record, bool $dry_run ): array { $id = $this->require_int( $args, 'id' ); if ( ! $id ) { return $this->err( 'MISSING_PARAM', 'Parameter id is required', 'validation_failed' ); } if ( $dry_run ) { return $this->ok( [ 'dry_run' => true, 'would_restore_id' => $id ] ); } $result = $this->untrash_to_previous_status( $id ); if ( ! $result ) { return $this->err( 'WP_ERROR', 'Failed to restore post', 'error', 500 ); } return $this->ok( [ 'id' => $id, 'restored' => true, 'status' => get_post_status( $id ) ], [ 'target_type' => 'post', 'target_id' => $id ] ); } public function handle_posts_delete( array $args, array $key_record, bool $dry_run ): array { $id = $this->require_int( $args, 'id' ); if ( ! $id ) { return $this->err( 'MISSING_PARAM', 'Parameter id is required', 'validation_failed' ); } $post = get_post( $id ); if ( ! $post ) { return $this->err( 'NOT_FOUND', "Post $id not found", 'error', 404 ); } if ( $dry_run ) { return $this->ok( [ 'dry_run' => true, 'would_delete_id' => $id ] ); } $result = wp_delete_post( $id, true ); // force_delete = true if ( ! $result ) { return $this->err( 'WP_ERROR', 'Failed to permanently delete post', 'error', 500 ); } return $this->ok( [ 'id' => $id, 'deleted' => true ], [ 'target_type' => 'post', 'target_id' => $id ] ); } public function handle_terms_list( array $args, array $key_record, bool $dry_run ): array { $taxonomy = sanitize_key( $args['taxonomy'] ?? '' ); if ( ! $taxonomy ) { return $this->err( 'MISSING_PARAM', 'Parameter taxonomy is required', 'validation_failed' ); } if ( ! AICOM_Policy_Engine::check_taxonomy_allowlist( $key_record, $taxonomy ) ) { return $this->err( 'DENIED_ALLOWLIST', "Taxonomy not in allowlist: $taxonomy", 'denied_allowlist', 403 ); } $terms = get_terms( [ 'taxonomy' => $taxonomy, 'hide_empty' => false, 'number' => 200 ] ); if ( is_wp_error( $terms ) ) { return $this->err( 'WP_ERROR', $terms->get_error_message(), 'error', 500 ); } $result = []; foreach ( $terms as $term ) { $result[] = [ 'id' => $term->term_id, 'name' => $term->name, 'slug' => $term->slug, 'description' => $term->description, 'count' => $term->count, 'parent' => $term->parent, ]; } return $this->ok( [ 'taxonomy' => $taxonomy, 'terms' => $result, 'total' => count( $result ) ] ); } public function handle_terms_get( array $args, array $key_record, bool $dry_run ): array { $term_id = $this->require_int( $args, 'term_id' ); $taxonomy = sanitize_key( $args['taxonomy'] ?? '' ); if ( ! $term_id || ! $taxonomy ) { return $this->err( 'MISSING_PARAM', 'Parameters term_id and taxonomy are required', 'validation_failed' ); } $term = get_term( $term_id, $taxonomy ); if ( is_wp_error( $term ) || ! $term ) { return $this->err( 'NOT_FOUND', "Term $term_id not found in $taxonomy", 'error', 404 ); } return $this->ok( [ 'id' => $term->term_id, 'name' => $term->name, 'slug' => $term->slug, 'description' => $term->description, 'count' => $term->count, 'parent' => $term->parent, 'taxonomy' => $taxonomy, ], [ 'target_type' => 'term', 'target_id' => $term_id ] ); } public function handle_terms_create( array $args, array $key_record, bool $dry_run ): array { $name = $this->require_string( $args, 'name' ); $taxonomy = sanitize_key( $args['taxonomy'] ?? '' ); if ( ! $name || ! $taxonomy ) { return $this->err( 'MISSING_PARAM', 'Parameters name and taxonomy are required', 'validation_failed' ); } if ( ! AICOM_Policy_Engine::check_taxonomy_allowlist( $key_record, $taxonomy ) ) { return $this->err( 'DENIED_ALLOWLIST', "Taxonomy not in allowlist: $taxonomy", 'denied_allowlist', 403 ); } if ( $dry_run ) { return $this->ok( [ 'dry_run' => true, 'would_create' => [ 'name' => $name, 'taxonomy' => $taxonomy ] ] ); } $term_data = [ 'description' => sanitize_text_field( $args['description'] ?? '' ), 'slug' => sanitize_title( $args['slug'] ?? $name ), 'parent' => (int) ( $args['parent'] ?? 0 ), ]; $clean_name = sanitize_text_field( $name ); $result = wp_insert_term( $clean_name, $taxonomy, $term_data ); if ( is_wp_error( $result ) ) { return $this->err( 'WP_ERROR', $result->get_error_message(), 'error', 500 ); } // Read-after-write: WordPress can adjust a duplicate slug, or a parent term // in a non-hierarchical taxonomy is silently ignored — confirm what stuck. $term = get_term( $result['term_id'], $taxonomy ); $requested = [ 'name' => $clean_name, 'slug' => $term_data['slug'], 'description' => $term_data['description'], 'parent' => $term_data['parent'] ]; $stored = is_wp_error( $term ) || ! $term ? [] : [ 'name' => $term->name, 'slug' => $term->slug, 'description' => $term->description, 'parent' => (int) $term->parent ]; $mismatched = array_keys( array_diff_assoc( $requested, $stored ) ); $verified = empty( $mismatched ); $response = [ 'term_id' => $result['term_id'], 'taxonomy' => $taxonomy, 'name' => $clean_name, 'requested' => $requested, 'persisted' => $stored, 'verified' => $verified, ]; if ( ! $verified ) { $response['warning'] = 'Field(s) ' . implode( ', ', $mismatched ) . ' were not stored exactly as requested — check for a duplicate slug or a non-hierarchical taxonomy ignoring parent.'; } return $this->ok( $response, [ 'target_type' => 'term', 'target_id' => $result['term_id'] ] ); } public function handle_terms_update( array $args, array $key_record, bool $dry_run ): array { $term_id = $this->require_int( $args, 'term_id' ); $taxonomy = sanitize_key( $args['taxonomy'] ?? '' ); if ( ! $term_id || ! $taxonomy ) { return $this->err( 'MISSING_PARAM', 'Parameters term_id and taxonomy are required', 'validation_failed' ); } if ( $dry_run ) { return $this->ok( [ 'dry_run' => true, 'would_update_term_id' => $term_id ] ); } $term_data = []; if ( isset( $args['name'] ) ) $term_data['name'] = sanitize_text_field( $args['name'] ); if ( isset( $args['slug'] ) ) $term_data['slug'] = sanitize_title( $args['slug'] ); if ( isset( $args['description'] ) ) $term_data['description'] = sanitize_text_field( $args['description'] ); if ( isset( $args['parent'] ) ) $term_data['parent'] = (int) $args['parent']; if ( empty( $term_data ) ) { return $this->ok( [ 'term_id' => $term_id, 'updated' => false, 'changed_fields' => [], 'warning' => 'No update fields were supplied — nothing was changed.', ] ); } $result = wp_update_term( $term_id, $taxonomy, $term_data ); if ( is_wp_error( $result ) ) { return $this->err( 'WP_ERROR', $result->get_error_message(), 'error', 500 ); } $term = get_term( $term_id, $taxonomy ); $stored = []; foreach ( array_keys( $term_data ) as $field ) { $stored[ $field ] = is_wp_error( $term ) || ! $term ? null : ( $field === 'parent' ? (int) $term->parent : $term->$field ); } $mismatched = array_keys( array_diff_assoc( $term_data, $stored ) ); $verified = empty( $mismatched ); $response = [ 'term_id' => $term_id, 'updated' => true, 'changed_fields' => array_keys( $term_data ), 'requested' => $term_data, 'persisted' => $stored, 'verified' => $verified, ]; if ( ! $verified ) { $response['warning'] = 'Field(s) ' . implode( ', ', $mismatched ) . ' were not stored exactly as requested — check for a duplicate slug or a non-hierarchical taxonomy ignoring parent.'; } return $this->ok( $response, [ 'target_type' => 'term', 'target_id' => $term_id, 'summary' => array_keys( $term_data ) ] ); } public function handle_terms_delete( array $args, array $key_record, bool $dry_run ): array { $term_id = $this->require_int( $args, 'term_id' ); $taxonomy = sanitize_key( $args['taxonomy'] ?? '' ); if ( ! $term_id || ! $taxonomy ) { return $this->err( 'MISSING_PARAM', 'Parameters term_id and taxonomy are required', 'validation_failed' ); } if ( $dry_run ) { return $this->ok( [ 'dry_run' => true, 'would_delete_term_id' => $term_id ] ); } $result = wp_delete_term( $term_id, $taxonomy ); if ( is_wp_error( $result ) || $result === false ) { return $this->err( 'WP_ERROR', is_wp_error( $result ) ? $result->get_error_message() : 'Failed to delete term', 'error', 500 ); } return $this->ok( [ 'term_id' => $term_id, 'deleted' => true ], [ 'target_type' => 'term', 'target_id' => $term_id ] ); } public function handle_terms_assign_to_post( array $args, array $key_record, bool $dry_run ): array { $post_id = $this->require_int( $args, 'post_id' ); $taxonomy = sanitize_key( $args['taxonomy'] ?? '' ); if ( ! $post_id ) { return $this->err( 'MISSING_PARAM', 'post_id is required. Example: "post_id": 123', 'validation_failed' ); } if ( ! $taxonomy ) { return $this->err( 'MISSING_PARAM', 'taxonomy is required. Example: "taxonomy": "category"', 'validation_failed' ); } if ( ! isset( $args['term_ids'] ) ) { return $this->err( 'MISSING_PARAM', 'term_ids is required. It must be an array of integers. Example: "term_ids": [200]', 'validation_failed' ); } if ( ! is_array( $args['term_ids'] ) ) { return $this->err( 'MISSING_PARAM', 'term_ids must be an array, not a single value. You sent ' . json_encode( $args['term_ids'] ) . ' — wrap it in brackets: "term_ids": [' . json_encode( $args['term_ids'] ) . ']', 'validation_failed' ); } $term_ids = array_values( array_filter( array_map( 'intval', $args['term_ids'] ), fn( $id ) => $id > 0 ) ); if ( empty( $term_ids ) ) { return $this->err( 'MISSING_PARAM', 'term_ids array is empty or contains no valid IDs. Example: "term_ids": [200]', 'validation_failed' ); } if ( $dry_run ) { return $this->ok( [ 'dry_run' => true, 'would_assign' => [ 'post_id' => $post_id, 'term_ids' => $term_ids ] ] ); } $result = wp_set_post_terms( $post_id, $term_ids, $taxonomy, ! empty( $args['append'] ) ); if ( is_wp_error( $result ) ) { return $this->err( 'WP_ERROR', $result->get_error_message(), 'error', 500 ); } // Read-after-write: confirm the requested terms actually stuck. A term // can silently fail to persist (e.g. Polylang remapping a category to // the post's language), so re-read rather than trust wp_set_post_terms's // return value alone. $persisted_ids = wp_get_post_terms( $post_id, $taxonomy, [ 'fields' => 'ids' ] ); $persisted_ids = is_wp_error( $persisted_ids ) ? [] : array_map( 'intval', $persisted_ids ); $verified = empty( array_diff( $term_ids, $persisted_ids ) ); $data = [ 'post_id' => $post_id, 'assigned' => $term_ids, 'requested' => $term_ids, 'persisted' => $persisted_ids, 'verified' => $verified, ]; if ( ! $verified ) { $missing = array_values( array_diff( $term_ids, $persisted_ids ) ); $data['warning'] = 'Requested term IDs ' . implode( ', ', $missing ) . ' are not present after the write — check for a taxonomy restriction or a language/translation plugin remapping the term.'; } return $this->ok( $data, [ 'target_type' => 'post', 'target_id' => $post_id ] ); } public function handle_terms_remove_from_post( array $args, array $key_record, bool $dry_run ): array { $post_id = $this->require_int( $args, 'post_id' ); $taxonomy = sanitize_key( $args['taxonomy'] ?? '' ); if ( ! $post_id || ! $taxonomy ) { return $this->err( 'MISSING_PARAM', 'Parameters post_id and taxonomy are required', 'validation_failed' ); } if ( $dry_run ) { return $this->ok( [ 'dry_run' => true, 'would_remove_terms_from' => $post_id ] ); } wp_set_post_terms( $post_id, [], $taxonomy ); // Read-after-write: for hierarchical taxonomies (category in particular), // WordPress can silently re-assign the site's default term instead of // leaving the post with none — confirm what's actually there now. $persisted_ids = wp_get_post_terms( $post_id, $taxonomy, [ 'fields' => 'ids' ] ); $persisted_ids = is_wp_error( $persisted_ids ) ? [] : array_map( 'intval', $persisted_ids ); $verified = empty( $persisted_ids ); $data = [ 'post_id' => $post_id, 'terms_removed' => $verified, 'persisted' => $persisted_ids, 'verified' => $verified, ]; if ( ! $verified ) { $data['warning'] = 'Terms are still present after removal — WordPress may have auto-assigned the default term for this taxonomy.'; } return $this->ok( $data, [ 'target_type' => 'post', 'target_id' => $post_id ] ); } public function handle_meta_get( array $args, array $key_record, bool $dry_run ): array { $post_id = $this->require_int( $args, 'post_id' ); $meta_key = $args['meta_key'] ?? null; if ( ! $post_id ) { return $this->err( 'MISSING_PARAM', 'Parameter post_id is required', 'validation_failed' ); } if ( $meta_key ) { if ( ! AICOM_Policy_Engine::check_meta_key_allowlist( $key_record, $meta_key ) ) { return $this->err( 'DENIED_ALLOWLIST', "Meta key not in allowlist: $meta_key", 'denied_allowlist', 403 ); } $value = get_post_meta( $post_id, $meta_key, false ); return $this->ok( [ 'post_id' => $post_id, 'meta_key' => $meta_key, 'values' => $value ] ); } // Return all meta (respects allowlist if set) $all_meta = get_post_meta( $post_id ); return $this->ok( [ 'post_id' => $post_id, 'meta' => AICOM_Json::obj( $all_meta ) ] ); } public function handle_meta_set( array $args, array $key_record, bool $dry_run ): array { $post_id = $this->require_int( $args, 'post_id' ); $meta_key = $this->require_string( $args, 'meta_key' ); $meta_value = $args['meta_value'] ?? null; if ( ! $post_id || ! $meta_key ) { return $this->err( 'MISSING_PARAM', 'Parameters post_id and meta_key are required', 'validation_failed' ); } if ( in_array( $meta_key, self::BLOCKED_META_KEYS, true ) ) { return $this->err( 'DENIED_SYSTEM_KEY', "Meta key is reserved for WordPress internals: $meta_key", 'validation_failed', 403 ); } if ( ! AICOM_Policy_Engine::check_meta_key_allowlist( $key_record, $meta_key ) ) { return $this->err( 'DENIED_ALLOWLIST', "Meta key not in allowlist: $meta_key", 'denied_allowlist', 403 ); } if ( $dry_run ) { return $this->ok( [ 'dry_run' => true, 'would_set' => [ 'post_id' => $post_id, 'meta_key' => $meta_key ] ] ); } // wp_slash() counteracts update_post_meta's internal wp_unslash(), preserving backslashes in JSON strings update_post_meta( $post_id, $meta_key, is_string( $meta_value ) ? wp_slash( $meta_value ) : $meta_value ); // Read-after-write: update_post_meta()'s own return value is unreliable here // (it returns false both on failure AND when the new value equals the old // one) — read the value straight back instead of trusting the boolean. $persisted_value = get_post_meta( $post_id, $meta_key, true ); $verified = $persisted_value === $meta_value; $response = [ 'post_id' => $post_id, 'meta_key' => $meta_key, 'updated' => $verified, 'requested' => $meta_value, 'persisted' => $persisted_value, 'verified' => $verified, ]; if ( ! $verified ) { $response['warning'] = "Meta key '$meta_key' does not match the requested value after the write."; } return $this->ok( $response, [ 'target_type' => 'post_meta', 'target_id' => $post_id ] ); } public function handle_meta_set_many( array $args, array $key_record, bool $dry_run ): array { $post_id = $this->require_int( $args, 'post_id' ); $meta = $args['meta'] ?? null; if ( ! $post_id ) { return $this->err( 'MISSING_PARAM', 'Parameter post_id is required', 'validation_failed' ); } if ( ! is_array( $meta ) || empty( $meta ) ) { return $this->err( 'MISSING_PARAM', 'Parameter meta must be a non-empty object of key→value pairs', 'validation_failed' ); } $denied = []; $blocked = []; foreach ( array_keys( $meta ) as $key ) { if ( in_array( $key, self::BLOCKED_META_KEYS, true ) ) { $blocked[] = $key; } elseif ( ! AICOM_Policy_Engine::check_meta_key_allowlist( $key_record, $key ) ) { $denied[] = $key; } } if ( $blocked ) { return $this->err( 'DENIED_SYSTEM_KEY', 'Reserved WordPress meta keys: ' . implode( ', ', $blocked ), 'validation_failed', 403 ); } if ( $denied ) { return $this->err( 'DENIED_ALLOWLIST', 'Meta keys not in allowlist: ' . implode( ', ', $denied ), 'denied_allowlist', 403 ); } if ( $dry_run ) { return $this->ok( [ 'dry_run' => true, 'would_set' => array_keys( $meta ) ] ); } foreach ( $meta as $key => $value ) { update_post_meta( $post_id, $key, is_string( $value ) ? wp_slash( $value ) : $value ); } // Read-after-write for every key in the batch — update_post_meta()'s own // return value is unreliable (false both on failure and on a no-op write). $persisted = []; $mismatched = []; foreach ( $meta as $key => $value ) { $persisted[ $key ] = get_post_meta( $post_id, $key, true ); if ( $persisted[ $key ] !== $value ) { $mismatched[] = $key; } } $verified = empty( $mismatched ); $response = [ 'post_id' => $post_id, 'updated' => array_keys( $meta ), 'requested' => $meta, 'persisted' => $persisted, 'verified' => $verified, ]; if ( ! $verified ) { $response['warning'] = 'Key(s) ' . implode( ', ', $mismatched ) . ' do not match the requested value after the write.'; } return $this->ok( $response, [ 'target_type' => 'post_meta', 'target_id' => $post_id ] ); } public function handle_meta_delete( array $args, array $key_record, bool $dry_run ): array { $post_id = $this->require_int( $args, 'post_id' ); $meta_key = $this->require_string( $args, 'meta_key' ); if ( ! $post_id || ! $meta_key ) { return $this->err( 'MISSING_PARAM', 'Parameters post_id and meta_key are required', 'validation_failed' ); } if ( in_array( $meta_key, self::BLOCKED_META_KEYS, true ) ) { return $this->err( 'DENIED_SYSTEM_KEY', "Meta key is reserved for WordPress internals: $meta_key", 'validation_failed', 403 ); } if ( ! AICOM_Policy_Engine::check_meta_key_allowlist( $key_record, $meta_key ) ) { return $this->err( 'DENIED_ALLOWLIST', "Meta key not in allowlist: $meta_key", 'denied_allowlist', 403 ); } if ( $dry_run ) { return $this->ok( [ 'dry_run' => true, 'would_delete_meta_key' => $meta_key ] ); } delete_post_meta( $post_id, $meta_key ); return $this->ok( [ 'post_id' => $post_id, 'meta_key' => $meta_key, 'deleted' => true ], [ 'target_type' => 'post_meta', 'target_id' => $post_id ] ); } public function handle_options_get( array $args, array $key_record, bool $dry_run ): array { $option_name = $this->require_string( $args, 'option_name' ); if ( ! $option_name ) { return $this->err( 'MISSING_PARAM', 'Parameter option_name is required', 'validation_failed' ); } if ( ! AICOM_Policy_Engine::check_option_allowlist( $key_record, $option_name ) ) { return $this->err( 'DENIED_ALLOWLIST', "Option not in allowlist: $option_name", 'denied_allowlist', 403 ); } $value = get_option( $option_name ); return $this->ok( [ 'option_name' => $option_name, 'value' => $value ] ); } public function handle_options_set( array $args, array $key_record, bool $dry_run ): array { $option_name = $this->require_string( $args, 'option_name' ); $option_value = $args['option_value'] ?? null; if ( ! $option_name ) { return $this->err( 'MISSING_PARAM', 'Parameter option_name is required', 'validation_failed' ); } if ( ! AICOM_Policy_Engine::check_option_allowlist( $key_record, $option_name ) ) { return $this->err( 'DENIED_ALLOWLIST', "Option not in allowlist: $option_name", 'denied_allowlist', 403 ); } if ( $dry_run ) { return $this->ok( [ 'dry_run' => true, 'would_set_option' => $option_name ] ); } update_option( $option_name, $option_value ); return $this->ok( [ 'option_name' => $option_name, 'updated' => true ], [ 'target_type' => 'option', 'target_id' => $option_name ] ); } public function handle_cache_purge( array $args, array $key_record, bool $dry_run ): array { if ( $dry_run ) { return $this->ok( [ 'dry_run' => true, 'would_purge_cache' => true ] ); } $problems = AICOM_Cache_Buster::purge_all(); return $this->ok( [ 'purged' => true, 'problems' => $problems, ] ); } /** XML-RPC as visitors get it, and why. */ private function xmlrpc_state(): array { return [ 'xmlrpc_enabled' => ! AICOM_Xmlrpc::is_off(), 'disabled_by_setting' => AICOM_Xmlrpc::is_disabled_by_setting(), 'disabled_by_aicom_only_mode' => AICOM_Lockdown::is_enabled(), 'needed_by' => array_values( AICOM_Xmlrpc::needed_by() ), ]; } public function handle_xmlrpc_get( array $args, array $key_record, bool $dry_run ): array { return $this->ok( $this->xmlrpc_state() ); } public function handle_xmlrpc_set( array $args, array $key_record, bool $dry_run ): array { // A missing value must never read as "false" (that would turn XML-RPC off): only real booleans count. $raw = $args['enabled'] ?? null; $enabled = is_bool( $raw ) ? $raw : ( ( is_string( $raw ) && $raw !== '' ) || is_int( $raw ) ? filter_var( $raw, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE ) : null ); if ( $enabled === null ) { return $this->err( 'MISSING_PARAM', 'Parameter enabled is required: false turns XML-RPC off, true turns it back on.', 'validation_failed' ); } $force = isset( $args['force'] ) && filter_var( $args['force'], FILTER_VALIDATE_BOOLEAN ); $disable = ! $enabled; $before = $this->xmlrpc_state(); if ( $disable && $before['needed_by'] && ! $force ) { return $this->err( 'XMLRPC_NEEDED', sprintf( 'Not turned off: %s is active and needs XML-RPC, so turning it off would break it. Pass force=true only if the site owner confirms.', implode( ', ', $before['needed_by'] ) ), 'validation_failed', 409 ); } $changed = AICOM_Xmlrpc::is_disabled_by_setting() !== $disable; if ( $dry_run ) { return $this->ok( array_merge( $before, [ 'dry_run' => true, 'would_change' => $changed ] ) ); } if ( $changed ) { AICOM_Xmlrpc::set_disabled( $disable ); } // Read back what visitors now get. $after = $this->xmlrpc_state(); $out = array_merge( $after, [ 'changed' => $changed, 'verified' => $after['disabled_by_setting'] === $disable ] ); if ( $enabled && $after['disabled_by_aicom_only_mode'] ) { $out['note'] = 'XML-RPC stays off: AICOM-only mode is on, and it turns XML-RPC off too (AICOM → Safety).'; } elseif ( $disable && $before['needed_by'] ) { $out['note'] = sprintf( 'Turned off although %s needs XML-RPC (forced).', implode( ', ', $before['needed_by'] ) ); } return $this->ok( $out, [ 'target_type' => 'option', 'target_id' => AICOM_Xmlrpc::OPTION ] ); } public function handle_plugins_list( array $args, array $key_record, bool $dry_run ): array { if ( ! function_exists( 'get_plugins' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } if ( ! empty( $args['force_refresh'] ) ) { delete_site_transient( 'update_plugins' ); wp_update_plugins(); } $all_plugins = get_plugins(); $active_plugins = (array) get_option( 'active_plugins', [] ); $update_cache = get_site_transient( 'update_plugins' ); $available = isset( $update_cache->response ) && is_array( $update_cache->response ) ? $update_cache->response : []; $plugins = []; foreach ( $all_plugins as $file => $data ) { $has_update = isset( $available[ $file ] ); $plugins[] = [ 'file' => $file, 'name' => $data['Name'], 'version' => $data['Version'], 'new_version' => $has_update ? ( $available[ $file ]->new_version ?? null ) : null, 'update_available' => $has_update, 'status' => in_array( $file, $active_plugins, true ) ? 'active' : 'inactive', 'author' => wp_strip_all_tags( $data['Author'] ?? '' ), ]; } $update_count = count( array_filter( $plugins, fn( $p ) => $p['update_available'] ) ); return $this->ok( [ 'plugins' => $plugins, 'total' => count( $plugins ), 'updates_available' => $update_count, 'cache_refreshed' => ! empty( $args['force_refresh'] ), ] ); } public function handle_plugins_update_all( array $args, array $key_record, bool $dry_run ): array { if ( ! function_exists( 'get_plugins' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } $update_cache = get_site_transient( 'update_plugins' ); $available = isset( $update_cache->response ) && is_array( $update_cache->response ) ? $update_cache->response : []; if ( empty( $available ) ) { return $this->ok( [ 'updated' => [], 'errors' => [], 'total_updated' => 0, 'message' => 'No plugin updates available.', ] ); } // Restrict to specific plugin files if caller provided an include list $include = isset( $args['include'] ) && is_array( $args['include'] ) ? $args['include'] : []; if ( ! empty( $include ) ) { $available = array_filter( $available, fn( $file ) => in_array( $file, $include, true ), ARRAY_FILTER_USE_KEY ); } $all_plugins = get_plugins(); if ( $dry_run ) { $would_update = []; foreach ( $available as $file => $update_data ) { $would_update[] = [ 'file' => $file, 'name' => $all_plugins[ $file ]['Name'] ?? $file, 'from' => $all_plugins[ $file ]['Version'] ?? '?', 'to' => $update_data->new_version ?? '?', ]; } return $this->ok( [ 'dry_run' => true, 'would_update' => $would_update, 'count' => count( $would_update ), ] ); } if ( ! class_exists( 'Plugin_Upgrader' ) ) { require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php'; } // WP_Filesystem must be initialised for the upgrader to write files if ( ! function_exists( 'WP_Filesystem' ) ) { require_once ABSPATH . 'wp-admin/includes/file.php'; } WP_Filesystem(); $results = []; $error_count = 0; foreach ( $available as $file => $update_data ) { $from = $all_plugins[ $file ]['Version'] ?? '?'; $to = $update_data->new_version ?? '?'; $name = $all_plugins[ $file ]['Name'] ?? $file; // Automatic_Upgrader_Skin runs silently — same skin used by WP background auto-updates $upgrader = new Plugin_Upgrader( new Automatic_Upgrader_Skin() ); $result = $upgrader->upgrade( $file ); $success = $result === true; if ( ! $success ) { $error_count++; } $results[] = [ 'file' => $file, 'name' => $name, 'from' => $from, 'to' => $to, 'success' => $success, 'error' => $success ? null : ( is_wp_error( $result ) ? $result->get_error_message() : (string) $result ), ]; } wp_clean_plugins_cache( true ); return $this->ok( [ 'updated' => array_values( array_filter( $results, fn( $r ) => $r['success'] ) ), 'errors' => array_values( array_filter( $results, fn( $r ) => ! $r['success'] ) ), 'total_updated' => count( $results ) - $error_count, 'total_errors' => $error_count, ], [ 'target_type' => 'plugins', 'target_id' => 0, 'summary' => [ 'updated' => count( $results ) - $error_count ] ] ); } /** * Normalize a date string to MySQL format (YYYY-MM-DD HH:MM:SS). * Accepts MySQL format directly or ISO 8601 with T separator. * Returns null if the value cannot be parsed as a valid date. */ private function normalize_post_date( string $raw ): ?string { $raw = trim( $raw ); // Normalize ISO 8601 T separator → space $normalized = str_replace( 'T', ' ', $raw ); // Strip timezone suffix (Z, +HH:MM, -HH:MM) — WP stores local time $normalized = preg_replace( '/[\+\-]\d{2}:\d{2}$|Z$/', '', $normalized ); $normalized = trim( $normalized ); // Validate with DateTime — rejects invalid dates like 2026-02-30 $dt = \DateTime::createFromFormat( 'Y-m-d H:i:s', $normalized ); if ( $dt && $dt->format( 'Y-m-d H:i:s' ) === $normalized ) { return $normalized; } // Accept date-only input, default to midnight $dt = \DateTime::createFromFormat( 'Y-m-d', $normalized ); if ( $dt && $dt->format( 'Y-m-d' ) === $normalized ) { return $normalized . ' 00:00:00'; } return null; } }