*/ private array $section_hooks = array(); /** * The queue depth, asked for once per request. */ private ?int $count = null; /** * Lazy providers for the approval-queue depth and the kill switch state. * * Closures rather than the repositories: each is only wanted while * building the menu or the page data, and the page stays testable * without a database. * * @param (\Closure(): int)|null $pending_count Queue-depth provider. * @param (\Closure(): array{engaged: bool, network: bool})|null $kill_switch Kill switch state provider. */ public function __construct( private readonly ?\Closure $pending_count = null, private readonly ?\Closure $kill_switch = null ) { } /** * Hook registration. */ public function register(): void { add_action( 'admin_menu', array( $this, 'register_menu' ) ); add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) ); } /** * Register the top-level menu and one submenu page per section. The * ledger's item repeats the parent's slug, so it is the parent's page * and WordPress does not add a copy of the parent as well. */ public function register_menu(): void { $title = __( 'Agent Firewall', 'agentic-daisy-ai-agent-firewall' ); add_menu_page( $title, $title . $this->pending_bubble(), 'manage_options', self::SLUG, array( $this, 'render' ), 'dashicons-shield', 81 ); foreach ( Sections::all() as $id => $label ) { $hook = add_submenu_page( self::SLUG, $label, 'pending' === $id ? $label . $this->pending_bubble() : $label, 'manage_options', Sections::slug( $id ), array( $this, 'render' ) ); if ( is_string( $hook ) && '' !== $hook ) { $this->section_hooks[ $hook ] = $id; } } add_action( 'load-' . self::SCREEN_HOOK, array( $this, 'redirect_old_tab' ) ); } /** * Send a link made before the split (`&tab=pending`) to its section's * page: emails, webhooks and digests already sent carry them. */ public function redirect_old_tab(): void { $url = $this->old_tab_redirect(); if ( null === $url ) { return; } wp_safe_redirect( $url ); exit; } /** * Where an old `&tab=` link on the main page belongs, or null when the * main page is right. */ public function old_tab_redirect(): ?string { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Navigation only: picks which page to show, changes nothing. $tab = isset( $_GET['tab'] ) && is_string( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : ''; $id = Sections::redirect_for_tab( $tab ); return null === $id ? null : Sections::url( $id ); } /** * The approval-queue depth, from the provider once per request. */ private function pending_count(): int { if ( null === $this->count ) { $this->count = null === $this->pending_count ? 0 : ( $this->pending_count )(); } return $this->count; } /** * The approval-queue bubble appended to the menu label. * * Deliberately the markup core uses for comments awaiting moderation, so * it inherits the admin color scheme and reads as the signal admins * already know. Empty queue, empty string: absence is the all-clear, and * a "(0)" would only be noise to dismiss. */ private function pending_bubble(): string { $count = $this->pending_count(); if ( $count < 1 ) { return ''; } $display = $count > self::COUNT_DISPLAY_CAP ? sprintf( /* translators: %s: capped number of queued actions, e.g. "99". */ __( '%s+', 'agentic-daisy-ai-agent-firewall' ), number_format_i18n( self::COUNT_DISPLAY_CAP ) ) : number_format_i18n( $count ); // The digit is decoration for assistive tech; this sentence carries // the meaning, and the real number even when the badge is capped. $label = sprintf( /* translators: %s: number of agent actions waiting for approval. */ _n( '%s action awaiting approval', '%s actions awaiting approval', $count, 'agentic-daisy-ai-agent-firewall' ), number_format_i18n( $count ) ); return sprintf( ' ' . '' . '%3$s', $count, esc_html( $display ), esc_html( $label ) ); } /** * Enqueue the built SPA bundle; only on the firewall's own pages. * * @param string $hook_suffix Current admin screen hook. */ public function enqueue_assets( string $hook_suffix ): void { $section = $this->section_hooks[ $hook_suffix ] ?? ( self::SCREEN_HOOK === $hook_suffix ? 'ledger' : null ); if ( null === $section ) { return; } $this->enqueue_bundle( $section ); } /** * Enqueue the admin bundle, its data and every premium bundle, on * whichever screen calls it - the site screen, or the Agency network * screen (AF-22.2c), whose own bundle mounts into its own root. * * @param string $section The section this page shows; none on the * network screen. */ public function enqueue_bundle( string $section = '' ): void { $asset_file = ADAF_PLUGIN_PATH . 'build/index.asset.php'; if ( ! file_exists( $asset_file ) ) { return; } $asset = require $asset_file; if ( ! is_array( $asset ) ) { return; } wp_enqueue_script( 'agentic-daisy-ai-agent-firewall-admin', ADAF_PLUGIN_URL . 'build/index.js', isset( $asset['dependencies'] ) && is_array( $asset['dependencies'] ) ? $asset['dependencies'] : array(), isset( $asset['version'] ) && is_string( $asset['version'] ) ? $asset['version'] : ADAF_VERSION, true ); // Without this the admin SPA's own __() strings never receive the // wp.org language pack, so a translated site still gets an English // firewall UI. No path argument: packs for a directory-hosted plugin // install into WP_LANG_DIR/plugins, which is where WordPress looks // when none is given. wp_set_script_translations( 'agentic-daisy-ai-agent-firewall-admin', 'agentic-daisy-ai-agent-firewall' ); // What the SPA may show, built from FeatureMap and failing closed // (ADR-14 §2). Deliberately no upgrade URL: the firewall screens carry // no upsell (ADR-14 §4); the Freemius menu item is the only one. wp_localize_script( self::SCRIPT_HANDLE, 'adafAdmin', array( 'features' => adaf_feature_flags(), 'licenseTier' => adaf_license_tier(), // Whether EVALUATE_AI rules are reviewed by the AI evaluator // first, so the rule builder can say what they do on this // site (AF-21.5). The filter is the one the pipeline asks; // nothing answers it in the free build. 'aiReviewActive' => true === apply_filters( 'adaf_ai_review_active', false ), // Network-aware screens ask about the network only on one // (ADR-16). 'isMultisite' => is_multisite(), ) + $this->page_data( $section ) ); $this->enqueue_premium_bundles(); // Only enqueue the stylesheet the build actually produced; a missing // file would otherwise be requested on every admin page load. if ( file_exists( ADAF_PLUGIN_PATH . 'build/style-index.css' ) ) { wp_enqueue_style( 'agentic-daisy-ai-agent-firewall-admin', ADAF_PLUGIN_URL . 'build/style-index.css', array(), isset( $asset['version'] ) && is_string( $asset['version'] ) ? $asset['version'] : ADAF_VERSION ); } } /** * What the app needs to know about this page: which section it shows, * where every section lives, and what the shell would otherwise ask the * REST API for on every page - the queue depth and the kill switch. * * @param string $section The section shown. * @return array */ private function page_data( string $section ): array { $sections = array(); foreach ( Sections::all() as $id => $label ) { $sections[] = array( 'id' => $id, 'label' => $label, 'url' => Sections::url( $id ), ); } $data = array( 'section' => $section, 'sections' => $sections, 'pendingCount' => $this->pending_count(), ); if ( null !== $this->kill_switch ) { $data['lockdown'] = ( $this->kill_switch )(); } return $data; } /** * Enqueue every premium bundle this build has (AF-19.5). * * Premium UI is compiled to its own build/premium-*.js entries, which the * free build is stripped of, so on the free build nothing matches here. * Each depends on the admin bundle for React, adafAdmin and the apiFetch * middleware, and renders only what its feature flag allows. A bundle * without its asset manifest is skipped rather than loaded without its * dependencies. */ private function enqueue_premium_bundles(): void { $bundles = glob( ADAF_PLUGIN_PATH . 'build/premium-*.js' ); foreach ( false === $bundles ? array() : $bundles as $bundle ) { $name = basename( $bundle, '.js' ); $asset_file = ADAF_PLUGIN_PATH . 'build/' . $name . '.asset.php'; if ( ! file_exists( $asset_file ) ) { continue; } $asset = require $asset_file; if ( ! is_array( $asset ) ) { continue; } $handle = 'agentic-daisy-ai-agent-firewall-' . $name; wp_enqueue_script( $handle, ADAF_PLUGIN_URL . 'build/' . $name . '.js', array_merge( isset( $asset['dependencies'] ) && is_array( $asset['dependencies'] ) ? $asset['dependencies'] : array(), array( self::SCRIPT_HANDLE ) ), isset( $asset['version'] ) && is_string( $asset['version'] ) ? $asset['version'] : ADAF_VERSION, true ); wp_set_script_translations( $handle, 'agentic-daisy-ai-agent-firewall' ); } } /** * Render the SPA mount point. */ public function render(): void { echo '
'; echo esc_html__( 'Loading Agent Firewall…', 'agentic-daisy-ai-agent-firewall' ); echo '
'; } }