'ids', 'number' => 0, ) ); foreach ( $site_ids as $site_id ) { switch_to_blog( (int) $site_id ); aafm_install_activity_log(); aafm_install_oauth_tables(); restore_current_blog(); } return; } aafm_install_activity_log(); aafm_install_oauth_tables(); } register_activation_hook( AAFM_PLUGIN_FILE, 'aafm_activate' ); /** * Create the plugin tables for a site added after a network activation. * * The activation loop above only covers sites that exist when the plugin is * activated; core fires wp_initialize_site for every site created later. Gated on * the plugin being network-active, and hooked at priority 100 - after core's own * wp_initialize_site() initializer at 10 has built the new site's base tables and * options, which the installers need. * * @param WP_Site|mixed $new_site The site object core just created. * @return void */ function aafm_initialize_new_site_tables( $new_site ): void { if ( ! $new_site instanceof WP_Site ) { return; } if ( ! function_exists( 'is_plugin_active_for_network' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } if ( ! is_plugin_active_for_network( AAFM_PLUGIN_BASENAME ) ) { return; } switch_to_blog( (int) $new_site->blog_id ); aafm_install_activity_log(); aafm_install_oauth_tables(); restore_current_blog(); } add_action( 'wp_initialize_site', 'aafm_initialize_new_site_tables', 100 ); /** * Schedule the daily OAuth cleanup event on activation, if not already scheduled. * * The event fires the `aafm_oauth_cleanup` action (wired at file scope below via * add_action( 'aafm_oauth_cleanup', … )), which prunes expired codes and dead * tokens and reaps abandoned DCR clients. * * Runs on activation and self-heals on admin_init and rest_api_init. A network * activation fires the activation hook once, on the activation blog only, so * subsites would otherwise never schedule this event; WP-Cron is per-site, and * without the reaper the public DCR endpoint could grow the clients table * without bound on any subsite that turns on OAuth. The admin_init heal also * covers a single-site install that predates this event and was updated in * place without a reactivation; the rest_api_init heal covers a subsite serving * only REST/MCP traffic. wp_next_scheduled() keeps every path idempotent. * * @return void */ function aafm_oauth_schedule_cleanup(): void { if ( ! wp_next_scheduled( 'aafm_oauth_cleanup' ) ) { wp_schedule_event( time(), 'daily', 'aafm_oauth_cleanup' ); } } register_activation_hook( AAFM_PLUGIN_FILE, 'aafm_oauth_schedule_cleanup' ); add_action( 'admin_init', 'aafm_oauth_schedule_cleanup' ); add_action( 'rest_api_init', 'aafm_oauth_schedule_cleanup' ); /** * Clear the scheduled OAuth cleanup event on deactivation. * * @return void */ function aafm_oauth_unschedule_cleanup(): void { wp_clear_scheduled_hook( 'aafm_oauth_cleanup' ); } register_deactivation_hook( AAFM_PLUGIN_FILE, 'aafm_oauth_unschedule_cleanup' ); // The cron event fires this action; the handler prunes expired OAuth artifacts. add_action( 'aafm_oauth_cleanup', 'aafm_oauth_cleanup' ); // PKCE helpers are pure functions with nothing to hook. require_once AAFM_PLUGIN_DIR . 'includes/oauth/pkce.php'; // HTTP helpers (transport policy, rate limiting) load before the client registry, // which calls into them and the audit log's aafm_source_ip(). require_once AAFM_PLUGIN_DIR . 'includes/oauth/http.php'; require_once AAFM_PLUGIN_DIR . 'includes/oauth/clients.php'; require_once AAFM_PLUGIN_DIR . 'includes/oauth/grants.php'; // OAuth lifecycle audit logging: one activity-log row per register/authorize/token/refresh/revoke // event (non-secret context only). Writes through the audit log module, required earlier. require_once AAFM_PLUGIN_DIR . 'includes/oauth/audit.php'; // Authorization codes: hashed storage, 60-second TTL, atomic one-time redemption. require_once AAFM_PLUGIN_DIR . 'includes/oauth/codes.php'; // Access/refresh token manager: hashed storage, refresh rotation, reuse detection. require_once AAFM_PLUGIN_DIR . 'includes/oauth/tokens.php'; // When a WordPress user is deleted, clean up the OAuth consents, tokens and codes they // leave behind rather than letting them go orphaned. See aafm_oauth_cleanup_deleted_user() // for why 'deleted_user' is the chosen hook and what multisite scope this does and does not // cover. add_action( 'deleted_user', 'aafm_oauth_cleanup_deleted_user' ); // Discovery documents: the two .well-known metadata files served before REST auth. require_once AAFM_PLUGIN_DIR . 'includes/oauth/discovery.php'; add_action( 'parse_request', 'aafm_oauth_maybe_serve_well_known', 0 ); // Seed the OAuth toggles at activation (add_option only - never clobbers a saved value): OAuth off, // dynamic client registration on. register_activation_hook( AAFM_PLUGIN_FILE, 'aafm_oauth_seed_default_options' ); // Flag a genuinely new install so the first-activation admin-menu pointer shows once. The callback // lives in includes/admin/onboarding-pointer.php, required here at top level so it is defined when // the activation hook fires: activating a plugin includes its main file and runs the activation hook // in the SAME request without ever firing plugins_loaded, so a callback defined only inside // aafm_bootstrap() (hooked on plugins_loaded) would be an undefined function - fatal on activation. // The matching require_once inside aafm_bootstrap() then no-ops. The callback uses add_option so a // later reactivation never re-arms it; per-user dismissal is tracked in the core dismissed-pointers // meta. require_once AAFM_PLUGIN_DIR . 'includes/admin/onboarding-pointer.php'; register_activation_hook( AAFM_PLUGIN_FILE, 'aafm_quickconnect_activate_menu_pointer' ); // One-time upgrade safety: an install updated in place from a pre-off-by-default version // holds no stored toggle row and ran on the old on-by-default reader, so the new // fail-closed default would disable its OAuth surface (and any live connection) on update. // Preserve the prior state once, early, before any request-time toggle read; fresh installs // (seeded '0' at activation) stay off. See aafm_oauth_preserve_toggle_on_upgrade(). add_action( 'plugins_loaded', 'aafm_oauth_preserve_toggle_on_upgrade', 1 ); // One-time DCR default-on adoption: dynamic client registration used to default off (the #90 // footgun that stopped ChatGPT and Claude connecting), so installs that predate this release hold // a stored '0'. Flip that old default on once, early, with its own guard. See // aafm_oauth_dcr_adopt_on_by_default(). add_action( 'plugins_loaded', 'aafm_oauth_dcr_adopt_on_by_default', 1 ); // Surface the transport's 401 challenge (resource_metadata) as a real // WWW-Authenticate header on the dispatched REST error response. add_filter( 'rest_post_dispatch', 'aafm_oauth_filter_rest_challenge', 10, 3 ); // Let browser-context (CORS) MCP clients read the OAuth challenge + MCP session // header off the response, and send the session/protocol headers back on follow-up // requests. Registered unconditionally and gated INSIDE each callback on the toggle, // so enabling OAuth at runtime takes effect on the same request; both no-op when off. add_filter( 'rest_exposed_cors_headers', 'aafm_oauth_filter_exposed_cors_headers' ); add_filter( 'rest_allowed_cors_headers', 'aafm_oauth_filter_allowed_cors_headers' ); // OAuth REST endpoints: dynamic client registration, token, and revocation. require_once AAFM_PLUGIN_DIR . 'includes/oauth/rest.php'; add_action( 'rest_api_init', 'aafm_oauth_register_rest_routes' ); // The same discovery documents under /wp-json, for hosts that never pass /.well-known/ to WordPress. add_action( 'rest_api_init', 'aafm_oauth_register_discovery_routes' ); // Re-shape core's malformed-JSON rejection into RFC 6749 on the three OAuth routes only; // every other route's rest_invalid_json response passes through untouched. add_filter( 'rest_post_dispatch', 'aafm_oauth_filter_malformed_json', 10, 3 ); // Bearer-token validator: resolve an OAuth access token to its approving user on // the same auth layer Application Passwords use, before the transport gate runs. require_once AAFM_PLUGIN_DIR . 'includes/oauth/validator.php'; // Priority 20 runs after cookie auth (10) and alongside core's Application // Password resolver. Ordering is not load-bearing for the frozen invariant: the // resolver returns early whenever a user is already set, so it can never preempt // an App Password (or any other) identity regardless of which runs first. We take // no position on rest_authentication_errors: a present-but-invalid OAuth token is // surfaced only through this resolver returning no user, never by attaching a // filter that could turn "no user resolved" into a hard failure on unrelated routes. add_filter( 'determine_current_user', 'aafm_oauth_resolve_current_user', 20 ); // In the normal routing order (the REST server built after the parse), registration on an MCP request // sees the approver (the same clear serve_request() makes, earlier). A handler other than the // adapter's own MCP handler that core matches on the MCP path is refused before its callback runs, // within the limits named at aafm_oauth_confine_bearer_to_mcp_handler(). The last priority keeps a // normal-priority rest_request_before_callbacks filter from undoing the refusal. add_action( 'rest_api_init', 'aafm_oauth_forget_anonymous_user_on_mcp_route', PHP_INT_MIN ); add_filter( 'rest_request_before_callbacks', 'aafm_oauth_confine_bearer_to_mcp_handler', PHP_INT_MAX, 3 ); // wp_kses allowlist helpers - loaded unconditionally so they are available to the // OAuth consent page (rendered on the front end, before aafm_bootstrap()). require_once AAFM_PLUGIN_DIR . 'includes/admin/kses.php'; // Authorization endpoint + consent screen: served off init at ?aafm_oauth=authorize. require_once AAFM_PLUGIN_DIR . 'includes/oauth/authorize.php'; require_once AAFM_PLUGIN_DIR . 'includes/oauth/consent-template.php'; add_action( 'init', 'aafm_oauth_handle_authorize' ); /** * Bootstraps the plugin once all plugins are loaded. * * @return void */ function aafm_bootstrap() { aafm_load_vendor_autoloader(); require_once AAFM_PLUGIN_DIR . 'includes/registry.php'; require_once AAFM_PLUGIN_DIR . 'includes/helpers.php'; require_once AAFM_PLUGIN_DIR . 'includes/wpml.php'; require_once AAFM_PLUGIN_DIR . 'includes/safety.php'; require_once AAFM_PLUGIN_DIR . 'includes/site-brief.php'; require_once AAFM_PLUGIN_DIR . 'includes/block-guard.php'; require_once AAFM_PLUGIN_DIR . 'includes/page-builder-guard.php'; require_once AAFM_PLUGIN_DIR . 'includes/allowlist.php'; require_once AAFM_PLUGIN_DIR . 'includes/class-aafm-registration-authority.php'; require_once AAFM_PLUGIN_DIR . 'includes/register.php'; require_once AAFM_PLUGIN_DIR . 'includes/class-aafm-rate-limited-ability.php'; require_once AAFM_PLUGIN_DIR . 'includes/server.php'; require_once AAFM_PLUGIN_DIR . 'includes/bridge.php'; require_once AAFM_PLUGIN_DIR . 'includes/default-server.php'; require_once AAFM_PLUGIN_DIR . 'includes/review-origin.php'; require_once AAFM_PLUGIN_DIR . 'includes/catalog.php'; require_once AAFM_PLUGIN_DIR . 'includes/integrations.php'; require_once AAFM_PLUGIN_DIR . 'includes/integration-manifest.php'; require_once AAFM_PLUGIN_DIR . 'includes/bootstrap.php'; // Rewrite four specific "not found" 404s on the MCP route to 200, so an unknown or // governance-disabled tool reads as an in-band error an agent can correct from, not as // the MCP session-terminated signal. -32005 (session not found) is deliberately left // out and keeps its 404. Registered after bootstrap.php, which is where // aafm_is_mcp_route() - the first thing this filter calls - is defined. add_filter( 'rest_post_dispatch', 'aafm_mcp_filter_governed_error_status', 10, 3 ); // Session-persistence guard: the adapter's SessionManager::create_session() returns a session id // without checking its update_user_meta() write succeeded, so a silent write failure hands the // client a valid-looking Mcp-Session-Id that its next request can't resolve (the "OAuth ok, then // can't connect" field report). This verifies the just-issued session is actually in the user's // store; when it is not, it strips the phantom header and rewrites the body to a JSON-RPC -32603 so // the client gets an honest, retryable error. Priority 11 so it runs AFTER the transport's own // priority-10 rest_post_dispatch closure has stamped the Mcp-Session-Id header, and registered // BEFORE aafm_log_mcp_transport_outcome() (also priority 11) so the -32603 it produces is logged as // a (transport) internal_error row. add_filter( 'rest_post_dispatch', 'aafm_mcp_guard_unpersisted_session', 11, 3 ); // Transport-visibility logging: write one (transport) activity row for an MCP-route JSON-RPC // error response, so a failed /mcp request is self-diagnosing (reached-WP-and-failed vs // never-reached-WP). Pure observability - reads the response, changes no status. Priority 11 so // it runs AFTER the governed-status rewrite above and records the final HTTP status the client sees. add_filter( 'rest_post_dispatch', 'aafm_log_mcp_transport_outcome', 11, 3 ); // The Connection Doctor's own probe: answered before any MCP code runs, only for a live single-use token. add_filter( 'rest_pre_dispatch', 'aafm_doctor_handle_probe', 1, 3 ); require_once AAFM_PLUGIN_DIR . 'includes/abilities/posts.php'; require_once AAFM_PLUGIN_DIR . 'includes/abilities/pages.php'; require_once AAFM_PLUGIN_DIR . 'includes/abilities/terms.php'; require_once AAFM_PLUGIN_DIR . 'includes/abilities/structure.php'; require_once AAFM_PLUGIN_DIR . 'includes/abilities/comments.php'; require_once AAFM_PLUGIN_DIR . 'includes/abilities/media.php'; require_once AAFM_PLUGIN_DIR . 'includes/abilities/users.php'; require_once AAFM_PLUGIN_DIR . 'includes/abilities/meta.php'; require_once AAFM_PLUGIN_DIR . 'includes/abilities/user-meta.php'; require_once AAFM_PLUGIN_DIR . 'includes/abilities/revisions.php'; require_once AAFM_PLUGIN_DIR . 'includes/abilities/search.php'; require_once AAFM_PLUGIN_DIR . 'includes/abilities/settings.php'; require_once AAFM_PLUGIN_DIR . 'includes/abilities/plugins.php'; require_once AAFM_PLUGIN_DIR . 'includes/abilities/activity-log.php'; require_once AAFM_PLUGIN_DIR . 'includes/abilities/blocks.php'; require_once AAFM_PLUGIN_DIR . 'includes/abilities/menus.php'; require_once AAFM_PLUGIN_DIR . 'includes/abilities/themes.php'; require_once AAFM_PLUGIN_DIR . 'includes/abilities/yoast.php'; require_once AAFM_PLUGIN_DIR . 'includes/abilities/rankmath.php'; require_once AAFM_PLUGIN_DIR . 'includes/abilities/aioseo.php'; require_once AAFM_PLUGIN_DIR . 'includes/abilities/slim-seo.php'; require_once AAFM_PLUGIN_DIR . 'includes/abilities/acf-integration.php'; require_once AAFM_PLUGIN_DIR . 'includes/abilities/woocommerce/_shared.php'; require_once AAFM_PLUGIN_DIR . 'includes/abilities/woocommerce/products.php'; require_once AAFM_PLUGIN_DIR . 'includes/abilities/woocommerce/variations.php'; require_once AAFM_PLUGIN_DIR . 'includes/abilities/woocommerce/attributes.php'; require_once AAFM_PLUGIN_DIR . 'includes/abilities/woocommerce/orders.php'; require_once AAFM_PLUGIN_DIR . 'includes/abilities/woocommerce/customers.php'; require_once AAFM_PLUGIN_DIR . 'includes/abilities/woocommerce/coupons.php'; require_once AAFM_PLUGIN_DIR . 'includes/abilities/woocommerce/shipping.php'; require_once AAFM_PLUGIN_DIR . 'includes/abilities/woocommerce/tax.php'; require_once AAFM_PLUGIN_DIR . 'includes/abilities/woocommerce/reports.php'; require_once AAFM_PLUGIN_DIR . 'includes/abilities/woocommerce/gateways.php'; require_once AAFM_PLUGIN_DIR . 'includes/abilities/tec/_shared.php'; require_once AAFM_PLUGIN_DIR . 'includes/abilities/tec/events.php'; require_once AAFM_PLUGIN_DIR . 'includes/abilities/tec/venues.php'; require_once AAFM_PLUGIN_DIR . 'includes/abilities/tec/organizers.php'; require_once AAFM_PLUGIN_DIR . 'includes/abilities/tec/tickets.php'; require_once AAFM_PLUGIN_DIR . 'includes/abilities/avada.php'; require_once AAFM_PLUGIN_DIR . 'includes/abilities/geodirectory.php'; add_action( 'wp_abilities_api_categories_init', 'aafm_register_categories' ); add_action( 'wp_abilities_api_init', 'aafm_register_enabled_abilities' ); // Bridged wrappers register at PHP_INT_MAX on the same init pass so EVERY foreign ability is // already in the registry when the bridge walks it - including ones a foreign plugin hooks at // a later-than-native priority (e.g. 20). A fixed low priority (11) would run before those and // silently fail to bridge them on every request. This still lands well before the MCP server // builds its tool list (mcp_adapter_init on rest_api_init:15), and after native registration. add_action( 'wp_abilities_api_init', 'aafm_register_enabled_bridged_abilities', PHP_INT_MAX ); // Registered unconditionally: admin_init only fires on admin requests (where // includes/admin/page.php is loaded), so this is behavior-identical to gating it // behind is_admin(), while remaining wired at plugin load for deterministic tests. add_action( 'admin_init', 'aafm_register_privacy_policy_content' ); // Same admin_init rationale: keeps the OAuth schema current on real upgrades // (the installer runs only when the recorded version is behind). Hooked on // rest_api_init as well, mirroring the activity-log wiring in includes/audit/log.php: // a headless site that auto-updates over cron and serves bearer traffic over REST may // never see an admin request, and its OAuth tables would stay on the old schema // forever. The version guard makes both hooks one option compare per request. add_action( 'admin_init', 'aafm_maybe_upgrade_oauth_tables' ); add_action( 'rest_api_init', 'aafm_maybe_upgrade_oauth_tables' ); // One-time backfill so a pre-marker install's agent user is recognised by the onboarding // checklist. Self-guarded by a stored flag; runs at most once. Defined in admin/connection.php. add_action( 'admin_init', 'aafm_backfill_agent_user_marker' ); require_once AAFM_PLUGIN_DIR . 'includes/admin/icons.php'; require_once AAFM_PLUGIN_DIR . 'includes/admin/notices.php'; require_once AAFM_PLUGIN_DIR . 'includes/admin/components.php'; require_once AAFM_PLUGIN_DIR . 'includes/admin/dashboard.php'; require_once AAFM_PLUGIN_DIR . 'includes/admin/review-inbox.php'; require_once AAFM_PLUGIN_DIR . 'includes/admin/missing-tools.php'; require_once AAFM_PLUGIN_DIR . 'includes/admin/connection.php'; require_once AAFM_PLUGIN_DIR . 'includes/admin/connection-cards.php'; require_once AAFM_PLUGIN_DIR . 'includes/admin/doctor.php'; require_once AAFM_PLUGIN_DIR . 'includes/admin/allowlist.php'; require_once AAFM_PLUGIN_DIR . 'includes/admin/quickconnect.php'; require_once AAFM_PLUGIN_DIR . 'includes/admin/onboarding-pointer.php'; require_once AAFM_PLUGIN_DIR . 'includes/admin/review-request.php'; require_once AAFM_PLUGIN_DIR . 'includes/admin/activity-log.php'; require_once AAFM_PLUGIN_DIR . 'includes/admin/notifications.php'; require_once AAFM_PLUGIN_DIR . 'includes/admin/disclosures.php'; require_once AAFM_PLUGIN_DIR . 'includes/admin/page.php'; require_once AAFM_PLUGIN_DIR . 'includes/admin/settings.php'; require_once AAFM_PLUGIN_DIR . 'includes/admin/integrations.php'; require_once AAFM_PLUGIN_DIR . 'includes/admin/bridge-directory.php'; if ( is_admin() ) { add_action( 'admin_menu', 'aafm_register_admin_menu' ); add_filter( 'submenu_file', 'aafm_highlight_tab_submenu' ); add_filter( 'plugin_action_links_' . AAFM_PLUGIN_BASENAME, 'aafm_plugin_action_links' ); add_action( 'admin_enqueue_scripts', 'aafm_enqueue_admin_assets' ); add_action( 'wp_ajax_aafm_save_abilities', 'aafm_ajax_save_abilities' ); add_action( 'wp_ajax_aafm_save_abilities_page', 'aafm_ajax_save_abilities_page' ); add_action( 'wp_ajax_aafm_save_bridged_abilities', 'aafm_ajax_save_bridged_abilities' ); add_action( 'wp_ajax_aafm_save_post_types', 'aafm_ajax_save_post_types' ); add_action( 'wp_ajax_aafm_save_meta_keys', 'aafm_ajax_save_meta_keys' ); add_action( 'wp_ajax_aafm_save_denied_meta_keys', 'aafm_ajax_save_denied_meta_keys' ); add_action( 'wp_ajax_aafm_save_user_meta_keys', 'aafm_ajax_save_user_meta_keys' ); add_action( 'wp_ajax_aafm_save_term_meta_keys', 'aafm_ajax_save_term_meta_keys' ); add_action( 'wp_ajax_aafm_save_settings', 'aafm_ajax_save_settings' ); add_action( 'wp_ajax_aafm_clear_log', 'aafm_ajax_clear_log' ); add_action( 'wp_ajax_aafm_get_log_page', 'aafm_ajax_get_log_page' ); add_action( 'admin_post_aafm_export_log', 'aafm_handle_export_activity_log' ); add_action( 'wp_ajax_aafm_reset_plugin', 'aafm_ajax_reset_plugin' ); add_action( 'wp_ajax_aafm_create_agent_user', 'aafm_ajax_create_agent_user' ); add_action( 'wp_ajax_aafm_test_connection', 'aafm_ajax_test_connection' ); add_action( 'wp_ajax_aafm_run_doctor', 'aafm_ajax_run_doctor' ); add_action( 'wp_ajax_aafm_oauth_revoke_client', 'aafm_ajax_oauth_revoke_client' ); add_action( 'wp_ajax_aafm_oauth_revoke_grant', 'aafm_ajax_oauth_revoke_grant' ); add_action( 'wp_ajax_aafm_save_oauth_lifetimes', 'aafm_ajax_save_oauth_lifetimes' ); add_action( 'wp_ajax_aafm_oauth_update_grant', 'aafm_ajax_oauth_update_grant' ); add_action( 'wp_ajax_aafm_revoke_app_password', 'aafm_ajax_revoke_app_password' ); add_action( 'wp_ajax_aafm_set_pause_all', 'aafm_ajax_set_pause_all' ); add_action( 'wp_ajax_aafm_set_client_agent_identity', 'aafm_ajax_set_client_agent_identity' ); add_action( 'wp_ajax_aafm_save_allowlist', 'aafm_ajax_save_allowlist' ); add_action( 'wp_ajax_aafm_quickconnect_oauth', 'aafm_ajax_quickconnect_oauth' ); add_action( 'wp_ajax_aafm_quickconnect_finish', 'aafm_ajax_quickconnect_finish' ); add_action( 'wp_ajax_aafm_quickconnect_dismiss', 'aafm_ajax_quickconnect_dismiss' ); add_action( 'wp_ajax_aafm_review_request', 'aafm_ajax_review_request' ); add_action( 'wp_ajax_aafm_review_publish', 'aafm_ajax_review_publish' ); add_action( 'wp_ajax_aafm_review_discard', 'aafm_ajax_review_discard' ); // The same answer, arriving as a plain link when the notice's footer script never ran. add_action( 'admin_post_aafm_review_request', 'aafm_handle_review_request_post' ); add_action( 'wp_ajax_aafm_notify_test', 'aafm_ajax_notify_test' ); add_action( 'admin_post_aafm_notify_dismiss', 'aafm_handle_notify_dismiss' ); add_action( 'admin_notices', 'aafm_notify_admin_notice' ); add_action( 'admin_notices', 'aafm_render_review_request_notice' ); add_action( 'admin_notices', 'aafm_notice_omitted_abilities' ); add_action( 'admin_enqueue_scripts', 'aafm_maybe_enqueue_menu_pointer' ); } if ( defined( 'WP_CLI' ) && WP_CLI ) { WP_CLI::add_command( 'aafm catalog export', 'aafm_cli_catalog_export' ); } aafm_init_mcp(); } add_action( 'plugins_loaded', 'aafm_bootstrap' );