From 1b42f9b64074c9c8fab2276d541776279cf5865e Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sun, 27 Sep 2026 04:06:56 +0200 Subject: [PATCH] fix: lit/divi-node-copy from a library entry needs the insert rights (I-1, L5) A library entry as source now requires LibraryAccess::canInsert() (add_library, writable library group) and follows lit-library-insert's Tools-tab state (disabled -> unsupported). DiviPageLoader::library() gets its caller. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/guides/divi-library.md | 7 ++- src/Integrations/Divi/DiviIntegration.php | 7 ++- .../Divi/Pages/DiviNodeCopyAbility.php | 37 ++++++++++++-- src/Plugin.php | 9 +++- .../Divi/Pages/DiviNodeCopyAbilityTest.php | 49 ++++++++++++++++++- 5 files changed, 97 insertions(+), 12 deletions(-) diff --git a/docs/guides/divi-library.md b/docs/guides/divi-library.md index 09663dd..a202598 100644 --- a/docs/guides/divi-library.md +++ b/docs/guides/divi-library.md @@ -29,7 +29,9 @@ Die Divi-Bibliothek speichert Layouts, Abschnitte, Zeilen und Module zur Wiederv - `lit/divi-node-copy`: einen Knoten von einem Eintrag an eine Position eines anderen kopieren (`source_post_id`, `source_id`, `source_tree_rev` aus `lit-divi-outline` der Quelle – Pflicht, weil Pfad-IDs nur für diesen Stand gelten; Ziel mit `post_id`, `tree_rev`, Position). Quelle und Ziel dürfen - Seiten oder Bibliothekseinträge sein. Die Kopie ist byte-gleich; globale Module bleiben verknüpft. + Seiten oder Bibliothekseinträge sein. Die Kopie ist byte-gleich; globale Module bleiben verknüpft. Aus + einem Bibliothekseintrag zu kopieren ist Einfügen aus der Bibliothek: es braucht dieselben Rechte und + Freigaben wie `lit-library-insert`. - Die Divi-Werkzeuge arbeiten auch auf Bibliothekseinträgen: `lit-divi-outline`, `lit-divi-node-get`, `lit-divi-insert`, `lit-divi-update`, `lit-divi-move`, `lit-divi-delete`, `lit/divi-duplicate-node` und `lit/divi-replace-text` mit der ID des Eintrags als `post_id`. @@ -79,7 +81,8 @@ sie in `notes`; ihren Inhalt mit `lit-divi-outline` und der ID lesen. Hinweis. - Einstellungen → LIT MCP → Tools: Gruppe „Divi-Bibliothek“ auf „nur lesen“ sperrt jeden Schreibzugriff auf Bibliothekseinträge, auch über die Divi-Werkzeuge; deaktiviert gilt jeder Eintrag als nicht vorhanden. - `lit-library-insert` und `lit/divi-node-copy` sind außerdem gesperrt, wenn `lit-divi-insert` gesperrt ist. + `lit-library-insert` und `lit/divi-node-copy` sind außerdem gesperrt, wenn `lit-divi-insert` gesperrt ist; + `lit/divi-node-copy` mit einem Bibliothekseintrag als Quelle auch, wenn `lit-library-insert` gesperrt ist. - Ohne das Recht `unfiltered_html` überträgt LIT MCP beim Kopieren, Einfügen und Speichern (`lit/divi-node-copy`, `lit-library-insert`, `lit/library-save`) nur Knoten, die das Konto auch selbst schreiben dürfte. Abgelehnt werden – „nur im Divi-Builder“ – Knoten mit eigenem HTML, CSS oder HTML-Attributen, Interaktionen, einer diff --git a/src/Integrations/Divi/DiviIntegration.php b/src/Integrations/Divi/DiviIntegration.php index d94676e..1ff9cbb 100644 --- a/src/Integrations/Divi/DiviIntegration.php +++ b/src/Integrations/Divi/DiviIntegration.php @@ -58,13 +58,16 @@ final class DiviIntegration implements Integration * "Divi-Bibliothek" not disabled, ruling P9); null = always. * @param (Closure(): bool)|null $diviWritable Whether lit/divi-insert is enabled – lit/divi-node-copy * follows it (ruling P9, E9); null = always. + * @param (Closure(): bool)|null $libraryInsertable Whether lit/library-insert is enabled – + * lit/divi-node-copy follows it for library entries as source (audit L5); null = always. */ public function __construct( private ContentSchema $schema, private ChangeLog $log, ?Closure $libraryWritable = null, ?Closure $libraryVisible = null, - private ?Closure $diviWritable = null + private ?Closure $diviWritable = null, + private ?Closure $libraryInsertable = null ) { $this->modules = new DiviModules(); $this->design = new DesignSystemReader(); @@ -119,7 +122,7 @@ final class DiviIntegration implements Integration new DiviDeleteAbility($writer), new DiviDuplicateNodeAbility($writer), new DiviReplaceTextAbility($this->schema, $loader, $writer), - new DiviNodeCopyAbility($writer, $loader, $this->diviWritable), + new DiviNodeCopyAbility($writer, $loader, $this->diviWritable, $this->libraryInsertable), new DiviDynamicSourcesAbility($this->schema, new DynamicContentMap(), $this->access->roles()), ]; } diff --git a/src/Integrations/Divi/Pages/DiviNodeCopyAbility.php b/src/Integrations/Divi/Pages/DiviNodeCopyAbility.php index cc6e8a7..148dddc 100644 --- a/src/Integrations/Divi/Pages/DiviNodeCopyAbility.php +++ b/src/Integrations/Divi/Pages/DiviNodeCopyAbility.php @@ -19,15 +19,22 @@ defined('ABSPATH') || exit; * hiding) and its own source_tree_rev, because path IDs are only valid for the state they were read * in; the source is only read, so it needs no lock. Target: like lit-divi-insert (tree_rev lock, * StructureRules, all or nothing, DiviWriter::commit with GlobalEdits – a global target entry never - * gets a nested instance). Follows lit/divi-insert's Tools-tab state (ruling P9/E9). + * gets a nested instance). Follows lit/divi-insert's Tools-tab state (ruling P9/E9). A library entry + * as source is an insert from the library (E3, audit L5): it needs LibraryAccess::canInsert() + * (add_library, writable group "Divi-Bibliothek") and follows lit-library-insert's Tools-tab state. */ final class DiviNodeCopyAbility extends DiviWriteAbility { - /** @param (Closure(): bool)|null $diviWritable Whether lit/divi-insert is enabled; null = always. */ + /** + * @param (Closure(): bool)|null $diviWritable Whether lit/divi-insert is enabled; null = always. + * @param (Closure(): bool)|null $libraryInsertable Whether lit/library-insert is enabled (gates + * library entries as source); null = always. + */ public function __construct( DiviWriter $writer, private DiviPageLoader $loader, - private ?Closure $diviWritable = null + private ?Closure $diviWritable = null, + private ?Closure $libraryInsertable = null ) { parent::__construct($writer); } @@ -74,7 +81,10 @@ final class DiviNodeCopyAbility extends DiviWriteAbility return false; } - /** lit/divi-insert's Tools-tab state, then the source's own rights – in permission() and execute(). */ + /** + * lit/divi-insert's Tools-tab state, then the source's own rights and, for a library entry as + * source, the rights of an insert from the library – in permission() and execute(). + */ protected function precheck(array $input): ?WP_Error { if ($this->diviWritable !== null && ! ($this->diviWritable)()) { @@ -86,8 +96,25 @@ final class DiviNodeCopyAbility extends DiviWriteAbility } $source = $this->loader->load($input['source_post_id'] ?? null, false); + if ($source instanceof WP_Error) { + return $source; + } - return $source instanceof WP_Error ? $source : null; + return $source->isLibraryItem() ? $this->libraryInsertRefusal() : null; + } + + /** E3/audit L5: copying out of a library entry is inserting from the library (lit-library-insert). */ + private function libraryInsertRefusal(): ?WP_Error + { + if ($this->libraryInsertable !== null && ! ($this->libraryInsertable)()) { + return Errors::unsupported( + 'Einfügen aus der Divi-Bibliothek ist für diese Verbindung abgeschaltet (lit-library-insert ' + . 'deaktiviert oder Gruppe „Divi-Bibliothek“ auf „nur lesen“).', + 'Einen Administrator fragen: Einstellungen → LIT MCP → Tools.' + ); + } + + return $this->loader->library()?->canInsert(); } protected function change(DiviPage $page, array $input, DiviComposition $composition): DiviChange|WP_Error diff --git a/src/Plugin.php b/src/Plugin.php index f831cc5..4bfc0dd 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -89,12 +89,19 @@ final class Plugin $libraryWritable = static function () use (&$policy): bool { return $policy instanceof ExposurePolicy && $policy->groupWritable(LibraryAccess::GROUP); }; + // Audit L5: lit/divi-node-copy from a library entry follows lit/library-insert's Tools-tab state. + $libraryInsertable = static function () use (&$registry, &$policy): bool { + return $registry instanceof IntegrationRegistry + && $policy instanceof ExposurePolicy + && $policy->allows('lit/library-insert', $registry->abilities()); + }; $divi = new DiviIntegration( $schema, $changeLog, $libraryWritable, libraryVisible: $libraryVisible, - diviWritable: $diviWritable + diviWritable: $diviWritable, + libraryInsertable: $libraryInsertable ); $registry = new IntegrationRegistry( new CoreIntegration(self::pluginDir() . '/docs/guides'), diff --git a/tests/Unit/Integrations/Divi/Pages/DiviNodeCopyAbilityTest.php b/tests/Unit/Integrations/Divi/Pages/DiviNodeCopyAbilityTest.php index c07980b..20b41cc 100644 --- a/tests/Unit/Integrations/Divi/Pages/DiviNodeCopyAbilityTest.php +++ b/tests/Unit/Integrations/Divi/Pages/DiviNodeCopyAbilityTest.php @@ -42,7 +42,8 @@ final class DiviNodeCopyAbilityTest extends TestCase private function ability( bool $diviWritable = true, bool $unfiltered = false, - ?LibraryAccess $access = null + ?LibraryAccess $access = null, + bool $libraryInsert = true ): DiviNodeCopyAbility { $access ??= LibraryFixtures::access(); $loader = LibraryFixtures::loader($access); @@ -54,7 +55,8 @@ final class DiviNodeCopyAbilityTest extends TestCase return new DiviNodeCopyAbility( DiviWriterFixture::writer($this->saved, $failures, $unfiltered, $loader, $globals), $loader, - static fn (): bool => $diviWritable + static fn (): bool => $diviWritable, + static fn (): bool => $libraryInsert ); } @@ -206,6 +208,49 @@ final class DiviNodeCopyAbilityTest extends TestCase self::assertStringContainsString('Library-Blurb', $this->saved[0]); } + /** @return array */ + private function librarySource(): array + { + return $this->input([ + 'source_post_id' => 1055, + 'source_tree_rev' => Rev::of((string) LibraryFixtures::item(1055)->post_content), + 'source_id' => 's0', + ]); + } + + public function testALibrarySourceNeedsAddLibrary(): void + { + $ability = $this->ability(access: LibraryFixtures::access(['add_library'])); + + self::assertToolError($ability->permission($this->librarySource()), 'forbidden'); + self::assertToolError($ability->execute($this->librarySource()), 'forbidden'); + self::assertSame([], $this->saved); + } + + public function testALibrarySourceNeedsAWritableLibraryGroup(): void + { + $ability = $this->ability(access: LibraryFixtures::access(writable: false)); + + self::assertToolError($ability->execute($this->librarySource()), 'forbidden'); + self::assertSame([], $this->saved); + } + + public function testALibrarySourceFollowsLibraryInsertExposure(): void + { + $ability = $this->ability(libraryInsert: false); + + self::assertToolError($ability->permission($this->librarySource()), 'unsupported'); + self::assertToolError($ability->execute($this->librarySource()), 'unsupported'); + self::assertSame([], $this->saved); + } + + public function testPageSourcesNeedNeitherAddLibraryNorLibraryInsert(): void + { + $ability = $this->ability(access: LibraryFixtures::access(['add_library']), libraryInsert: false); + + self::assertTrue($ability->execute($this->input())['ok']); + } + public function testAHiddenLibraryIsNoSource(): void { $item = LibraryFixtures::item(1055);