*/
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(
' '
. '%2$s'
. '%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 '