Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions src/BlogPlugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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());
}
Expand Down
96 changes: 96 additions & 0 deletions src/BlogToolset.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<?php

declare(strict_types=1);

namespace NimbusCMS\Blog;

use Nimbus\Api\EntryOpContext;
use Nimbus\Api\TokenPrincipal;
use Nimbus\Mcp\PluginTool;
use Nimbus\Mcp\PluginToolset;
use Nimbus\Mcp\ToolResult;

/**
* The agent surface for syndication (ADR 0016). Both tools gate on the plugin's
* `syndicate` action, so only a token explicitly scoped `nimbuscms.blog:syndicate`
* can reach them; a content token cannot even enumerate them. They call the same
* {@see Syndicator} the admin button uses, so the two surfaces behave identically.
*/
final class BlogToolset extends PluginToolset
{
public function __construct(private Syndicator $syndicator)
{
}

public function namespace(): string
{
return 'blog';
}

protected function tools(): array
{
$slug = ['type' => '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<string,mixed> $a
* @return array<string,mixed>
*/
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<string,mixed> $a
* @return array<string,mixed>
*/
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<string,mixed> $a */
private function str(array $a, string $key): string
{
$v = $a[$key] ?? '';
return is_scalar($v) ? trim((string) $v) : '';
}
}
107 changes: 107 additions & 0 deletions src/SyndicationAdmin.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
<?php

declare(strict_types=1);

namespace NimbusCMS\Blog;

/**
* The "Syndication" admin page (ADR 0020): published posts, each with a button per
* target to post or update it on that platform, the resulting link, and the last
* status. Gated on nimbuscms.blog:syndicate; the buttons post to the page's own
* CSRF-checked action. Renders raw HTML inside the admin shell; every value is
* escaped, and the only inline style is a nonce'd block (admin CSP drops inline
* style= attributes).
*/
final class SyndicationAdmin
{
private const ACTION = '/admin/blog-syndication/push';

public function __construct(private Syndicator $syndicator, private Posts $posts)
{
}

public function render(string $csrf, ?string $notice, string $nonce): string
{
$targets = $this->syndicator->targets();
$posts = $this->posts->published(200);

$html = $this->styles($nonce)
. '<div class="rz-head"><h1>Syndication</h1></div>'
. '<p class="nb-muted rz-intro">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.</p>';

if ($notice !== null && $notice !== '') {
$html .= '<div class="nb-notice">' . self::e($notice) . '</div>';
}

if ($targets === []) {
return $html . '<p class="nb-muted">No syndication targets are available.</p>';
}
if ($posts === []) {
return $html . '<p class="nb-muted">No published posts yet.</p>';
}

$html .= '<table class="rz-table"><thead><tr><th>Post</th>';
foreach ($targets as $target) {
$html .= '<th>' . self::e($target->label()) . '</th>';
}
$html .= '</tr></thead><tbody>';

foreach ($posts as $post) {
$slug = (string) $post['slug'];
$status = $this->syndicator->statusFor($slug);
$records = [];
foreach ($status['records'] ?? [] as $r) {
$records[$r['target']] = $r;
}
$html .= '<tr><td>' . self::e((string) $post['title']) . '</td>';
foreach ($targets as $target) {
$html .= '<td>' . $this->cell($target, $slug, $records[$target->id()] ?? null, $csrf) . '</td>';
}
$html .= '</tr>';
}

return $html . '</tbody></table>';
}

/**
* @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 '<span class="nb-muted">Not configured</span>';
}
$synced = $record !== null;
$verb = $synced ? 'Update' : 'Post';
$out = '<form method="post" action="' . self::e(self::ACTION) . '" class="rz-syn-form">'
. '<input type="hidden" name="_csrf" value="' . self::e($csrf) . '">'
. '<input type="hidden" name="slug" value="' . self::e($slug) . '">'
. '<input type="hidden" name="target" value="' . self::e($target->id()) . '">'
. '<button type="submit" class="nb-btn">' . self::e($verb) . '</button></form>';

if ($record !== null && ($record['external_url'] ?? '') !== '') {
$out .= ' <a class="rz-syn-link" href="' . self::e((string) $record['external_url']) . '" target="_blank" rel="noopener">view</a>';
}
if ($record !== null && $record['status'] === 'error') {
$out .= ' <span class="rz-syn-err">last attempt failed</span>';
}
return $out;
}

private function styles(string $nonce): string
{
return '<style nonce="' . self::e($nonce) . '">'
. '.rz-intro{max-width:60ch}'
. '.rz-table{width:100%;border-collapse:collapse;margin-top:1rem}'
. '.rz-table th,.rz-table td{text-align:left;padding:.55rem .6rem;border-bottom:1px solid rgba(128,128,128,.2);vertical-align:middle}'
. '.rz-syn-form{display:inline}'
. '.rz-syn-link{margin-left:.5rem;font-size:.85rem}'
. '.rz-syn-err{margin-left:.5rem;font-size:.8rem;color:#c0392b}'
. '</style>';
}

private static function e(string $v): string
{
return htmlspecialchars($v, ENT_QUOTES, 'UTF-8');
}
}
83 changes: 83 additions & 0 deletions tests/BlogToolsetTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<?php

declare(strict_types=1);

namespace NimbusCMS\Blog\Tests;

use Nimbus\Api\EntryOpContext;
use Nimbus\Api\TokenPrincipal;
use Nimbus\Mcp\McpError;
use NimbusCMS\Blog\BlogToolset;
use NimbusCMS\Blog\Syndicator;
use PHPUnit\Framework\TestCase;

/**
* The MCP surface: the tools exist and delegate to the Syndicator, and both gate on
* nimbuscms.blog:syndicate, so a token without that exact scope can neither see nor
* call them (the wildcard-immune management gate the base enforces). No DB, no network.
*/
final class BlogToolsetTest extends TestCase
{
private BlogToolset $toolset;
private FakeTarget $target;
private EntryOpContext $ctx;

protected function setUp(): void
{
$this->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);
}
}
18 changes: 0 additions & 18 deletions tests/DevToTargetTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string,mixed> */
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
Expand Down
24 changes: 24 additions & 0 deletions tests/FakeHttpClient.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

declare(strict_types=1);

namespace NimbusCMS\Blog\Tests;

use NimbusCMS\Blog\HttpClient;

/** A fake HTTP client that records the last request and returns a canned response. */
final class FakeHttpClient implements HttpClient
{
/** @var array<string,mixed> */
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];
}
}
Loading
Loading