diff --git a/.gitmodules b/.gitmodules
index 59c95bea89e947f98ba61bf7f8e3e12e039789cd..a17826c40d12815d562853c5a940aa40a339ab71 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -16,3 +16,9 @@
 [submodule "library/matomo-php-tracker"]
 	path = library/matomo-php-tracker
 	url = http://git.afi-sa.fr/afi/matomo-php-tracker.git
+[submodule "library/phpseclib"]
+	path = library/phpseclib
+	url = https://git.afi-sa.net/afi/phpseclib.git
+[submodule "library/activitystreams"]
+	path = library/activitystreams
+	url = https://git.afi-sa.net/afi/activitystreams.git
diff --git a/VERSIONS_WIP/93553 b/VERSIONS_WIP/93553
new file mode 100644
index 0000000000000000000000000000000000000000..8da49c3bd45147c062b43fa549b27dfa873fabf1
--- /dev/null
+++ b/VERSIONS_WIP/93553
@@ -0,0 +1 @@
+ - ticket #93553 : Avis : Ajout d'une fédération des avis
\ No newline at end of file
diff --git a/application/modules/activitypub/controllers/ReviewController.php b/application/modules/activitypub/controllers/ReviewController.php
new file mode 100644
index 0000000000000000000000000000000000000000..eb25b048281f79b194e697800bf74bef89c38896
--- /dev/null
+++ b/application/modules/activitypub/controllers/ReviewController.php
@@ -0,0 +1,310 @@
+<?php
+/**
+ * Copyright (c) 2012-2017, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+require_once __DIR__ . '/../../../../library/activitystreams/autoload.php';
+
+use Patbator\ActivityStreams\Model\Join;
+use Patbator\ActivityStreams\Model\Leave;
+use Patbator\ActivityStreams\Model\Accept;
+use Patbator\ActivityStreams\Model\Reject;
+use Patbator\ActivityStreams\Model\CollectionPage;
+use Patbator\ActivityStreams\Stream;
+
+
+class Activitypub_ReviewController extends ZendAfi_Controller_Action {
+  use Trait_TimeSource;
+
+  public function preDispatch() {
+    parent::preDispatch();
+
+    $this->getHelper('ViewRenderer')->setNoRender();
+    $this->_log = null;//new Zend_Log(new Zend_Log_Writer_Stream(PATH_TEMP . 'activitypub_server.log'));
+  }
+
+
+  public function unavailableAction() {
+    $this->_response->setHttpResponseCode(503);
+  }
+
+
+  public function indexAction() {
+    if (Class_WebService_ActivityPub::MIME_TYPE != $this->_request->getHeader('Accept'))
+      return $this->_response->setHttpResponseCode(500);
+
+    $service = $this->_service()->asActor()
+
+                    ->inbox($this->_absoluteUrl(['module' => 'activitypub',
+                                                 'controller' => 'review',
+                                                 'action' => 'inbox']))
+
+                    ->outbox($this->_absoluteUrl(['module' => 'activitypub',
+                                                  'controller' => 'review',
+                                                  'action' => 'outbox']))
+
+                    ->publicKey($this->_absoluteUrl(['module' => 'activitypub',
+                                                     'controller' => 'review',
+                                                     'action' => 'pubkey']))
+      ;
+
+    $this->_activityResponseWith($service);
+  }
+
+
+  public function pubkeyAction() {
+    if (Class_WebService_ActivityPub::MIME_TYPE != $this->_request->getHeader('Accept'))
+      return $this->_response->setHttpResponseCode(500);
+
+    $key = (new Class_ActivityPub_PublicKey)
+      ->id($this->_absoluteUrl(['module' => 'activitypub',
+                                'controller' => 'review',
+                                'action' => 'pubkey']))
+
+      ->owner($this->_absoluteUrl(['module' => 'activitypub',
+                                   'controller' => 'review']))
+
+      ->publicKeyPem((new Class_Federation())->getPublicKey());
+
+    $this->_activityResponseWith($key);
+  }
+
+
+  public function inboxAction() {
+    if (!$this->_request->isPost()
+        || Class_WebService_ActivityPub::MIME_TYPE != $this->_request->getHeader('Content-Type'))
+      return $this->_response->setHttpResponseCode(500);
+
+    if (!$signature = $this->_request->getHeader('Signature'))
+      return $this->_response->setHttpResponseCode(400);
+
+    $rawBody = $this->_request->getRawBody();
+
+    if ((!$activity = Stream::fromJson($rawBody)->getRoot())
+        || (!$actor = $activity->actor()))
+      return $this->_response->setHttpResponseCode(400);
+
+    $service = (new Class_WebService_ActivityPub($actor->id()))
+      ->setLogger($this->_log);
+
+    if (!$service->validateRequest($this->_request))
+      return $this->_response->setHttpResponseCode(400);
+
+    if ($handler = Activitypub_ReviewController_GroupHandler::handlerFor($activity)) {
+      $response = $handler->handle()
+                          ->actor($this->_service());
+      return $this->_activityResponseWith($response);
+    }
+
+    $this->_response->setHttpResponseCode(400);
+  }
+
+
+  public function outboxAction() {
+    if (Class_WebService_ActivityPub::MIME_TYPE != $this->_request->getHeader('Accept'))
+      return $this->_response->setHttpResponseCode(500);
+
+    if ((!$auth = $this->_request->getHeader('Authorization'))
+        || (!$bearer = trim(str_replace('Bearer ', '', $auth))))
+      return $this->_response->setHttpResponseCode(403);
+
+    if ($key = $this->_getParam('key'))
+      return $this->_receiveRecordQueryFrom($key, $bearer);
+
+    if (Class_AdminVar::get('FEDERATION_COMMUNITY_SERVER') != $bearer)
+      return $this->_response->setHttpResponseCode(403);
+
+    $filters = ['abon_ou_bib' => Class_AvisNotice::TYPE_LIBRARIAN];
+    if (Class_AdminVar::isLibrarianReviewsModerated())
+      $filters['statut'] = Class_AvisNotice::STATUS_VALIDATED;
+
+    if ($from = $this->_getParam('from')) {
+      if (false === $from_date = DateTime::createFromFormat('Y-m-d', $from))
+        return $this->_response->setHttpResponseCode(400);
+
+      $filters['where'] = 'date_avis >= "' . $from_date->format('Y-m-d') . '"';
+    }
+
+    $this->_reviewPageFilteredBy($filters);
+  }
+
+
+  protected function _receiveRecordQueryFrom($key, $bearer) {
+    if (!Class_Federation_GroupMembership::findFirstBy(['actor_id' => $bearer,
+                                                        'group_name' => 'REVIEW_DISPLAY']))
+      return $this->_response->setHttpResponseCode(403);
+
+    $filters = ['clef_oeuvre' => $key, 'source_actor_id not' => null];
+
+    $this->_reviewPageFilteredBy($filters, ['key' => $key]);
+  }
+
+
+  protected function _reviewPageFilteredBy($filters, $url_params=[]) {
+    $items_by_page = 15;
+    $total = Class_AvisNotice::countBy($filters);
+    $page = $this->_getParam('page', 1);
+    $models = Class_AvisNotice::findAllBy(array_merge($filters,
+                                                      ['limitPage' => [$page, $items_by_page]]));
+    $items = array_map([$this, '_filterAttributes'], $models);
+
+    $activity = (new CollectionPage)
+      ->id($this->_absoluteUrl(array_merge(['module' => 'activitypub',
+                                            'controller' => 'review',
+                                            'action' => 'outbox',
+                                            'page' => $page],
+                                           $url_params)))
+      ->totalItems($total)
+      ->items($items);
+
+    $this->_activityResponseWith($activity);
+  }
+
+
+  protected function _filterAttributes($review) {
+    return ['id' => $review->getId(),
+            'clef_oeuvre' => $review->getClefOeuvre(),
+            'date_avis' => $review->getDateAvis(),
+            'date_mod' => $review->getDateMod(),
+            'note' => $review->getNote(),
+            'entete' => $review->getEntete(),
+            'avis' => $review->getAvis(),
+            'abon_ou_bib' => $review->getAbonOuBib(),
+            'source_author' => $review->getSourceAuthor()];
+  }
+
+
+  protected function _absoluteUrl($params) {
+    return Class_Url::absolute($params, null, true, false);
+  }
+
+
+  protected function _service() {
+    return (new Class_ActivityPub_Service())
+      ->id($this->_absoluteUrl(['module' => 'activitypub',
+                                'controller' => 'review']))
+      ->name((new Class_Federation())->getActorName());
+  }
+
+
+  protected function _activityResponseWith($activity) {
+    (new Class_WebService_ActivityPubServer($this->_absoluteUrl(['module' => 'activitypub',
+                                                                 'controller' => 'review'])))
+      ->setLogger($this->_log)
+      ->respondTo($this->_request, $this->_response, $activity);
+  }
+}
+
+
+
+
+abstract class Activitypub_ReviewController_GroupHandler {
+  protected $_group_name, $_actor_id, $_activity_id;
+
+  public static function handlerFor($activity) {
+    if (!$activity->object()
+        || (!$group_name = $activity->object()->name()))
+      return;
+
+    if ($activity instanceof Join)
+      return new Activitypub_ReviewController_JoinHandler($activity);
+
+    if ($activity instanceof Leave)
+      return new Activitypub_ReviewController_LeaveHandler($activity);
+  }
+
+
+  public function __construct($activity) {
+    $this->_group_name = $activity->object()->name();
+    $this->_actor_id = $activity->actor()->id();
+    $this->_activity_id = $activity->id();
+  }
+
+
+  protected function _findMembership() {
+    return Class_Federation_GroupMembership::findFirstBy($this->_membershipParams());
+  }
+
+
+  protected function _newMembership() {
+    return Class_Federation_GroupMembership::newInstance($this->_membershipParams());
+  }
+
+
+  protected function _membershipParams() {
+    return ['actor_id' => $this->_actor_id,
+            'group_name' => $this->_group_name];
+  }
+
+
+  protected function _reject($message) {
+    return (new Reject)->summary($message);
+  }
+
+
+  protected function _accept($member) {
+    return (new Accept)->id(Class_Url::absolute(['module' => 'activitypub',
+                                                 'controller' => 'review',
+                                                 'action' => 'group-membership',
+                                                 'id' => $member->getId()],
+                                                null, true, false));
+  }
+
+
+  public function handle() {
+    return Class_AdminVar::isFederationCommunityServer()
+      ? $this->_handle()
+      : $this->_reject('Service unavailable');
+  }
+
+
+  abstract protected function _handle();
+}
+
+
+
+
+class Activitypub_ReviewController_JoinHandler extends Activitypub_ReviewController_GroupHandler {
+  protected function _handle() {
+    if (!$member = $this->_findMembership())
+      $member = $this->_newMembership();
+
+    $response = ($member->isNew() && !$member->save())
+      ? $this->_reject(implode(', ', $member->getErrors()))
+      : $this->_accept($member);
+
+    return $response->object((new Join)->id($this->_activity_id));
+  }
+}
+
+
+
+
+class Activitypub_ReviewController_LeaveHandler extends Activitypub_ReviewController_GroupHandler {
+  protected function _handle() {
+    if ($member = $this->_findMembership())
+      $member->delete();
+
+    $response = (!$member)
+      ? $this->_reject('Cannot leave a group you did not join')
+      : $this->_accept($member);
+
+    return $response->object((new Leave)->id($this->_activity_id));
+  }
+}
\ No newline at end of file
diff --git a/application/modules/admin/controllers/FederationReviewsController.php b/application/modules/admin/controllers/FederationReviewsController.php
new file mode 100644
index 0000000000000000000000000000000000000000..524c2953410c79c4925b560a1a1b639f62a6b26f
--- /dev/null
+++ b/application/modules/admin/controllers/FederationReviewsController.php
@@ -0,0 +1,64 @@
+<?php
+/**
+ * Copyright (c) 2012-2017, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+class Admin_FederationReviewsController extends ZendAfi_Controller_Action {
+  public function indexAction() {
+    $this->view->titre = $this->_('Avis communautaires');
+  }
+
+
+  public function enableDisplayAction() {
+    $federation = Class_FederationReview::getInstance();
+    $message = $federation->enableDisplay()
+      ? $this->_('Affichage des avis communautaires activé')
+      : $this->_('Activation impossible : %s',
+                 $federation->getLastMessage());
+
+    $this->_helper->notify($message);
+    $this->_redirectToIndex();
+  }
+
+
+  public function disableDisplayAction() {
+    Class_FederationReview::getInstance()->disableDisplay();
+    $this->_helper->notify($this->_('Affichage des avis communautaires désactivé'));
+    $this->_redirectToIndex();
+  }
+
+
+  public function enableShareAction() {
+    $federation = Class_FederationReview::getInstance();
+    $message = $federation->enableShare()
+      ? $this->_('Partage des avis à la communauté activé')
+      : $this->_('Activation impossible : %s',
+                 $federation->getLastMessage());
+
+    $this->_helper->notify($message);
+    $this->_redirectToIndex();
+  }
+
+
+  public function disableShareAction() {
+    Class_FederationReview::getInstance()->disableShare();
+    $this->_helper->notify($this->_('Partage des avis à la communauté désactivé'));
+    $this->_redirectToIndex();
+  }
+}
\ No newline at end of file
diff --git a/application/modules/admin/controllers/JournalController.php b/application/modules/admin/controllers/JournalController.php
new file mode 100644
index 0000000000000000000000000000000000000000..3920b3ae52e864a8b159a83f2aaeb782458e9876
--- /dev/null
+++ b/application/modules/admin/controllers/JournalController.php
@@ -0,0 +1,28 @@
+<?php
+/**
+ * Copyright (c) 2012-2017, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+
+class Admin_JournalController extends ZendAfi_Controller_Action {
+
+  public function getPlugins() {
+    return ['ZendAfi_Controller_Plugin_ResourceDefinition_Journal'];
+  }
+}
\ No newline at end of file
diff --git a/application/modules/admin/views/scripts/federation-reviews/index.phtml b/application/modules/admin/views/scripts/federation-reviews/index.phtml
new file mode 100644
index 0000000000000000000000000000000000000000..40af77a22a6762bd503072187d4e3be3a0f5f208
--- /dev/null
+++ b/application/modules/admin/views/scripts/federation-reviews/index.phtml
@@ -0,0 +1,23 @@
+<?php
+echo $this->tag('p',
+                $this->_('Bokeh peut se connecter à un serveur communautaire de partage d\'avis.'));
+
+$this->disable_onchange = true;
+
+$federation_review = Class_FederationReview::getInstance();
+
+echo $federation_review->isDisplayEnabled()
+  ? $this->Button_Cancel((new Class_Entity())
+                         ->setText($this->_('Désactiver l\'affichage des avis communautaires'))
+                         ->setUrl($this->url(['action' => 'disable-display'])))
+  : $this->Button_New((new Class_Entity())
+                      ->setText($this->_('Activer l\'affichage des avis communautaires'))
+                      ->setUrl($this->url(['action' => 'enable-display'])));
+
+echo $federation_review->isShareEnabled()
+  ? $this->Button_Cancel((new Class_Entity())
+                         ->setText($this->_('Désactiver l\'envoi des avis de ce portail à la communauté'))
+                         ->setUrl($this->url(['action' => 'disable-share'])))
+  : $this->Button_New((new Class_Entity())
+                      ->setText($this->_('Activer l\'envoi des avis de ce portail à la communauté'))
+                      ->setUrl($this->url(['action' => 'enable-share'])));
diff --git a/application/modules/opac/controllers/AbonneController.php b/application/modules/opac/controllers/AbonneController.php
index e2cfe91a7c40d9d1fab994277c4427e2c0ca4b70..f068b135d0cc0f8ad958cd5619eb2d1228f53c90 100644
--- a/application/modules/opac/controllers/AbonneController.php
+++ b/application/modules/opac/controllers/AbonneController.php
@@ -181,20 +181,22 @@ class AbonneController extends ZendAfi_Controller_Action {
         $this->_user
           ->setPseudo($this->_request->getParam('avisSignature'))
           ->save();
+
         $this->_helper->notify($this->_('Votre avis à bien été enregistré'));
+
         return $this->_redirectClose($this->_getReferer());
       }
 
       $this->view->message = implode('.', $avis->getErrors());
     }
 
-
     if ($avis != null) {
       $this->view->id = $avis->getId();
       $this->view->avisEntete = $avis->getEntete();
       $this->view->avisTexte = $avis->getAvis();
       $this->view->avisNote = $avis->getNote();
     }
+
     $this->view->avisSignature = $this->_user->getNomAff();
     $this->view->id_notice = $id_notice;
   }
diff --git a/cosmogramme/sql/patch/patch_379.php b/cosmogramme/sql/patch/patch_379.php
new file mode 100644
index 0000000000000000000000000000000000000000..d3218fe18584f6b5a0ea813ee323145618e5866a
--- /dev/null
+++ b/cosmogramme/sql/patch/patch_379.php
@@ -0,0 +1,56 @@
+<?php
+$adapter = Zend_Db_Table_Abstract::getDefaultAdapter();
+
+try {
+  $adapter->query(
+                  'CREATE TABLE `journal` ('
+                  . '`id` int(11) unsigned not null auto_increment,'
+                  . '`type` varchar(255) not null,'
+                  . '`created_at` datetime not null,'
+                  . 'primary key (id),'
+                  . 'key `type` (`type`),'
+                  . 'key `created_at` (`created_at`)'
+                  . ') engine=MyISAM default charset=utf8'
+  );
+} catch(Exception $e) {}
+
+try {
+  $adapter->query(
+                  'CREATE TABLE `journal_detail` ('
+                  . '`id` int(11) unsigned not null auto_increment,'
+                  . '`journal_id` int(11) unsigned not null,'
+                  . '`type` varchar(255) not null,'
+                  . '`value` text not null,'
+                  . 'primary key (id),'
+                  . 'key `journal_id` (`journal_id`),'
+                  . 'key `type` (`type`)'
+                  . ') engine=MyISAM default charset=utf8'
+  );
+} catch(Exception $e) {}
+
+try {
+  $adapter->query(
+                  'CREATE TABLE `federation_group_membership` ('
+                  . '`id` int(11) unsigned not null auto_increment,'
+                  . '`group_name` varchar(255) not null,'
+                  . '`actor_id` varchar(255) not null,'
+                  . '`accepted_at` datetime not null,'
+                  . 'primary key (id),'
+                  . 'key `accepted_at` (`accepted_at`),'
+                  . 'key `group_name` (`group_name`),'
+                  . 'key `actor_id` (`actor_id`)'
+                  . ') engine=MyISAM default charset=utf8'
+  );
+} catch(Exception $e) {}
+
+try {
+  $adapter->query(
+                  'ALTER TABLE `notices_avis` '
+                  . 'ADD COLUMN `source_actor_id` varchar(255) null default null,'
+                  . 'ADD COLUMN `source_author` varchar(255) null default null,'
+                  . 'ADD COLUMN `source_primary` int(11) null default null,'
+                  . 'ADD KEY `source_actor_id` (`source_actor_id`),'
+                  . 'ADD KEY `source_author` (`source_author`),'
+                  . 'ADD KEY `source_primary` (`source_primary`)'
+  );
+} catch(Exception $e) {}
diff --git a/doc/extern_libs.org b/doc/extern_libs.org
index aad4fa9a7747d314b288a62166a10e0827f69741..c4fca33445cda79b2cad7c0a22be4437ef516400 100644
--- a/doc/extern_libs.org
+++ b/doc/extern_libs.org
@@ -45,4 +45,6 @@
 | icon slideshow by Javier Cabezas        | CCBY                     |               | editeur d'articles                                                   |                                                            | https://thenounproject.com/term/slideshow/6517/                                                                                |
 | PHP-Parser                              | BSD-3-Clauses            | -             | validation de fichiers php (formulaires de recherche)                |                                                            | https://github.com/nikic/PHP-Parser                                                                                            |
 | Jquery Notification                     | MIT ?                    |               | barre bleue de notification                                          | oui (barre en bas)                                         | n'existe plus                                                                                                                  |
+| activitystreams                         | MIT                      |               | Avis communautaires                                                  |                                                            | https://gitlab.com/patbator/activitystreams                                                                                    |
+| phpseclib                               | MIT                      |               | Avis communautaires                                                  | X ajout d'un autoload.php                                  | https://github.com/phpseclib/phpseclib                                                                                         |
 | leaflet.fullscreen                      | MIT                      |               | bouton plein écran sur la carte des bibliothèques                    |                                                            | https://github.com/brunob/leaflet.fullscreen                                                                                   |
diff --git a/library/Class/ActivityPub/PublicKey.php b/library/Class/ActivityPub/PublicKey.php
new file mode 100644
index 0000000000000000000000000000000000000000..c8bcd98424171f7bd6ca322a50ed815ac9207af3
--- /dev/null
+++ b/library/Class/ActivityPub/PublicKey.php
@@ -0,0 +1,32 @@
+<?php
+/**
+ * Copyright (c) 2012-2019, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+require_once __DIR__ . '/../../activitystreams/autoload.php';
+
+use \Patbator\ActivityStreams\Model\Base;
+
+
+class Class_ActivityPub_PublicKey extends Base {
+  protected $_attribs = ['id' => null,
+                         'type' => 'Key',
+                         'owner' => null,
+                         'publicKeyPem' => null];
+}
diff --git a/library/Class/ActivityPub/Service.php b/library/Class/ActivityPub/Service.php
new file mode 100644
index 0000000000000000000000000000000000000000..294d8a2611f79af6d4cfcb1624f5d1b7d56560e3
--- /dev/null
+++ b/library/Class/ActivityPub/Service.php
@@ -0,0 +1,37 @@
+<?php
+/**
+ * Copyright (c) 2012-2019, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+require_once __DIR__ . '/../../activitystreams/autoload.php';
+
+use \Patbator\ActivityStreams\Model\Service;
+
+
+class Class_ActivityPub_Service extends Service {
+  public function __construct() {
+    parent::__construct();
+    $this->_actor_attribs[] = 'publicKey';
+  }
+
+
+  public function type() {
+    return 'Service';
+  }
+}
diff --git a/library/Class/AdminVar.php b/library/Class/AdminVar.php
index f011c795fa9525f6dd2dff344e70c695145c64ef..7d3429f41812d860cacd015c1f89364a40647e38 100644
--- a/library/Class/AdminVar.php
+++ b/library/Class/AdminVar.php
@@ -136,6 +136,7 @@ class Class_AdminVarLoader extends Storm_Model_Loader {
        'static_map' => $this->_getStaticMapVars(),
        'search' => $this->_getSearchVars(),
        'file-manager' => $this->_getFileManagerVars(),
+       'federation-reviews' => $this->_getFederationVars(),
        'usergroup-agenda' => $this->_getRendezVousVars(),
        ];
   }
@@ -490,6 +491,16 @@ class Class_AdminVarLoader extends Storm_Model_Loader {
   }
 
 
+  protected function _getFederationVars() {
+    return ['FEDERATION_COMMUNITY_SERVER' => Class_AdminVar_Meta::newDefault($this->_('URL du serveur communautaire de la fédération, vide pour désactiver')),
+            'FEDERATION_ACTOR_NAME' => Class_AdminVar_Meta::newDefault($this->_('Nom d\'affichage de ce Bokeh dans la communautée. Si vide, l\'url sera utilisée')),
+            'FEDERATION_IS_COMMUNITY_SERVER' => Class_AdminVar_Meta::newOnOff($this->_('Ce Bokeh est un serveur communautaire')),
+            'FEDERATION_PUBKEY' => Class_AdminVar_Meta::newCryptKey($this->_('Clé publique permettant la vérification de signature des messages envoyés par ce Bokeh à la fédération')),
+            'FEDERATION_PRIVKEY' => Class_AdminVar_Meta::newCryptKey($this->_('Clé privée permettant de générer les signatures des messages envoyés par ce Bokeh à la fédération')),
+            ];
+  }
+
+
   protected function _getRendezVousVars() {
     return ['ENABLE_RENDEZ_VOUS' => Class_AdminVar_Meta::newOnOff($this->_('Activer la gestion des rendez-vous')),
             'NOTIFICATION_TEMPLATE_RENDEZ_VOUS' => Class_AdminVar_Meta::newEditor($this->_('Modèle utilisé pour les courriels de notifications de rendez-vous.'),
@@ -1052,6 +1063,26 @@ class Class_AdminVarLoader extends Storm_Model_Loader {
   }
 
 
+  public function isFederationCommunityServer() {
+    return Class_AdminVar::isModuleEnabled('FEDERATION_IS_COMMUNITY_SERVER');
+  }
+
+
+  public function isFederationEnabled() {
+    return ('' != trim(Class_AdminVar::get('FEDERATION_COMMUNITY_SERVER')));
+  }
+
+
+  public function isFederationServiceAvailable() {
+    return Class_AdminVar::isFederationEnabled() || Class_AdminVar::isFederationCommunityServer();
+  }
+
+
+  public function isLibrarianReviewsModerated() {
+    return Class_AdminVar::isModuleEnabled('MODO_AVIS_BIBLIO');
+  }
+
+
   public function isRendezVousEnabled() {
     return Class_AdminVar::isModuleEnabled('ENABLE_RENDEZ_VOUS');
   }
diff --git a/library/Class/AdminVar/Meta.php b/library/Class/AdminVar/Meta.php
index 8cf86285c80532fcdb85dcbfbc1d8e4b251e514f..17d0e632ea24c3b0ed4a9d7512c6f59d1f13de4b 100644
--- a/library/Class/AdminVar/Meta.php
+++ b/library/Class/AdminVar/Meta.php
@@ -21,6 +21,8 @@
 
 
 class Class_AdminVar_Meta {
+  use Trait_Translator;
+
   const
     TYPE_DEFAULT = 'default',
     TYPE_ENCODED_DATA = 'encoded-data',
@@ -28,7 +30,8 @@ class Class_AdminVar_Meta {
     TYPE_MULTI_INPUT = 'multi-input',
     TYPE_COMBO = 'combo',
     TYPE_RAW_TEXT = 'raw-text',
-    TYPE_EDITOR = 'editor';
+    TYPE_EDITOR = 'editor',
+    TYPE_CRYPT_KEY = 'crypt-key';
 
 
   protected
@@ -37,13 +40,14 @@ class Class_AdminVar_Meta {
 
 
   public static function __callStatic($name, $args) {
-    $mapping = ['Default' => self::TYPE_DEFAULT,
-                'EncodedData' => self::TYPE_ENCODED_DATA,
-                'OnOff' => self::TYPE_ON_OFF,
-                'MultiInput' => self::TYPE_MULTI_INPUT,
-                'Combo' => self::TYPE_COMBO,
-                'RawText' => self::TYPE_RAW_TEXT,
-                'Editor' => self::TYPE_EDITOR];
+    $mapping = ['Default' => static::TYPE_DEFAULT,
+                'EncodedData' => static::TYPE_ENCODED_DATA,
+                'OnOff' => static::TYPE_ON_OFF,
+                'MultiInput' => static::TYPE_MULTI_INPUT,
+                'Combo' => static::TYPE_COMBO,
+                'RawText' => static::TYPE_RAW_TEXT,
+                'Editor' => static::TYPE_EDITOR,
+                'CryptKey' => static::TYPE_CRYPT_KEY];
 
     $type_name = substr($name, 3);
 
@@ -65,8 +69,14 @@ class Class_AdminVar_Meta {
     $this->_type = $type;
     $this->_description = $description;
     $this->_attributes = $attributes;
+
     if (!isset($this->_attributes['role_level']))
       $this->_attributes['role_level'] = ZendAfi_Acl_AdminControllerRoles::ADMIN_PORTAIL;
+
+    if (!isset($this->_attributes['renderer']) && static::TYPE_CRYPT_KEY == $type)
+      $this->_attributes['renderer'] = function($value, $view) {
+        return $value ? $this->_('*** CLÉ MASQUÉE ***') : $this->_('*** VIDE ***');
+      };
   }
 
 
diff --git a/library/Class/Avis.php b/library/Class/Avis.php
index d47820117cabcd59b536cfbf920adf3c29a70d9d..72033c3c30c8b721200b9bc47f0733678fdf8315 100644
--- a/library/Class/Avis.php
+++ b/library/Class/Avis.php
@@ -123,6 +123,10 @@ class Class_Avis extends Storm_Model_Abstract {
   public function getFlags() {
     return -1;
   }
-}
 
-?>
\ No newline at end of file
+
+  /** API compatibility with Class_AvisNotice */
+  public function getSourceAuthor() {
+    return null;
+  }
+}
diff --git a/library/Class/AvisNotice.php b/library/Class/AvisNotice.php
index 2f7820332623152e0b5d46cff82ee6a3ef4e5a80..0de9864d578c9bb41f09a4ecb2c980e7f89ff1f5 100644
--- a/library/Class/AvisNotice.php
+++ b/library/Class/AvisNotice.php
@@ -239,9 +239,11 @@ class AvisNoticeLoader extends Storm_Model_Loader {
 
 class Class_AvisNotice  extends Storm_Model_Abstract {
   use Trait_Avis, Trait_Translator;
-  const NO_FLAG=0;
-  const ORPHAN_FLAG=1;
-  const ARCHIVED_FLAG=2;
+  const NO_FLAG = 0;
+  const ORPHAN_FLAG = 1;
+  const ARCHIVED_FLAG = 2;
+  const STATUS_VALIDATED = 1;
+  const TYPE_LIBRARIAN = 1;
 
   protected $_loader_class = 'AvisNoticeLoader';
   protected $_table_name = 'notices_avis';
@@ -520,6 +522,14 @@ class Class_AvisNotice  extends Storm_Model_Abstract {
   }
 
 
+  public function acceptJournalVisitor($visitor) {
+    if ($author = $this->getUserName())
+      $visitor->visitDetail('AUTHOR', $author);
+
+    $visitor->visitDetail('NEEDS_VALIDATION', $this->shouldBeModerated());
+  }
+
+
   public function isMine() {
     if (!$user = Class_Users::getIdentity())
       return false;
diff --git a/library/Class/Batch.php b/library/Class/Batch.php
index 1901cd757af0ef839c403b3720360012e0a93e70..ff78f8f303a901ba68983f6825847325b349fdfc 100644
--- a/library/Class/Batch.php
+++ b/library/Class/Batch.php
@@ -41,8 +41,10 @@ class Class_BatchLoader extends Storm_Model_Loader {
                         Class_Batch_BuildSiteMap::TYPE => new Class_Batch_BuildSiteMap(),
                         Class_Batch_PremierChapitre::TYPE => new Class_Batch_PremierChapitre(),
                         Class_Batch_NoveltyFacet::TYPE => new Class_Batch_NoveltyFacet(),
+                        Class_Batch_ExternalAgenda::TYPE => new Class_Batch_ExternalAgenda(),
+                        Class_Batch_FederationReviewHarvest::TYPE => new Class_Batch_FederationReviewHarvest(),
                         Class_Batch_SendRendezVousNotification::TYPE => new Class_Batch_SendRendezVousNotification(),
-                        Class_Batch_ExternalAgenda::TYPE => new Class_Batch_ExternalAgenda]);
+                       ]);
   }
 
 
diff --git a/library/Class/Batch/FederationReviewHarvest.php b/library/Class/Batch/FederationReviewHarvest.php
new file mode 100644
index 0000000000000000000000000000000000000000..aafffe230560216317ef4562cef0326a8a034048
--- /dev/null
+++ b/library/Class/Batch/FederationReviewHarvest.php
@@ -0,0 +1,78 @@
+<?php
+/**
+ * Copyright (c) 2012-2019, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+
+class Class_Batch_FederationReviewHarvest extends Class_Batch_Abstract {
+  const TYPE = 'FEDERATION_REVIEW_HARVEST';
+
+  public function getLabel() {
+    return $this->_('Moissonner les avis des portails Bokeh ayant activé le partage d\'avis');
+  }
+
+
+  public function isEnabled() {
+    return Class_AdminVar::isFederationCommunityServer();
+  }
+
+
+  public function run() {
+    foreach (Class_Federation_GroupMembership::findAllReviewShare() as $member)
+      $this->_harvestMember($member);
+  }
+
+
+  protected function _harvestMember($member) {
+    $service = new Class_WebService_ActivityPub($member->getActorId());
+    if (!$service->isValid())
+      return;
+
+    $journal_type = $member->getHarvestJournalType();
+    $from = ($last = Class_Journal::lastOf($journal_type))
+      ? $last->getCreatedAt()
+      : '';
+
+    $reviews = $service->harvestReviews($from);
+    if (!$reviews && $service->getLastMessage()) {
+      // todo log message
+      return;
+    }
+
+    $service_name = $service->name();
+    foreach($reviews as $review)
+      $this->_harvestReviewOf($review, $member, $service_name);
+
+    Class_Journal::factory($journal_type);
+  }
+
+
+  protected function _harvestReviewOf($review, $member, $name) {
+    $source_primary = $review['id'];
+    unset($review['id']);
+    $key_params = ['source_primary' => $source_primary,
+                   'source_actor_id' => $member->getActorId()];
+
+    if (!$model = Class_AvisNotice::findFirstBy($key_params))
+      $model = Class_AvisNotice::newInstance($key_params);
+
+    $review['source_author'] = $name;
+    $model->updateAttributes($review)->save();
+  }
+}
diff --git a/library/Class/Federation.php b/library/Class/Federation.php
new file mode 100644
index 0000000000000000000000000000000000000000..190dfade698e2a2b9d79007f246a3b288430bf96
--- /dev/null
+++ b/library/Class/Federation.php
@@ -0,0 +1,93 @@
+<?php
+/**
+ * Copyright (c) 2012-2019, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+require_once 'library/phpseclib/autoload.php';
+
+use \phpseclib\Crypt\RSA;
+
+class Class_Federation {
+  const
+    PUBLIC_KEY = 'publickey',
+    PRIVATE_KEY = 'privatekey';
+
+
+  public function getActorName() {
+    return ($name = Class_AdminVar::get('FEDERATION_ACTOR_NAME'))
+      ? $name
+      : Class_Url::absolute([], null, true);
+  }
+
+
+  public function getPrivateKey() {
+    return $this->_getOrGenerate('FEDERATION_PRIVKEY', 'privatePart');
+  }
+
+
+  public function getPublicKey() {
+    return $this->_getOrGenerate('FEDERATION_PUBKEY', 'publicPart');
+  }
+
+
+  protected function _getOrGenerate($var_name, $method_name) {
+    if ($key = Class_AdminVar::get($var_name))
+      return $key;
+
+    $pair = (new Class_Federation_KeyPair())->injectInAdminVars();
+    return call_user_func([$pair, $method_name]);
+  }
+}
+
+
+
+class Class_Federation_KeyPair {
+  const
+    PUBLIC_KEY = 'publickey',
+    PRIVATE_KEY = 'privatekey',
+    KEY_LENGTH = 2048;
+
+  protected
+    $_private_part,
+    $_public_part;
+
+
+  public function __construct() {
+    $key = (new RSA())->createKey(static::KEY_LENGTH);
+    $this->_private_part = $key[static::PRIVATE_KEY];
+    $this->_public_part = $key[static::PUBLIC_KEY];
+  }
+
+
+  public function injectInAdminVars() {
+    Class_AdminVar::set('FEDERATION_PUBKEY', $this->_public_part);
+    Class_AdminVar::set('FEDERATION_PRIVKEY', $this->_private_part);
+    return $this;
+  }
+
+
+  public function privatePart() {
+    return $this->_private_part;
+  }
+
+
+  public function publicPart() {
+    return $this->_public_part;
+  }
+}
diff --git a/library/Class/Federation/GroupMembership.php b/library/Class/Federation/GroupMembership.php
new file mode 100644
index 0000000000000000000000000000000000000000..e0bcf275a030294f87c743c02470d2235757050d
--- /dev/null
+++ b/library/Class/Federation/GroupMembership.php
@@ -0,0 +1,65 @@
+<?php
+/**
+ * Copyright (c) 2012-2019, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+class Class_Federation_GroupMembershipLoader extends Storm_Model_Loader {
+  public function findAllReviewShare() {
+    return Class_Federation_GroupMembership::findAllBy(['group_name' => Class_Federation_GroupMembership::REVIEW_SHARE]);
+  }
+}
+
+
+
+class Class_Federation_GroupMembership extends Storm_Model_Abstract {
+  use Trait_TimeSource, Trait_Translator;
+
+  const
+    REVIEW_DISPLAY = 'REVIEW_DISPLAY',
+    REVIEW_SHARE = 'REVIEW_SHARE';
+
+  protected $_table_name = 'federation_group_membership';
+  protected $_loader_class = 'Class_Federation_GroupMembershipLoader';
+
+  public function beforeSave() {
+    if ($this->isNew())
+      $this->setAcceptedAt($this->getCurrentDateTime());
+  }
+
+
+  public function afterDelete() {
+    if (static::REVIEW_SHARE == $this->getGroupName()) {
+      Class_AvisNotice::deleteBy(['source_actor_id' => $this->getActorId()]);
+      Class_Journal::deleteBy(['type' => $this->getHarvestJournalType()]);
+    }
+  }
+
+
+  public function validate() {
+    $this->checkAttribute('group_name',
+                          in_array($this->getGroupName(), [static::REVIEW_DISPLAY,
+                                                           static::REVIEW_SHARE]),
+                          $this->_('Cannot join unknown group %s', $this->getGroupName()));
+  }
+
+
+  public function getHarvestJournalType() {
+    return 'AP_REVIEW_HARVEST_' . $this->getId();
+  }
+}
diff --git a/library/Class/FederationReview.php b/library/Class/FederationReview.php
new file mode 100644
index 0000000000000000000000000000000000000000..0c9eb4b368d63a10ee37f15f6383877cf66ee455
--- /dev/null
+++ b/library/Class/FederationReview.php
@@ -0,0 +1,169 @@
+<?php
+/**
+ * Copyright (c) 2012-2019, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+
+class Class_FederationReview {
+  use Trait_Singleton, Trait_Translator, Trait_LastMessage;
+
+  const
+    FEATURE_SHARE   = 'SHARE',
+    FEATURE_DISPLAY = 'DISPLAY';
+
+
+  public function enableDisplay() {
+    return $this->_enableFeature(static::FEATURE_DISPLAY);
+  }
+
+
+  public function isDisplayEnabled() {
+    return $this->_isEnabled(static::FEATURE_DISPLAY);
+  }
+
+
+  public function disableDisplay() {
+    return $this->_disableFeature(static::FEATURE_DISPLAY);
+  }
+
+
+  public function enableShare() {
+    return $this->_enableFeature(static::FEATURE_SHARE);
+  }
+
+
+  public function isShareEnabled() {
+    return $this->_isEnabled(static::FEATURE_SHARE);
+  }
+
+
+  public function disableShare() {
+    return $this->_disableFeature(static::FEATURE_SHARE);
+  }
+
+
+  protected function _enableFeature($feature) {
+    if (!$this->_isKnownFeature($feature))
+      return $this->_error($this->_('Impossible d\'activer une fonctionnalité inconnue'));
+
+    if ($this->_isEnabled($feature))
+      return true;
+
+    if (!$community_server = $this->_getCommunityServer())
+      return $this->_error($this->_('L\'adresse du serveur communautaire n\'est pas paramétrée'));
+
+    $service = (new Class_WebService_ActivityPub($community_server));
+    //->setLogger(new Zend_Log(new Zend_Log_Writer_Stream(PATH_TEMP . 'activitypub_client.log')));
+
+    if (!$service->isValid())
+      return $this->_error($this->_('Le serveur communautaire paramétré n\'est pas compatible'));
+
+    if (!$service->join('REVIEW_' . $feature ))
+      return $this->_error($this->_('Une erreur est survenue : %s', $service->getLastMessage()));
+
+    Class_Journal::newInstance(['type' => 'AP_REVIEW_' . $feature . '_JOIN'])->save();
+    return true;
+  }
+
+
+  protected function _isEnabled($feature) {
+    if (!$this->_isKnownFeature($feature))
+      return false;
+
+    $join  = Class_Journal::lastOf('AP_REVIEW_' . $feature . '_JOIN');
+    $leave  = Class_Journal::lastOf('AP_REVIEW_' . $feature . '_LEAVE');
+
+    if (!$join)
+      return false;
+
+    return $leave
+      ? $join->isAfter($leave)
+      : true;
+  }
+
+
+  protected function _disableFeature($feature) {
+    if (!$this->_isKnownFeature($feature))
+      return $this->_error($this->_('Impossible de désactiver une fonctionnalité inconnue'));
+
+    if (!$this->_isEnabled($feature))
+      return;
+
+    Class_Journal::newInstance(['type' => 'AP_REVIEW_' . $feature . '_LEAVE'])->save();
+
+    if (!$community_server = $this->_getCommunityServer())
+      return;
+
+    $service = new Class_WebService_ActivityPub($community_server);
+    if (!$service->isValid())
+      return;
+
+    $service->leave('REVIEW_' . $feature);
+  }
+
+
+  protected function _isKnownFeature($feature) {
+    return in_array($feature, [static::FEATURE_DISPLAY, static::FEATURE_SHARE]);
+  }
+
+
+  /**
+   * @param $record Class_Notice
+   * @param $page int
+   * @return Class_Notice_ReviewsSet
+   */
+  public function getAvis($record, $page) {
+    if (!$this->isDisplayEnabled() || (!$service = $this->_getCommunityService()))
+      return Class_Notice_ReviewsSet::emptyInstance();
+
+    //$service->setLogger(new Zend_Log(new Zend_Log_Writer_Stream(PATH_TEMP . 'activitypub_client.log')));
+    if (!$collection_page = $service->reviews($record, $page))
+      return Class_Notice_ReviewsSet::emptyInstance();
+
+    $reviews = array_map([$this, '_receiveReview'], $collection_page->items());
+
+    return new Class_Notice_ReviewsSet($this->_('Avis communautaires'),
+                                       $reviews,
+                                       Class_AvisNotice::getNoteAverage($reviews),
+                                       $collection_page->totalItems(),
+                                       ceil($collection_page->totalItems()/5));
+  }
+
+
+  protected function _receiveReview($review) {
+    unset($review['id']);
+    return (new Class_AvisNotice())->updateAttributes($review);
+  }
+
+
+  protected function _getCommunityService() {
+    if (!$community_server = $this->_getCommunityServer())
+      return;
+
+    $service = new Class_WebService_ActivityPub($community_server);
+    return $service->isValid()
+      ? $service
+      : null;
+  }
+
+
+  protected function _getCommunityServer() {
+    return Class_AdminVar::getValueOrDefault('FEDERATION_COMMUNITY_SERVER');
+  }
+}
diff --git a/library/Class/HttpSignature.php b/library/Class/HttpSignature.php
new file mode 100644
index 0000000000000000000000000000000000000000..e42e25ef0ab3d7ed3d98256c4e351cb44ad894bd
--- /dev/null
+++ b/library/Class/HttpSignature.php
@@ -0,0 +1,194 @@
+<?php
+/**
+ * Copyright (c) 2012-2019, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+require_once 'library/phpseclib/autoload.php';
+
+
+class Class_HttpSignature extends Class_Entity {
+  const REQUEST_TARGET = '(request-target)';
+
+  protected $_logger;
+
+  public function setLogger($logger) {
+    $this->_logger = $logger;
+    return $this;
+  }
+
+
+  protected function _log($message) {
+    if ($this->_logger)
+      $this->_logger->info($message);
+
+    return $this;
+  }
+
+
+  public function __construct($content) {
+    $map = ['keyId', 'signature', 'headers', 'algorithm'];
+
+    $parts = explode(',', $content);
+    foreach($parts as $part) {
+      if (!preg_match('/([^=]+)="([^"]+)"/', $part, $matches))
+        continue;
+
+      if (!in_array(trim($matches[1]), $map))
+        continue;
+
+      $this->set(ucfirst(trim($matches[1])), trim($matches[2]));
+    }
+  }
+
+
+  public function getHeaders() {
+    $headers = $this->get('Headers', '');
+    if (static::REQUEST_TARGET != substr($headers, 0, strlen(static::REQUEST_TARGET)))
+      $headers = static::REQUEST_TARGET . ' ' . $headers;
+    return $headers;
+  }
+
+
+  public function verify($headers, $key) {
+    if ('hmac' == substr($this->getAlgorithm(), 0, 4))
+      return $this->_verifyHmac($headers, $key);
+
+    if ('rsa' == substr($this->getAlgorithm(), 0, 3))
+      return $this->_verifyRsa($headers, $key);
+  }
+
+
+  protected function _verifyRsa($headers, $key) {
+    $rsa = new \phpseclib\Crypt\RSA();
+    $rsa->loadKey($key);
+    $this->_log(sprintf('Will verify rsa : %s', $this->_messageFrom($headers, $this->getHeaders())));
+    return @$rsa->verify($this->_messageFrom($headers, $this->getHeaders()),
+                         base64_decode($this->getSignature()));
+  }
+
+
+  protected function _verifyHmac($headers, $key) {
+    return hash_equals($this->getSignature(),
+                       $this->sign($headers, $this->getHeaders(), $key, $this->getAlgorithm()));
+  }
+
+
+  public function signResponseTo($request, $headers, $key) {
+    $signature = $this->sign($this->injectRequestTargetIn($request, $headers),
+                             $this->getHeaders(),
+                             $key,
+                             $this->getAlgorithm());
+    return $this->_assemble($signature);
+  }
+
+
+  public function signRequest($headers, $key) {
+    $signature = $this->sign($headers, $this->getHeaders(), $key, $this->getAlgorithm());
+    return $this->_assemble($signature);
+  }
+
+
+  protected function _assemble($signature) {
+    $signature = ['keyId="' . Class_Url::absolute(['module' => 'activitypub',
+                                                   'controller' => 'review',
+                                                   'action' => 'pubkey'], null, true) . '"',
+                  'algorithm="'. $this->getAlgorithm() . '"',
+                  'headers="' . $this->getHeaders() . '"',
+                  'signature="' . $signature . '"'];
+
+    return implode(', ', $signature);
+  }
+
+
+  public function sign($headers, $headers_to_sign, $key, $algorithm) {
+    if ('hmac' == substr($algorithm, 0, 4))
+      return $this->_signHmac($headers, $headers_to_sign, $key, substr($algorithm, 5));
+
+    if ('rsa' == substr($algorithm, 0, 3))
+      return $this->_signRsa($headers, $headers_to_sign, $key);
+  }
+
+
+  protected function _signHmac($headers, $headers_to_sign, $key, $algorithm) {
+    return base64_encode(hash_hmac($algorithm,
+                                   $this->_messageFrom($headers, $headers_to_sign), $key, true));
+  }
+
+
+  protected function _signRsa($headers, $headers_to_sign, $key) {
+    $rsa = new \phpseclib\Crypt\RSA();
+    $rsa->loadKey($key);
+    $this->_log(sprintf('Will sign rsa : %s', $this->_messageFrom($headers, $headers_to_sign)));
+    return base64_encode($rsa->sign($this->_messageFrom($headers, $headers_to_sign)));
+  }
+
+
+  protected function _messageFrom($headers, $headers_to_sign) {
+    $headers_to_sign = str_replace(static::REQUEST_TARGET, '', $headers_to_sign);
+    $parts = array_filter(explode(' ', $headers_to_sign));
+
+    /* $this->_log('Headers to sign: ' . json_encode($parts, JSON_PRETTY_PRINT)); */
+    /* $this->_log('Headers: ' . json_encode($headers, JSON_PRETTY_PRINT)); */
+
+    $datas = [ $this->_getHeader($headers, static::REQUEST_TARGET) ];
+
+    foreach($parts as $part) {
+      if (!$part)
+        continue;
+
+      if ($data = $this->_getHeader($headers, $part))
+        $datas[] = $data;
+    }
+
+    //$this->_log('Datas: ' . json_encode($datas, JSON_PRETTY_PRINT));
+
+    return implode("\n", $datas);
+  }
+
+
+  protected function _getHeader($headers, $header) {
+    $headers = array_change_key_case($headers);
+    $data = array_key_exists($header, $headers)
+      ? trim($headers[$header])
+      : '';
+
+    return strtolower($header) . ': ' . $data;
+  }
+
+
+  public function injectRequestHeadersIn($request, $datas) {
+    foreach(explode(' ', $this->getHeaders()) as $header) {
+      if (!$header || static::REQUEST_TARGET == $header)
+        continue;
+
+      $datas[$header] = $request->getHeader($header);
+    }
+
+    return $this->injectRequestTargetIn($request, $datas);
+  }
+
+
+  public function injectRequestTargetIn($request, $datas) {
+    $datas[static::REQUEST_TARGET] = strtolower($request->getMethod())
+      . ' '
+      . $request->getBaseUrl() . $request->getPathInfo();
+
+    return $datas;
+  }
+}
diff --git a/library/Class/Journal.php b/library/Class/Journal.php
new file mode 100644
index 0000000000000000000000000000000000000000..41f1bd939feed2dde70d13abeb85037dcf7d16a6
--- /dev/null
+++ b/library/Class/Journal.php
@@ -0,0 +1,88 @@
+<?php
+/**
+ * Copyright (c) 2012-2017, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+class Class_JournalLoader extends Storm_Model_Loader {
+  protected $_current_journal;
+
+  public function factory($type, $model=null) {
+    $journal = Class_Journal::newInstance(['type' => $type]);
+    if (!$journal->save())
+      return;
+
+    if (!$model)
+      return $journal;
+
+    $this->_current_journal = $journal;
+    $model->acceptJournalVisitor($this);
+    $this->_current_journal = null;
+
+    return $journal;
+  }
+
+
+  public function visitDetail($type, $value) {
+    Class_JournalDetail::newInstance(['type' => $type,
+                                      'value' => $value,
+                                      'journal' => $this->_current_journal])
+      ->save();
+  }
+
+
+  public function lastOf($type) {
+    return Class_Journal::findFirstBy(['type' => $type, 'order' => 'created_at desc']);
+  }
+}
+
+
+
+class Class_Journal extends Storm_Model_Abstract {
+  use Trait_TimeSource;
+
+  protected $_table_name = 'journal';
+  protected $_loader_class = 'Class_JournalLoader';
+  protected $_has_many = ['details' => ['model' => 'Class_JournalDetail',
+                                        'role' => 'journal',
+                                        'dependents' => 'delete']];
+  protected $_default_attribute_values = ['type' => '',
+                                          'created_at' => ''];
+
+  public function beforeSave() {
+    if ($this->isNew())
+      $this->setCreatedAt($this->getTimeSource()->dateDayAndHours());
+  }
+
+
+  public function getLibelle() {
+    return $this->getType() . ' ' . $this->getCreatedAt();
+  }
+
+
+  public function getDateTime() {
+    return new DateTime($this->getCreatedAt());
+  }
+
+
+  public function isAfter($other) {
+    return $other
+      ? $this->getDateTime() > $other->getDateTime()
+      : true;
+  }
+}
diff --git a/library/Class/JournalDetail.php b/library/Class/JournalDetail.php
new file mode 100644
index 0000000000000000000000000000000000000000..5d94028844919000ca703c6f66b61cc9babd2dbc
--- /dev/null
+++ b/library/Class/JournalDetail.php
@@ -0,0 +1,33 @@
+<?php
+/**
+ * Copyright (c) 2012-2017, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+
+class Class_JournalDetail extends Storm_Model_Abstract {
+  use Trait_TimeSource;
+
+  protected $_table_name = 'journal_detail';
+  protected $_belongs_to = ['journal' => ['model' => 'Class_Journal']];
+
+  protected $_default_attribute_values = ['type' => '',
+                                          'value' => ''];
+
+
+}
diff --git a/library/Class/Notice.php b/library/Class/Notice.php
index a8c434e3e06749745d77bdc0d0ab0fa4b6ca0425..db1ff67bb7fd37488c159b1ff1b6fb634e5119b6 100644
--- a/library/Class/Notice.php
+++ b/library/Class/Notice.php
@@ -241,7 +241,7 @@ class Class_Notice extends Storm_Model_Abstract {
   }
 
 
-  public function getAvisByUser($user)  {
+  public function getAvisByUser($user) {
     return Class_AvisNotice::findAllBy(['clef_oeuvre' => $this->getClefOeuvre(),
                                         'id_user' => $user->getId()]);
   }
@@ -257,13 +257,22 @@ class Class_Notice extends Storm_Model_Abstract {
   }
 
 
+  public function getLocalAvis() {
+    if (!isset($this->_local_avis))
+      $this->_local_avis = Class_AvisNotice::findAllBy(['clef_oeuvre' => $this->getClefOeuvre(),
+                                                        'source_actor_id' => null]);
+
+    return $this->_local_avis;
+  }
+
+
   public function getAvisBibliothecaire() {
-    return Class_AvisNotice::filterByBibliothecaire($this->getAvis());
+    return Class_AvisNotice::filterByBibliothecaire($this->getLocalAvis());
   }
 
 
   public function getAvisAbonne() {
-    return Class_AvisNotice::filterByAbonne($this->getAvis());
+    return Class_AvisNotice::filterByAbonne($this->getLocalAvis());
   }
 
 
@@ -369,19 +378,27 @@ class Class_Notice extends Storm_Model_Abstract {
 
 
   public function getAllAvisPerSource($page = null) {
-    $all_avis = array('bib' => array('liste' => $avis_bib = $this->getAvisBibliothecaires(),
-                                     'note' => $this->getNoteMoyenneAvisBibliothecaires(),
-                                     'nombre' => count($avis_bib),
-                                     'titre' => 'Bibliothécaires'),
-                      'abonne' => array('liste' => $avis_abon = $this->getAvisAbonnes(),
-                                        'note' => $this->getNoteMoyenneAvisAbonnes(),
-                                        'nombre' => count($avis_abon),
-                                        'titre' => 'Lecteurs du portail'));
-
-    foreach (array('Class_WebService_Babelio', 'Class_WebService_Amazon') as $provider_class) {
+    $avis_bib = $this->getAvisBibliothecaires();
+    $avis_abon = $this->getAvisAbonnes();
+
+    $all_avis = ['bib' => new Class_Notice_ReviewsSet($this->_('Bibliothécaires'),
+                                                      $avis_bib,
+                                                      $this->getNoteMoyenneAvisBibliothecaires(),
+                                                      count($avis_bib)),
+
+                 'abonne' => new Class_Notice_ReviewsSet($this->_('Lecteurs du portail'),
+                                                         $avis_abon,
+                                                         $this->getNoteMoyenneAvisAbonnes(),
+                                                         count($avis_abon)),
+    ];
+
+    foreach (['Class_WebService_Babelio', 'Class_WebService_Amazon', 'Class_FederationReview']
+             as $provider_class) {
       $provider = new $provider_class();
       $source = strtolower(array_last(explode('_', $provider_class)));
-      if ($data = $provider->getAvis($this, $page)) $all_avis[$source] = $data;
+      $reviews_set = $provider->getAvis($this, $page);
+      if (!$reviews_set->isEmpty())
+        $all_avis[$source] = $reviews_set;
     }
 
     return $all_avis;
@@ -389,7 +406,7 @@ class Class_Notice extends Storm_Model_Abstract {
 
 
   public function getAvisBibliothecaires() {
-    return Class_AvisNotice::filterByBibliothecaire($this->getAvis());
+    return Class_AvisNotice::filterByBibliothecaire($this->getLocalAvis());
   }
 
 
@@ -399,7 +416,7 @@ class Class_Notice extends Storm_Model_Abstract {
 
 
   public function getAvisAbonnes() {
-    return Class_AvisNotice::filterByAbonne($this->getAvis());
+    return Class_AvisNotice::filterByAbonne($this->getLocalAvis());
   }
 
 
diff --git a/library/Class/Notice/ReviewsSet.php b/library/Class/Notice/ReviewsSet.php
new file mode 100644
index 0000000000000000000000000000000000000000..c20973ff75afec36c8d7e2abf3f2ed7cb26cc1e9
--- /dev/null
+++ b/library/Class/Notice/ReviewsSet.php
@@ -0,0 +1,73 @@
+<?php
+/**
+ * Copyright (c) 2012-2019, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+
+class Class_Notice_ReviewsSet {
+  protected
+    $_reviews    = [],
+    $_rating     = 0,
+    $_count      = 0,
+    $_page_count = 0,
+    $_label      = '';
+
+  public static function emptyInstance() {
+    return new static('', [], 0, 0, 0, 0);
+  }
+
+
+  public function __construct($label, $reviews, $rating, $count, $page_count=0) {
+    $this->_label = $label;
+    $this->_reviews = $reviews;
+    $this->_rating = $rating;
+    $this->_count = $count;
+    $this->_page_count = $page_count;
+  }
+
+
+  public function isEmpty() {
+    return 0 == $this->_count;
+  }
+
+
+  public function hasPages() {
+    return !$this->isEmpty() && 0 < $this->_page_count;
+  }
+
+
+  public function getReviews() {
+    return $this->_reviews;
+  }
+
+
+  public function getRating() {
+    return $this->_rating;
+  }
+
+
+  public function getCount() {
+    return $this->_count;
+  }
+
+
+  public function getLabel() {
+    return $this->_label;
+  }
+}
diff --git a/library/Class/TimeSource.php b/library/Class/TimeSource.php
index 2ebc5bd9421a4cb71f76e1ae233f978496457600..fa7bfa2bd640dcb8d1907f91015f657e44c1d3a2 100644
--- a/library/Class/TimeSource.php
+++ b/library/Class/TimeSource.php
@@ -52,6 +52,11 @@ class Class_TimeSource {
   }
 
 
+  public function dateHttpHeader() {
+    return gmdate('D, d M Y H:i:s \G\M\T', $this->time());
+  }
+
+
   public function date() {
     $time = $this->time();
     return $this->midnightTime(date('n', $time), date('j', $time), date('Y', $time));
diff --git a/library/Class/WebService/ActivityPub.php b/library/Class/WebService/ActivityPub.php
new file mode 100644
index 0000000000000000000000000000000000000000..2f85af98942b68d448cae24db30a456ed9cc8e7a
--- /dev/null
+++ b/library/Class/WebService/ActivityPub.php
@@ -0,0 +1,414 @@
+<?php
+/**
+ * Copyright (c) 2012-2017, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+require_once __DIR__ . '/../../activitystreams/autoload.php';
+
+use
+  Patbator\ActivityStreams\Model\Base,
+  Patbator\ActivityStreams\Model\Factory,
+  Patbator\ActivityStreams\Model\Service,
+  Patbator\ActivityStreams\Model\Accept,
+  Patbator\ActivityStreams\Model\Reject,
+  Patbator\ActivityStreams\Model\Group,
+  Patbator\ActivityStreams\Model\Join,
+  Patbator\ActivityStreams\Model\Leave,
+  Patbator\ActivityStreams\Model\CollectionPage,
+  Patbator\ActivityStreams\Stream;
+
+
+class Class_WebService_ActivityPub {
+  use Trait_SimpleWebClient, Trait_TimeSource, Trait_LastMessage, Trait_Translator;
+
+  const
+    MIME_TYPE = 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
+    EMPTY_DIGEST = 'ZDQxZDhjZDk4ZjAwYjIwNGU5ODAwOTk4ZWNmODQyN2U=';
+
+  protected static
+    $_throw_errors = false,
+    $_signer;
+
+  protected
+    $_endpoint,
+    $_identity_cache,
+    $_logger;
+
+
+  /** @category testing */
+  public static function setThrowErrors($flag) {
+    static::$_throw_errors = (bool)$flag;
+  }
+
+
+  /** @category testing */
+  public static function setSigner($signer) {
+    static::$_signer = $signer;
+  }
+
+
+  protected function _signerFor($signature) {
+    if (static::$_signer)
+      return static::$_signer;
+
+    return (new Class_HttpSignature($signature))
+      ->setLogger($this->_logger);
+  }
+
+
+  public static function identityOf($actor) {
+    if (!$actor || !$actor->id())
+      return;
+
+    return (new static($actor->id()))->identify();
+  }
+
+
+  public static function publicKeyOf($actor) {
+    if (!$actor || !$actor->publicKey())
+      return;
+
+    return (new static($actor->publicKey()))->identify();
+  }
+
+
+  public function __construct($endpoint) {
+    $this->_endpoint = (substr($endpoint, -1) == '/'
+                        ? substr($endpoint, 0, strlen($endpoint) - 1)
+                        : $endpoint);
+
+    Base::setFactory((new Factory)
+                     ->mapTypeToClass('Service', 'Class_ActivityPub_Service')
+                     ->mapTypeToClass('Key', 'Class_ActivityPub_PublicKey'));
+  }
+
+
+  public function setLogger($logger) {
+    $this->_logger = $logger;
+    return $this;
+  }
+
+
+  protected function _log($message) {
+    if ($this->_logger)
+      $this->_logger->info($message);
+
+    return $this;
+  }
+
+
+  public function isValid() {
+    return (bool) $this->_inboxUrl();
+  }
+
+
+  public function identify() {
+    if (($stream = Stream::fromJson($this->_identity()))
+        && ($object = $stream->getRoot()))
+      return $object;
+
+    $this->_log('Cannot fetch valid identity from ' . $this->_endpoint);
+  }
+
+
+  protected function _identity() {
+    if ($this->_identity_cache)
+      return $this->_identity_cache;
+
+    try {
+      return $this->_identity_cache = $this
+        ->getWebClient()
+        ->open_url($this->_endpoint, ['headers' => ['Accept' => static::MIME_TYPE]]);
+    } catch(Exception $e) {
+      $this->_log($e->getMessage());
+      if (static::$_throw_errors)
+        throw $e;
+    }
+  }
+
+
+  protected function _inboxUrl() {
+    return (($service = $this->identify())
+            && ($service instanceof Service))
+      ? $service->inbox()
+      : null;
+  }
+
+
+  public function name() {
+    return (($service = $this->identify())
+            && ($service instanceof Service))
+      ? $service->name()
+      : null;
+  }
+
+
+  /**
+   * @param $group_name string
+   * @return bool
+   *
+   * Join a group by its name
+   * Return true on Accept or false on error or Reject
+   * Rejection reason can be retreived with $this->getLastMessage()
+   */
+  public function join($group_name) {
+    return ($group_name = trim($group_name))
+      ? $this->_postActivityWithValidation($this->_groupActivityWith(new Join(), $group_name))
+      : $this->_error($this->_('Impossible de rejoindre un groupe vide'));
+  }
+
+
+  /**
+   * @param $group_name string
+   * @return bool
+   *
+   * Leave a group by its name
+   * Return true on Accept or false on error or Reject
+   * Rejection reason can be retreived with $this->getLastMessage()
+   */
+  public function leave($group_name) {
+    return ($group_name = trim($group_name))
+      ? $this->_postActivityWithValidation($this->_groupActivityWith(new Leave(), $group_name))
+      : $this->_error($this->_('Impossible de quitter un groupe vide'));
+  }
+
+
+  protected function _groupActivityWith($activity, $group_name) {
+    return $activity
+      ->actor((new Service())->name((new Class_Federation)->getActorName())
+              ->id(Class_Url::absolute(['module' => 'activitypub',
+                                        'controller' => 'review'], null, true)))
+      ->object((new Group())->name($group_name));
+  }
+
+
+  protected function _postActivityWithValidation($activity) {
+    if (!$inbox = $this->_inboxUrl())
+      return $this->_error($this->_('Serveur invalide'));
+
+    $request_target = 'post ' . Zend_Uri::factory($inbox)->getPath();
+
+    $response = $this->_activityPost($inbox, (new Stream($activity))->render(), $request_target);
+    $this->_log('Received response with headers : ' . json_encode($response->getHeaders(), JSON_PRETTY_PRINT));
+    if (!$response_body = $this->_validateResponseAndBody($response, $request_target))
+      return false;
+
+    if ((!$stream = Stream::fromJson($response_body)) || (!$response_activity = $stream->getRoot()))
+      return $this->_error($this->_('La réponse du serveur n\'était pas valide'));
+
+    if ($response_activity instanceof Accept)
+      return true;
+
+    $message = $this->_('Le serveur a refusé votre demande');
+    if (($response_activity instanceof Reject)
+        && ($summary = $response_activity->summary()))
+      $message .= ': ' . $summary;
+
+    return $this->_error($message);
+  }
+
+
+  public function reviews($notice, $page) {
+    return $this->_getReviewsPage(['key' => $notice->getClefOeuvre(),
+                                   'page' => $page]);
+  }
+
+
+  public function harvestReviews($from) {
+    $reviews = new Storm_Collection();
+    $page = 1;
+    while (($collection_page = $this->_getReviewsPage(['from' => $from,
+                                                       'page' => $page]))
+           && ($items = $collection_page->items())) {
+      $reviews->addAll($items);
+      $page++;
+    }
+
+    return $reviews;
+  }
+
+
+  protected function _getReviewsPage($query_params) {
+    if (!$outbox = $this->_outboxUrl())
+      return;
+
+    $outbox .= '?' . http_build_query($query_params);
+    $request_target = 'get ' . Zend_Uri::factory($outbox)->getPath();
+
+    $response = $this->_activityGet($outbox, $request_target);
+
+    if (!$body = $this->_validateResponseAndBody($response, $request_target))
+      return;
+
+    if ((!$stream = Stream::fromJson($body)) || (!$activity = $stream->getRoot()))
+      return;
+
+    return $activity instanceof CollectionPage
+      ? $activity
+      : null;
+  }
+
+
+  protected function _activityGet($url, $request_target) {
+    $headers = ['date' => $this->getTimeSource()->dateHttpHeader(),
+                'digest' => 'MD5=' . static::EMPTY_DIGEST,
+                Class_HttpSignature::REQUEST_TARGET => $request_target];
+
+    return $this
+      ->getWebClient()
+      ->getResponse($url,
+                    ['headers' => ['Date' => $headers['date'],
+                                   'Digest' => $headers['digest'],
+                                   'Accept' => static::MIME_TYPE,
+                                   'Signature' => $this->_sign($headers),
+                                   'Authorization' => 'Bearer ' . Class_Url::absolute(['module' => 'activitypub',
+                                                                                       'controller' => 'review'], null, true)]]);
+  }
+
+
+  protected function _outboxUrl() {
+    return (($service = $this->identify())
+            && ($service instanceof Service))
+      ? $service->outbox()
+      : null;
+  }
+
+
+  protected function _activityPost($url, $content, $request_target) {
+    $headers = ['date' => $this->getTimeSource()->dateHttpHeader(),
+                'digest' => 'MD5=' . base64_encode(md5($content)),
+                Class_HttpSignature::REQUEST_TARGET => $request_target];
+
+    return $this
+      ->getWebClient()
+      ->postRawDataResponse($url, $content, static::MIME_TYPE,
+                            ['headers' => ['Date' => $headers['date'],
+                                           'Digest' => $headers['digest'],
+                                           'Signature' => $this->_sign($headers)]]);
+  }
+
+
+  public function validateRequest($request) {
+    if (!$signature = $request->getHeader('Signature')) {
+      $this->_log('Request without Signature header');
+      return false;
+    }
+    $this->_log('Received request with signature : ' . $signature);
+
+    if (!$actor = $this->identify())
+      return false;
+
+    if (!$actor_key = $actor->publicKey()) {
+      $this->_log('Cannot verify signature without publickey of ' . $this->_endpoint);
+      return false;
+    }
+
+    $signer = $this->_signerFor($signature);
+    if ($actor_key != $signer->getKeyId()) {
+      $this->_log(sprintf('Signature keyId differs from actor keyId : %s <---> %s',
+                          $signer->getKeyId(), $actor_key));
+      return false;
+    }
+
+    if (!$key = (new static($actor_key))->setLogger($this->_logger)->identify()) {
+      $this->_log('Cannot get actor key details from ' . $actor_key);
+      return false;
+    }
+
+    if ($key->owner() != $actor->id()) {
+      $this->_log(sprintf('Key owner is not actor : %s <---> %s',
+                          $key->owner(), $actor->id()));
+      return false;
+    }
+
+    $headers = $signer->injectRequestHeadersIn($request, []);
+    if (!$signer->verify($headers, $key->publicKeyPem())) {
+      $this->_log(sprintf('Invalid signature : %s does not valid %s',
+                          $key->publicKeyPem(), $signature));
+      return false;
+    }
+
+    return true;
+  }
+
+
+  protected function _validateResponseAndBody($response, $request_target) {
+    if (!$this->_validateResponse($response, $request_target))
+      return false;
+
+    if (!$body = $response->getBody())
+      return $this->_error($this->_('La réponse du serveur était vide'));
+
+    return $body;
+  }
+
+
+  protected function _validateResponse($response, $request_target) {
+    if ($response->isError())
+      return $this->_error($response->getMessage());
+
+    if (!$signature = $response->getHeader('Signature'))
+      return $this->_error($this->_('Le serveur n\'a pas signé sa réponse'));
+
+    if (!$identity = $this->identify())
+      return $this->_error($this->_('Le serveur ne fournit pas son identité'));
+
+    $signer = $this->_signerFor($signature);
+    if ($identity->publicKey() != $signer->getKeyId())
+      return $this->_error($this->_('La signature de la réponse n\'est pas la signature du serveur'));
+
+    $headers = $response->getHeaders();
+    $headers[Class_HttpSignature::REQUEST_TARGET] = $request_target;
+
+    if (!$key = (new static($identity->publicKey()))->setLogger($this->_logger)->identify()) {
+      $this->_log('Cannot get actor key details from ' . $identity->publicKey());
+      return $this->_error($this->_('Impossible de récupérer les détails de la clé publique du serveur'));
+    }
+
+    if ($key->owner() != $identity->id()) {
+      $this->_log(sprintf('Key owner is not actor : %s <---> %s',
+                          $key->owner(), $identity->id()));
+      return $this->_error($this->_('Le serveur n\'est pas le propriétaire de la clé qu\'il fourni'));
+    }
+
+    if (!$signer->verify($headers, $key->publicKeyPem())) {
+      $this->_log(sprintf('Invalid signature : %s does not valid %s',
+                          $key->publicKeyPem(), $signature));
+      return $this->_error($this->_('La signature ne semble pas provenir du serveur'));
+    }
+
+    return true;
+  }
+
+
+  protected function _sign($headers) {
+    return $this->_signerFor('algorithm="rsa-sha256", headers="date digest"')
+                ->signRequest($headers, $this->_privateKey());
+  }
+
+
+  protected function _privateKey() {
+    return (new Class_Federation())->getPrivateKey();
+  }
+
+
+  public function getEndpoint() {
+    return $this->_endpoint;
+  }
+}
diff --git a/library/Class/WebService/ActivityPubServer.php b/library/Class/WebService/ActivityPubServer.php
new file mode 100644
index 0000000000000000000000000000000000000000..09c38cbd5142ef46de2b77e9960a6bd3d2d72ef5
--- /dev/null
+++ b/library/Class/WebService/ActivityPubServer.php
@@ -0,0 +1,41 @@
+<?php
+/**
+ * Copyright (c) 2012-2017, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+require_once __DIR__ . '/../../activitystreams/autoload.php';
+
+use Patbator\ActivityStreams\Stream;
+
+
+class Class_WebService_ActivityPubServer extends Class_WebService_ActivityPub {
+  public function respondTo($request, $response, $activity) {
+    $body = (new Stream($activity))->render();
+    $digest = 'MD5=' . base64_encode(md5($body));
+    $signer = $this->_signerFor('algorithm="rsa-sha256", headers="digest"');
+
+    $response
+      ->setHeader('Content-Type', static::MIME_TYPE, true)
+      ->setHeader('Digest', $digest)
+      ->setHeader('Signature',
+                  $signer->signResponseTo($request, ['digest' => $digest], $this->_privateKey()))
+      ->setBody($body)
+      ;
+  }
+}
diff --git a/library/Class/WebService/Amazon.php b/library/Class/WebService/Amazon.php
index 3a5d4e1267fdfd829c284a099e995ad52ebb7368..9e8a52463585dca9362aa40ff0d740784d7bdf7c 100644
--- a/library/Class/WebService/Amazon.php
+++ b/library/Class/WebService/Amazon.php
@@ -18,33 +18,24 @@
  * along with BOKEH; if not, write to the Free Software
  * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
  */
-//////////////////////////////////////////////////////////////////////////////////////////
-// OPAC3 - WEB-SERVICE AMAZON
-//////////////////////////////////////////////////////////////////////////////////////////
 
-class Class_WebService_Amazon
-{
-  var $xml;                                                         // Pointeur sur la classe xml de base
-  private $req;                                                     // Racine requete http
-  private $id_afi="AKIAINZSICEPECFZ4RPQ";                           // ID afi chez amazon dans cfg
-  private $secret_key="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";   // Clé secrete chez amazon
 
-//------------------------------------------------------------------------------------------------------
-// Constructeur
-//------------------------------------------------------------------------------------------------------
-  function __construct()
-  {
+class Class_WebService_Amazon {
+  use Trait_Translator;
+
+  public  $xml;
+  private $req;
+  private $id_afi="AKIAINZSICEPECFZ4RPQ";
+  private $secret_key="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";
+
+  public function __construct()  {
     $this->xml= new Class_Xml();
     $this->req="http://webservices.amazon.fr/onca/xml?Service=AWSECommerceService";
     $this->req.="&AWSAccessKeyId=".$this->id_afi;
   }
 
 
-//------------------------------------------------------------------------------------------------------
-// Execution requete http et test erreur
-//------------------------------------------------------------------------------------------------------
-  function requete($req)
-  {
+  public function requete($req) {
     $url=$this->req.$req;
 
     // Ajout de signature AMAZON
@@ -68,11 +59,8 @@ class Class_WebService_Amazon
     return $this->test_erreur();
   }
 
-//------------------------------------------------------------------------------------------------------
-// Retourne la notice d'après un noeud de type item
-//------------------------------------------------------------------------------------------------------
-  function rend_notice($node)
-  {
+
+  public function rend_notice($node) {
     $notice["asin"]=$this->xml->get_child_value($node,"asin");
     $img=$this->xml->get_child_node($node,"smallimage");
     if($img)
@@ -106,48 +94,42 @@ class Class_WebService_Amazon
   }
 
 
-  // pour rendre le service polymorphique avec Babelio, Amazon, Notices ....
   public function getAvis($notice, $page) {
-    if (! $notice->isLivre()) return false;
-    $avis = $this->rend_avis($notice, $page);
-    if ($avis == false) return false;
-
-    $avis['titre'] = 'Lecteurs Amazon';
-    return $avis;
+    return $notice->isLivre()
+      ? $this->rend_avis($notice, $page)
+      : Class_Notice_ReviewsSet::emptyInstance();
   }
 
-//------------------------------------------------------------------------------------------------------
-// Avis des lecteurs
-//------------------------------------------------------------------------------------------------------
-  function rend_avis($notice, $page)  {
-    if ($notice instanceof Class_Notice)
-      $isbn = $notice->getIsbn();
-    else
-      $isbn = $notice;
-
-    if(!trim($isbn)){
-      return false;
-    }
-    if($page>0){
+
+  public function rend_avis($notice, $page)  {
+    $isbn = $notice instanceof Class_Notice
+      ? $notice->getIsbn()
+      : $notice;
+
+    if (!trim($isbn))
+      return Class_Notice_ReviewsSet::emptyInstance();
+
+    if ($page > 0)
       $page="&ReviewPage=".$page;
-    }
-    $req=$this->req_isbn($isbn)."&ResponseGroup=Reviews".$page;
-    if(!$this->requete($req)){
-      return false;
-    }
-    $item=$this->xml->getNode("customerreviews");
-    $avis["note"]=$this->xml->get_child_value($item,"averagerating");
 
-    if(!$avis["note"]){
-      return false;
-    }
-    $avis["nombre"]=$this->xml->get_child_value($item,"totalreviews");
-    $avis["nb_pages"]=$this->xml->get_child_value($item,"totalreviewpages");
-    $item=$this->xml->get_child_node($item,"review");
+    $req = $this->req_isbn($isbn) . "&ResponseGroup=Reviews" . $page;
+    if (!$this->requete($req))
+      return Class_Notice_ReviewsSet::emptyInstance();
+
+    $item = $this->xml->getNode("customerreviews");
+    $rating = $this->xml->get_child_value($item, "averagerating");
+    if (!$rating)
+      return Class_Notice_ReviewsSet::emptyInstance();
+
+    $count = $this->xml->get_child_value($item, "totalreviews");
+    $page_count = $this->xml->get_child_value($item, "totalreviewpages");
+
+    $item = $this->xml->get_child_node($item, "review");
     $dateClass = new Class_Date();
-    while( $item ) {
-      $avis_notice = new Class_AvisNotice();
-      $avis_notice
+    $reviews = [];
+
+    while($item) {
+      $reviews[] = (new Class_AvisNotice())
         ->setNote($this->xml->get_child_value($item,"rating"))
         ->setDateAvis($dateClass->LocalizedDate($this->xml->get_child_value($item,"date"), 'yyyy-MM-dd'))
         ->setEntete(utf8_encode($this->xml->get_child_value($item,"summary")))
@@ -155,17 +137,17 @@ class Class_WebService_Amazon
         ->setNotice($notice)
         ->setUser(null);
 
-      $index=count($avis["liste"]);
-      $avis["liste"][$index] = $avis_notice;
-      $item=$this->xml->get_sibling($item);
+      $item = $this->xml->get_sibling($item);
     }
 
-    return $avis;
+    return new Class_Notice_ReviewsSet($this->_('Lecteurs Amazon'),
+                                       $reviews,
+                                       $rating,
+                                       $count,
+                                       $page_count);
   }
 
-//------------------------------------------------------------------------------------------------------
-// Résumés et analyses
-//------------------------------------------------------------------------------------------------------
+
   public function getResumes($notice) {
     if (!$service = $notice->getIsbnOrEan())
       return array();
@@ -174,7 +156,7 @@ class Class_WebService_Amazon
   }
 
 
-  function rend_analyses($isbn) {
+  public function rend_analyses($isbn) {
     if(!trim($isbn))
       return array();
 
diff --git a/library/Class/WebService/Babelio.php b/library/Class/WebService/Babelio.php
index 993d271fa245f87c929baa114154a775a03ef0a3..e51bf47d370e12aa2f65fc21e2f5becdae910f43 100644
--- a/library/Class/WebService/Babelio.php
+++ b/library/Class/WebService/Babelio.php
@@ -19,6 +19,8 @@
  * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
  */
 class Class_WebService_Babelio {
+  use Trait_Translator;
+
   const USER = 'afi_test';
   const PASS = 'af_45_POQ_d';
 
@@ -130,18 +132,9 @@ class Class_WebService_Babelio {
    * @return mixed
    */
   public function getAvis($notice, $page) {
-    if (! $notice->isLivre())
-      return false;
-
-    if (! $this->_serviceActivated())
-      return false;
-
-    $avis = $this->getCritiques($notice);
-    if ($avis == false)
-      return false;
-
-    $avis['titre'] = 'Lecteurs Babelio';
-    return $avis;
+    return $notice->isLivre() && $this->_serviceActivated()
+      ? $this->getCritiques($notice)
+      : Class_Notice_ReviewsSet::emptyInstance();
   }
 
 
@@ -162,7 +155,7 @@ class Class_WebService_Babelio {
     $isbn = $notice->getIsbn();
     $this->requete($isbn);
     if (!$this->_xml)
-      return false;
+      return Class_Notice_ReviewsSet::emptyInstance();
 
     $liste_avis = array();
     foreach($this->_xml->url as $avis)  {
@@ -185,9 +178,10 @@ class Class_WebService_Babelio {
       $liste_avis[] = $avis_notice;
     }
 
-    return array("liste" => $liste_avis,
-                 "nombre" => count($liste_avis),
-                 "note" => Class_AvisNotice::getNoteAverage($liste_avis));
+    return new Class_Notice_ReviewsSet($this->_('Lecteurs Babelio'),
+                                       $liste_avis,
+                                       Class_AvisNotice::getNoteAverage($liste_avis),
+                                       count($liste_avis));
   }
 
 
diff --git a/library/Class/WebService/SimpleWebClient.php b/library/Class/WebService/SimpleWebClient.php
index 5ec8ffc117230e3f8539a530c83e639aef7172f5..247b85e1e34890da985d86465d969dc158518433 100644
--- a/library/Class/WebService/SimpleWebClient.php
+++ b/library/Class/WebService/SimpleWebClient.php
@@ -78,6 +78,12 @@ class Class_WebService_SimpleWebClient {
 
 
   public function postRawData($url, $datas, $encoding, $options = []) {
+    return $this->_postRawData($url, $datas, $encoding, $options)
+                ->getBody();
+  }
+
+
+  protected function _postRawData($url, $datas, $encoding, $options) {
     $httpClient = $this->getHttpClient();
     $httpClient->resetParameters();
     $httpClient->setUri($url);
@@ -87,7 +93,12 @@ class Class_WebService_SimpleWebClient {
     if (isset($options['headers']))
       $httpClient->setHeaders($options['headers']);
 
-    return $httpClient->request()->getBody();
+    return $httpClient->request();
+  }
+
+
+  public function postRawDataResponse($url, $datas, $encoding, $options = []) {
+    return $this->_postRawData($url, $datas, $encoding, $options);
   }
 
 
@@ -104,5 +115,3 @@ class Class_WebService_SimpleWebClient {
       : null;
   }
 }
-
-?>
\ No newline at end of file
diff --git a/library/Trait/LastMessage.php b/library/Trait/LastMessage.php
new file mode 100644
index 0000000000000000000000000000000000000000..48b81119d0f8763cd49b49e724d02f30c2f3170d
--- /dev/null
+++ b/library/Trait/LastMessage.php
@@ -0,0 +1,35 @@
+<?php
+/**
+ * Copyright (c) 2012-2019, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+
+trait Trait_LastMessage {
+  protected $_last_message = '';
+
+  public function getLastMessage() {
+    return $this->_last_message;
+  }
+
+
+  protected function _error($message) {
+    $this->_last_message = $message;
+    return false;
+  }
+}
diff --git a/library/ZendAfi/Acl/AdminControllerGroup.php b/library/ZendAfi/Acl/AdminControllerGroup.php
index ba103b09e36bf4e0c5abc73459dd913a39f31729..08c2b62b14c1b0596df128a5ec8ca5e8507c79e4 100644
--- a/library/ZendAfi/Acl/AdminControllerGroup.php
+++ b/library/ZendAfi/Acl/AdminControllerGroup.php
@@ -85,6 +85,7 @@ class ZendAfi_Acl_AdminControllerGroup {
                           'custom-fields-report' => Class_AdminVar::isCustomFieldsReportEnabled(),
                           'usergroup-agenda' => Class_AdminVar::isRendezVousEnabled(),
                           'rendez-vous' => Class_AdminVar::isRendezVousEnabled(),
+                          'federation-reviews' => Class_AdminVar::isFederationEnabled(),
                           ];
 
     $this->_activated = array_merge($this->_activated,
diff --git a/library/ZendAfi/Acl/AdminControllerRoles.php b/library/ZendAfi/Acl/AdminControllerRoles.php
index 28e56a51fabf5231a8f54123941c3c32352294af..a891142242831ed029413c75e70e6ef875bf4a0d 100644
--- a/library/ZendAfi/Acl/AdminControllerRoles.php
+++ b/library/ZendAfi/Acl/AdminControllerRoles.php
@@ -100,6 +100,7 @@ class ZendAfi_Acl_AdminControllerRoles extends Zend_Acl {
     $this->add(new Zend_Acl_Resource('search-form'));
     $this->add(new Zend_Acl_Resource('usergroup-agenda'));
     $this->add(new Zend_Acl_Resource('rendez-vous'));
+    $this->add(new Zend_Acl_Resource('journal'));
 
     $codifications = ['codification-browser',
                       'thesauri',
@@ -204,6 +205,7 @@ class ZendAfi_Acl_AdminControllerRoles extends Zend_Acl {
     $this->deny('modo_portail','systeme/phpinfo');
     $this->deny('modo_portail','usergroup-agenda');
     $this->deny('modo_portail','rendez-vous');
+    $this->deny('modo_portail','journal');
     foreach($codifications as $controller)
       $this->deny('modo_portail', $controller);
 
diff --git a/library/ZendAfi/Controller/Action/Helper/Journal.php b/library/ZendAfi/Controller/Action/Helper/Journal.php
new file mode 100644
index 0000000000000000000000000000000000000000..307bd958153ccdaabf6fa49863ce9967cd33237a
--- /dev/null
+++ b/library/ZendAfi/Controller/Action/Helper/Journal.php
@@ -0,0 +1,34 @@
+<?php
+/**
+ * Copyright (c) 2012-2017, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+
+class ZendAfi_Controller_Action_Helper_Journal
+  extends Zend_Controller_Action_Helper_Abstract{
+
+  public function journal($type, $model=null) {
+    return Class_Journal::factory($type, $model);
+  }
+
+
+  public function direct($type, $model=null) {
+    return $this->journal($type, $model);
+  }
+}
diff --git a/library/ZendAfi/Controller/Action/Helper/ListViewMode/Abstract.php b/library/ZendAfi/Controller/Action/Helper/ListViewMode/Abstract.php
index 6d983cca9ae77825ea10696b9d07260ca2323677..566dbe8e6e8bcc09218b33c11263b8c267504d1d 100644
--- a/library/ZendAfi/Controller/Action/Helper/ListViewMode/Abstract.php
+++ b/library/ZendAfi/Controller/Action/Helper/ListViewMode/Abstract.php
@@ -43,6 +43,12 @@ abstract class ZendAfi_Controller_Action_Helper_ListViewMode_Abstract
   }
 
 
+  protected function _initParams($params) {
+    $this->_params = $params;
+    return $this;
+  }
+
+
   public function getName() {
     return str_replace('ZendAfi_Controller_Action_Helper_', '', get_class($this));
   }
diff --git a/library/ZendAfi/Controller/Action/Helper/ListViewMode/Codification/Flat.php b/library/ZendAfi/Controller/Action/Helper/ListViewMode/Codification/Flat.php
index 8f69470b496cbc17834c8ef895e8a960c36a4541..2c6c5d958b18bee3bcd764130f1ba4019b04225d 100644
--- a/library/ZendAfi/Controller/Action/Helper/ListViewMode/Codification/Flat.php
+++ b/library/ZendAfi/Controller/Action/Helper/ListViewMode/Codification/Flat.php
@@ -26,7 +26,7 @@ class ZendAfi_Controller_Action_Helper_ListViewMode_Codification_Flat
   protected $_model_class;
 
   protected function _initParams($params) {
-    $this->_params = $params;
+    parent::_initParams($params);
     $this->_model_class = get_class($this->getModel());
   }
 
diff --git a/library/ZendAfi/Controller/Action/Helper/ListViewMode/Journal.php b/library/ZendAfi/Controller/Action/Helper/ListViewMode/Journal.php
new file mode 100644
index 0000000000000000000000000000000000000000..b58106630f71297f996a5c4ad058d3c2e1dbe980
--- /dev/null
+++ b/library/ZendAfi/Controller/Action/Helper/ListViewMode/Journal.php
@@ -0,0 +1,69 @@
+<?php
+/**
+ * Copyright (c) 2012-2017, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+
+class ZendAfi_Controller_Action_Helper_ListViewMode_Journal
+  extends ZendAfi_Controller_Action_Helper_ListViewMode_Abstract {
+
+  public function ListViewMode_Journal($params) {
+    return parent::_initParams($params);
+  }
+
+
+  public function direct($params) {
+    return $this->ListViewMode_Journal($params);
+  }
+
+
+  public function isSearchEnabled() {
+    return false;
+  }
+
+
+  protected function _describeCategoriesIn($description) {
+    return $description;
+  }
+
+
+  protected function _describeItemsIn($description) {
+    return $description
+      ->addColumn($this->_('Date'), 'created_at')
+      ->addColumn($this->_('Type'), 'type')
+      ->setSorterServer();
+  }
+
+
+  public function getItems() {
+    $params = ['limitPage' => [$this->getPage(),  $this->_items_by_page],
+               'order' => $this->getOrder()];
+    return Class_Journal::findAllBy($params);
+  }
+
+
+  protected function getOrder() {
+    return $this->getParam('order', 'created_at desc');
+  }
+
+
+  protected function enabledSorter() {
+    return false;
+  }
+}
diff --git a/library/ZendAfi/Controller/Action/Helper/ListViewMode/Library.php b/library/ZendAfi/Controller/Action/Helper/ListViewMode/Library.php
index 0427097d0e9d33cfbf0bee1678bf994304d4d830..373cd3f43f6de3d1a8372decb941ecf9bd0964b8 100644
--- a/library/ZendAfi/Controller/Action/Helper/ListViewMode/Library.php
+++ b/library/ZendAfi/Controller/Action/Helper/ListViewMode/Library.php
@@ -23,8 +23,7 @@
 class ZendAfi_Controller_Action_Helper_ListViewMode_Library extends ZendAfi_Controller_Action_Helper_ListViewMode_Abstract {
 
   public function ListViewMode_Library($params) {
-    $this->_params = $params;
-    return $this;
+    return parent::_initParams($params);
   }
 
 
diff --git a/library/ZendAfi/Controller/Plugin/AdminAuth.php b/library/ZendAfi/Controller/Plugin/AdminAuth.php
index 871b912c036eeb26b836922f90947fe59766a943..7bd9242d2b366fa6a33e1a6c00f65fe8b097b193 100644
--- a/library/ZendAfi/Controller/Plugin/AdminAuth.php
+++ b/library/ZendAfi/Controller/Plugin/AdminAuth.php
@@ -46,6 +46,15 @@ class ZendAfi_Controller_Plugin_AdminAuth extends Zend_Controller_Plugin_Abstrac
       return;
     }
 
+    // activitypub
+    if ('activitypub' == $module
+        && !Class_AdminVar::isFederationServiceAvailable()) {
+      $request->setModuleName('activitypub')
+              ->setControllerName('review')
+              ->setActionName('unavailable');
+      return;
+    }
+
     // Entree dans opac on teste si le site a été désactivé
     if (Class_AdminVar::get("SITE_OK") == "0" and $module == 'opac')  {
       $controller = 'index';
diff --git a/library/ZendAfi/Controller/Plugin/ResourceDefinition/Journal.php b/library/ZendAfi/Controller/Plugin/ResourceDefinition/Journal.php
new file mode 100644
index 0000000000000000000000000000000000000000..15f210c74c4c96901e62d88660178762b6c93029
--- /dev/null
+++ b/library/ZendAfi/Controller/Plugin/ResourceDefinition/Journal.php
@@ -0,0 +1,36 @@
+<?php
+/**
+ * Copyright (c) 2012-2017, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+
+class ZendAfi_Controller_Plugin_ResourceDefinition_Journal
+  extends ZendAfi_Controller_Plugin_ResourceDefinition_Abstract {
+
+  public function getDefinitions() {
+    return
+      ['model' => ['class' => 'Class_Journal',
+                   'name' => 'journal',
+                   'order' => 'created_at desc'],
+
+       'listViewMode' => ['helper_method' => 'ListViewMode_Journal'],
+
+       'actions' => ['index' => ['title' => $this->_('Journal d\'évènements')]]];
+  }
+}
\ No newline at end of file
diff --git a/library/ZendAfi/Form/Admin/AdminVarFactory.php b/library/ZendAfi/Form/Admin/AdminVarFactory.php
index 60faeae3124e217e761a539cb96db2f65fe979a1..8d45130554e8252c565c409a306c9d7cef57a59c 100644
--- a/library/ZendAfi/Form/Admin/AdminVarFactory.php
+++ b/library/ZendAfi/Form/Admin/AdminVarFactory.php
@@ -33,7 +33,8 @@ class ZendAfi_Form_Admin_AdminVarFactory {
      Class_AdminVar_Meta::TYPE_ENCODED_DATA => 'ZendAfi_Form_Admin_AdminVar_EncodedData',
      Class_AdminVar_Meta::TYPE_RAW_TEXT => 'ZendAfi_Form_Admin_AdminVar_RawText',
      Class_AdminVar_Meta::TYPE_COMBO => 'ZendAfi_Form_Admin_AdminVar_Combo',
-     Class_AdminVar_Meta::TYPE_EDITOR => 'ZendAfi_Form_Admin_AdminVar_Editor'
+     Class_AdminVar_Meta::TYPE_EDITOR => 'ZendAfi_Form_Admin_AdminVar_Editor',
+     Class_AdminVar_Meta::TYPE_CRYPT_KEY => 'ZendAfi_Form_Admin_AdminVar'
     ];
 
 
diff --git a/library/ZendAfi/Validate/ActivityPubEndpoint.php b/library/ZendAfi/Validate/ActivityPubEndpoint.php
new file mode 100644
index 0000000000000000000000000000000000000000..cdc823f6a47481e345c2df5371cd6f6b1335e40c
--- /dev/null
+++ b/library/ZendAfi/Validate/ActivityPubEndpoint.php
@@ -0,0 +1,40 @@
+<?php
+/**
+ * Copyright (c) 2012-2017, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+
+class ZendAfi_Validate_ActivityPubEndpoint extends Zend_Validate_Abstract {
+  const INVALID_VALUE = 'invalidValue';
+
+  protected $_messageTemplates =
+    [
+     self::INVALID_VALUE => "'%value%' n'est pas un service de fédération valide."
+    ];
+
+  public function isValid($value) {
+    $this->_setValue((string)$value);
+
+    $service = new Class_WebService_ActivityPub((string)$value);
+    if ($service->isValid())
+      return true;
+
+    $this->_error(static::INVALID_VALUE);
+  }
+}
diff --git a/library/ZendAfi/Validate/Url.php b/library/ZendAfi/Validate/Url.php
index 8d20c40c254cd26701e3a2b1da88a5e97c5c8b50..eb56e38ff342304b2c1fcdc2985a25245d6c77da 100644
--- a/library/ZendAfi/Validate/Url.php
+++ b/library/ZendAfi/Validate/Url.php
@@ -18,6 +18,8 @@
  * along with BOKEH; if not, write to the Free Software
  * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
  */
+
+
 class ZendAfi_Validate_Url extends Zend_Validate_Abstract {
   const INVALID_URL = 'invalidUrl';
 
@@ -39,4 +41,3 @@ class ZendAfi_Validate_Url extends Zend_Validate_Abstract {
     return true;
   }
 }
-?>
\ No newline at end of file
diff --git a/library/ZendAfi/View/Helper/Admin/ContentNav.php b/library/ZendAfi/View/Helper/Admin/ContentNav.php
index e763e735787452eab58df1d92cad33ef9c7b76ee..af75679b6b0e9d53de89b5842c5fcee0e5f11639 100644
--- a/library/ZendAfi/View/Helper/Admin/ContentNav.php
+++ b/library/ZendAfi/View/Helper/Admin/ContentNav.php
@@ -32,6 +32,7 @@ class ZendAfi_View_Helper_Admin_ContentNav extends ZendAfi_View_Helper_BaseHelpe
                 $this->menuMiseEnPage(),
                 $this->menuStats(),
                 $this->menuPortail(),
+                $this->menuFederation(),
                 $this->menuCatalogue(),
                 $this->menuSysteme(),
     ];
@@ -131,6 +132,15 @@ class ZendAfi_View_Helper_Admin_ContentNav extends ZendAfi_View_Helper_BaseHelpe
   }
 
 
+  protected function menuFederation() {
+    return $this
+      ->renderBloc($this->_('Fédération'),
+                   [
+                    ['frbr', $this->_('Avis'),        '/admin/federation-reviews'],
+                   ]);
+  }
+
+
   public function menuSysteme() {
     $is_admin = function($user) { return $user->isAdmin(); };
     $is_super_admin = function($user) { return $user->isSuperAdmin(); };
@@ -160,7 +170,8 @@ class ZendAfi_View_Helper_Admin_ContentNav extends ZendAfi_View_Helper_BaseHelpe
                     ['search_form', $this->_('Formulaires de recherche'), '/admin/search-form'],
 
                     ['customfields', $this->_('Champs personnalisés'), '/admin/custom-fields/index', [], $is_admin],
-                    ['customreports', $this->_('Rapports statistiques'), '/admin/custom-fields-report']
+                    ['customreports', $this->_('Rapports statistiques'), '/admin/custom-fields-report'],
+                    ['variables', $this->_('Journal'), '/admin/journal']
                    ]);
   }
 
diff --git a/library/ZendAfi/View/Helper/Admin/HelpLink.php b/library/ZendAfi/View/Helper/Admin/HelpLink.php
index 4635bab7b5498c6c7dac8c17e27d514123f815cc..521275a5c37810be41524215d59cde2d143d03d3 100644
--- a/library/ZendAfi/View/Helper/Admin/HelpLink.php
+++ b/library/ZendAfi/View/Helper/Admin/HelpLink.php
@@ -123,6 +123,7 @@ class ZendAfi_View_Helper_Admin_HelpLinkBokehWiki {
      'registration'           => ['index' => 'Gérer_les_demandes_d\'inscription'],
      'usergroup-agenda'       => ['index' => 'Rendez-vous'],
      'rendez-vous'            => ['index' => 'Rendez-vous'],
+     'federation-reviews'     => ['index' => 'Avis_communautaires'],
      'genre'                  => ['index' => 'Codification_des_genres,_emplacements,_annexes,_...'],
      'emplacement'            => ['index' => 'Codification_des_genres,_emplacements,_annexes,_...'],
      'section'                => ['index' => 'Codification_des_genres,_emplacements,_annexes,_...'],
diff --git a/library/ZendAfi/View/Helper/Avis.php b/library/ZendAfi/View/Helper/Avis.php
index d18e4b4e2926520cc0ddd7db1e53e182f77b5c4c..2cfe418a97a7aa306bd77b4567201cb772a24d49 100644
--- a/library/ZendAfi/View/Helper/Avis.php
+++ b/library/ZendAfi/View/Helper/Avis.php
@@ -170,6 +170,9 @@ class ZendAfi_View_Helper_Avis extends ZendAfi_View_Helper_BaseHelper {
 
 
   protected function _renderAuthor($avis) {
+    if ($avis->hasSourceAuthor())
+      return $this->_renderFederationAuthor($avis);
+
     $auteur = $this->view->escape($avis->getUserName());
     $url_auteur = $this->_urlWithContext($this->_getUrlAuthor($avis));
 
@@ -186,6 +189,16 @@ class ZendAfi_View_Helper_Avis extends ZendAfi_View_Helper_BaseHelper {
   }
 
 
+  protected function _renderFederationAuthor($avis) {
+    $html = $avis->getSourceAuthor()
+      . ' '
+      . $this->_tag('span', '- ' . $avis->getReadableDateAvis());
+
+    return $this->_tag('span', $html,
+                       ['class' => 'auteur_critique']);
+  }
+
+
   protected function _getUrlAuthor($avis) {
     if ($avis->isAvisNotice())
       return ['module' => 'opac',
@@ -325,4 +338,4 @@ class ZendAfi_View_Helper_Avis extends ZendAfi_View_Helper_BaseHelper {
     return ['text_avis' => nl2br($content),
             'lire_la_suite' => $read_link];
   }
-}
\ No newline at end of file
+}
diff --git a/library/ZendAfi/View/Helper/Notice/Avis.php b/library/ZendAfi/View/Helper/Notice/Avis.php
index 6af6890666640a4a5d81ddacc27d369b273b0553..0cb70c40746196de892a1282c86d2f59b7f4831f 100644
--- a/library/ZendAfi/View/Helper/Notice/Avis.php
+++ b/library/ZendAfi/View/Helper/Notice/Avis.php
@@ -18,9 +18,7 @@
  * along with BOKEH; if not, write to the Free Software
  * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
  */
-class ZendAfi_View_Helper_Notice_Avis extends Zend_View_Helper_HtmlElement {
-  use Trait_Translator;
-
+class ZendAfi_View_Helper_Notice_Avis extends ZendAfi_View_Helper_BaseHelper {
   protected $_notice, $_avis, $_params;
 
   public function Notice_Avis($notice, $avis, $params)  {
@@ -31,8 +29,7 @@ class ZendAfi_View_Helper_Notice_Avis extends Zend_View_Helper_HtmlElement {
     $user = Class_Users::getIdentity();
 
     return $this->_tag('table',
-                       $this->_header($user)
-                       . $this->_advices($user),
+                       $this->_header($user) . $this->_advices($user),
                        ['cellspacing' => 0, 'width' => '100%']);
   }
 
@@ -45,43 +42,40 @@ class ZendAfi_View_Helper_Notice_Avis extends Zend_View_Helper_HtmlElement {
     if ($user && $user->isBibliothecaire())
       $avis_helper->setAdminActions(['edit', 'del']);
 
-    $html = $this->_tag('tr',
-                        $this->_tag('td', '&nbsp;', ['colspan' => 3]))
-      . $this->_tag('tr',
-                        $this->_tag('td', $this->_avis[$source]["titre"],
-                                    ['class' => 'notice_info_ligne_titre',
-                                     'align' => 'left',
-                                     'colspan' => 3]));
-
-    if (0 == $this->_avis[$source]['nombre']) {
-      return $html .= $this->_tag('tr',
-                                  $this->_tag('td', '&nbsp;', ['colspan' => 3]))
-        . $this->_tag('tr',
-                      $this->_tag('td', $this->_('Aucun avis pour le moment'),
-                                  ['colspan' => 3,
-                                   'class' => 'notice_info',
-                                   'style' => 'text-align:left']))
-        . $this->_tag('tr',
-                      $this->_tag('td', '&nbsp;', ['colspan' => 3]));
-    }
+    $reviews_set = array_key_exists($source, $this->_avis)
+      ? $this->_avis[$source]
+      : Class_Notice_ReviewsSet::emptyInstance();
+
+    $html = $this->_tag('tr', $this->_tag('td', '&nbsp;', ['colspan' => 3]))
+      . $this->_tag('tr', $this->_tag('td', $reviews_set->getLabel(),
+                                      ['class' => 'notice_info_ligne_titre',
+                                       'align' => 'left',
+                                       'colspan' => 3]));
 
-    foreach($this->_avis[$source]["liste"] as $detail) {
+    if ($reviews_set->isEmpty())
+      return $html .= $this->_tag('tr', $this->_tag('td', '&nbsp;', ['colspan' => 3]))
+        . $this->_tag('tr', $this->_tag('td', $this->_('Aucun avis pour le moment'),
+                                        ['colspan' => 3,
+                                         'class' => 'notice_info',
+                                         'style' => 'text-align:left']))
+        . $this->_tag('tr', $this->_tag('td', '&nbsp;', ['colspan' => 3]));
+
+
+    foreach($reviews_set->getReviews() as $detail) {
       if ($detail->isVisibleForUser($user))
         $html .= $this->_tag('tr',
                              $this->_tag('td', $avis_helper->contenu_avis($detail),
                                          ['colspan' => 3]));
     }
 
-    if (isset($this->_avis[$source]["nb_pages"])
-        && ($this->_avis[$source]["nb_pages"] > 1)) {
+    if ($reviews_set->hasPages()) {
       $pager = $this->view->getHelper('Pager');
-      $lien = "javascript:".$fct."('".$this->_params["onglet"]."','".$this->_notice->getId()."','avis','".$source."',1,@PAGE@)";
-      $urlPagesHtml = $pager->Pager($this->_avis[$source]["nombre"], 5, $this->_params["page"], $lien);
-      $html .= $this->_tag('tr',
-                           $this->_tag('td', $urlPagesHtml,
-                                       ['colspan' => 3,
-                                        'class' => 'notice_info',
-                                        'style' => 'text-align:center']));
+      $lien = "javascript:" . $this->_jsFunction() . "('".$this->_params["onglet"]."','".$this->_notice->getId()."','avis','".$source."',1,@PAGE@)";
+      $urlPagesHtml = $pager->Pager($reviews_set->getCount(), 5, $this->_params["page"], $lien);
+      $html .= $this->_tag('tr', $this->_tag('td', $urlPagesHtml,
+                                             ['colspan' => 3,
+                                              'class' => 'notice_info',
+                                              'style' => 'text-align:center']));
     }
 
     return $html;
@@ -92,11 +86,12 @@ class ZendAfi_View_Helper_Notice_Avis extends Zend_View_Helper_HtmlElement {
     if (array_key_exists('cherche', $this->_params))
       return $this->_params['cherche'];
 
-    if($this->_avis["bib"]["nombre"] > 0)
+    if (!$this->_avis['bib']->isEmpty())
       return 'bib';
 
-    if($this->_avis["abonne"]["nombre"] > 0)
+    if (!$this->_avis['abonne']->isEmpty())
       return 'abonne';
+
     return null;
   }
 
@@ -117,20 +112,15 @@ class ZendAfi_View_Helper_Notice_Avis extends Zend_View_Helper_HtmlElement {
 
   protected function _headerSources() {
     $html = '';
-    foreach($this->_avis as $source => $ligne) {
-      $count = $this->_avis[$source]['nombre'];
-      if (0 == $count)
+    foreach($this->_avis as $key => $reviews_set) {
+      if ($reviews_set->isEmpty())
         continue;
 
-      $fct = (isset($this->_params['onglet'])
-              && (substr($this->_params['onglet'], 0, 4)) == 'bloc') ?
-        'infos_bloc' : 'infos_onglet';
-
-      $url_site = "javascript:".$fct."('" . $this->_params["onglet"]."','".$this->_notice->getId() . "','avis','" . $source . "',1,1)";
+      $url_site = "javascript:" . $this->_jsFunction() . "('" . $this->_params["onglet"]."','".$this->_notice->getId() . "','avis','" . $key . "',1,1)";
 
-      $html .= $this->_headerSource($this->_avis[$source]["titre"],
-                                    $this->_getAdviceCountLabel($count),
-                                    $ligne['note'],
+      $html .= $this->_headerSource($reviews_set->getLabel(),
+                                    $this->_getAdviceCountLabel($reviews_set->getCount()),
+                                    $reviews_set->getRating(),
                                     $url_site);
     }
 
@@ -138,6 +128,14 @@ class ZendAfi_View_Helper_Notice_Avis extends Zend_View_Helper_HtmlElement {
   }
 
 
+  protected function _jsFunction() {
+    return (isset($this->_params['onglet'])
+            && (substr($this->_params['onglet'], 0, 4)) == 'bloc')
+      ? 'infos_bloc'
+      : 'infos_onglet';
+  }
+
+
   protected function _headerSource($label, $count, $note, $url) {
     return $this->_tag('li',
                        $this->view->NoteImg($note) . '&nbsp;&nbsp;'
@@ -171,6 +169,7 @@ class ZendAfi_View_Helper_Notice_Avis extends Zend_View_Helper_HtmlElement {
     return '';
   }
 
+
   protected function getLink($id_notice) {
     return $this->view->tagAnchor($this->view->url(['controller' => 'noticeajax',
                                                     'action' => 'add-avis',
@@ -179,10 +178,4 @@ class ZendAfi_View_Helper_Notice_Avis extends Zend_View_Helper_HtmlElement {
                                   ['class' => 'notice',
                                    'data-popup' => 'true']);
   }
-
-
-  protected function _tag() {
-    return call_user_func_array([$this->view, 'tag'], func_get_args());
-  }
 }
-?>
\ No newline at end of file
diff --git a/library/activitystreams b/library/activitystreams
new file mode 160000
index 0000000000000000000000000000000000000000..fb573517b032e10ad2630b7d1e2440b9e6e28a63
--- /dev/null
+++ b/library/activitystreams
@@ -0,0 +1 @@
+Subproject commit fb573517b032e10ad2630b7d1e2440b9e6e28a63
diff --git a/library/phpseclib b/library/phpseclib
new file mode 160000
index 0000000000000000000000000000000000000000..bc6ac8edfdea41760743acedd8e431677ebef75d
--- /dev/null
+++ b/library/phpseclib
@@ -0,0 +1 @@
+Subproject commit bc6ac8edfdea41760743acedd8e431677ebef75d
diff --git a/tests/application/modules/AbstractControllerTestCase.php b/tests/application/modules/AbstractControllerTestCase.php
index 2e9a9a47feffa686971862aeef2c1c5025f3bc59..40e8e4d11bcd9260581eb05964fc3cf98d48c31a 100644
--- a/tests/application/modules/AbstractControllerTestCase.php
+++ b/tests/application/modules/AbstractControllerTestCase.php
@@ -197,6 +197,17 @@ abstract class AbstractControllerTestCase extends Zend_Test_PHPUnit_ControllerTe
     return $this->dispatch($url, true);
   }
 
+
+  public function postDispatchRaw($url, $data, $headers=[]) {
+    $this->getRequest()
+         ->setMethod('POST')
+         ->setRawBody($data)
+         ->setHeaders($headers);
+
+    return $this->dispatch($url, true);
+  }
+
+
   /**
    * Retourne la valeur du header Location
    * @return String
@@ -488,5 +499,3 @@ abstract class AbstractControllerTestCase extends Zend_Test_PHPUnit_ControllerTe
     var_dump('XHProfile data: ' . BASE_URL."/xhprof/xhprof_html/index.php?run={$run_id}&source=xhprof_testing");
   }
 }
-
-?>
\ No newline at end of file
diff --git a/tests/application/modules/admin/controllers/AdminAvisModerationControllerTest.php b/tests/application/modules/admin/controllers/AdminAvisModerationControllerTest.php
index 3cbedea6db63b24887a68ca650206547b7342b85..1751f5bce64de6444296ee4ddbd734e23d7502b5 100644
--- a/tests/application/modules/admin/controllers/AdminAvisModerationControllerTest.php
+++ b/tests/application/modules/admin/controllers/AdminAvisModerationControllerTest.php
@@ -70,7 +70,8 @@ abstract class AdminAvisModerationControllerTestCase extends AbstractControllerT
                                             'date_avis' => '2005-03-27',
                                             'abon_ou_bib' => 0,
                                             'statut' => 0,
-                                            'note' => 4]);
+                                            'note' => 4,
+                                            'source_author' => null]);
 
     $this->avis_marcus_routard = $this->fixture('Class_AvisNotice',
                                                 ['id' => 42,
@@ -81,7 +82,8 @@ abstract class AdminAvisModerationControllerTestCase extends AbstractControllerT
                                                  'date_avis' => '2010-07-21',
                                                  'abon_ou_bib' => 0,
                                                  'statut' => 0,
-                                                 'note' => 2]);
+                                                 'note' => 2,
+                                                 'source_author' => null]);
 
   }
 }
diff --git a/tests/application/modules/admin/controllers/JournalControllerTest.php b/tests/application/modules/admin/controllers/JournalControllerTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..795032bb8541b68b5c5f15beac612837af04de39
--- /dev/null
+++ b/tests/application/modules/admin/controllers/JournalControllerTest.php
@@ -0,0 +1,59 @@
+<?php
+/**
+ * Copyright (c) 2012, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+
+class Admin_JournalControllerIndexTest extends Admin_AbstractControllerTestCase {
+  protected $_storm_default_to_volatile = true;
+
+  public function setUp() {
+    parent::setUp();
+
+    Class_Journal::setTimeSource(new TimeSourceForTest('2018-07-06 15:18:04'));
+    Class_Journal::newInstance(['type' => 'REVIEW_SAVE'])->assertSave();
+
+    $this->dispatch('/admin/journal', true);
+  }
+
+
+  public function tearDown() {
+    Class_Journal::setTimeSource(null);
+    parent::tearDown();
+  }
+
+
+  /** @test */
+  public function titleShouldContainsJournal() {
+    $this->assertXPathContentContains('//h1', 'Journal d\'évènements');
+  }
+
+
+  /** @test */
+  public function firstEventTypeShouldBePresent() {
+    $this->assertXPathContentContains('//td', 'REVIEW_SAVE');
+  }
+
+
+  /** @test */
+  public function firstEventCreatedAtShouldBePresent() {
+    $this->assertXPathContentContains('//td', '2018-07-06 15:18:04',
+                                      $this->_response->getBody());
+  }
+}
\ No newline at end of file
diff --git a/tests/application/modules/admin/controllers/ModoControllerTest.php b/tests/application/modules/admin/controllers/ModoControllerTest.php
index 91df915f932228b6fcfa8af3afa2cfa65ea3dc70..e5fe26e09ec117fba6726d929124ce0e32f1195d 100644
--- a/tests/application/modules/admin/controllers/ModoControllerTest.php
+++ b/tests/application/modules/admin/controllers/ModoControllerTest.php
@@ -56,7 +56,8 @@ abstract class ModoControllerIndexActionTestCase extends Admin_AbstractControlle
                                         'id_user' => null,
                                         'avis' => 'Ce livre est vraiment bien !',
                                         'statut' => 0,
-                                        'abon_ou_bib' => 1]);
+                                        'abon_ou_bib' => 1,
+                                        'source_author' => null]);
 
     $this->fixture('Class_AvisNotice', ['id' => 223,
                                         'id_notice' => 1002,
@@ -67,7 +68,8 @@ abstract class ModoControllerIndexActionTestCase extends Admin_AbstractControlle
                                         'avis' => ' Pour faire aimer la biere aux enfants!',
                                         'id_notice' => 1032,
                                         'statut' => 1,
-                                        'abon_ou_bib' => 1]);
+                                        'abon_ou_bib' => 1,
+                                        'source_author' => null]);
 
     $this->fixture('Class_Notice', ['id' => 1032,
                                     'titre_principal' => 'B comme bière : la bière expliquée aux (grands) enfants']);
@@ -106,7 +108,8 @@ abstract class ModoControllerIndexActionTestCase extends Admin_AbstractControlle
                                           'abon_ou_bib' => 0,
                                           'date_avis' => $i,
                                           'id_notice' => 1032,
-                                          'statut' => 1]);
+                                          'statut' => 1,
+                                          'source_author' => null]);
   }
 }
 
@@ -745,7 +748,8 @@ class ModoControllerAvisnoticeActionTest extends Admin_AbstractControllerTestCas
                                         'avis' => 'Un bon livre !',
                                         'id_notice' => 1032,
                                         'statut' => 0,
-                                        'abon_ou_bib' => 1]);
+                                        'abon_ou_bib' => 1,
+                                        'source_author' => null]);
 
     $this->dispatch('admin/modo/avisnotice', true);
   }
diff --git a/tests/application/modules/opac/controllers/AbonneControllerAvisTest.php b/tests/application/modules/opac/controllers/AbonneControllerAvisTest.php
index 44fe54f783d61da5f369849971f8d7aaa0b92526..07955802c1f1bbfb089e72328d2247eb81e9190e 100644
--- a/tests/application/modules/opac/controllers/AbonneControllerAvisTest.php
+++ b/tests/application/modules/opac/controllers/AbonneControllerAvisTest.php
@@ -51,7 +51,6 @@ abstract class AbonneFlorenceIsLoggedControllerTestCase extends AbstractControll
                                       'fiche_sigb' => ['type_com' => 0]]);
 
     ZendAfi_Auth::getInstance()->logUser($this->florence);
-
   }
 }
 
@@ -61,9 +60,8 @@ abstract class AbonneControllerAvisTestCase extends AbonneFlorenceIsLoggedContro
   public function setUp() {
     parent::setUp();
 
-    $this->potter = Class_Notice::newInstanceWithId(53,
-                                                    ['clef_oeuvre' =>'POTTER']);
-    $this->potter->save();
+    $this->potter = $this->fixture('Class_Notice',
+                                   ['id' => 53, 'clef_oeuvre' => 'POTTER']);
   }
 }
 
@@ -104,24 +102,14 @@ class AbonneControllerAvisNoticeWithoutAvisTest extends AbonneControllerAvisTest
 
 
 
-class AbonneControllerAvisInvalidNoticeAvisSaveTest extends  AbonneControllerAvisTestCase {
+class AbonneControllerAvisInvalidNoticeAvisSaveTest extends AbonneControllerAvisTestCase {
   public $xpath,$json;
+
   public function setUp() {
     parent::setUp();
 
-    $this->avis_min_saisie = new Class_AdminVar();
-    $this->avis_min_saisie
-      ->setId('AVIS_MIN_SAISIE')
-      ->setValeur(10);
-
-    $this->avis_max_saisie = new Class_AdminVar();
-    $this->avis_max_saisie
-      ->setId('AVIS_MAX_SAISIE')
-      ->setValeur(1200);
-
-    Class_AdminVar::getLoader()
-      ->cacheInstance($this->avis_min_saisie)
-      ->cacheInstance($this->avis_max_saisie);
+    Class_AdminVar::set('AVIS_MIN_SAISIE', 10);
+    Class_AdminVar::set('AVIS_MAX_SAISIE', 1200);
   }
 
 
@@ -130,11 +118,10 @@ class AbonneControllerAvisInvalidNoticeAvisSaveTest extends  AbonneControllerAvi
              'avisTexte' => 'On adore',
              'avisNote' => 5,
              'avisSignature' => 'FloCouv'];
+
     $this->xpath = new Storm_Test_XPath();
-    $this->getRequest()
-         ->setMethod('POST')
-         ->setPost($data);
-    $this->dispatch('/opac/abonne/avis/id_notice/53/render/popup');
+    $this->postDispatch('/opac/abonne/avis/id_notice/53/render/popup', $data);
+
     $this->json = json_decode($this->_response->getbody());
     $this->assertController('abonne');
     $this->assertAction('avis');
@@ -144,68 +131,58 @@ class AbonneControllerAvisInvalidNoticeAvisSaveTest extends  AbonneControllerAvi
   }
 
   public function testEmptyEntete() {
-    $data = array('avisEntete' => '',
-                  'avisTexte' => 'On adore la magie',
-                  'avisNote' => 5,
-                  'avisSignature' => '');
+    $data = ['avisEntete' => '',
+             'avisTexte' => 'On adore la magie',
+             'avisNote' => 5,
+             'avisSignature' => ''];
+
     $this->xpath = new Storm_Test_XPath();
-    $this->getRequest()
-         ->setMethod('POST')
-         ->setPost($data);
-    $this->dispatch('/opac/abonne/avis/id_notice/53/render/popup');
+    $this->postDispatch('/opac/abonne/avis/id_notice/53/render/popup', $data);
+
     $this->json = json_decode($this->_response->getbody());
     $this->assertController('abonne');
     $this->assertAction('avis');
-    $this->xpath->assertXPathContentContains($this->json->content,'//p[@class="error"]','Vous devez saisir un titre',$this->_response->getBody());
+    $this->xpath->assertXPathContentContains($this->json->content,
+                                             '//p[@class="error"]','Vous devez saisir un titre',
+                                             $this->_response->getBody());
   }
 }
 
 
 
-class AbonneControllerAvisNoticeAvisSaveTest extends  AbonneControllerAvisTestCase {
-  public function testSaveNewAvis() {
-    $expected_avis = Class_AvisNotice::newInstance();
-    $expected_avis
-      ->setEntete('Sorcellerie')
-      ->setAvis('On adore la magie')
-      ->setNote(5)
-      ->setClefOeuvre('POTTER')
-      ->setUser($this->florence)
-      ->setAbonOuBib(1)
-      ->setStatut(0);
-    $expected_avis->save();
-    $this->postAndAssertAvisIsSaved($expected_avis);
-  }
-
-
-  public function postAndAssertAvisIsSaved($expected_avis) {
+class AbonneControllerAvisNoticeAvisSaveTest extends AbonneControllerAvisTestCase {
+  /** @test */
+  public function newAvisShouldBeSaved() {
     $data = ['avisEntete' => 'Sorcellerie',
              'avisTexte' => 'On adore la magie',
              'avisNote' => 5,
              'avisSignature' => 'FloCouv'];
 
-    $this->getRequest()
-         ->setMethod('POST')
-         ->setPost($data);
-    $this->dispatch('/opac/abonne/avis/id_notice/53');
-
-    $this->assertEquals('FloCouv', $this->florence->getPseudo());
+    $this->postDispatch('/opac/abonne/avis/id_notice/53', $data);
+    $this->assertNotNull(Class_AvisNotice::findFirstBy(['entete' => 'Sorcellerie']));
   }
 
 
-  public function testSaveExistingAvis() {
-
-    $expected_avis = Class_AvisNotice::newInstanceWithId(12);
-    $expected_avis
-      ->setEntete('Sorcellerie')
-      ->setAvis('On adore la magie')
-      ->setNote(5)
-      ->setClefOeuvre('POTTER')
-      ->setUser($this->florence)
-      ->setAbonOuBib(1)
-      ->setStatut(0);
+  /** @test */
+  public function existingAvisShouldBeUpdated() {
+    $this->fixture('Class_AvisNotice',
+                   ['id' => 12,
+                    'entete' => 'Sorcellerie',
+                    'avis' => 'On adore la magie',
+                    'note' => 5,
+                    'clef_oeuvre' => 'POTTER',
+                    'user' => $this->florence,
+                    'abon_ou_bib' => 1,
+                    'statut' => 0,
+                    'source_author' => null]);
+
+    $data = ['avisEntete' => 'Sorcellerie mais pas trop',
+             'avisTexte' => 'On adore la magie',
+             'avisNote' => 4,
+             'avisSignature' => 'FloCouv'];
 
-    $this->postAndAssertAvisIsSaved($expected_avis);
+    $this->postDispatch('/opac/abonne/avis/id_notice/53', $data);
+    $this->assertEquals(4, Class_AvisNotice::find(12)->getNote());
   }
 }
 
@@ -220,7 +197,8 @@ class AbonneControllerAvisNoticeWithAvisTest extends AbonneControllerAvisTestCas
                                                 'Entete' => 'Le sorcier super mimi',
                                                 'note' => 4,
                                                 'clef_oeuvre' => 'POTTER',
-                                                'user' => $this->florence]);
+                                                'user' => $this->florence,
+                                                'source_author' => null]);
     $this->dispatch('/opac/abonne/avis/id_notice/53/render/popup');
     $this->_xpath = new Storm_Test_XPath();
     $this->_json = json_decode($this->_response->getBody());
@@ -308,6 +286,7 @@ abstract class AvisControllersFixturesTestCase extends AbonneFlorenceIsLoggedCon
                                                                  'user' => $this->florence,
                                                                  'statut' => 0,
                                                                  'abon_ou_bib'=>1 ,
+                                                                 'source_author' => null,
                                                                  'notices' => [$this->millenium,
                                                                                $this->millenium_with_vignette] ]);
 
@@ -319,14 +298,15 @@ abstract class AvisControllersFixturesTestCase extends AbonneFlorenceIsLoggedCon
 
     $this->avis_potter = $this->fixture('Class_AvisNotice',
                                         ['id' => 25,
-                                        'entete' => 'Prenant',
-                                        'avis' => "Mais un peu trop naïf",
-                                        'note'=>4,
-                                        'date_avis' => '2010-10-12 10:00:00',
-                                        'user'=>$this->florence,
-                                        'statut' => 1,
-                                        'abon_out_bib' => 1,
-                                        'notices' => [$this->potter]]);
+                                         'entete' => 'Prenant',
+                                         'avis' => "Mais un peu trop naïf",
+                                         'note'=>4,
+                                         'date_avis' => '2010-10-12 10:00:00',
+                                         'user'=>$this->florence,
+                                         'statut' => 1,
+                                         'abon_out_bib' => 1,
+                                         'source_author' => null,
+                                         'notices' => [$this->potter]]);
 
     $lost=$this->fixture('Class_AvisNotice', ['id' => 178,
                                               'entete' => "Lost highway",
@@ -338,6 +318,7 @@ abstract class AvisControllersFixturesTestCase extends AbonneFlorenceIsLoggedCon
                                               'statut' => 1,
                                               'flags' =>  Class_AvisNotice::ORPHAN_FLAG,
                                               'abon_ou_bib'=>1,
+                                              'source_author' => null,
                                               'id_notice' => 30]);
 
 
@@ -351,6 +332,7 @@ abstract class AvisControllersFixturesTestCase extends AbonneFlorenceIsLoggedCon
                                          'statut' => 1,
                                          'abon_out_bib' => 1,
                                          'flags' => 1,
+                                         'source_author' => null,
                                          'notices' => []]);
 
 
@@ -372,6 +354,7 @@ abstract class AvisControllersFixturesTestCase extends AbonneFlorenceIsLoggedCon
                                              'user' => $dupont,
                                              'statut' => 0,
                                              'abon_out_bib' => 1,
+                                             'source_author' => null,
                                              'notices' =>[$this->millenium]
                                             ]);
     Storm_Test_ObjectWrapper::onLoaderOfModel('Class_Notice')
@@ -845,7 +828,8 @@ class AbonneControllerAvisBlogControllerViewReadAvisTest extends  AbonneFlorence
       ->setUser($this->florence)
       ->setAbonOuBib(1)
       ->setStatut(0)
-      ->setNotices([$millenium]);
+      ->setNotices([$millenium])
+      ->setSourceAuthor(null);
   }
 
 
@@ -913,7 +897,8 @@ class AbonneControllerEditAvisNoticeNotAdminLoggedActionTest extends AbstractCon
     parent::setUp();
     $avis = $this->fixture('Class_AvisNotice', ['id' => 54,
                                                 'entete' => 'Bonjour !',
-                                                'avis' => 'Ceci est le contenu de l\'avis']);
+                                                'avis' => 'Ceci est le contenu de l\'avis',
+                                                'source_author' => null]);
     $this->dispatch('/opac/abonne/editavisnotice/id/54', true);
   }
 
@@ -932,7 +917,8 @@ class AbonneControllerEditAvisNoticeAdminLoggedActionTest extends AbstractContro
     parent::setUp();
     $avis = $this->fixture('Class_AvisNotice', ['id' => 54,
                                                 'entete' => 'Bonjour !',
-                                                'avis' => 'Ceci est le contenu de l\'avis']);
+                                                'avis' => 'Ceci est le contenu de l\'avis',
+                                                'source_author' => null]);
 
     $this->dispatch('/opac/abonne/editavisnotice/id/54', true);
     $this->json = json_decode($this->_response->getBody());
@@ -985,6 +971,7 @@ class AbonneControllerEditAvisNoticeAdminLoggedPostActionTest extends AbstractCo
                                         'entete' => 'Bonjour !',
                                         'avis' => 'Ceci est le contenu de l\'avis',
                                         'note' => 1,
+                                        'source_author' => null,
                                         'clef_oeuvre' => 'HUITIEMECOULEURLA-DASTYLE']);
 
     $this->fixture('Class_Notice', ['id' => 1190178,
@@ -1034,7 +1021,8 @@ class AbonneControllerDeleteAvisNoticeAdminLoggedActionTest extends AbstractCont
     $_SERVER['HTTP_REFERER'] ='opac/recherche/viewnotice/id/1';
     $avis = $this->fixture('Class_AvisNotice', ['id' => 54,
                                                 'entete' => 'Bonjour !',
-                                                'avis' => 'Ceci est le contenu de l\'avis']);
+                                                'avis' => 'Ceci est le contenu de l\'avis',
+                                                'source_author' => null]);
     $this->dispatch('/opac/abonne/delavisnotice/id/54/expressionRecherche/1', true);
   }
 
diff --git a/tests/application/modules/opac/controllers/BlogControllerTest.php b/tests/application/modules/opac/controllers/BlogControllerTest.php
index 6144366da1567f14fad2bcb3d12ee21cdf43f6a8..6516c32892c079d1f87c27c830476a50e3d1a901 100644
--- a/tests/application/modules/opac/controllers/BlogControllerTest.php
+++ b/tests/application/modules/opac/controllers/BlogControllerTest.php
@@ -76,6 +76,7 @@ class BlogControllerHierarchicalTest extends AbstractControllerTestCase {
                     'statut' => 1,
                     'date_avis' => '2015-05-18 00:00:00',
                     'id_user' => 3,
+                    'source_author' => null,
                     'clef_oeuvre' => $this->ksp->getClefOeuvre()]);
 
     $this->fixture('Class_AvisNotice',
@@ -86,6 +87,7 @@ class BlogControllerHierarchicalTest extends AbstractControllerTestCase {
                     'statut' => 1,
                     'date_avis' => '2016-05-18 00:00:00',
                     'id_user' => 3,
+                    'source_author' => null,
                     'clef_oeuvre' => $this->ksp->getClefOeuvre()]);
 
     $this->fixture('Class_AvisNotice',
@@ -96,6 +98,7 @@ class BlogControllerHierarchicalTest extends AbstractControllerTestCase {
                     'statut' => 1,
                     'date_avis' => '2014-05-18 00:00:00',
                     'id_user' => 3,
+                    'source_author' => null,
                     'clef_oeuvre' => $this->ksp->getClefOeuvre()]);
 
     $this->fixture('Class_Users',
diff --git a/tests/application/modules/opac/controllers/NoticeAjaxControllerTest.php b/tests/application/modules/opac/controllers/NoticeAjaxControllerTest.php
index 49a564d145b07f1986af514bae316a60d905c1a6..7b77a6cccd9e86594844f4ad9821a9d7b38d5a59 100644
--- a/tests/application/modules/opac/controllers/NoticeAjaxControllerTest.php
+++ b/tests/application/modules/opac/controllers/NoticeAjaxControllerTest.php
@@ -1536,6 +1536,8 @@ abstract class NoticeAjaxControllerNoticeWithAvisTestCase
                                     'clef_oeuvre' => 'potter',
                                     'abon_ou_bib' => '0',
                                     'statut' => 1,
+                                    'source_actor_id' => null,
+                                    'source_author' => null,
                                    ]);
 
     $avis_de_francois = Class_AvisNotice::newInstanceWithId(24,
@@ -1545,20 +1547,155 @@ abstract class NoticeAjaxControllerNoticeWithAvisTestCase
                                                              'avis'=>'tres bien',
                                                              'date_avis'=>'16/02/2013',
                                                              'clef_oeuvre'=>'potter',
-                                                             'abon_ou_bib'=>'1'])
+                                                             'abon_ou_bib' => '1',
+                                                             'source_actor_id' => null])
       ->setModerationOk();
 
-    Class_Notice::newInstanceWithId(34,
-                                    ['titre_principal'=>'Potter',
-                                     'clef_oeuvre'=>'potter',
-                                     'avis' => [$avis_de_paul,
-                                                $avis_de_francois]]);
+    $this->fixture('Class_Notice', ['id' => 34,
+                                    'titre_principal' => 'Potter',
+                                    'clef_oeuvre' => 'potter']);
+  }
+}
+
+
+
+
+abstract class NoticeAjaxControllerNoticeWithAvisFederationReviewGuestLoggedTestCase
+  extends NoticeAjaxControllerNoticeWithAvisTestCase {
+
+  protected $_endpoint = 'https://commu.server.io/activitypub/reviews';
+
+  protected function _loginHook($account) {
+    $account->ROLE_LEVEL = ZendAfi_Acl_AdminControllerRoles::INVITE;
+    $account->ROLE = 'invite';
+  }
+
 
+  public function setUp() {
+    parent::setUp();
+
+    $this->fixture('Class_Journal',
+                   ['id' => 150,
+                    'type' => 'AP_REVIEW_DISPLAY_JOIN']);
+
+    $endpoint = $this->_endpoint;
+    Class_WebService_ActivityPub::setThrowErrors(true);
+
+    $signer = $this->mock()
+                   ->whenCalled('signRequest')->answers('MySignature')
+                   ->whenCalled('getKeyId')->answers($endpoint . '/pubkey')
+
+                   ->whenCalled('verify')
+                   ->with(['(request-target)' => 'get /activitypub/reviews/outbox'], 'TheirKey')
+                   ->answers(true);
+
+    Class_WebService_ActivityPub::setSigner($signer);
+
+    $client = $this->mock()
+                   ->whenCalled('open_url')
+                   ->with($endpoint,
+                          ['headers' => ['Accept' => Class_WebService_ActivityPub::MIME_TYPE]])
+                   ->answers(json_encode(['@context' => 'https://www.w3.org/ns/activitystreams',
+                                          'type' => 'Service',
+                                          'id' => $endpoint,
+                                          'inbox' => $endpoint . '/inbox',
+                                          'outbox' => $endpoint . '/outbox',
+                                          'publicKey' => $endpoint . '/pubkey']))
+
+                   ->whenCalled('open_url')
+                   ->with($endpoint . '/pubkey',
+                          ['headers' => ['Accept' => Class_WebService_ActivityPub::MIME_TYPE]])
+                   ->answers(json_encode(['@context' => 'https://www.w3.org/ns/activitystreams',
+                                          'type' => 'Key',
+                                          'id' => $endpoint . '/pubkey',
+                                          'owner' => $endpoint,
+                                          'publicKeyPem' => 'TheirKey']))
+
+                   ->whenCalled('getResponse')
+                   ->answers($this->mock()
+                             ->whenCalled('isError')->answers(false)
+                             ->whenCalled('getHeader')->with('Signature')->answers('TheirSignature')
+                             ->whenCalled('getHeaders')->answers([])
+                             ->whenCalled('getBody')
+                             ->answers(json_encode(['@context' => 'https://www.w3.org/ns/activitystreams',
+                                                    'type' => 'CollectionPage',
+                                                    'totalItems' => 16,
+                                                    'items' => [['date_avis' => '2019-04-19 17:46:27',
+                                                                 'entete' => 'Au top',
+                                                                 'avis' => 'trop bien !',
+                                                                 'note' => '4',
+                                                                 'abon_ou_bib' => 1,
+                                                                 'source_author' => 'Arcadia']]])));
+
+    Class_WebService_ActivityPub::setWebClient($client);
+  }
+
+
+  public function tearDown() {
+    Class_WebService_ActivityPub::setThrowErrors(false);
+    Class_WebService_ActivityPub::setSigner(null);
+    Class_WebService_ActivityPub::setWebClient(null);
+
+    parent::tearDown();
   }
 }
 
 
 
+
+class NoticeAjaxControllerNoticeWithAvisFederationReviewGuestLoggedDisabledTest
+  extends NoticeAjaxControllerNoticeWithAvisFederationReviewGuestLoggedTestCase {
+
+  public function setUp() {
+    parent::setUp();
+    Class_AdminVar::set('FEDERATION_COMMUNITY_SERVER', '');
+
+    $this->dispatch('/opac/noticeajax/avis/id_notice/34/page/0/onglet/bloc/cherche/federationreview', true);
+  }
+
+
+  /** @test */
+  public function shouldNotHaveCommunityReviews() {
+    $this->assertNotXPathContentContains('//a', 'Avis communautaires');
+  }
+
+
+  /** @test */
+  public function shouldNotCallWebservice() {
+    $this->assertFalse(Class_WebService_ActivityPub::getWebClient()
+                       ->methodHasBeenCalled('open_url'));
+  }
+}
+
+
+
+
+class NoticeAjaxControllerNoticeWithAvisFederationReviewGuestLoggedEnabledTest
+  extends NoticeAjaxControllerNoticeWithAvisFederationReviewGuestLoggedTestCase {
+
+  public function setUp() {
+    parent::setUp();
+    Class_AdminVar::set('FEDERATION_COMMUNITY_SERVER', $this->_endpoint);
+
+    $this->dispatch('/opac/noticeajax/avis/id_notice/34/page/0/onglet/bloc/cherche/federationreview', true);
+  }
+
+
+  /** @test */
+  public function shouldHaveCommunityReviews() {
+    $this->assertXPathContentContains('//a', 'Avis communautaires');
+  }
+
+
+  /** @test */
+  public function shouldHaveAuthorArcadia() {
+    $this->assertXPathContentContains('//span[@class="auteur_critique"]', 'Arcadia');
+  }
+}
+
+
+
+
 class NoticeAjaxControllerNoticeWithAvisEditLinkNotLoggedTest
   extends NoticeAjaxControllerNoticeWithAvisTestCase {
   /**
@@ -1630,11 +1767,12 @@ class NoticeAjaxControllerNoticeWithAvisWithModerationAndAuthorLostNotLoggedTest
 
 
 
-class NoticeAjaxControllerNoticeWithAvisEditLinkGuestLoggedTest extends NoticeAjaxControllerNoticeWithAvisTestCase {
+class NoticeAjaxControllerNoticeWithAvisEditLinkGuestLoggedTest
+  extends NoticeAjaxControllerNoticeWithAvisTestCase {
+
   protected function _loginHook($account) {
     $account->ROLE_LEVEL = ZendAfi_Acl_AdminControllerRoles::INVITE;
     $account->ROLE = 'invite';
-
   }
 
 
@@ -2495,4 +2633,4 @@ class NoticeAjaxControllerWithKiosqueInResumeTest extends AbstractControllerTest
     $this->dispatch('/noticeajax/detail/id/2', true);
     $this->assertXPathContentContains( '//dd', 'Ceci est un logiciel libre.');
   }
-}
\ No newline at end of file
+}
diff --git a/tests/application/modules/opac/controllers/RssControllerTest.php b/tests/application/modules/opac/controllers/RssControllerTest.php
index 7ae5b88f23826320166e4819fd1c9d6ed9b81607..8ebc1fdce4b73767657f87cff2adc104e99fe43a 100644
--- a/tests/application/modules/opac/controllers/RssControllerTest.php
+++ b/tests/application/modules/opac/controllers/RssControllerTest.php
@@ -366,24 +366,26 @@ class RssControllerCritiquesTest extends AbstractControllerTestCase {
     ]);
 
     $avis = [
-      $this->fixture('Class_AvisNotice', [
-        'id' => 1,
-        'id_user' => 1,
-        'avis' => 'Testing comment 1',
-        'entete' => 'Testing comment 1',
-        'note' => 4,
-        'clef_oeuvre' => 'testing-comment',
-        'date_avis' => '2012-01-01',
-      ]),
-      $this->fixture('Class_AvisNotice', [
-        'id' => 2,
-        'id_user' => 1,
-        'avis' => 'Testing comment 2',
-        'entete' => 'Testing comment 2',
-        'note' => 4,
-        'clef_oeuvre' => 'testing-comment',
-        'date_avis' => '2012-01-01',
-      ]),
+             $this->fixture('Class_AvisNotice', [
+                                                 'id' => 1,
+                                                 'id_user' => 1,
+                                                 'avis' => 'Testing comment 1',
+                                                 'entete' => 'Testing comment 1',
+                                                 'note' => 4,
+                                                 'clef_oeuvre' => 'testing-comment',
+                                                 'source_author' => null,
+                                                 'date_avis' => '2012-01-01',
+                                                 ]),
+             $this->fixture('Class_AvisNotice', [
+                                                 'id' => 2,
+                                                 'id_user' => 1,
+                                                 'avis' => 'Testing comment 2',
+                                                 'entete' => 'Testing comment 2',
+                                                 'note' => 4,
+                                                 'clef_oeuvre' => 'testing-comment',
+                                                 'date_avis' => '2012-01-01',
+                                                 'source_author' => null,
+                                                 ]),
     ];
 
     $avis[0]->setNotice($notice1);
diff --git a/tests/application/modules/telephone/controllers/BlogControllerTest.php b/tests/application/modules/telephone/controllers/BlogControllerTest.php
index 6f95518457fc5a8536a2465daf65a8c7abf78265..298492f889534b3485f0af998a1914e7c332b02a 100644
--- a/tests/application/modules/telephone/controllers/BlogControllerTest.php
+++ b/tests/application/modules/telephone/controllers/BlogControllerTest.php
@@ -16,7 +16,7 @@
  *
  * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
  * along with BOKEH; if not, write to the Free Software
- * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA 
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
  */
 
 require_once 'TelephoneAbstractControllerTestCase.php';
@@ -47,7 +47,8 @@ abstract class Telephone_BlogControllerAvisActionTestCase extends TelephoneAbstr
                              ->setNote(3)
                              ->setEntete('bien')
                              ->setAvis('bla bla')
-                             ->setUser($patouche)));
+                             ->setUser($patouche)
+                             ->setSourceAuthor(null)));
   }
 }
 
@@ -119,7 +120,7 @@ class Telephone_BlogControllerViewCritiquesActionTest extends Telephone_BlogCont
   public function actionShouldBeViewCritiques() {
     $this->assertAction('viewcritiques');
   }
-  
+
 }
 
 ?>
\ No newline at end of file
diff --git a/tests/db/UpgradeDBTest.php b/tests/db/UpgradeDBTest.php
index 2173d8d6dfb309de802abc781036f673c1987f1b..e573e0015211d005f209659a65c7c5c4cb9dbd29 100644
--- a/tests/db/UpgradeDBTest.php
+++ b/tests/db/UpgradeDBTest.php
@@ -2227,7 +2227,6 @@ class UpgradeDB_352_Test extends UpgradeDBTestCase {
 
 
 
-
 class UpgradeDB_353_Test extends UpgradeDBTestCase {
   public function prepare() {}
 
@@ -2792,4 +2791,131 @@ class UpgradeDB_378_Test extends UpgradeDBTestCase {
   public function tableExemplaireShouldHaveColumnIdDataProfileInt() {
     $this->assertFieldType('exemplaires', 'id_data_profile', 'int(11) unsigned');
   }
-}
\ No newline at end of file
+}
+
+
+
+
+class UpgradeDB_379_Test extends UpgradeDBTestCase {
+  public function prepare() {
+    $this->dropTable('journal');
+    $this->dropTable('journal_detail');
+    $this->dropTable('federation_group_membership');
+    $this->dropIndexedFieldFrom('notices_avis', 'source_actor_id');
+    $this->dropIndexedFieldFrom('notices_avis', 'source_author');
+    $this->dropIndexedFieldFrom('notices_avis', 'source_primary');
+  }
+
+
+  /** @test */
+  public function journalTableShouldExists() {
+    $this->assertTable('journal');
+  }
+
+
+  public function journalFields() {
+    return [['id', 'int(11) unsigned'],
+            ['type', 'varchar(255)'],
+            ['created_at', 'datetime']];
+  }
+
+
+  /**
+   * @test
+   * @dataProvider journalFields
+   */
+  public function journalFieldShouldExists($field, $type) {
+    $this->assertFieldType('journal', $field, $type);
+  }
+
+
+  public function journalIndexes() {
+    return [['type'], ['created_at']];
+  }
+
+
+  /**
+   * @test
+   * @dataProvider journalIndexes
+   */
+  public function journalIndexShouldExists($name) {
+    $this->assertIndex('journal', $name);
+  }
+
+
+  /** @test */
+  public function journalDetailTableShouldExists() {
+    $this->assertTable('journal_detail');
+  }
+
+
+  public function journalDetailFields() {
+    return [['id', 'int(11) unsigned'],
+            ['journal_id', 'int(11) unsigned'],
+            ['type', 'varchar(255)'],
+            ['value', 'text']];
+  }
+
+
+  /**
+   * @test
+   * @dataProvider journalDetailFields
+   */
+  public function journalDetailFieldShouldExists($field, $type) {
+    $this->assertFieldType('journal_detail', $field, $type);
+  }
+
+
+  public function journalDetailIndexes() {
+    return [['journal_id'], ['type']];
+  }
+
+
+  /**
+   * @test
+   * @dataProvider journalDetailIndexes
+   */
+  public function journalDetailIndexShouldExists($name) {
+    $this->assertIndex('journal_detail', $name);
+  }
+
+
+  /** @test */
+  public function federationGroupMembershipTableShouldExists() {
+    $this->assertTable('federation_group_membership');
+  }
+
+
+  public function federationGroupMembershipFields() {
+    return [['id', 'int(11) unsigned'],
+            ['group_name', 'varchar(255)'],
+            ['actor_id', 'varchar(255)'],
+            ['accepted_at', 'datetime']];
+  }
+
+
+  /**
+   * @test
+   * @dataProvider federationGroupMembershipFields
+   */
+  public function federationGroupMembershipFieldShouldExists($field, $type) {
+    $this->assertFieldType('federation_group_membership', $field, $type);
+  }
+
+
+  public function noticesAvisNewFields() {
+    return [['source_actor_id', 'varchar(255)'],
+            ['source_author', 'varchar(255)'],
+            ['source_primary', 'int(11)']];
+  }
+
+
+  /**
+   * @test
+   * @dataProvider noticesAvisNewFields
+   */
+  public function noticesAvisNewFieldShouldExistsAndBeIndexed($field, $type) {
+    $this->assertFieldType('notices_avis', $field, $type);
+    $this->assertIndex('notices_avis', $field);
+  }
+}
diff --git a/tests/library/Class/HttpSignatureTest.php b/tests/library/Class/HttpSignatureTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..9ea89ca215e9c512c008d82a1fc8c96e4c916770
--- /dev/null
+++ b/tests/library/Class/HttpSignatureTest.php
@@ -0,0 +1,118 @@
+<?php
+/**
+ * Copyright (c) 2012-2019, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+class Class_HttpSignatureTest extends ModelTestCase {
+  protected
+    $_pubkey = '-----BEGIN PUBLIC KEY-----
+MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAmUEi4rcQARpDBIUoQI7E
+D9/WidC0ILPrxhWPcHg6+Mo24Mkj9cKER2E1jZ+nMv0xfKeAt6juXqOxWqD2CUuh
+Dgp3ndJkI+9x8sLUHnGBIprUa8c++CVJ6nsMqzHoCMzRTYvbeaFkYjRGWDQES/0J
+Co/BtrM0csuRkRunJb98SqkGaP0+mhmDljphebqHvtAAsU1N3jc1BY2/HLuzPADd
+2fOEcvsYPXd5YGp/DnfhejyctC4w+NpGZobaZ8jtp4AacXVcox9SJ1C07zqZzxhP
+r1ieSwML9mDKueYe6BjdYFhXEwV2+7fsqNykV3dZDs/5reGyFLMqIMcE4VDMojY8
+yQIDAQAB
+-----END PUBLIC KEY-----',
+
+    $_privkey = '-----BEGIN RSA PRIVATE KEY-----
+MIIEpQIBAAKCAQEAmUEi4rcQARpDBIUoQI7ED9/WidC0ILPrxhWPcHg6+Mo24Mkj
+9cKER2E1jZ+nMv0xfKeAt6juXqOxWqD2CUuhDgp3ndJkI+9x8sLUHnGBIprUa8c+
++CVJ6nsMqzHoCMzRTYvbeaFkYjRGWDQES/0JCo/BtrM0csuRkRunJb98SqkGaP0+
+mhmDljphebqHvtAAsU1N3jc1BY2/HLuzPADd2fOEcvsYPXd5YGp/DnfhejyctC4w
++NpGZobaZ8jtp4AacXVcox9SJ1C07zqZzxhPr1ieSwML9mDKueYe6BjdYFhXEwV2
++7fsqNykV3dZDs/5reGyFLMqIMcE4VDMojY8yQIDAQABAoIBABR3TmFYcRq0lx6T
+aby1VBmKmuvsoyF65ZGeb3lllPqEhq+eLN81CtU9dhljqMB2b5VmCRp9xNd+pMCl
+njW/k9J8M10wK49g+qagvhMStVwZsSRzh0U8NZLKu/Zgw8vpDkp80uJ7WxyCPqKo
+z6oWMI7og8YSSH7MELSALOItoDuYAgfto0oA/b1KYcXxJ0lYmC08D0nBM1Dug2/W
+UVeiA3pw1J9KZAgjj4lK4ZiN7JqY+FbIogbgR0jKePLTjECETY3X2LZ1Yvth8TP9
+vdgHLUkGQfh/ZMbK44oBEX/2qiacVi1XAzuhlrz3+NyvgCtqMgkMS/uEMN+HgvCR
+w9vIIwECgYEAyucnH7vio1MsVkL9uu5cZEQDWDxV3fPWNMrPZ+sPccqmCI30fUWF
+0w5vJHyt0AaviW1KYAAhkpnj+qtsKiU8Xx6PeOQoJ04plEDGPENtkRlGaEAzABlX
+PAdMo2YjGPI7wuJbTMlzlsY8BhuKKYrgaXTdZNDnTjtu2Xgr9/3JDU0CgYEAwVvw
+ADlZwAUKU77XjrrtP5dJIA0KJHZI9npatZ0hTUZmyKTwCzde8FuBWngQHk5qW1t0
+LD9SuwWuhfUX/lvfKqWTy+1ak6jN1pAQoE1Gb3yx8q+C56VasEBFX2WkCcZgEN1x
+DRpB7RgEmeLCsF25WV75Y/f5XH0zl8CGC/BcX20CgYEAso5SvrlwC7yg8tSHRx6G
+DfJQYzDNe8IeCl1DwjZ4Y/IqxLJvqmIpD3/PTPOvXbbUeQLFhc/3u3RTzP9X84rL
+IwXYylE2CMjfDEkoalYIML1mWU3N09N5Eil2RwEV99kLwEfEgsFxSAjxP4qyvjYp
+oIQoZJT2SMFCnnwDbXxXlq0CgYEAmEBgVozSEtTlMNQQv56IuY3SUp5x4gwRn6Lw
+UhkL4+EPheX57ZsH8pLa4/WuG277aDw22bBy4Di1F13KKssEinweSHD45VQB4HVH
+4jF2yMqTA9kXZndZVXcGKPvLkrbVZfI31m1ag+pplRJs4pqqG6khDopvm1gqi89Y
+vYXh9nECgYEAm9f+N3Mxx/bGRzU5D1IjIwwPCynEx8M/NLmdu3GWstRjl8B9lTKG
+OSm8iswUChadsHDhP1slvjeywDE7pTh8IPunmwFuc7/Q0J0vAmNHl8+oeAqwVJ82
+wQY7fGvKXZZ06i7+GfBDH0wUvUESJYR2strZqfaSB+GelLV9vp0p7iE=
+-----END RSA PRIVATE KEY-----';
+
+
+  /** @test */
+  public function signingRsaShouldReturn344CharsLongSignature() {
+    $sign = (new Class_HttpSignature(''))
+      ->sign(['(request-target)' => 'post /activitypub/reviews',
+              'date' => 'Thu, 21 Dec 2000 16:01:07 +0100',
+              'digest' => '99914b932bd37a50b983c5e7c90ae93b'],
+             Class_HttpSignature::REQUEST_TARGET . ' date digest',
+             $this->_privkey,
+             'rsa-sha256');
+
+    $this->assertEquals(344, strlen($sign));
+  }
+
+
+  /** @test */
+  public function verifyRsaShouldSucceedWithKnownValidSignature() {
+    $sign = new Class_HttpSignature('keyId="rsa-key-1",algorithm="rsa-sha256",headers="(request-target) date digest",signature="dfflJw4lqkpVh9aOwXPLdCqSM8LmD2IX0jDUgVNFZ/frkjGttriewAq2iUAYFWNr9oWvHt6QaW/sspydDyYl752PlSAFFjDewgk6m+lhhMasMwWrST4U+16AXuFpjJJoPWlWuTHgpfcYz2QquPTIcA4TfA5ANORzISfsMIRyb7eJBD9W/d3qSXutn5RuBLLrp0Hx2abGM31rr4gzzZUJ6OhocTI2ZA0XdjuXGF/eIexqV4snlnXUXEpZULW+B6iwPHYj23ADRz3SwF9GpovrEOhYXxmLgAYHY9ErV/82YWr9+NIXPwc/UL4zpZSHqMab0T3+IC/qK+U2g2XW9QKqZA=="');
+
+    $this->assertTrue($sign->verify(['(request-target)' => 'post /activitypub/reviews',
+                                     'date' => 'Thu, 21 Dec 2000 16:01:07 +0100',
+                                     'digest' => '99914b932bd37a50b983c5e7c90ae93b'],
+                                    $this->_pubkey));
+  }
+
+
+  /** @test */
+  public function verifyRsaShouldFailWithBadSignature() {
+    $sign = new Class_HttpSignature('keyId="rsa-key-1",algorithm="rsa-sha256",headers="(request-target) date digest",signature="pHxV6i6cn9ySijY46m+3Tx+NZpCVwEQ0YWNowa4KWAJmI9wNd9hDwlSOG++Hf/vuAM9Pi2bGgaCT7y1I3jNB1fcsi9QlBVK1vW7o7RM5qL55fSHYOvx03KWb0n4ed4D+rcTiO9Pv74+ERMZAgTX4pEzFqJlr1v7UO6Qe5cM6WRT461L8KASwtRJgJ1vmRNLisNZz+YWuQNz9C0Da2y/YWEryxPeZxSZCi0T3bDI/fUNmzLpxNwVtijwH2xivRmcGoUFvB/YEgOSLdJhJf/2AvbXQi4A+C2+OqFPeHx5hkLhoSLLjMwh93wDfIjrfdy7eN4jjVRdn5UYggiWDKxhR+g=="');
+
+    $this->assertFalse($sign->verify(['(request-target)' => 'post /activitypub/reviews',
+                                      'date' => 'Thu, 21 Dec 2000 16:01:07 +0100',
+                                      'digest' => '99914b932bd37a50b983c5e7c90ae93b'],
+                                     $this->_pubkey));
+  }
+
+
+  /** @test */
+  public function verifyRsaShouldFailWithBadPubkey() {
+    $sign = new Class_HttpSignature('keyId="rsa-key-1",algorithm="rsa-sha256",headers="(request-target) date digest",signature="dfflJw4lqkpVh9aOwXPLdCqSM8LmD2IX0jDUgVNFZ/frkjGttriewAq2iUAYFWNr9oWvHt6QaW/sspydDyYl752PlSAFFjDewgk6m+lhhMasMwWrST4U+16AXuFpjJJoPWlWuTHgpfcYz2QquPTIcA4TfA5ANORzISfsMIRyb7eJBD9W/d3qSXutn5RuBLLrp0Hx2abGM31rr4gzzZUJ6OhocTI2ZA0XdjuXGF/eIexqV4snlnXUXEpZULW+B6iwPHYj23ADRz3SwF9GpovrEOhYXxmLgAYHY9ErV/82YWr9+NIXPwc/UL4zpZSHqMab0T3+IC/qK+U2g2XW9QKqZA=="');
+
+    $bad_key = '-----BEGIN PUBLIC KEY-----
+MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA9AYHqfFq69j/nCKxOI5T
+NaJgXVYVTXqUZCrwMTrTViUf4CdhenW/akMKhPR7+Z6okFAwnY6DWRZUanDp9jYV
+WLS4TtMq4svS8E0i94vWWg3x+6vBuuScZaYksM6nOjaOVvd1SIqvZzrVO2QrYfd9
+O/gV5k6ySPhcWYuPfcT+EFUkcwKQ/4enXiSZO9lKi6uKgaabVHS28JgYv1991cau
+eZgVoRGTvjeHg0oZmIygKeV03xqqB87Hry4HlBAgWTL9TsEjdMNiVpVNxtXdHYi7
+eRzZBqZNEl5LphJoTKhb+A/wPIskqHXGUvvCqTgY7ofNtOTsZdQALthWrBNvHMga
+nQIDAQAB
+-----END PUBLIC KEY-----';
+
+    $this->assertFalse($sign->verify(['(request-target)' => 'post /activitypub/reviews',
+                                      'date' => 'Thu, 21 Dec 2000 16:01:07 +0100',
+                                      'digest' => '99914b932bd37a50b983c5e7c90ae93b'],
+                                     $bad_key));
+  }
+}
diff --git a/tests/library/Class/NoticeTest.php b/tests/library/Class/NoticeTest.php
index 4c21255d917c6d6babbd2a543d104d698fe139de..41f1089c824584d5d2fbc8219e73bd57b0821b24 100644
--- a/tests/library/Class/NoticeTest.php
+++ b/tests/library/Class/NoticeTest.php
@@ -446,8 +446,11 @@ class NoticeTestTypeDoc extends ModelTestCase {
 class NoticeTestGetAvis extends ModelTestCase {
   public function setUp() {
     parent::setUp();
+
     $base_properties = ['avis' => 'Testing comment',
-                        'entete' => 'Testing comment'];
+                        'entete' => 'Testing comment',
+                        'clef_oeuvre' => 'TESTING-RECORD--',
+                        'source_actor_id' => null];
 
     $user_bib = $this->fixture('Class_Users',
                                ['id' => 1,
@@ -478,12 +481,6 @@ class NoticeTestGetAvis extends ModelTestCase {
                                                    ['id' => 4,
                                                     'abon_ou_bib' => 0,
                                                     'note' => 3]));
-    $this->onLoaderOfModel('Class_AvisNotice')
-         ->whenCalled('findAllBy')
-         ->with(['clef_oeuvre' => 'TESTING-RECORD--'])
-         ->answers([$this->avis_bib1, $this->avis_bib2,
-                    $this->avis_abon1, $this->avis_abon2])
-         ->beStrict();
 
     $this->notice = $this->fixture('Class_Notice',
                                    ['id' => 12,
diff --git a/tests/library/ZendAfi/View/Helper/Abonne/AbonnementTest.php b/tests/library/ZendAfi/View/Helper/Abonne/AbonnementTest.php
index 023c743fa742119df5f74c611dc3f3a6ba1d5a3a..c402e75a86b0ab243add2348ab1595ce6519efb2 100644
--- a/tests/library/ZendAfi/View/Helper/Abonne/AbonnementTest.php
+++ b/tests/library/ZendAfi/View/Helper/Abonne/AbonnementTest.php
@@ -18,6 +18,7 @@
  * along with BOKEH; if not, write to the Free Software
  * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
  */
+
 require_once 'library/ZendAfi/View/Helper/ViewHelperTestCase.php';
 
 class View_Helper_Abonne_AbonnementTest extends ViewHelperTestCase {
@@ -28,8 +29,17 @@ class View_Helper_Abonne_AbonnementTest extends ViewHelperTestCase {
 
   public function setUp() {
     parent::setUp();
+
+    Class_User_ILSSubscription::setTimeSource(new TimeSourceForTest('2018-10-03'));
+
     $this->_helper = new ZendAfi_View_Helper_Abonne_Abonnement();
-    $this->_helper->setView(new ZendAfi_Controller_Action_Helper_View());
+    $this->_helper->setView($this->view);
+  }
+
+
+  public function tearDown() {
+    Class_User_ILSSubscription::setTimeSource(null);
+    parent::tearDown();
   }
 
 
@@ -48,7 +58,4 @@ class View_Helper_Abonne_AbonnementTest extends ViewHelperTestCase {
                                       '//div[@class="abonnement"]',
                                       'Votre abonnement est valide');
   }
-
-}
-
-?>
\ No newline at end of file
+}
\ No newline at end of file
diff --git a/tests/library/ZendAfi/View/Helper/Accueil/CritiquesTest.php b/tests/library/ZendAfi/View/Helper/Accueil/CritiquesTest.php
index 431f986365bd281025e58d9e79711337667f1702..d88f4113ceff7729c4f7b52949c8bceb03b7f97a 100644
--- a/tests/library/ZendAfi/View/Helper/Accueil/CritiquesTest.php
+++ b/tests/library/ZendAfi/View/Helper/Accueil/CritiquesTest.php
@@ -95,6 +95,7 @@ abstract class CritiquesAvisTestCase extends ViewHelperTestCase {
                                       'note' => 5,
                                       'date_avis' => '2010-03-18 13:00:00',
                                       'user' => $lolo,
+                                      'source_author' => null,
                                       'statut' => 1,
                                       'notices' => [$millenium]]);
 
@@ -106,6 +107,7 @@ abstract class CritiquesAvisTestCase extends ViewHelperTestCase {
                                                  'date_avis' => '2010-03-18 13:00:00',
                                                  'user' => $super_lolo,
                                                  'statut' => 1,
+                                                 'source_author' => null,
                                                  'notices' => [$millenium]]);
 
     $avis_millenium_from_suplo_with_html = $this->fixture('Class_AvisNotice',
@@ -116,6 +118,7 @@ abstract class CritiquesAvisTestCase extends ViewHelperTestCase {
                                                            'date_avis' => '2010-03-18 13:00:00',
                                                            'user' => $super_lolo,
                                                            'statut' => 1,
+                                                           'source_author' => null,
                                                            'notices' => [$millenium]]);
 
     $avis_orphan = $this->fixture('Class_AvisNotice',
@@ -126,6 +129,7 @@ abstract class CritiquesAvisTestCase extends ViewHelperTestCase {
                                    'date_avis' => '2010-03-18 13:00:00',
                                    'user' => $lolo,
                                    'abon_ou_bib' => 0,
+                                   'source_author' => null,
                                    'statut' => 1,
                                    'notices' => []]);
 
@@ -142,6 +146,7 @@ abstract class CritiquesAvisTestCase extends ViewHelperTestCase {
                                    'date_avis' => '2010-03-18 13:00:00',
                                    'user' => $lolo,
                                    'abon_ou_bib' => 0,
+                                   'source_author' => null,
                                    'statut' => 1,
                                    'notices' => [$potter]]);
 
diff --git a/tests/library/ZendAfi/View/Helper/AvisTest.php b/tests/library/ZendAfi/View/Helper/AvisTest.php
index be52f9d785600e08bdd51c433be0152258407dc8..fec5ed23b50b81a1755cde60d32797e6bee8e5a7 100644
--- a/tests/library/ZendAfi/View/Helper/AvisTest.php
+++ b/tests/library/ZendAfi/View/Helper/AvisTest.php
@@ -57,6 +57,7 @@ class ViewHelperAvisTestWithAvisNotice extends ViewHelperTestCase {
       ->setUser($lolo)
       ->setAbonOuBib(0)
       ->setStatut(1)
+      ->setSourceAuthor(null)
       ->setNotices(array($millenium,
                          $millenium_with_vignette));
 
@@ -199,6 +200,7 @@ class ViewHelperAvisTestAsBib extends ViewHelperTestCase {
       ->setUser($tintin)
       ->setStatut(0)
       ->setAbonOuBib(1)
+      ->setSourceAuthor(null)
       ->setNotices(array());
 
     $helper = new ZendAfi_View_Helper_Avis();
@@ -209,9 +211,10 @@ class ViewHelperAvisTestAsBib extends ViewHelperTestCase {
   public function testBrInAvis() {
     $this->assertTrue(strpos($this->html, "Pas<br />\nterrible") !== false, $this->html);
   }
+}
+
 
 
-}
 
 class ViewHelperAvisTestWithoutAvisNoticeAndModeration extends ViewHelperTestCase {
   public function setUp() {
@@ -234,6 +237,7 @@ class ViewHelperAvisTestWithoutAvisNoticeAndModeration extends ViewHelperTestCas
       ->setUser($tintin)
       ->setStatut(0)
       ->setAbonOuBib(0)
+      ->setSourceAuthor(null)
       ->setNotices(array());
 
 
@@ -339,6 +343,7 @@ class ViewHelperAvisTestHtmlForCritiquesModule extends ViewHelperTestCase {
                                       'user' => $tintin,
                                       'abon_ou_bib' => 0,
                                       'statut' => 0,
+                                      'source_author' => null,
                                       'notices' => [$millenium]]);
 
     $helper = new ZendAfi_View_Helper_Avis();
@@ -384,6 +389,7 @@ class ViewHelperAvisAmazonTestContenuAvisHtml extends ViewHelperTestCase {
       ->setDateAvis('2010-03-18 13:00:00')
       ->setAbonOuBib(0)
       ->setStatut(0)
+      ->setSourceAuthor(null)
       ->setUser(null);
 
     $helper = new ZendAfi_View_Helper_Avis();
diff --git a/tests/library/ZendAfi/View/Helper/Notice/AvisTest.php b/tests/library/ZendAfi/View/Helper/Notice/AvisTest.php
index c6db98c423c3653c2967745199266430c8778de9..ca030a2c7c3851a2f34b2aac07b7925de7009316 100644
--- a/tests/library/ZendAfi/View/Helper/Notice/AvisTest.php
+++ b/tests/library/ZendAfi/View/Helper/Notice/AvisTest.php
@@ -21,80 +21,62 @@
 
 
 class ZendAfi_View_Helper_Notice_AvisTest extends ViewHelperTestCase {
-  protected $_html;
+  protected
+    $_storm_default_to_volatile = true,
+    $_millenium,
+    $_avis;
 
   public function setUp() {
     parent::setUp();
-    $this->_helper = new ZendAfi_View_Helper_Notice_Avis();
-    $this->_helper->setView(new ZendAfi_Controller_Action_Helper_View());
-
-    $this->_view = new ZendAfi_Controller_Action_Helper_View();
-
-    Class_Profil::setCurrentProfil(new Class_Profil());
-    Zend_Registry::get('locale')->setLocale('fr');
-    Zend_Registry::get('translate')->setLocale('fr');
 
-    $this->millenium = new Class_Notice();
-    $this->millenium
-      ->setId(25);
+    $this->_helper = new ZendAfi_View_Helper_Notice_Avis();
+    $this->_helper->setView($this->view);
 
-    $this->avis = array("bib" => array("nombre" => 0),
-                        "abonne" => array("nombre" => 0));
+    $this->_millenium = $this->fixture('Class_Notice', ['id' => 25]);
 
-    $this->avis_bib_seulement = Class_AdminVar::newInstanceWithId('AVIS_BIB_SEULEMENT');
+    $this->_avis = ["bib" => new Class_Notice_ReviewsSet('Bibliothécaires', [], 0, 0),
+                    "abonne" => new Class_Notice_ReviewsSet('Lecteurs du portail', [], 0, 0)];
 
-    $account = new stdClass();
-    $account->username     = 'AutoTest' . time();
-    $account->password     = md5( 'password' );
-    $account->ID_USER      = 0;
-    $account->ROLE_LEVEL   = 4;
-    $account->confirmed    = true;
-    $account->enabled      = true;
-    ZendAfi_Auth::getInstance()->getStorage()->write($account);
+    $this->login(ZendAfi_Acl_AdminControllerRoles::ADMIN_BIB);
   }
 
 
-  public function testVisibleWithAdminAndAvisReaderAllowed() {
-    ZendAfi_Auth::getInstance()->getIdentity()->ROLE_LEVEL = 5;
-    $this->avis_bib_seulement->setValeur('0');
+  /** @test */
+  public function modoPortailWithAvisReaderAllowedShouldBeAbleToAddReview() {
+    Class_Users::getIdentity()->beModoPortail();
+    Class_AdminVar::set('AVIS_BIB_SEULEMENT', '0');
 
-    $html = $this->_helper->Notice_Avis($this->millenium, $this->avis, ['onglet' => 'bloc']);
-    $this->assertTrue(strpos($html, 'Donnez ou modifiez votre avis') != false, $html);
+    $html = $this->_helper->notice_Avis($this->_millenium, $this->_avis, ['onglet' => 'bloc']);
+    $this->assertContains('Donnez ou modifiez votre avis', $html);
   }
 
 
-  public function testVisibleWithReaderAndAvisReaderAllowed() {
-    ZendAfi_Auth::getInstance()->getIdentity()->ROLE_LEVEL = 1;
-    $this->avis_bib_seulement->setValeur('0');
+  /** @test */
+  public function inviteWithAvisReaderAllowedShouldBeAbleToAddReview() {
+    Class_Users::getIdentity()->beInvite();
+    Class_AdminVar::set('AVIS_BIB_SEULEMENT', '0');
 
-    $html = $this->_helper->Notice_Avis($this->millenium, $this->avis, ['onglet' => 'bloc']);
-    $this->assertTrue(strpos($html, 'Donnez ou modifiez votre avis') != false);
+    $html = $this->_helper->notice_Avis($this->_millenium, $this->_avis, ['onglet' => 'bloc']);
+    $this->assertContains('Donnez ou modifiez votre avis', $html);
   }
 
 
-  public function testVisibleWithAdminAndAvisReaderForbidden() {
-    ZendAfi_Auth::getInstance()->getIdentity()->ROLE_LEVEL = 5;
-    $this->avis_bib_seulement->setValeur('1');
+  /** @test */
+  public function modoPortailAndAvisReaderForbiddenShouldBeAbleToAddReview() {
+    Class_Users::getIdentity()->beModoPortail();
+    Class_AdminVar::set('AVIS_BIB_SEULEMENT', '1');
 
-    $html = $this->_helper->Notice_Avis($this->millenium, $this->avis, ['onglet' => 'bloc']);
-    $this->assertTrue(strpos($html, 'Donnez ou modifiez votre avis') != false);
+    $html = $this->_helper->Notice_Avis($this->_millenium, $this->_avis, ['onglet' => 'bloc']);
+    $this->assertContains('Donnez ou modifiez votre avis', $html);
   }
 
 
+  /** @test */
   public function testInvisibleWithReaderAndAvisReaderForbidden() {
-    $user = $this->fixture('Class_Users',
-                           ['id' => 1,
-                            'login' => 'pom',
-                            'password' => '123',
-                           ]);
-    $user->setRoleLevel(ZendAfi_Acl_AdminControllerRoles::INVITE)->save();
-
-    ZendAfi_Auth::getInstance()->logUser($user);
-
-    $this->avis_bib_seulement->setValeur('1');
+    Class_Users::getIdentity()->beInvite();
+    Class_AdminVar::set('AVIS_BIB_SEULEMENT', '1');
 
-    $html = $this->_helper->Notice_Avis($this->millenium, $this->avis, ['onglet' => 'bloc']);
-    $this->assertFalse(strpos($html, 'Donnez ou modifiez votre avis'));
+    $html = $this->_helper->Notice_Avis($this->_millenium, $this->_avis, ['onglet' => 'bloc']);
+    $this->assertNotContains('Donnez ou modifiez votre avis', $html);
   }
 }
-?>
\ No newline at end of file
diff --git a/tests/scenarios/Activitypub/ActivitypubAdminTest.php b/tests/scenarios/Activitypub/ActivitypubAdminTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..0b267c6967f56512926623e13437a717da8cc672
--- /dev/null
+++ b/tests/scenarios/Activitypub/ActivitypubAdminTest.php
@@ -0,0 +1,383 @@
+<?php
+/**
+ * Copyright (c) 2012-2017, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+require_once __DIR__ . '/../../../library/activitystreams/autoload.php';
+
+use Patbator\ActivityStreams\Model\Service;
+use Patbator\ActivityStreams\Stream;
+
+
+
+class ActivitypubAdminMenuDisabledTest extends Admin_AbstractControllerTestCase {
+  protected $_storm_default_to_volatile = true;
+
+  public function setUp() {
+    parent::setUp();
+    Class_AdminVar::set('FEDERATION_COMMUNITY_SERVER', '');
+
+    $this->dispatch('/admin', true);
+  }
+
+
+  /** @test */
+  public function federationMenuShouldNotBePresent() {
+    $this->assertNotXPathContentContains('//td', 'Fédération');
+  }
+
+
+  /** @test */
+  public function reviewsLinkShouldNotBePresent() {
+    $this->assertNotXPathContentContains('//a[contains(@href, "/federation-reviews")]', 'Avis');
+  }
+}
+
+
+
+
+abstract class ActivitypubAdminEnabledTestCase extends Admin_AbstractControllerTestCase {
+  protected $_storm_default_to_volatile = true;
+
+  public function setUp() {
+    parent::setUp();
+    Class_AdminVar::set('FEDERATION_COMMUNITY_SERVER',
+                        'https://reviews.my-server.io/activitypub/reviews');
+  }
+}
+
+
+
+
+class ActivitypubAdminMenuEnabledTest extends ActivitypubAdminEnabledTestCase {
+  public function setUp() {
+    parent::setUp();
+    $this->dispatch('/admin', true);
+  }
+
+
+  /** @test */
+  public function federationMenuShouldBePresent() {
+    $this->assertXPathContentContains('//td', 'Fédération');
+  }
+
+
+  /** @test */
+  public function reviewsLinkShouldBePresent() {
+    $this->assertXPathContentContains('//a[contains(@href, "/federation-reviews")]', 'Avis');
+  }
+
+
+  /** @test */
+  public function journalMenuShouldBePresent() {
+    $this->assertXPathContentContains('//td', 'Journal');
+  }
+}
+
+
+
+
+class ActivitypubAdminFederationReviewsControllerIndexTest extends ActivitypubAdminEnabledTestCase {
+  public function setUp() {
+    parent::setUp();
+    $this->dispatch('/admin/federation-reviews');
+  }
+
+
+  /** @test */
+  public function pageShouldContainsEnableDisplay() {
+    $this->assertXPath('//button[contains(@data-url, "/federation-reviews/enable-display")]');
+  }
+
+
+  /** @test */
+  public function pageShouldContainsEnableShare() {
+    $this->assertXPath('//button[contains(@data-url, "/federation-reviews/enable-share")]');
+  }
+}
+
+
+
+
+class ActivitypubAdminFederationReviewsControllerEnableDisplayWithoutCommunityServerTest
+  extends Admin_AbstractControllerTestCase {
+  protected $_storm_default_to_volatile = true;
+
+
+  public function setUp() {
+    parent::setUp();
+    $this->dispatch('/admin/federation-reviews/enable-display');
+  }
+
+
+  /** @test */
+  public function shouldRedirect() {
+    $this->assertRedirect();
+  }
+
+
+  /** @test */
+  public function reviewDisplayShouldNotBeEnabled() {
+    $this->assertFalse(Class_FederationReview::getInstance()->isDisplayEnabled());
+  }
+}
+
+
+
+abstract class ActivitypubAdminFederationReviewsControllerWithCommunityServerTestCase
+  extends ActivitypubAdminEnabledTestCase {
+
+  protected $_signer;
+
+
+  public function setUp() {
+    parent::setUp();
+
+    $endpoint = Class_AdminVar::get('FEDERATION_COMMUNITY_SERVER');
+    Class_WebService_ActivityPub::setThrowErrors(true);
+
+    $this->_signer = $this
+      ->mock()
+      ->whenCalled('signRequest')->answers('MySignature')
+      ->whenCalled('getKeyId')->answers($endpoint . '/pubkey')
+
+      ->whenCalled('verify')
+      ->with(['(request-target)' => 'post /activitypub/reviews/inbox'], 'TheirKey')
+      ->answers(true);
+
+    Class_WebService_ActivityPub::setSigner($this->_signer);
+
+    $client = $this->mock()
+                   ->whenCalled('open_url')
+                   ->with($endpoint,
+                          ['headers' => ['Accept' => Class_WebService_ActivityPub::MIME_TYPE]])
+                   ->answers(json_encode(['@context' => 'https://www.w3.org/ns/activitystreams',
+                                          'type' => 'Service',
+                                          'id' => $endpoint,
+                                          'inbox' => $endpoint . '/inbox',
+                                          'publicKey' => $endpoint . '/pubkey']))
+
+                   ->whenCalled('open_url')
+                   ->with($endpoint . '/pubkey',
+                          ['headers' => ['Accept' => Class_WebService_ActivityPub::MIME_TYPE]])
+                   ->answers(json_encode(['@context' => 'https://www.w3.org/ns/activitystreams',
+                                          'type' => 'Key',
+                                          'id' => $endpoint . '/pubkey',
+                                          'owner' => $endpoint,
+                                          'publicKeyPem' => 'TheirKey']))
+
+                   ->whenCalled('postRawDataResponse')
+                   ->answers($this->mock()
+                             ->whenCalled('isError')->answers(false)
+                             ->whenCalled('getHeader')->with('Signature')->answers('TheirSignature')
+                             ->whenCalled('getHeaders')->answers([])
+                             ->whenCalled('getBody')
+                             ->answers(json_encode(['@context' => 'https://www.w3.org/ns/activitystreams',
+                                                                  'type' => 'Accept'])))
+      ;
+
+    Class_WebService_ActivityPub::setWebClient($client);
+  }
+
+
+  public function tearDown() {
+    Class_WebService_ActivityPub::setThrowErrors(false);
+    Class_WebService_ActivityPub::setSigner(null);
+    parent::tearDown();
+  }
+}
+
+
+
+class ActivitypubAdminFederationReviewsControllerEnableDisplayTest
+  extends ActivitypubAdminFederationReviewsControllerWithCommunityServerTestCase {
+
+  protected $_json;
+
+  public function setUp() {
+    parent::setUp();
+    Class_AdminVar::set('FEDERATION_ACTOR_NAME', 'Arcadia');
+
+    $this->dispatch('/admin/federation-reviews/enable-display');
+    $params = Class_WebService_ActivityPub::getWebClient()
+      ->getAttributesForLastCallOn('postRawDataResponse');
+
+    $this->_json = json_decode($params[1], true);
+  }
+
+
+  /** @test */
+  public function sentNameShouldBeArcadia() {
+    $this->assertEquals('Arcadia', $this->_json['actor']['name']);
+  }
+
+
+  /** @test */
+  public function shouldRedirect() {
+    $this->assertRedirectTo('/admin/federation-reviews/index');
+  }
+
+
+  /** @test */
+  public function reviewDisplayShouldBeEnabled() {
+    $this->assertTrue(Class_FederationReview::getInstance()->isDisplayEnabled());
+  }
+
+
+  /** @test */
+  public function federationPubkeyShouldNotBeEmpty() {
+    $pubkey = Class_AdminVar::get('FEDERATION_PUBKEY');
+    $this->assertContains('-----BEGIN PUBLIC KEY-----', $pubkey);
+    $this->assertContains('-----END PUBLIC KEY-----', $pubkey);
+  }
+
+
+  /** @test */
+  public function federationPrivkeyShouldNotBeEmpty() {
+    $privkey = Class_AdminVar::get('FEDERATION_PRIVKEY');
+    $this->assertContains('-----BEGIN RSA PRIVATE KEY-----', $privkey);
+    $this->assertContains('-----END RSA PRIVATE KEY-----', $privkey);
+  }
+}
+
+
+
+class ActivitypubAdminFederationReviewsControllerEnableShareWithoutCommunityServerTest
+  extends Admin_AbstractControllerTestCase {
+  protected $_storm_default_to_volatile = true;
+
+
+  public function setUp() {
+    parent::setUp();
+    $this->dispatch('/admin/federation-reviews/enable-share');
+  }
+
+
+  /** @test */
+  public function shouldRedirect() {
+    $this->assertRedirect();
+  }
+
+
+  /** @test */
+  public function reviewShareShouldNotBeEnabled() {
+    $this->assertFalse(Class_FederationReview::getInstance()->isShareEnabled());
+  }
+}
+
+
+
+class ActivitypubAdminFederationReviewsControllerEnableShareTest
+  extends ActivitypubAdminFederationReviewsControllerWithCommunityServerTestCase {
+
+  public function setUp() {
+    parent::setUp();
+    $this->dispatch('/admin/federation-reviews/enable-share');
+  }
+
+
+  /** @test */
+  public function shouldRedirect() {
+    $this->assertRedirectTo('/admin/federation-reviews/index');
+  }
+
+
+  /** @test */
+  public function reviewShareShouldBeEnabled() {
+    $this->assertTrue(Class_FederationReview::getInstance()->isShareEnabled(),
+                      json_encode($this->_getFlashMessengerNotifications(), JSON_PRETTY_PRINT));
+  }
+}
+
+
+
+class ActivitypubAdminFederationReviewsControllerIndexAllEnabledTest
+  extends ActivitypubAdminEnabledTestCase {
+  protected $_storm_default_to_volatile = true;
+
+
+  public function setUp() {
+    parent::setUp();
+    $this->fixture('Class_Journal',
+                   ['id' => 150,
+                    'type' => 'AP_REVIEW_DISPLAY_JOIN']);
+    $this->fixture('Class_Journal',
+                   ['id' => 151,
+                    'type' => 'AP_REVIEW_SHARE_JOIN']);
+
+    $this->dispatch('/admin/federation-reviews');
+  }
+
+
+  /** @test */
+  public function pageShouldContainsDisableDisplay() {
+    $this->assertXPath('//button[contains(@data-url, "/federation-reviews/disable-display")]');
+  }
+
+
+  /** @test */
+  public function pageShouldContainsDisableShare() {
+    $this->assertXPath('//button[contains(@data-url, "/federation-reviews/disable-share")]');
+  }
+}
+
+
+
+class ActivitypubAdminFederationReviewsControllerDisableDisplayTest
+  extends ActivitypubAdminFederationReviewsControllerWithCommunityServerTestCase {
+
+  public function setUp() {
+    parent::setUp();
+    $this->dispatch('/admin/federation-reviews/disable-display');
+  }
+
+
+  /** @test */
+  public function shouldRedirect() {
+    $this->assertRedirectTo('/admin/federation-reviews/index');
+  }
+
+
+  /** @test */
+  public function reviewDisplayShouldBeDisabled() {
+    $this->assertFalse(Class_FederationReview::getInstance()->isDisplayEnabled());
+  }
+}
+
+
+
+class ActivitypubAdminFederationReviewsControllerDisableShareTest
+  extends ActivitypubAdminFederationReviewsControllerWithCommunityServerTestCase {
+
+  public function setUp() {
+    parent::setUp();
+    $this->dispatch('/admin/federation-reviews/disable-share');
+  }
+
+
+  /** @test */
+  public function shouldRedirect() {
+    $this->assertRedirectTo('/admin/federation-reviews/index');
+  }
+
+
+  /** @test */
+  public function reviewShareShouldBeDisabled() {
+    $this->assertFalse(Class_FederationReview::getInstance()->isShareEnabled());
+  }
+}
diff --git a/tests/scenarios/Activitypub/ActivitypubReviewTest.php b/tests/scenarios/Activitypub/ActivitypubReviewTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..4ae91337733672b8c5ad0b43194e4e038a55690a
--- /dev/null
+++ b/tests/scenarios/Activitypub/ActivitypubReviewTest.php
@@ -0,0 +1,883 @@
+<?php
+/**
+ * Copyright (c) 2012-2017, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+require_once __DIR__ . '/../../../library/activitystreams/autoload.php';
+require_once __DIR__ . '/../../../application/modules/activitypub/controllers/ReviewController.php';
+
+use Patbator\ActivityStreams\Model\Follow;
+use Patbator\ActivityStreams\Model\Accept;
+use Patbator\ActivityStreams\Model\Reject;
+use Patbator\ActivityStreams\Model\Service;
+use Patbator\ActivityStreams\Model\Join;
+use Patbator\ActivityStreams\Model\Leave;
+use Patbator\ActivityStreams\Model\Group;
+use Patbator\ActivityStreams\Stream;
+
+
+abstract class ActivitypubReviewTestCase extends AbstractControllerTestCase {
+  protected
+    $_storm_default_to_volatile = true,
+    $_json;
+}
+
+
+
+abstract class ActivitypubReviewClientTestCase extends ActivitypubReviewTestCase {
+  public function setUp() {
+    parent::setUp();
+    Class_AdminVar::set('FEDERATION_COMMUNITY_SERVER',
+                        'https://my.communityserver.la/activitypub/review');
+  }
+}
+
+
+
+class ActivitypubReviewIndexDisabledTest extends ActivitypubReviewTestCase {
+  public function setUp() {
+    parent::setUp();
+    Class_AdminVar::set('FEDERATION_ACTOR_NAME', 'Arcadia');
+
+    $this->dispatch('/activitypub/review', true,
+                    ['Accept' => Class_WebService_ActivityPub::MIME_TYPE]);
+    $this->_json = json_decode($this->_response->getBody(), true);
+  }
+
+
+  /** @test */
+  public function responseShouldBeServiceUnavailable() {
+    $this->assertResponseCode(503);
+  }
+}
+
+
+
+class ActivitypubReviewIndexEnabledTest extends ActivitypubReviewClientTestCase {
+  public function setUp() {
+    parent::setUp();
+    Class_AdminVar::set('FEDERATION_ACTOR_NAME', 'Arcadia');
+
+    $this->dispatch('/activitypub/review', true,
+                    ['Accept' => Class_WebService_ActivityPub::MIME_TYPE]);
+    $this->_json = json_decode($this->_response->getBody(), true);
+  }
+
+
+  /** @test */
+  public function typeShouldBeService() {
+    $this->assertEquals('Service', $this->_json['type']);
+  }
+
+
+  /** @test */
+  public function nameShouldBeArcadia() {
+    $this->assertEquals('Arcadia', $this->_json['name']);
+  }
+
+
+  /** @test */
+  public function outboxShouldBePresent() {
+    $this->assertContains('outbox', array_keys($this->_json));
+    $this->assertContains('/activitypub/review/outbox', $this->_json['outbox']);
+  }
+
+
+  /** @test */
+  public function inboxShouldBePresent() {
+    $this->assertContains('inbox', array_keys($this->_json));
+    $this->assertContains('/activitypub/review/outbox', $this->_json['outbox']);
+  }
+
+
+  /** @test */
+  public function publicKeyShouldBePresent() {
+    $this->assertContains('publicKey', array_keys($this->_json));
+    $this->assertContains('/activitypub/review/pubkey', $this->_json['publicKey']);
+  }
+}
+
+
+
+
+class ActivitypubReviewPubkeyDisabledTest extends ActivitypubReviewTestCase {
+  public function setUp() {
+    parent::setUp();
+    $this->dispatch('/activitypub/review/pubkey', true,
+                    ['Accept' => Class_WebService_ActivityPub::MIME_TYPE]);
+    $this->_json = json_decode($this->_response->getBody(), true);
+  }
+
+
+  /** @test */
+  public function responseShouldBeServiceUnavailable() {
+    $this->assertResponseCode(503);
+  }
+}
+
+
+
+
+class ActivitypubReviewPubkeyTest extends ActivitypubReviewClientTestCase {
+  public function setUp() {
+    parent::setUp();
+    $this->dispatch('/activitypub/review/pubkey', true,
+                    ['Accept' => Class_WebService_ActivityPub::MIME_TYPE]);
+    $this->_json = json_decode($this->_response->getBody(), true);
+  }
+
+
+  /** @test */
+  public function typeShouldBeKey() {
+    $this->assertEquals('Key', $this->_json['type']);
+  }
+
+
+  /** @test */
+  public function idShouldBePresent() {
+    $this->assertTrue(array_key_exists('id', $this->_json));
+    $this->assertContains('/activitypub/review/pubkey', $this->_json['id']);
+  }
+
+
+  /** @test */
+  public function ownerShouldBePresent() {
+    $this->assertTrue(array_key_exists('owner', $this->_json));
+    $this->assertContains('/activitypub/review', $this->_json['owner']);
+  }
+
+
+  /** @test */
+  public function publicKeyPemShouldBePresent() {
+    $this->assertTrue(array_key_exists('publicKeyPem', $this->_json));
+    $this->assertContains('-----BEGIN PUBLIC KEY-----',
+                          $this->_json['publicKeyPem']);
+  }
+
+
+  /** @test */
+  public function federationPubkeyShouldNotBeEmpty() {
+    $pubkey = Class_AdminVar::get('FEDERATION_PUBKEY');
+    $this->assertContains('-----BEGIN PUBLIC KEY-----', $pubkey);
+    $this->assertContains('-----END PUBLIC KEY-----', $pubkey);
+  }
+
+
+  /** @test */
+  public function federationPrivkeyShouldNotBeEmpty() {
+    $privkey = Class_AdminVar::get('FEDERATION_PRIVKEY');
+    $this->assertContains('-----BEGIN RSA PRIVATE KEY-----', $privkey);
+    $this->assertContains('-----END RSA PRIVATE KEY-----', $privkey);
+  }
+}
+
+
+
+class ActivitypubReviewOutboxTest extends ActivitypubReviewClientTestCase {
+  public function setUp() {
+    parent::setUp();
+
+    $client_endpoint = Class_AdminVar::get('FEDERATION_COMMUNITY_SERVER');
+
+    $this->dispatch('/activitypub/review/outbox',
+                    true,
+                    ['Accept' => Class_WebService_ActivityPub::MIME_TYPE,
+                     'Authorization' => 'Bearer ' . $client_endpoint]);
+    $this->_json = json_decode($this->_response->getBody(), true);
+  }
+
+
+  /** @test */
+  public function responseTypeShouldBeCollectionPage() {
+    $this->assertEquals('CollectionPage', $this->_json['type']);
+  }
+}
+
+
+
+class ActivitypubReviewOutboxPageOneTest extends ActivitypubReviewClientTestCase {
+  public function setUp() {
+    parent::setUp();
+    $this->fixture('Class_AvisNotice',
+                   ['id' => 42,
+                    'entete' => 'Not so bad',
+                    'user' => Class_Users::getIdentity(),
+                    'date_mod' => null,
+                    'source_author' => null]);
+
+    $client_endpoint = Class_AdminVar::get('FEDERATION_COMMUNITY_SERVER');
+
+    $this->dispatch('/activitypub/review/outbox/page/1',
+                    true,
+                    ['Accept' => Class_WebService_ActivityPub::MIME_TYPE,
+                     'Authorization' => 'Bearer ' . $client_endpoint]);
+
+    $this->_json = json_decode($this->_response->getBody(), true);
+  }
+
+
+  /** @test */
+  public function typeShouldBeCollectionPage() {
+    $this->assertEquals('CollectionPage', $this->_json['type']);
+  }
+
+
+  /** @test */
+  public function firstReviewShouldHaveEnteteNotSoBad() {
+    $this->assertEquals('Not so bad', $this->_json['items'][0]['entete']);
+  }
+}
+
+
+
+class ActivitypubReviewOutboxBadFromTest extends ActivitypubReviewClientTestCase {
+  public function setUp() {
+    parent::setUp();
+
+    $client_endpoint = Class_AdminVar::get('FEDERATION_COMMUNITY_SERVER');
+
+    $this->dispatch('/activitypub/review/outbox?from=no%20valid',
+                    true,
+                    ['Accept' => Class_WebService_ActivityPub::MIME_TYPE,
+                     'Authorization' => 'Bearer ' . $client_endpoint]);
+    $this->_json = json_decode($this->_response->getBody(), true);
+  }
+
+
+  /** @test */
+  public function responseShouldBeError400() {
+    $this->assertResponseCode(400);
+  }
+}
+
+
+
+class ActivitypubReviewOutboxValidFromTest extends ActivitypubReviewClientTestCase {
+  public function setUp() {
+    parent::setUp();
+
+    $client_endpoint = Class_AdminVar::get('FEDERATION_COMMUNITY_SERVER');
+
+    $this->dispatch('/activitypub/review/outbox?from=2019-05-17',
+                    true,
+                    ['Accept' => Class_WebService_ActivityPub::MIME_TYPE,
+                     'Authorization' => 'Bearer ' . $client_endpoint]);
+    $this->_json = json_decode($this->_response->getBody(), true);
+  }
+
+
+  /** @test */
+  public function responseShouldBeSuccess200() {
+    $this->assertResponseCode(200);
+  }
+
+
+  /** @test */
+  public function typeShouldBeCollectionPage() {
+    $this->assertEquals('CollectionPage', $this->_json['type']);
+  }
+}
+
+
+
+abstract class ActivitypubReviewServerTestCase extends ActivitypubReviewTestCase {
+  public function setUp() {
+    parent::setUp();
+    Class_AdminVar::set('FEDERATION_IS_COMMUNITY_SERVER', '1');
+  }
+}
+
+
+
+abstract class ActivitypubReviewInboxJoinTestCase extends ActivitypubReviewServerTestCase {
+  protected
+    $_web_client,
+    $_endpoint,
+    $_activity;
+
+  public function setUp() {
+    parent::setUp();
+
+    $this->_web_client = $this
+      ->mock()
+      ->whenCalled('open_url')
+      ->with('https://my-library-portal.fr/activitypub/review',
+             ['headers' => ['Accept' => Class_WebService_ActivityPub::MIME_TYPE]])
+      ->answers('{
+  "@context": "https://www.w3.org/ns/activitystreams",
+  "type": "Service",
+  "id": "https://my-library-portal.fr/activitypub/review",
+  "inbox": "https://my-library-portal.fr/activitypub/review/inbox",
+  "outbox": "https://my-library-portal.fr/activitypub/review/outbox",
+  "publicKey": "https://my-library-portal.fr/activitypub/review/pubkey"
+}')
+
+      ->whenCalled('open_url')
+      ->with('https://my-library-portal.fr/activitypub/review/pubkey',
+             ['headers' => ['Accept' => Class_WebService_ActivityPub::MIME_TYPE]])
+      ->answers(json_encode(['@context' => 'https://www.w3.org/ns/activitystreams',
+                             'type' => 'Key',
+                             'id' => 'https://my-library-portal.fr/activitypub/review/pubkey',
+                             'owner' => 'https://my-library-portal.fr/activitypub/review',
+                             'publicKeyPem' => 'TheirKey']))
+
+      ->beStrict()
+      ;
+
+    $signer = Storm_Test_ObjectWrapper::on(new Class_HttpSignature('keyId="https://my-library-portal.fr/activitypub/review/pubkey", algorithm="rsa-sha256", headers="(request-target) date digest", signature="TheirSignature"'))
+      ->whenCalled('sign')->answers('MySignature')
+
+      ->whenCalled('verify')
+      ->with(['date' => 'Thu, 12 Mar 2015 15:54:35 +0100',
+              'digest' => 'MD5=TheirDigest',
+              '(request-target)' => 'post /activitypub/review/inbox'],
+             'TheirKey')
+      ->answers(true);
+
+    Class_WebService_ActivityPub::setSigner($signer);
+    Class_WebService_ActivityPub::setTimeSource(new TimeSourceForTest('2019-02-08 16:41:33'));
+    Class_WebService_ActivityPub::setWebClient($this->_web_client);
+    Class_WebService_ActivityPub::setThrowErrors(true);
+
+    $actor = (new Service())->name('My library')
+                            ->id('https://my-library-portal.fr/activitypub/review');
+
+    $this->_endpoint = Class_Url::absolute(['module' => 'activitypub',
+                                            'controller' => 'review'], null, true);
+
+    $object = (new Group())->name($this->_groupName());
+
+    $join = (new Join)
+      ->summary('My library followed your reviews')
+      ->id('https://my-library-portal.fr/activitypub/review/follow/id/42')
+      ->actor($actor)
+      ->object($object);
+
+    $this->postDispatchRaw('/activitypub/review/inbox',
+                           (new Stream($join))->render(),
+                           ['Content-Type' => Class_WebService_ActivityPub::MIME_TYPE,
+                            'Date' => 'Thu, 12 Mar 2015 15:54:35 +0100',
+                            'Digest' => 'MD5=TheirDigest',
+                            'Signature' => 'keyId="https://my-library-portal.fr/activitypub/review/pubkey", algorithm="rsa-sha256", headers="(request-target) date digest", signature="TheirSignature"']);
+
+    if ($stream = Stream::fromJson($this->_response->getBody()))
+      $this->_activity = $stream->getRoot();
+  }
+
+
+  public function tearDown() {
+    Class_WebService_ActivityPub::setWebClient(null);
+    Class_WebService_ActivityPub::setSigner(null);
+    parent::tearDown();
+  }
+}
+
+
+
+
+class ActivitypubReviewInboxJoinDisplayGroupTest extends ActivitypubReviewInboxJoinTestCase {
+  protected function _groupName() {
+    return 'REVIEW_DISPLAY';
+  }
+
+
+  /** @test */
+  public function responseShouldBeSuccess() {
+    $this->assertEquals(200, $this->_response->getHttpResponseCode());
+  }
+
+
+  /** @test */
+  public function shouldBeAccepted() {
+    $this->assertTrue($this->_activity instanceof Accept);
+  }
+
+
+  /** @test */
+  public function actorShouldBeMemberOfReviewDisplayGroup() {
+    $this->assertNotNull(Class_Federation_GroupMembership::findFirstBy(['actor_id' => 'https://my-library-portal.fr/activitypub/review',
+                                                                        'group_name' => 'REVIEW_DISPLAY']));
+  }
+}
+
+
+
+class ActivitypubReviewInboxJoinDisplayGroupTwiceTest extends ActivitypubReviewInboxJoinTestCase {
+  protected function _groupName() {
+    $this->fixture('Class_Federation_GroupMembership',
+                   ['id' => 44,
+                    'actor_id' => 'https://my-library-portal.fr/activitypub/review',
+                    'group_name' => 'REVIEW_DISPLAY']);
+
+    return 'REVIEW_DISPLAY';
+  }
+
+
+  /** @test */
+  public function responseShouldBeSuccess() {
+    $this->assertEquals(200, $this->_response->getHttpResponseCode());
+  }
+
+
+  /** @test */
+  public function shouldBeAccepted() {
+    $this->assertTrue($this->_activity instanceof Accept);
+  }
+
+
+  /** @test */
+  public function actorShouldBeMemberOfReviewDisplayGroupOnce() {
+    $this->assertEquals(1, Class_Federation_GroupMembership::countBy(['actor_id' => 'https://my-library-portal.fr/activitypub/review',
+                                                                      'group_name' => 'REVIEW_DISPLAY']));
+  }
+}
+
+
+
+class ActivitypubReviewInboxJoinUnknownGroupTest extends ActivitypubReviewInboxJoinTestCase {
+  protected function _groupName() {
+    return 'ANY_UNKNOWN_GROUP';
+  }
+
+
+  /** @test */
+  public function responseShouldBeSuccess() {
+    $this->assertEquals(200, $this->_response->getHttpResponseCode());
+  }
+
+
+  /** @test */
+  public function shouldBeRejected() {
+    $this->assertTrue($this->_activity instanceof Reject);
+  }
+
+
+  /** @test */
+  public function actorShouldNotBeMemberOfAnyGroup() {
+    $this->assertEquals(0, Class_Federation_GroupMembership::countBy(['actor_id' => 'https://my-library-portal.fr/activitypub/review']));
+  }
+}
+
+
+
+class ActivitypubReviewInboxJoinShareGroupTest extends ActivitypubReviewInboxJoinTestCase {
+  protected function _groupName() {
+    return 'REVIEW_SHARE';
+  }
+
+
+  /** @test */
+  public function responseShouldBeSuccess() {
+    $this->assertEquals(200, $this->_response->getHttpResponseCode());
+  }
+
+
+  /** @test */
+  public function shouldBeAccepted() {
+    $this->assertTrue($this->_activity instanceof Accept);
+  }
+
+
+  /** @test */
+  public function actorShouldBeMemberOfReviewDisplayGroup() {
+    $this->assertNotNull(Class_Federation_GroupMembership::findFirstBy(['actor_id' => 'https://my-library-portal.fr/activitypub/review',
+                                                                        'group_name' => 'REVIEW_SHARE']));
+  }
+}
+
+
+
+abstract class ActivitypubReviewInboxLeaveTestCase extends ActivitypubReviewServerTestCase {
+  protected
+    $_web_client,
+    $_endpoint,
+    $_activity;
+
+  public function setUp() {
+    parent::setUp();
+
+    $this->fixture('Class_Federation_GroupMembership',
+                   ['id' => 345,
+                    'actor_id' => 'https://my-library-portal.fr/activitypub/review',
+                    'group_name' => 'REVIEW_DISPLAY']);
+
+
+    $this->fixture('Class_Federation_GroupMembership',
+                   ['id' => 346,
+                    'actor_id' => 'https://my-library-portal.fr/activitypub/review',
+                    'group_name' => 'REVIEW_SHARE']);
+
+    $this->fixture('Class_AvisNotice',
+                   ['id' => 55,
+                    'user' => Class_Users::getIdentity(),
+                    'CLEF_OEUVRE' => 'UNAMOURDELAPIN44--DAVISJ-',
+                    'ID_NOTICE' => '113132',
+                    'DATE_AVIS' => '2019-04-19 17:46:27',
+                    'DATE_MOD' => 'NULL',
+                    'NOTE' => '3',
+                    'ENTETE' => 'Au top',
+                    'AVIS' => 'comme d\'habitude trop bien !',
+                    'STATUT' => '0',
+                    'abon_ou_bib' => '1',
+                    'USER_KEY' => '0--0--sysadm',
+                    'flags' => '0',
+                    'type_doc' => '1',
+                    'source_actor_id' => 'https://my-library-portal.fr/activitypub/review',
+                    'source_author' => 'Arcadia',
+                    'source_primary' => 4839
+                   ]);
+
+    $this->fixture('Class_Journal',
+                   ['id' => 344,
+                    'type' => 'AP_REVIEW_HARVEST_346']);
+
+    $this->_web_client = $this
+      ->mock()
+      ->whenCalled('open_url')
+      ->with('https://my-library-portal.fr/activitypub/review',
+             ['headers' => ['Accept' => Class_WebService_ActivityPub::MIME_TYPE]])
+      ->answers('{
+  "@context": "https://www.w3.org/ns/activitystreams",
+  "type": "Service",
+  "id": "https://my-library-portal.fr/activitypub/review",
+  "inbox": "https://my-library-portal.fr/activitypub/review/inbox",
+  "outbox": "https://my-library-portal.fr/activitypub/review/outbox",
+  "publicKey": "https://my-library-portal.fr/activitypub/review/pubkey"
+}')
+
+      ->whenCalled('open_url')
+      ->with('https://my-library-portal.fr/activitypub/review/pubkey',
+             ['headers' => ['Accept' => Class_WebService_ActivityPub::MIME_TYPE]])
+      ->answers(json_encode(['@context' => 'https://www.w3.org/ns/activitystreams',
+                             'type' => 'Key',
+                             'id' => 'https://my-library-portal.fr/activitypub/review/pubkey',
+                             'owner' => 'https://my-library-portal.fr/activitypub/review',
+                             'publicKeyPem' => 'TheirKey']))
+
+      ->beStrict()
+      ;
+
+    $signer = Storm_Test_ObjectWrapper::on(new Class_HttpSignature('keyId="https://my-library-portal.fr/activitypub/review/pubkey", algorithm="rsa-sha256", headers="(request-target) date digest", signature="TheirSignature"'))
+      ->whenCalled('sign')->answers('MySignature')
+
+      ->whenCalled('verify')
+      ->with(['date' => 'Thu, 12 Mar 2015 15:54:35 +0100',
+              'digest' => 'MD5=TheirDigest',
+              '(request-target)' => 'post /activitypub/review/inbox'],
+             'TheirKey')
+      ->answers(true);
+
+    Class_WebService_ActivityPub::setSigner($signer);
+    Class_WebService_ActivityPub::setTimeSource(new TimeSourceForTest('2019-02-08 16:41:33'));
+    Class_WebService_ActivityPub::setWebClient($this->_web_client);
+    Class_WebService_ActivityPub::setThrowErrors(true);
+
+    $actor = (new Service())->name('My library')
+                            ->id('https://my-library-portal.fr/activitypub/review');
+
+    $this->_endpoint = Class_Url::absolute(['module' => 'activitypub',
+                                            'controller' => 'review'], null, true);
+
+    $object = (new Group())->name($this->_groupName());
+
+    $leave = (new Leave)
+      ->summary('My library followed your reviews')
+      ->id('https://my-library-portal.fr/activitypub/review/follow/id/42')
+      ->actor($actor)
+      ->object($object);
+
+    $this->postDispatchRaw('/activitypub/review/inbox',
+                           (new Stream($leave))->render(),
+                           ['Content-Type' => Class_WebService_ActivityPub::MIME_TYPE,
+                            'Date' => 'Thu, 12 Mar 2015 15:54:35 +0100',
+                            'Digest' => 'MD5=TheirDigest',
+                            'Signature' => 'keyId="https://my-library-portal.fr/activitypub/review/pubkey", algorithm="rsa-sha256", headers="(request-target) date digest", signature="TheirSignature"']);
+
+    if ($stream = Stream::fromJson($this->_response->getBody()))
+      $this->_activity = $stream->getRoot();
+  }
+
+
+  public function tearDown() {
+    Class_WebService_ActivityPub::setWebClient(null);
+    Class_WebService_ActivityPub::setSigner(null);
+    parent::tearDown();
+  }
+}
+
+
+
+
+class ActivitypubReviewInboxLeaveDisplayGroupTest extends ActivitypubReviewInboxLeaveTestCase {
+  protected function _groupName() {
+    return 'REVIEW_DISPLAY';
+  }
+
+
+  /** @test */
+  public function responseShouldBeSuccess() {
+    $this->assertEquals(200, $this->_response->getHttpResponseCode());
+  }
+
+
+  /** @test */
+  public function shouldBeAccepted() {
+    $this->assertTrue($this->_activity instanceof Accept);
+  }
+
+
+  /** @test */
+  public function actorShouldBeNoLongerMemberOfReviewDisplayGroup() {
+    $this->assertNull(Class_Federation_GroupMembership::findFirstBy(['actor_id' => 'https://my-library-portal.fr/activitypub/review',
+                                                                     'group_name' => 'REVIEW_DISPLAY']));
+  }
+}
+
+
+
+
+class ActivitypubReviewInboxLeaveShareGroupTest extends ActivitypubReviewInboxLeaveTestCase {
+  protected function _groupName() {
+    return 'REVIEW_SHARE';
+  }
+
+
+  /** @test */
+  public function responseShouldBeSuccess() {
+    $this->assertEquals(200, $this->_response->getHttpResponseCode());
+  }
+
+
+  /** @test */
+  public function shouldBeAccepted() {
+    $this->assertTrue($this->_activity instanceof Accept);
+  }
+
+
+  /** @test */
+  public function actorShouldBeNoLongerMemberOfReviewShareGroup() {
+    $this->assertNull(Class_Federation_GroupMembership::findFirstBy(['actor_id' => 'https://my-library-portal.fr/activitypub/review',
+                                                                     'group_name' => 'REVIEW_SHARE']));
+  }
+
+
+  /** @test */
+  public function sharedReviewsShouldBeRemoved() {
+    $this->assertNull(Class_AvisNotice::findFirstBy(['source_actor_id' => 'https://my-library-portal.fr/activitypub/review']));
+  }
+
+
+  /** @test */
+  public function harvestJournalShouldBeRemoved() {
+    $this->assertNull(Class_Journal::findFirstBy(['type' => 'AP_REVIEW_HARVEST_346']));
+  }
+}
+
+
+
+
+class ActivitypubReviewInboxLeaveUnkownGroupTest extends ActivitypubReviewInboxLeaveTestCase {
+  protected function _groupName() {
+    return 'I_M_UNKNOWN';
+  }
+
+
+  /** @test */
+  public function responseShouldBeSuccess() {
+    $this->assertEquals(200, $this->_response->getHttpResponseCode());
+  }
+
+
+  /** @test */
+  public function shouldBeRejected() {
+    $this->assertTrue($this->_activity instanceof Reject);
+  }
+
+
+  /** @test */
+  public function actorShouldStillBeMemberOfReviewDisplayGroup() {
+    $this->assertNotNull(Class_Federation_GroupMembership::findFirstBy(['actor_id' => 'https://my-library-portal.fr/activitypub/review',
+                                                                     'group_name' => 'REVIEW_DISPLAY']));
+  }
+}
+
+
+
+class ActivitypubReviewOutboxForRecordTest extends ActivitypubReviewServerTestCase {
+  public function setUp() {
+    parent::setUp();
+
+    $this->fixture('Class_AvisNotice',
+                   ['id' => 55,
+                    'user' => Class_Users::getIdentity(),
+                    'CLEF_OEUVRE' => 'UNAMOURDELAPIN44--DAVISJ-',
+                    'ID_NOTICE' => '113132',
+                    'DATE_AVIS' => '2019-04-19 17:46:27',
+                    'DATE_MOD' => 'NULL',
+                    'NOTE' => '3',
+                    'ENTETE' => 'Au top',
+                    'AVIS' => 'comme d\'habitude trop bien !',
+                    'STATUT' => '0',
+                    'abon_ou_bib' => '1',
+                    'USER_KEY' => '0--0--sysadm',
+                    'flags' => '0',
+                    'type_doc' => '1',
+                    'source_actor_id' => 'https://other.bokeh.es/activitypub/review',
+                    'source_author' => 'Arcadia',
+                    'source_primary' => 4839
+                   ]);
+
+    $client_endpoint = 'https://my.super_bokeh.nl/activitypub/review';
+
+    $this->fixture('Class_Federation_GroupMembership',
+                   ['id' => 1,
+                    'actor_id' => $client_endpoint,
+                    'group_name' => 'REVIEW_DISPLAY']);
+
+    $this->dispatch('/activitypub/review/outbox?key=UNAMOURDELAPIN44--DAVISJ-&page=1',
+                    true,
+                    ['Accept' => Class_WebService_ActivityPub::MIME_TYPE,
+                     'Authorization' => 'Bearer ' . $client_endpoint]);
+
+    $this->_json = json_decode($this->_response->getBody(), true);
+  }
+
+
+  /** @test */
+  public function responseShouldBeACollectionPage() {
+    $this->assertEquals('CollectionPage', $this->_json['type']);
+  }
+
+
+  /** @test */
+  public function firstReviewTitleShouldBeAuTop() {
+    $this->assertEquals('Au top', $this->_json['items'][0]['entete']);
+  }
+
+
+  /** @test */
+  public function firstReviewAuthorShouldBeArcadia() {
+    $this->assertEquals('Arcadia', $this->_json['items'][0]['source_author']);
+  }
+}
+
+
+
+class ActivitypubReviewOutboxHarvestingTest extends ActivitypubReviewClientTestCase {
+  protected $_review;
+
+  public function setUp() {
+    parent::setUp();
+
+    $this->fixture('Class_AvisNotice',
+                   ['id' => 55,
+                    'user' => Class_Users::getIdentity(),
+                    'CLEF_OEUVRE' => 'UNAMOURDELAPIN44--DAVISJ-',
+                    'ID_NOTICE' => '113132',
+                    'DATE_AVIS' => '2019-04-19 17:46:27',
+                    'DATE_MOD' => 'NULL',
+                    'NOTE' => '3',
+                    'ENTETE' => 'Au top',
+                    'AVIS' => 'comme d\'habitude trop bien !',
+                    'STATUT' => '0',
+                    'USER_KEY' => '0--0--sysadm',
+                    'flags' => '0',
+                    'type_doc' => '1',
+                    'source_author' => null,
+                   ]);
+
+    $client_endpoint = Class_AdminVar::get('FEDERATION_COMMUNITY_SERVER');
+
+    $this->dispatch('/activitypub/review/outbox?from=&page=1',
+                    true,
+                    ['Accept' => Class_WebService_ActivityPub::MIME_TYPE,
+                     'Authorization' => 'Bearer ' . $client_endpoint]);
+
+    $this->_json = json_decode($this->_response->getBody(), true);
+    $this->_review = $this->_json['items'][0];
+  }
+
+
+  /** @test */
+  public function responseTypeShouldBeCollectionPage() {
+    $this->assertEquals('CollectionPage', $this->_json['type']);
+  }
+
+
+  /** @test */
+  public function collectionShouldHaveOneItemInTotal() {
+    $this->assertEquals(1, $this->_json['totalItems']);
+  }
+
+
+  /** @test */
+  public function reviewIdShouldBe55() {
+    $this->assertEquals(55, $this->_review['id']);
+  }
+
+
+  /** @test */
+  public function reviewClefOeuvreShouldBeUnAmourDeLapin() {
+    $this->assertEquals('UNAMOURDELAPIN44--DAVISJ-', $this->_review['clef_oeuvre']);
+  }
+
+
+  /** @test */
+  public function reviewDateShouldBe2019_04_19() {
+    $this->assertEquals('2019-04-19 17:46:27', $this->_review['date_avis']);
+  }
+
+
+  /** @test */
+  public function reviewShouldHaveBeenRated3() {
+    $this->assertEquals(3, $this->_review['note']);
+  }
+
+
+  /** @test */
+  public function reviewEnteteShouldBeAuTop() {
+    $this->assertEquals('Au top', $this->_review['entete']);
+  }
+
+
+  /** @test */
+  public function reviewContentShouldBeCommeDHabitude() {
+    $this->assertEquals('comme d\'habitude trop bien !', $this->_review['avis']);
+  }
+
+
+  public function filteredFields() {
+    return array_map(function($item) { return [$item]; },
+                     ['id_user',
+                      'id_notice',
+                      'statut',
+                      'user_key',
+                      'flags',
+                      'type_doc',
+                      'source_actor_id',
+                      'source_primary']);
+  }
+
+
+  /** @test @dataProvider filteredFields */
+  public function reviewShouldNotHaveFilteredField($field) {
+    $this->assertNotContains($field, array_keys($this->_review));
+    $this->assertNotContains(strtoupper($field), array_keys($this->_review));
+  }
+}
diff --git a/tests/scenarios/Activitypub/FederationReviewBatchTest.php b/tests/scenarios/Activitypub/FederationReviewBatchTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..cffa410cdc927af4a1ba312a11ff9ad8df4130b0
--- /dev/null
+++ b/tests/scenarios/Activitypub/FederationReviewBatchTest.php
@@ -0,0 +1,139 @@
+<?php
+/**
+ * Copyright (c) 2012-2019, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+
+class ActivitypubAdminBatchControllerTest extends Admin_AbstractControllerTestCase {
+  protected $_storm_default_to_volatile = true;
+
+  /** @test */
+  public function withServerDisabledFederationBatchShouldNotBeAvailable() {
+    Class_AdminVar::set('FEDERATION_IS_COMMUNITY_SERVER', '0');
+    $this->dispatch('/admin/batch');
+    $this->assertNotXPath('//a[contains(@href, "FEDERATION_REVIEW_HARVEST")]');
+  }
+
+
+  /** @test */
+  public function withServerEnabledFederationBatchShouldBeAvailable() {
+    Class_AdminVar::set('FEDERATION_IS_COMMUNITY_SERVER', '1');
+    $this->dispatch('/admin/batch');
+    $this->assertXPath('//a[contains(@href, "FEDERATION_REVIEW_HARVEST")]');
+  }
+}
+
+
+
+class ActivitypubFederationReviewBatchTest extends ModelTestCase {
+  protected
+    $_actor_id,
+    $_get_response_called = false,
+    $_web_client;
+
+  public function setUp() {
+    parent::setUp();
+    $this->_actor_id = 'https://review-share.member.com/activitypub/review';
+
+    $this->fixture('Class_Federation_GroupMembership',
+                   ['id' => 1,
+                    'group_name' => 'REVIEW_SHARE',
+                    'actor_id' => $this->_actor_id,
+                    'accepted_at' => '2019-05-03 14:15:54']);
+
+    $this->_web_client = $this
+      ->mock()
+      ->whenCalled('open_url')
+      ->with($this->_actor_id, ['headers' => ['Accept' => Class_WebService_ActivityPub::MIME_TYPE]])
+      ->answers(str_replace('{actor}',
+                            $this->_actor_id,
+                            '{
+  "@context": "https://www.w3.org/ns/activitystreams",
+  "type": "Service",
+  "id": "{actor}",
+  "name": "Arcadia",
+  "inbox": "{actor}/inbox",
+  "outbox": "{actor}/outbox",
+  "publicKey": "{actor}/pubkey"
+}'))
+
+      ->whenCalled('open_url')
+      ->with($this->_actor_id . '/pubkey',
+             ['headers' => ['Accept' => Class_WebService_ActivityPub::MIME_TYPE]])
+      ->answers(str_replace('{actor}',
+                            $this->_actor_id,
+                            '{
+  "@context": "https://www.w3.org/ns/activitystreams",
+  "type": "Key",
+  "id": "{actor}/pubkey",
+  "owner": "{actor}",
+  "publicKeyPem": "TheirKey"
+}'))
+
+      ->whenCalled('getResponse')
+      ->willDo(function()
+               {
+                 if ($this->_get_response_called)
+                   return $this->_responseWithItems([]);
+
+                 $this->_get_response_called = true;
+                 return $this->_responseWithItems([['id' => '3773',
+                                                    'clef_oeuvre' => 'UNAMOURDELAPIN44--DAVISJ-',
+                                                    'date_avis' => '2019-04-19 17:46:27',
+                                                    'date_mod' => null,
+                                                    'note' => '3',
+                                                    'entete' => 'Au top',
+                                                    'avis' => 'Comme d\'habitude, trop bien !',
+                                                    'source_author' => 'SuperBibliothécaire']]);
+               })
+      ;
+
+    $signer = Storm_Test_ObjectWrapper::on(new Class_HttpSignature('keyId="'. $this->_actor_id .'/pubkey", algorithm="rsa-sha256", headers="(request-target) date digest", signature="TheirSignature"'))
+      ->whenCalled('sign')->answers('MySignature')
+      ->whenCalled('verify')->answers(true);
+
+    Class_WebService_ActivityPub::setSigner($signer);
+    Class_WebService_ActivityPub::setTimeSource(new TimeSourceForTest('2019-05-03 16:10:35'));
+    Class_WebService_ActivityPub::setWebClient($this->_web_client);
+    Class_WebService_ActivityPub::setThrowErrors(true);
+
+    (new Class_Batch_FederationReviewHarvest)->run();
+  }
+
+
+  protected function _responseWithItems($items) {
+    return $this->mock()
+                ->whenCalled('isError')->answers(false)
+                ->whenCalled('getHeader')->with('Signature')->answers('TheirSignature')
+                ->whenCalled('getHeaders')->answers([])
+                ->whenCalled('getBody')
+                ->answers(json_encode(['@context' => 'https://www.w3.org/ns/activitystreams',
+                                       'type' => 'CollectionPage',
+                                       'totalItems' => count($items),
+                                       'items' => $items]));
+  }
+
+
+  /** @test */
+  public function shouldHaveAReviewWithActorIdAndAuthor() {
+    $review = Class_AvisNotice::findFirstBy(['source_actor_id' => $this->_actor_id,
+                                             'source_author' => 'Arcadia']);
+    $this->assertNotNull($review);
+  }
+}