feat: field validation, term resolution, post guard and revision points

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
This commit is contained in:
2026-09-25 22:29:15 +02:00
co-authored by Claude Opus 5.5
parent 98a910f056
commit 95aae74466
11 changed files with 748 additions and 0 deletions
+79
View File
@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use WP_Error;
use WP_Post;
/**
* Loads entries and post types for abilities, including the capability checks
* of spec §4.5. Every error is ready to be returned from permission()/execute().
* Input is not schema-validated yet when permission() runs – hence the defensive id().
*/
final class PostGuard
{
public static function editable(mixed $postId, ContentSchema $schema, string $field = 'post_id'): WP_Post|WP_Error
{
$id = self::id($postId);
if ($id === null) {
return Errors::invalidInput(
sprintf('%s fehlt oder ist ungültig.', $field),
[$field => 'positive Ganzzahl erwartet']
);
}
$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));
}
if (! current_user_can('edit_post', $id)) {
return Errors::forbidden(sprintf('Keine Berechtigung, Eintrag %d zu bearbeiten.', $id));
}
return $post;
}
/**
* The brief declares this `: object|WP_Error`, but `object` already covers every
* class type, so PHP refuses `object` inside a union ("Type object cannot be
* part of a union type" – fatal at compile time). WP_Error is itself an object,
* so a plain `object` return type is accurate and still lets callers branch with
* `is_wp_error()` / `instanceof WP_Error`, exactly like editable() above.
*/
public static function postType(mixed $postType, ContentSchema $schema, string $cap): object
{
$name = is_string($postType) ? $postType : '';
$object = $name !== '' && $schema->isManaged($name) ? get_post_type_object($name) : null;
if (! is_object($object)) {
return Errors::notFound(
sprintf('Inhaltstyp "%s" ist nicht verfügbar.', $name),
'Verfügbare Inhaltstypen über lit-content-types abrufen.'
);
}
$capability = isset($object->cap->{$cap}) ? (string) $object->cap->{$cap} : $cap;
if (! current_user_can($capability)) {
return Errors::forbidden(sprintf('Keine Berechtigung für Inhaltstyp "%s".', $name));
}
return $object;
}
public static function id(mixed $value): ?int
{
if (is_int($value)) {
return $value > 0 ? $value : null;
}
if (is_string($value) && ctype_digit($value) && (int) $value > 0) {
return (int) $value;
}
return null;
}
}
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress;
/**
* Restore point before a change (spec §2 principle 7): WordPress stores the
* current state as a revision (only if it differs from the newest one) and the
* newest revision ID is returned as revision_id of the ToolResult.
* Note: revisions cover title, content and excerpt; meta and terms are not revisioned.
*/
final class RevisionPoint
{
public static function capture(int $postId): ?int
{
wp_save_post_revision($postId);
return self::latest($postId);
}
public static function latest(int $postId): ?int
{
$revisions = wp_get_post_revisions($postId, ['posts_per_page' => 1]);
$newest = is_array($revisions) ? reset($revisions) : false;
return is_object($newest) ? (int) $newest->ID : null;
}
}
@@ -0,0 +1,182 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Schema;
use DateTimeImmutable;
/**
* Checks field values against the detected schema before anything is written.
*/
final class FieldValidator
{
private const LIST_TYPES = ['relation', 'file', 'choice', 'array'];
/**
* @param array<string, FieldDefinition> $definitions
* @param array<string, mixed> $values
* @return array{values: array<string, mixed>, errors: array<string, string>, warnings: list<string>}
*/
public function validate(array $definitions, array $values, bool $isCreate): array
{
$clean = [];
$errors = [];
$warnings = [];
foreach ($values as $key => $value) {
$key = (string) $key;
$field = $definitions[$key] ?? null;
if ($field === null) {
$errors[$key] = 'Unbekanntes Feld; verfügbare Felder über lit-content-types abrufen.';
continue;
}
if ($field->status === FieldDefinition::READONLY) {
$errors[$key] = 'Feld ist schreibgeschützt.';
continue;
}
$error = $value === null ? null : $this->check($field, $value);
if ($error !== null) {
$errors[$key] = $error;
continue;
}
if ($field->status === FieldDefinition::UNKNOWN) {
$warnings[] = sprintf(
'Feld "%s" hat keinen bekannten Typ; der Wert wurde unverändert gespeichert.',
$key
);
}
$clean[$key] = $value;
}
foreach ($definitions as $key => $field) {
if (! $field->required || isset($errors[$key])) {
continue;
}
$provided = array_key_exists($key, $clean);
if ($isCreate && (! $provided || $this->isEmpty($clean[$key]))) {
$errors[$key] = 'Pflichtfeld fehlt.';
} elseif (! $isCreate && $provided && $this->isEmpty($clean[$key])) {
$errors[$key] = 'Pflichtfeld darf nicht geleert werden.';
}
}
return ['values' => $clean, 'errors' => $errors, 'warnings' => $warnings];
}
private function check(FieldDefinition $field, mixed $value): ?string
{
if ($field->multiple && in_array($field->type, self::LIST_TYPES, true)) {
if (! is_array($value) || ! array_is_list($value)) {
return 'Liste erwartet.';
}
if ($field->type === 'array') {
return null;
}
foreach ($value as $item) {
$error = $this->checkScalar($field, $item);
if ($error !== null) {
return $error;
}
}
return null;
}
return $this->checkScalar($field, $value);
}
private function checkScalar(FieldDefinition $field, mixed $value): ?string
{
switch ($field->type) {
case 'string':
case 'text':
return is_string($value) ? null : 'Text erwartet.';
case 'integer':
return is_int($value) ? null : 'Ganzzahl erwartet.';
case 'number':
return (is_int($value) || is_float($value)) ? null : 'Zahl erwartet.';
case 'boolean':
return is_bool($value) ? null : 'true oder false erwartet.';
case 'date':
return $this->matchesFormat($value, 'Y-m-d') ? null : 'Datum im Format JJJJ-MM-TT erwartet.';
case 'datetime':
return $this->matchesFormat($value, 'Y-m-d H:i:s')
? null
: 'Datum und Uhrzeit im Format JJJJ-MM-TT HH:MM:SS erwartet.';
case 'time':
return $this->matchesFormat($value, 'H:i:s') ? null : 'Uhrzeit im Format HH:MM:SS erwartet.';
case 'choice':
return $this->checkChoice($field, $value);
case 'relation':
case 'file':
return $this->checkRelation($field, $value);
case 'array':
return (is_array($value) && array_is_list($value)) ? null : 'Liste erwartet.';
case 'object':
return is_array($value) ? null : 'Objekt erwartet.';
default:
return null;
}
}
private function checkChoice(FieldDefinition $field, mixed $value): ?string
{
$allowed = array_column($field->choices ?? [], 'value');
return is_string($value) && in_array($value, $allowed, true)
? null
: sprintf('Erlaubte Werte: %s.', implode(', ', $allowed));
}
private function checkRelation(FieldDefinition $field, mixed $value): ?string
{
if (! is_int($value) || $value <= 0) {
return 'ID (positive Ganzzahl) erwartet.';
}
$objectType = $field->related['object_type'] ?? '';
if ($objectType === 'media') {
return get_post_type($value) === 'attachment'
? null
: sprintf('%d ist kein Medium der Mediathek.', $value);
}
if ($objectType === 'post_type') {
$actual = get_post_type($value);
if ($actual === false) {
return sprintf('Eintrag %d existiert nicht.', $value);
}
$expected = $field->related['name'] ?? null;
if ($expected !== null && $actual !== $expected) {
return sprintf('Eintrag %d ist vom Typ %s, erwartet %s.', $value, $actual, $expected);
}
}
return null;
}
private function matchesFormat(mixed $value, string $format): bool
{
if (! is_string($value)) {
return false;
}
$parsed = DateTimeImmutable::createFromFormat('!' . $format, $value);
return $parsed !== false && $parsed->format($format) === $value;
}
private function isEmpty(mixed $value): bool
{
return $value === null || $value === '' || $value === [];
}
}
@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Schema;
use RuntimeException;
use WP_Term;
/**
* Resolves term input ({taxonomy: [id | name, …]}) and replaces a post's terms.
* resolve() never writes, so a whole call can be validated first (all or nothing).
*/
final class TermAssigner
{
/**
* @param array<string, mixed> $terms
* @return array{terms: array<string, list<int|string>>, errors: array<string, string>}
*/
public function resolve(string $postType, array $terms): array
{
$allowed = get_object_taxonomies($postType, 'names');
$resolved = [];
$errors = [];
foreach ($terms as $taxonomy => $items) {
$taxonomy = (string) $taxonomy;
if (! in_array($taxonomy, $allowed, true)) {
$errors[$taxonomy] = 'Taxonomie gehört nicht zu diesem Inhaltstyp.';
continue;
}
$object = get_taxonomy($taxonomy);
if (! is_object($object) || ! current_user_can($object->cap->assign_terms)) {
$errors[$taxonomy] = 'Keine Berechtigung, Begriffe dieser Taxonomie zuzuweisen.';
continue;
}
$result = $this->resolveItems($taxonomy, is_array($items) ? $items : [$items], $object);
if (is_string($result)) {
$errors[$taxonomy] = $result;
continue;
}
$resolved[$taxonomy] = $result;
}
return ['terms' => $resolved, 'errors' => $errors];
}
/**
* @param array<string, list<int|string>> $resolved Output of resolve().
* @throws RuntimeException
*/
public function assign(int $postId, array $resolved): void
{
foreach ($resolved as $taxonomy => $items) {
$ids = [];
foreach ($items as $item) {
if (is_int($item)) {
$ids[] = $item;
continue;
}
$created = wp_insert_term($item, $taxonomy);
if (is_wp_error($created)) {
throw new RuntimeException('Term could not be created: ' . $created->get_error_message());
}
$ids[] = (int) $created['term_id'];
}
$result = wp_set_object_terms($postId, $ids, $taxonomy, false);
if (is_wp_error($result)) {
throw new RuntimeException('Terms could not be assigned: ' . $result->get_error_message());
}
}
}
/**
* @param list<mixed> $items
* @return list<int|string>|string Resolved items or an error message.
*/
private function resolveItems(string $taxonomy, array $items, object $object): array|string
{
$resolved = [];
foreach ($items as $item) {
if (is_int($item)) {
if (! get_term($item, $taxonomy) instanceof WP_Term) {
return sprintf('Begriff %d existiert nicht.', $item);
}
$resolved[] = $item;
continue;
}
if (! is_string($item) || trim($item) === '') {
return 'Begriffe als ID (Zahl) oder Name (Text) angeben.';
}
$existing = term_exists(trim($item), $taxonomy);
if (is_array($existing)) {
$resolved[] = (int) $existing['term_id'];
continue;
}
if (! current_user_can($object->cap->edit_terms)) {
return sprintf('Begriff "%s" existiert nicht; keine Berechtigung, ihn anzulegen.', trim($item));
}
$resolved[] = trim($item);
}
return $resolved;
}
}