array( 'users' => true, 'index' => true, 'ai_guest' => true, 'batch' => true, 'ai_off' => false, 'app_readonly' => false, 'app_passwords_off' => false, 'xmlrpc' => false, 'hide_discovery' => false, ), 'ai_namespaces' => array( 'mcp', 'wp-abilities' ), 'learn' => true, ); } /** * Decide on a request. * * @param array $request route, method, roles (empty for guests), auth (none|cookie|app_password). * @param array $settings Settings as returned by defaults(). * @return array allow (bool), reason (string). */ public static function decide( array $request, array $settings ) { $route = isset( $request['route'] ) ? (string) $request['route'] : '/'; // A guest has no login. Without the logged_in flag (pure tests), no roles means guest. $guest = isset( $request['logged_in'] ) ? empty( $request['logged_in'] ) : empty( $request['roles'] ); $doors = isset( $settings['doors'] ) ? (array) $settings['doors'] : array(); $auth = isset( $request['auth'] ) ? (string) $request['auth'] : 'none'; $method = isset( $request['method'] ) ? strtoupper( (string) $request['method'] ) : 'GET'; $machine = 'cookie' !== $auth; if ( $machine && ! empty( $doors['ai_off'] ) && self::is_ai( $route, $settings ) ) { return array( 'allow' => false, 'reason' => 'door_ai_off' ); } if ( 'app_password' === $auth && ! empty( $doors['app_readonly'] ) && ! in_array( $method, array( 'GET', 'HEAD', 'OPTIONS' ), true ) ) { return array( 'allow' => false, 'reason' => 'door_app_readonly' ); } if ( $guest && ! empty( $doors['users'] ) && self::matches( '/wp/v2/users*', $route ) ) { return array( 'allow' => false, 'reason' => 'door_users' ); } if ( $guest && ! empty( $doors['ai_guest'] ) && self::is_ai( $route, $settings ) ) { return array( 'allow' => false, 'reason' => 'door_ai_guest' ); } if ( $guest && ! empty( $doors['batch'] ) && self::matches( '/batch/v1', $route ) ) { return array( 'allow' => false, 'reason' => 'door_batch' ); } if ( $guest && ! empty( $doors['index'] ) && self::is_index( $request, $route ) ) { return array( 'allow' => false, 'reason' => 'door_index' ); } if ( $guest ) { return isset( $settings['guest'] ) ? self::decide_list( (array) $settings['guest'], $route, 'guest' ) : array( 'allow' => true, 'reason' => 'default' ); } $roles = array_map( 'strval', (array) $request['roles'] ); if ( in_array( 'administrator', $roles, true ) || ! empty( $request['super_admin'] ) ) { return array( 'allow' => true, 'reason' => 'admin' ); } $policies = isset( $settings['roles'] ) ? (array) $settings['roles'] : array(); $denied = null; foreach ( $roles as $role ) { if ( ! isset( $policies[ $role ] ) ) { return array( 'allow' => true, 'reason' => 'default' ); } $result = self::decide_list( (array) $policies[ $role ], $route, 'role' ); if ( $result['allow'] ) { return $result; } $denied = $result; } return null === $denied ? array( 'allow' => true, 'reason' => 'default' ) : $denied; } /** * Apply a list policy: allow list beats deny list, both beat the default. * * @param array $policy default (allow|deny), allow (patterns), deny (patterns). * @param string $route Route. * @param string $prefix Reason prefix. * @return array */ private static function decide_list( array $policy, $route, $prefix ) { foreach ( isset( $policy['allow'] ) ? (array) $policy['allow'] : array() as $pattern ) { if ( self::matches( $pattern, $route ) ) { return array( 'allow' => true, 'reason' => $prefix . '_allow', 'pattern' => $pattern ); } } foreach ( isset( $policy['deny'] ) ? (array) $policy['deny'] : array() as $pattern ) { if ( self::matches( $pattern, $route ) ) { return array( 'allow' => false, 'reason' => $prefix . '_deny', 'pattern' => $pattern ); } } $allow = ! isset( $policy['default'] ) || 'deny' !== $policy['default']; return array( 'allow' => $allow, 'reason' => $prefix . '_default' ); } /** * Whether the route belongs to an AI access namespace (MCP adapter, Abilities API). * * @param string $route Route. * @param array $settings Settings. * @return bool */ public static function is_ai( $route, array $settings ) { $namespaces = isset( $settings['ai_namespaces'] ) ? (array) $settings['ai_namespaces'] : array(); foreach ( $namespaces as $ns ) { $ns = '/' . trim( (string) $ns, '/' ); if ( 0 === strcasecmp( $route, $ns ) || 0 === stripos( $route, $ns . '/' ) ) { return true; } } return false; } /** * Whether the route lists routes: the REST index or the root of a namespace. * * The WordPress layer passes namespace_root when it knows the registered namespaces; * without it, a route like /name/v1 counts as a namespace root. * * @param array $request Request. * @param string $route Route. * @return bool */ private static function is_index( array $request, $route ) { if ( '/' === $route || '' === $route ) { return true; } if ( isset( $request['namespace_root'] ) ) { return (bool) $request['namespace_root']; } return (bool) preg_match( '#^/[^/]+/v?\d+(\.\d+)?/?$#i', $route ); } /** * Match a route against a pattern. * * A pattern without * matches the route itself and everything below it * (contact-form-7/v1 matches /contact-form-7/v1/contact-forms/5). * matches any characters. * * @param string $pattern Pattern, leading slash optional. * @param string $route Route. * @return bool */ public static function matches( $pattern, $route ) { $pattern = '/' . ltrim( trim( (string) $pattern ), '/' ); if ( '/' === $pattern ) { return '/' === $route; } if ( false === strpos( $pattern, '*' ) ) { $pattern = rtrim( $pattern, '/' ); return 0 === strcasecmp( $route, $pattern ) || 0 === stripos( $route, $pattern . '/' ); } $regex = '#^' . str_replace( '\*', '.*', preg_quote( $pattern, '#' ) ) . '$#i'; return (bool) preg_match( $regex, $route ); } }