feat: Divi read tools accept library entries (library access, E1/E3)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-27 01:02:25 +02:00
co-authored by Claude Opus 5.5
parent 2700fd615a
commit 48ecc627aa
13 changed files with 487 additions and 13 deletions
+9
View File
@@ -181,6 +181,15 @@ final class ExposurePolicy
return isset($this->readonlyGroups[$group]);
}
/**
* Whether $group is switched on at all: not disabled as a whole ("nur lesen" still reads). The
* group "Divi-Bibliothek" disabled hides library entries from the Divi read tools (ruling P9).
*/
public function groupEnabled(string $group): bool
{
return $this->groupMode($group) !== self::DISABLED;
}
public function defaultMode(Ability $ability): string
{
return $ability->isDirect() ? self::DIRECT : self::INDIRECT;
+19 -3
View File
@@ -6,8 +6,12 @@ namespace LIT\MCP\Integrations\Divi;
use Closure;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
use LIT\MCP\Integrations\Divi\Design\DiviDesignSystemAbility;
use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore;
use LIT\MCP\Integrations\Divi\Library\DiviRoles;
use LIT\MCP\Integrations\Divi\Library\LibraryAccess;
use LIT\MCP\Integrations\Divi\Pages\DiviComposition;
use LIT\MCP\Integrations\Divi\Pages\DiviContentComposer;
use LIT\MCP\Integrations\Divi\Pages\DiviDeleteAbility;
@@ -39,11 +43,23 @@ final class DiviIntegration implements Integration
{
private DiviModules $modules;
private DesignSystemReader $design;
private LibraryAccess $access;
public function __construct(private ContentSchema $schema, private ChangeLog $log)
{
/**
* @param (Closure(): bool)|null $libraryWritable Tools-tab gate for writes to library entries
* (group "Divi-Bibliothek" writable); null = always.
* @param (Closure(): bool)|null $libraryVisible Tools-tab gate for library entries at all (group
* "Divi-Bibliothek" not disabled, ruling P9); null = always.
*/
public function __construct(
private ContentSchema $schema,
private ChangeLog $log,
?Closure $libraryWritable = null,
?Closure $libraryVisible = null
) {
$this->modules = new DiviModules();
$this->design = new DesignSystemReader();
$this->access = new LibraryAccess(new DiviLibraryStore(), new DiviRoles(), $libraryWritable, $libraryVisible);
}
public function id(): string
@@ -80,7 +96,7 @@ final class DiviIntegration implements Integration
public function abilities(): array
{
$loader = new DiviPageLoader($this->schema);
$loader = new DiviPageLoader($this->schema, new Parser(), $this->access);
$composition = new DiviComposition($this->modules, $this->design);
$writer = new DiviWriter($loader, new DiviSaver(), $this->log, $this->design, $composition);
@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Library;
use Closure;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Integrations\WordPress\PostGuard;
use WP_Error;
use WP_Post;
defined('ABSPATH') || exit;
/**
* Rights on Divi Library entries (spec §4.5, E3 – never laxer than Divi): the library at all needs
* edit_posts and the Divi role option divi_library; one entry additionally edit_post on it (Divi's
* builder routes do not check that, F9). The Divi tools reach library entries only through
* item(); every other ID goes on to PostGuard unchanged.
* Not final so abilities can be tested with a mock.
*/
class LibraryAccess
{
/** Tool group "Divi-Bibliothek" (Tools tab); its switches gate library entries (E9, ruling P9). */
public const GROUP = 'divi-library';
/**
* @param (Closure(): bool)|null $writable Whether the Tools tab lets this connection write to the
* library (group "Divi-Bibliothek" not read-only or disabled); null = always.
* @param (Closure(): bool)|null $visible Whether library entries exist for this connection at all
* (group "Divi-Bibliothek" not disabled, ruling P9); null = always.
*/
public function __construct(
private DiviLibraryStore $store,
private DiviRoles $roles,
private ?Closure $writable = null,
private ?Closure $visible = null
) {
}
public function store(): DiviLibraryStore
{
return $this->store;
}
public function roles(): DiviRoles
{
return $this->roles;
}
/**
* The library entry $postId after the checks for reading, or their refusal; null when $postId
* names no library entry (the caller goes on with PostGuard). With the library hidden (ruling
* P9), an entry is refused exactly like an ID that does not exist – before any rights check.
*/
public function item(mixed $postId, bool $forWrite): WP_Post|WP_Error|null
{
$id = PostGuard::id($postId);
$post = $id === null ? null : get_post($id);
if (! $post instanceof WP_Post || $post->post_type !== DiviLibraryStore::POST_TYPE) {
return null;
}
if ($this->visible !== null && ! ($this->visible)()) {
return PostGuard::notFound((int) $post->ID);
}
return $this->canRead($post) ?? $post;
}
/** Listing the library: edit_posts and the Divi role option divi_library. */
public function canList(): ?WP_Error
{
if (! current_user_can('edit_posts')) {
return Errors::forbidden('Keine Berechtigung, die Divi-Bibliothek zu nutzen.');
}
return $this->roles->refusal('divi_library');
}
/** Reading one entry: canList() and edit_post on the entry. */
public function canRead(WP_Post $item): ?WP_Error
{
$refusal = $this->canList();
if ($refusal !== null) {
return $refusal;
}
return current_user_can('edit_post', (int) $item->ID)
? null
: Errors::forbidden(sprintf('Keine Berechtigung, Bibliothekseintrag %d zu bearbeiten.', (int) $item->ID));
}
public function isGlobal(WP_Post $item): bool
{
return $this->store->isGlobal((int) $item->ID);
}
}
+12 -2
View File
@@ -21,10 +21,17 @@ final class DiviPage
public function __construct(
public readonly WP_Post $post,
public readonly Document $document,
public readonly NodeIndex $index
public readonly NodeIndex $index,
private bool $libraryItem = false
) {
}
/** Whether the entry is a Divi Library entry (et_pb_layout), loaded through LibraryAccess. */
public function isLibraryItem(): bool
{
return $this->libraryItem;
}
public function treeRev(): string
{
return Rev::of((string) $this->post->post_content);
@@ -40,7 +47,10 @@ final class DiviPage
public function editUrl(): string
{
return self::builderUrl($this->post);
// A library entry has no public permalink (public: false); Divi's library list opens it here.
return $this->libraryItem
? (string) get_edit_post_link($this->post, 'raw')
: self::builderUrl($this->post);
}
/** The entry opened in the Divi Visual Builder – the edit_url of every Divi write (review I-1). */
+20 -6
View File
@@ -8,31 +8,45 @@ use LIT\MCP\Abilities\Errors;
use LIT\MCP\Integrations\Divi\BlockTree\MalformedMarkup;
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
use LIT\MCP\Integrations\Divi\Library\LibraryAccess;
use LIT\MCP\Integrations\WordPress\PostGuard;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use LIT\MCP\Log;
use WP_Error;
use WP_Post;
defined('ABSPATH') || exit;
/**
* Loads an entry for the Divi tools: rights and status via PostGuard (spec §4.5), then the
* block tree. Divi 4 shortcode content and unbalanced markup are refused with a hint –
* spec §3.4: Divi 4 content is kept, never edited.
* Loads an entry for the Divi tools: Divi Library entries via LibraryAccess (E1/E3), every other
* entry with its rights and status via PostGuard (spec §4.5), then the block tree. Divi 4
* shortcode content and unbalanced markup are refused with a hint – spec §3.4: Divi 4 content
* is kept, never edited.
*/
final class DiviPageLoader
{
public const OUTLINE_HINT = 'Aktuellen Stand mit lit/divi-outline (direkt: lit-divi-outline) neu lesen und die '
. 'Änderung mit der neuen tree_rev wiederholen.';
public function __construct(private ContentSchema $schema, private Parser $parser = new Parser())
public function __construct(
private ContentSchema $schema,
private Parser $parser = new Parser(),
private ?LibraryAccess $library = null
) {
}
/** The library access the Divi tools use, null when library entries are not reachable. */
public function library(): ?LibraryAccess
{
return $this->library;
}
/** Readers pass $forWrite = false (trashed entries may be read, like lit-content-get). */
public function load(mixed $postId, bool $forWrite): DiviPage|WP_Error
{
$post = PostGuard::editable($postId, $this->schema, 'post_id', ! $forWrite);
// Library entries (E1): only for reading so far, and only with a library access.
$item = $forWrite ? null : $this->library?->item($postId, false);
$post = $item ?? PostGuard::editable($postId, $this->schema, 'post_id', ! $forWrite);
if ($post instanceof WP_Error) {
return $post;
}
@@ -64,7 +78,7 @@ final class DiviPageLoader
);
}
return new DiviPage($post, $document, NodeIndex::build($document));
return new DiviPage($post, $document, NodeIndex::build($document), $item instanceof WP_Post);
}
/** The entry loaded for writing and checked by writable() – the refusal of either step. */
+7 -1
View File
@@ -45,7 +45,7 @@ final class PostGuard
$post = get_post($id);
if (! $post instanceof WP_Post || ! $schema->isManaged((string) $post->post_type)) {
return Errors::notFound(sprintf('Eintrag %d nicht gefunden (oder kein unterstützter Inhaltstyp).', $id));
return self::notFound($id);
}
if (! current_user_can('edit_post', $id)) {
@@ -63,6 +63,12 @@ final class PostGuard
return $post;
}
/** The refusal for an ID that names no entry the tools manage – the same for every such ID. */
public static function notFound(int $id): WP_Error
{
return Errors::notFound(sprintf('Eintrag %d nicht gefunden (oder kein unterstützter Inhaltstyp).', $id));
}
/**
* A scheduled entry whose publication time has passed (WordPress' cron missed it) or is
* less than a minute away: wp_insert_post() turns "future" into "publish" on any save when
+6 -1
View File
@@ -23,6 +23,7 @@ use LIT\MCP\ChangeLog\ChangeLogInstaller;
use LIT\MCP\Guides\InstructionsBuilder;
use LIT\MCP\Integrations\Core\CoreIntegration;
use LIT\MCP\Integrations\Divi\DiviIntegration;
use LIT\MCP\Integrations\Divi\Library\LibraryAccess;
use LIT\MCP\Integrations\IntegrationRegistry;
use LIT\MCP\Integrations\Media\MediaIntegration;
use LIT\MCP\Integrations\Pods\PodsFieldSource;
@@ -71,9 +72,13 @@ final class Plugin
(new ChangeLogCleanup($changeLog, $limits))->register();
$schema = self::contentSchema();
$divi = new DiviIntegration($schema, $changeLog);
$registry = null;
$policy = null;
// Ruling P9: with the group "Divi-Bibliothek" disabled, the Divi tools do not see library entries.
$libraryVisible = static function () use (&$policy): bool {
return $policy instanceof ExposurePolicy && $policy->groupEnabled(LibraryAccess::GROUP);
};
$divi = new DiviIntegration($schema, $changeLog, libraryVisible: $libraryVisible);
// Security M2: content-create's divi input follows lit/divi-insert (asked at execute time,
// when registry and policy exist), so the Divi group's switches stop every Divi layout write.
$diviWritable = static function () use (&$registry, &$policy): bool {