From 5f8b1dd32501148d6d8f002ac0bf44097ac4d3e1 Mon Sep 17 00:00:00 2001 From: DanMat Date: Sun, 6 Sep 2026 20:32:46 -0400 Subject: [PATCH] Syndication slice 2: admin page + MCP tools, both on blog:syndicate Wires the slice-1 Syndicator to its two surfaces, sharing one service, capability, and audit. - MCP (ADR 0016): BlogToolset with syndicate_post {slug, target} and syndication_status {slug}, both gating on nimbuscms.blog:syndicate. A content wildcard can neither enumerate nor call them (wildcard-immune); a denied call reports as unknown (non-enumerating). - Admin (ADR 0020): a Syndication page listing published posts with a button per post per target (Post / Update), the external link, and last status; the push action inherits the page capability + CSRF. Targets not configured show as unavailable. Nonce'd styles (admin CSP drops inline style=). - Both built from one Syndicator wired in register() (DevToTarget from DEVTO_API_KEY, published-only reader, canonical from Config::appUrl()). Test fakes extracted to their own files (FakeHttpClient/FakeTarget/FakeStore) so they autoload across suites. BlogToolsetTest proves the MCP gate (syndicate token sees + calls; a content wildcard sees nothing and is refused). 32 tests green, PHPStan L6 + cs-fixer clean. Co-Authored-By: Claude Opus 4.8 --- src/BlogPlugin.php | 37 +++++++++++++ src/BlogToolset.php | 96 ++++++++++++++++++++++++++++++++++ src/SyndicationAdmin.php | 107 ++++++++++++++++++++++++++++++++++++++ tests/BlogToolsetTest.php | 83 +++++++++++++++++++++++++++++ tests/DevToTargetTest.php | 18 ------- tests/FakeHttpClient.php | 24 +++++++++ tests/FakeStore.php | 36 +++++++++++++ tests/FakeTarget.php | 44 ++++++++++++++++ tests/SyndicatorTest.php | 63 ---------------------- 9 files changed, 427 insertions(+), 81 deletions(-) create mode 100644 src/BlogToolset.php create mode 100644 src/SyndicationAdmin.php create mode 100644 tests/BlogToolsetTest.php create mode 100644 tests/FakeHttpClient.php create mode 100644 tests/FakeStore.php create mode 100644 tests/FakeTarget.php diff --git a/src/BlogPlugin.php b/src/BlogPlugin.php index bd8d8b9..60430bd 100644 --- a/src/BlogPlugin.php +++ b/src/BlogPlugin.php @@ -8,8 +8,10 @@ use Nimbus\Http\Response; use Nimbus\Plugin\Plugin; use Nimbus\Plugin\PluginContext; +use Nimbus\Plugin\PluginStorage; use Nimbus\Site\PageView; use Nimbus\Support\Config; +use Nimbus\Support\Env; /** * The official Blog plugin — turns a plain `blog` collection into a real blog: @@ -81,6 +83,41 @@ public function register(PluginContext $context): void ], ['title' => 'Tagged: ' . $name]); }, __DIR__ . '/../templates'); + // --- Syndication (cross-post to dev platforms) ---------------------- + // One Syndicator behind two surfaces: a capability-gated admin page and the + // MCP toolset, both on nimbuscms.blog:syndicate. Target credentials are read + // from server env only; the reader is published-only (ADR 0029). + $syndicator = new Syndicator( + ['devto' => new DevToTarget(new CurlHttpClient(), Env::get('DEVTO_API_KEY'))], + new SyndicationRepository(static fn (): PluginStorage => $context->storage()), + static fn (string $slug): ?array => $context->content()->entryBySlug(self::COLLECTION, $slug), + Config::appUrl(), + '/' . self::COLLECTION, + ); + + // The agent surface (ADR 0016) — same service, same capability. + $context->mcp()->register(new BlogToolset($syndicator)); + + // The admin surface (ADR 0020) — a Syndication page with a button per post + // per target; the push action inherits the page's capability and CSRF. + $context->adminPages()->register( + 'blog-syndication', + 'Syndication', + '📣', + static fn (Request $r, string $nonce = '', string $csrf = ''): string => (new SyndicationAdmin($syndicator, $posts()))->render($csrf, $r->query('ok') ?? $r->query('err'), $nonce), + self::ID . ':syndicate', + ); + $context->adminPages()->action('blog-syndication', 'push', static function (Request $r) use ($syndicator): Response { + $slug = (string) ($r->input('slug') ?? ''); + $target = (string) ($r->input('target') ?? ''); + try { + $result = $syndicator->syndicate($slug, $target, date('Y-m-d H:i:s')); + return Response::redirect('/admin/blog-syndication?ok=' . rawurlencode('Syndicated to ' . $result['target'] . '.')); + } catch (SyndicationError $e) { + return Response::redirect('/admin/blog-syndication?err=' . rawurlencode($e->getMessage())); + } + }); + // Agent-facing reference (ADR 0013). $context->skills()->register('Blog', Guide::text()); } diff --git a/src/BlogToolset.php b/src/BlogToolset.php new file mode 100644 index 0000000..4a1a969 --- /dev/null +++ b/src/BlogToolset.php @@ -0,0 +1,96 @@ + 'string', 'description' => 'The blog post slug.']; + + return [ + new PluginTool( + 'syndicate_post', + 'syndicate', + 'Cross-post a published blog post to an external platform (e.g. Dev.to), setting the canonical back to this site. Re-running updates the existing copy rather than duplicating it.', + [ + 'type' => 'object', + 'required' => ['slug', 'target'], + 'properties' => [ + 'slug' => $slug, + 'target' => ['type' => 'string', 'description' => 'The target platform id, e.g. "devto".'], + ], + ], + $this->syndicatePost(...), + ), + new PluginTool( + 'syndication_status', + 'syndicate', + 'Where a published blog post has been syndicated, and to what URLs.', + [ + 'type' => 'object', + 'required' => ['slug'], + 'properties' => ['slug' => $slug], + ], + $this->syndicationStatus(...), + ), + ]; + } + + /** + * @param array $a + * @return array + */ + private function syndicatePost(array $a, TokenPrincipal $p, EntryOpContext $c): array + { + try { + $result = $this->syndicator->syndicate($this->str($a, 'slug'), $this->str($a, 'target'), date('Y-m-d H:i:s')); + } catch (SyndicationError $e) { + return ToolResult::error($e->getMessage(), 'syndication_failed'); + } + return ToolResult::ok($result); + } + + /** + * @param array $a + * @return array + */ + private function syndicationStatus(array $a, TokenPrincipal $p, EntryOpContext $c): array + { + $status = $this->syndicator->statusFor($this->str($a, 'slug')); + if ($status === null) { + return ToolResult::error('No published post with that slug.', 'not_found'); + } + return ToolResult::ok($status); + } + + /** @param array $a */ + private function str(array $a, string $key): string + { + $v = $a[$key] ?? ''; + return is_scalar($v) ? trim((string) $v) : ''; + } +} diff --git a/src/SyndicationAdmin.php b/src/SyndicationAdmin.php new file mode 100644 index 0000000..aacb0a6 --- /dev/null +++ b/src/SyndicationAdmin.php @@ -0,0 +1,107 @@ +syndicator->targets(); + $posts = $this->posts->published(200); + + $html = $this->styles($nonce) + . '

Syndication

' + . '

Cross-post a published post to an external platform. The canonical is set back to this site automatically, and re-posting updates the existing copy rather than duplicating it. Credentials are set on the server; a target with no key configured is shown as unavailable.

'; + + if ($notice !== null && $notice !== '') { + $html .= '
' . self::e($notice) . '
'; + } + + if ($targets === []) { + return $html . '

No syndication targets are available.

'; + } + if ($posts === []) { + return $html . '

No published posts yet.

'; + } + + $html .= ''; + foreach ($targets as $target) { + $html .= ''; + } + $html .= ''; + + foreach ($posts as $post) { + $slug = (string) $post['slug']; + $status = $this->syndicator->statusFor($slug); + $records = []; + foreach ($status['records'] ?? [] as $r) { + $records[$r['target']] = $r; + } + $html .= ''; + foreach ($targets as $target) { + $html .= ''; + } + $html .= ''; + } + + return $html . '
Post' . self::e($target->label()) . '
' . self::e((string) $post['title']) . '' . $this->cell($target, $slug, $records[$target->id()] ?? null, $csrf) . '
'; + } + + /** + * @param array{external_url:?string,status:string,synced_at:string}|null $record + */ + private function cell(SyndicationTarget $target, string $slug, ?array $record, string $csrf): string + { + if (!$target->isConfigured()) { + return 'Not configured'; + } + $synced = $record !== null; + $verb = $synced ? 'Update' : 'Post'; + $out = '
' + . '' + . '' + . '' + . '
'; + + if ($record !== null && ($record['external_url'] ?? '') !== '') { + $out .= ' view'; + } + if ($record !== null && $record['status'] === 'error') { + $out .= ' last attempt failed'; + } + return $out; + } + + private function styles(string $nonce): string + { + return ''; + } + + private static function e(string $v): string + { + return htmlspecialchars($v, ENT_QUOTES, 'UTF-8'); + } +} diff --git a/tests/BlogToolsetTest.php b/tests/BlogToolsetTest.php new file mode 100644 index 0000000..44412aa --- /dev/null +++ b/tests/BlogToolsetTest.php @@ -0,0 +1,83 @@ +target = new FakeTarget('devto'); + $post = ['id' => 7, 'slug' => 'hello', 'title' => 'Hello', 'fields' => ['body' => '# Hi', 'tags' => 'php', 'canonical_url' => '']]; + $syndicator = new Syndicator( + ['devto' => $this->target], + new FakeStore(), + static fn (string $s): ?array => $s === 'hello' ? $post : null, + 'https://danmat.dev', + '/blog', + ); + $this->toolset = new BlogToolset($syndicator); + $this->toolset->bindTo('nimbuscms.blog'); // the registrar does this in prod + $this->ctx = new EntryOpContext('127.0.0.1', '/api/v1/mcp'); + } + + private function principal(string ...$scopes): TokenPrincipal + { + return new TokenPrincipal(1, 'blog-bot', array_values($scopes)); + } + + public function test_a_syndicate_token_sees_both_tools(): void + { + $names = array_column($this->toolset->definitions($this->principal('nimbuscms.blog:syndicate')), 'name'); + self::assertContains('blog_syndicate_post', $names); + self::assertContains('blog_syndication_status', $names); + } + + public function test_a_content_wildcard_sees_nothing(): void + { + self::assertSame([], $this->toolset->definitions($this->principal('*:write')), 'syndicate is wildcard-immune'); + self::assertSame([], $this->toolset->definitions($this->principal('posts:write'))); + } + + public function test_syndicate_post_delegates_when_scoped(): void + { + $out = $this->toolset->call('blog_syndicate_post', ['slug' => 'hello', 'target' => 'devto'], $this->principal('nimbuscms.blog:syndicate'), $this->ctx); + + self::assertNotNull($out, 'a scoped token gets a result'); + self::assertNotNull($this->target->lastCall, 'the tool reached the Syndicator'); + } + + public function test_syndicate_post_is_refused_without_the_scope(): void + { + // A denied call reports as an unknown tool (non-enumerating) and never runs. + try { + $this->toolset->call('blog_syndicate_post', ['slug' => 'hello', 'target' => 'devto'], $this->principal('*:write'), $this->ctx); + self::fail('expected the call to be refused'); + } catch (McpError) { + self::assertNull($this->target->lastCall, 'it never reaches the Syndicator'); + } + } + + public function test_status_returns_a_result_when_scoped(): void + { + $out = $this->toolset->call('blog_syndication_status', ['slug' => 'hello'], $this->principal('nimbuscms.blog:syndicate'), $this->ctx); + self::assertNotNull($out); + } +} diff --git a/tests/DevToTargetTest.php b/tests/DevToTargetTest.php index 7a3d1f4..d6e4dc0 100644 --- a/tests/DevToTargetTest.php +++ b/tests/DevToTargetTest.php @@ -5,27 +5,9 @@ namespace NimbusCMS\Blog\Tests; use NimbusCMS\Blog\DevToTarget; -use NimbusCMS\Blog\HttpClient; use NimbusCMS\Blog\SyndicationError; use PHPUnit\Framework\TestCase; -/** A fake HTTP client that records the last request and returns a canned response. */ -final class FakeHttpClient implements HttpClient -{ - /** @var array */ - public array $last = []; - - public function __construct(private int $status, private string $body) - { - } - - public function send(string $method, string $url, array $headers, ?string $body): array - { - $this->last = ['method' => $method, 'url' => $url, 'headers' => $headers, 'body' => $body]; - return ['status' => $this->status, 'body' => $this->body]; - } -} - /** * The Dev.to adapter: it builds the right request to create and to update, sends the * canonical and sanitised tags, and turns a non-2xx into a clear error. The fake HTTP diff --git a/tests/FakeHttpClient.php b/tests/FakeHttpClient.php new file mode 100644 index 0000000..801dbf3 --- /dev/null +++ b/tests/FakeHttpClient.php @@ -0,0 +1,24 @@ + */ + public array $last = []; + + public function __construct(private int $status, private string $body) + { + } + + public function send(string $method, string $url, array $headers, ?string $body): array + { + $this->last = ['method' => $method, 'url' => $url, 'headers' => $headers, 'body' => $body]; + return ['status' => $this->status, 'body' => $this->body]; + } +} diff --git a/tests/FakeStore.php b/tests/FakeStore.php new file mode 100644 index 0000000..201da5e --- /dev/null +++ b/tests/FakeStore.php @@ -0,0 +1,36 @@ + */ + public array $rows = []; + + public function get(int $entryId, string $target): ?array + { + return $this->rows[$entryId . ':' . $target] ?? null; + } + + public function record(int $entryId, string $target, ?string $externalId, string $externalUrl, string $status, string $now): void + { + $this->rows[$entryId . ':' . $target] = ['external_id' => $externalId, 'external_url' => $externalUrl, 'status' => $status]; + } + + public function forEntry(int $entryId): array + { + $out = []; + foreach ($this->rows as $key => $row) { + [$e, $t] = explode(':', $key, 2); + if ((int) $e === $entryId) { + $out[] = ['target' => $t, 'external_id' => $row['external_id'], 'external_url' => $row['external_url'], 'status' => $row['status'], 'synced_at' => '2026-01-01 00:00:00']; + } + } + return $out; + } +} diff --git a/tests/FakeTarget.php b/tests/FakeTarget.php new file mode 100644 index 0000000..525ffb9 --- /dev/null +++ b/tests/FakeTarget.php @@ -0,0 +1,44 @@ +,externalId:?string}|null */ + public ?array $lastCall = null; + public bool $throw = false; + + public function __construct(private string $id = 'devto', private bool $configured = true) + { + } + + public function id(): string + { + return $this->id; + } + + public function label(): string + { + return ucfirst($this->id); + } + + public function isConfigured(): bool + { + return $this->configured; + } + + public function push(array $post, ?string $externalId): array + { + $this->lastCall = ['post' => $post, 'externalId' => $externalId]; + if ($this->throw) { + throw new SyndicationError('boom'); + } + return ['external_id' => '42', 'external_url' => 'https://dev.to/dan/hello-42']; + } +} diff --git a/tests/SyndicatorTest.php b/tests/SyndicatorTest.php index 0f496f9..0235707 100644 --- a/tests/SyndicatorTest.php +++ b/tests/SyndicatorTest.php @@ -5,72 +5,9 @@ namespace NimbusCMS\Blog\Tests; use NimbusCMS\Blog\SyndicationError; -use NimbusCMS\Blog\SyndicationStore; -use NimbusCMS\Blog\SyndicationTarget; use NimbusCMS\Blog\Syndicator; use PHPUnit\Framework\TestCase; -/** A fake target that records its last call and can be told to fail. */ -final class FakeTarget implements SyndicationTarget -{ - /** @var array{post:array,externalId:?string}|null */ - public ?array $lastCall = null; - public bool $throw = false; - - public function id(): string - { - return 'devto'; - } - - public function label(): string - { - return 'Dev.to'; - } - - public function isConfigured(): bool - { - return true; - } - - public function push(array $post, ?string $externalId): array - { - $this->lastCall = ['post' => $post, 'externalId' => $externalId]; - if ($this->throw) { - throw new SyndicationError('boom'); - } - return ['external_id' => '42', 'external_url' => 'https://dev.to/dan/hello-42']; - } -} - -/** An in-memory {@see SyndicationStore}. */ -final class FakeStore implements SyndicationStore -{ - /** @var array */ - public array $rows = []; - - public function get(int $entryId, string $target): ?array - { - return $this->rows[$entryId . ':' . $target] ?? null; - } - - public function record(int $entryId, string $target, ?string $externalId, string $externalUrl, string $status, string $now): void - { - $this->rows[$entryId . ':' . $target] = ['external_id' => $externalId, 'external_url' => $externalUrl, 'status' => $status]; - } - - public function forEntry(int $entryId): array - { - $out = []; - foreach ($this->rows as $key => $row) { - [$e, $t] = explode(':', $key, 2); - if ((int) $e === $entryId) { - $out[] = ['target' => $t, 'external_id' => $row['external_id'], 'external_url' => $row['external_url'], 'status' => $row['status'], 'synced_at' => '2026-01-01 00:00:00']; - } - } - return $out; - } -} - /** * The Syndicator: canonical computation (self vs a declared original), create vs * update from the stored external id, recording success and failure, and the