Перейти к содержимому
Версия 1.0 ещё не выпущена; проект находится на стадии pre-MVP. См. возможности и ограничения версии 1.0.

Разработка плагинов

Плагин — это отдельно развёртываемый сервис без доступа к привязкам базы данных ядра. Серверные команды передаются по HTTPS. Проверенный встроенный интерфейс работает в изолированном фрейме, а его действия проверяет хост. Это руководство охватывает весь цикл: манифест, установку, выдачу прав, запуск, публикацию с проверкой условий и удаление.

Вам нужно место, где запустить HTTPS-сервис, и место, где хранить его состояние. Эталонный плагин (исходный код плагина Scheduling, plugins/scheduling в репозитории Dee Wan) использует Cloudflare Worker с базой данных D1, и этот документ исходит из такой конфигурации, но protocol v1 ничего из этого не требует.

Каждый запрос и ответ ниже воспроизводится через настоящие маршруты в собственных тестах Dee Wan. Примеры соответствуют ответам сервера. Идентификаторы и секреты вымышлены и намеренно недействительны.

За что отвечаете вы За что отвечает Dee Wan
Ваш сервис, его база данных, его часы и его повторы Контент, версии, правила workflow, права доступа, аудит
Ваши собственные браузерные сессии и ваш собственный UI Допустим ли переход и его атомарное применение
Когда отправлять команду Разрешено ли её выполнить
Удаление ваших собственных данных Отзыв вашего доступа

Dee Wan никогда не доверяет утверждению плагина о том, что переход разрешён, что версия текущая или что снятие с публикации безопасно. Всё это перепроверяется внутри мутации.

Манифест — это один HTTPS-документ, описывающий ваш плагин и то, что ему нужно. Dee Wan получает его в три момента и никогда — при обычных запросах:

  • Проверка получает и валидирует его, показывает администратору и ничего не сохраняет.
  • Установка получает его снова, снова валидирует и сохраняет эту копию. Сохранённая копия — это то, что используют все последующие проверки, поэтому при установке отдавайте тот же манифест, что и при проверке.
  • Обновление снова получает его по сохранённому URL манифеста, когда администратор принимает новый релиз (см. Выпуск нового релиза).
{
"manifest_version": 1,
"id": "com.example.myplugin",
"name": "My Plugin",
"version": "0.1.0",
"protocol_versions": [1],
"base_url": "https://plugin.example",
"activation_url": "https://plugin.example/dee-wan/activate",
"management_url": "https://plugin.example/installations/{installation_id}",
"requested_capabilities": [
"content:read",
"workflow:read",
"workflow:publish",
"workflow:unpublish"
],
"content_actions": [{ "id": "schedule", "label": "Schedule" }]
}

Перед подготовкой манифеста ознакомьтесь с правилами ниже. Все они проверяются автоматически:

  • id — стабильный идентификатор в формате обратного домена: строчные буквы, начинается с буквы, минимум три метки, разделённые точками, не более 128 символов. Он никогда не меняется между релизами. version — это ваш релиз, и он меняется свободно.
  • name — от 1 до 64 символов, без управляющих символов и без < или >.
  • version — семантическое версионирование, MAJOR.MINOR.PATCH с необязательным суффиксом -prerelease (без суффикса +build), не более 64 символов.
  • manifest_version, protocol_versions и version — три разные вещи. manifest_version равен 1. В protocol_versions перечисляются от 1 до 8 поддерживаемых вами версий протокола. Хотя бы одну из них должно поддерживать ядро, иначе манифест отклоняется с protocol_unsupported.
  • requested_capabilities перечисляет хотя бы одну возможность, без дубликатов.
  • Каждый URL — HTTPS, не содержит учётных данных и фрагмента, и все они — плюс сам URL манифеста — должны иметь ОДИН origin. Второй origin — это manifest_origin_mismatch.
  • Неизвестные ключи верхнего уровня отклоняются. Манифест не может полагаться на поле, которое это ядро игнорирует, поэтому нельзя выпустить ключ сегодня в надежде, что его начнут учитывать позже.
  • Редиректы отклоняются при получении манифеста и при активации. Отдавайте оба напрямую.
  • Приватные, loopback, link-local и зарезервированные адреса назначения отклоняются. Для локальной разработки см. Тестирование плагина.
  • content_actions необязателен и содержит не более четырёх пар { id, label } с уникальными id. id соответствует ^[a-z][a-z0-9_-]{0,31}$. label — от 1 до 24 символов без управляющих символов и без < или >. Без разметки, без скриптов, без CSS, без URL иконки: ядро рендерит собственную кнопку в собственном стиле и только для записей моделей, к которым установке плагина предоставлен доступ.
  • manage_views необязателен: список прямых страниц управления из закрытого словаря: calendar и releases. Для каждой ядро показывает ссылку с собственной подписью у установки плагина в Настройки → Плагины, рядом с редакционным календарём в разделе «Обзор» и в списке «Контент». Плагин не задаёт подпись.
  • management_url может содержать {installation_id} и служит только для навигации. Он никогда не получает долгоживущих учётных данных в URL.
  • ui_extensions необязателен: от 1 до 16 декларативных дополнений к экранам Dee Wan. См. Карточки и редакторы полей.

Полная схема, сгенерированная из объекта, по которому валидирует ядро, — schemas/manifest-v1.schema.json.

ui_extensions добавляет карточки и редакторы полей. Декларативные элементы используют компоненты ядра. Необязательный проверенный frame открывает изолированный редактор или экран управления с origin плагина. Фрейм получает ограниченное значение поля и MessageChannel, без сессии администратора и токена инсталляции. Изменения поля проходят обычное сохранение; объявленные операции — проверку полномочий и подтверждение хоста. Редактор не меняет тип хранения поля.

"ui_extensions": [
{
"id": "schedule-card",
"placement": "entry_sidebar",
"title": "Schedule",
"description": "Plan when this goes live.",
"launch": { "kind": "content_action", "action": "schedule" }
},
{
"id": "calendar-row",
"placement": "settings_row",
"title": "Calendar",
"launch": { "kind": "manage_view", "view": "calendar" }
},
{
"id": "priority",
"placement": "field_editor",
"control": "select",
"label": "Priority",
"help_text": "How soon this should go out.",
"options": [
{ "value": "low", "label": "Low" },
{ "value": "high", "label": "High" }
]
}
]
  • У каждой записи есть id, который соответствует ^[a-z][a-z0-9_-]{0,31}$, уникален в манифесте и не меняется между релизами. На него ссылаются поля моделей. manage, core, dee-wan, workflow, history, seo, preview и settings зарезервированы.
  • placement — это entry_sidebar, page_sidebar, settings_row или field_editor. Всё остальное отклоняется.
  • У карточки есть title (от 1 до 40 символов), необязательный description (от 1 до 160) и один launch. { "kind": "content_action", "action": "<id>" } открывает одно из ваших content_actions для элемента на экране. { "kind": "manage_view", "view": "calendar" } открывает одно из ваших manage_views. Запуск того, чего манифест не объявляет, отклоняется. Карточка settings_row запускает только страницу управления. Каждый запуск идёт через делегированный запуск из раздела 5.
  • Редактор поля рисует обычное поле. control — это text, textarea или url для текстового поля и number, boolean, date или select для поля этого типа. Сохранённое значение остаётся значением этого типа. label (от 1 до 40) называет редактор, а help_text (от 1 до 200) показывается под ним.
  • Редактор select перечисляет от 1 до 50 options, каждая — { "value", "label" } с уникальным значением. Редакторы text и textarea могут задать validation.min_length и max_length. Редактор URL может задать max_length, а Dee Wan добавляет своё правило http(s). Редакторы чисел могут задать min и max. Редакторы дат могут задать min_date и max_date в виде YYYY-MM-DD. Больше ничего не принимается, поэтому при сохранении Dee Wan проверяет каждое объявленное правило.
  • Редактор group рисует одно JSON-поле как небольшую форму. Он перечисляет от 1 до 8 members, у каждого уникальный key (^[a-z][a-z0-9_]{0,31}$), label и control: text (с обязательным max_length до 500), number (min, max, integer), boolean или select (до 12 options). required делает поле типа text, number или select обязательным. Значение — один JSON-объект не более 8192 байт. Когда создатель сайта выбирает ваш редактор, Dee Wan записывает в поле закрытую схему. По умолчанию обязательные элементы можно оставить пустыми при сохранении черновика; при строгой проверке модели их нужно заполнять перед каждым сохранением. Неверные переданные значения, значения вне допустимых границ или вариантов, неизвестные ключи и значения, не являющиеся объектом, по-прежнему отклоняются. Перед публикацией обязательные элементы проверяются в той сохранённой версии, которая будет опубликована.
  • Создатель сайта выбирает ваш редактор для поля модели. Dee Wan копирует его правила и варианты в поле, поэтому поле сохраняет их, если ваш плагин исчезнет. Если инсталляция отключена или удалена либо релиз убрал редактор, поле сохраняет значение, а Dee Wan рисует свой редактор с пометкой, где назван ваш плагин.
  • Текст печатный, без управляющих символов и без < или >. Неизвестные ключи отклоняются.

operations — действия, которые Dee Wan выполняет на вашем сервисе и показывает со своей формой и своим результатом. Человек запускает операцию из раздела Настройки → Плагины (scope: "site") или из записи (scope: "entry"). Агент запускает её через API управления или MCP. policies — проверки, которые Dee Wan запрашивает перед сохранением, восстановлением, удалением, публикацией, снятием с публикации или переходом workflow. Политика save или restore может также привести в порядок контент, о котором её спрашивают.

"operations": [
{
"id": "schedule-status",
"label": "Schedule status",
"description": "Pending scheduled changes for this entry.",
"endpoint": "https://plugin.example/dee-wan/operations/schedule-status",
"scope": "entry",
"audience": "interactive",
"requires": ["content:read"],
"result": ["rows"]
}
]
  • audience обязателен. Операции interactive появляются в админке, API управления, CLI и MCP. Операции automation там не появляются, и их маршруты выполнения отклоняют такие операции; вызывает одну из них только именованный процесс Dee Wan, например планировщик контента, вызывающий schedule-draft плагина Scheduling.
  • endpoint находится на едином origin вашего манифеста. requires называет возможности, которые вы тоже запрашиваете. Если хотя бы одна из них не выдана, операция не предлагается.
  • input перечисляет не больше 8 полей: text, number, boolean или select. Dee Wan рисует форму и проверяет значения. result называет компоненты, которые может использовать ваш ответ: text, rows, links (только https) и json. Добавляйте "destructive": true только если операция меняет или удаляет данные; тогда Dee Wan просит человека подтвердить.
  • Dee Wan отправляет POST с небольшим конвертом на endpoint. Обменяйте его code на POST /api/plugin/v1/invocations/exchange своим токеном, затем ответьте на POST { "status": "completed", "result": { "blocks": [...] } } или { "status": "refused", "code", "message" }. Отвечайте только после обмена. Формы и лимиты — в справочнике протокола: Операции и политики.
  • Обмен для операции entry также возвращает delegation_id: делегирование, привязанное к человеку, который её запустил, к вашей установке, к записи и её модели. Сохраните его, если операция создаёт отложенное задание, например расписание, и используйте его так же, как делегирование запуска. Dee Wan заново проверяет полномочия пользователя при каждом использовании. Обмен операции site возвращает делегирование, как при запуске manage: без привязки к записи и с разрешёнными моделями, которые пользователь может читать. Операции релизов Scheduling используют это делегирование для каждой записи. Политика получает null.
"policies": [
{
"id": "tone",
"label": "Tone check",
"kind": "save",
"endpoint": "https://plugin.example/dee-wan/policies/tone",
"requires": []
}
]
  • kind — одно из:
    • save: каждый язык, значения, связи, медиа или настройки поиска которого меняет сохранение. Вы получаете изменённые ключи в changed_fields и новые части в values, links (поле связи → id записей), media (медиаполе → id медиа) и seo. Общие связи и медиа приходят с основным языком. Об изменении только связей, медиа или SEO тоже спрашивают (seo — ключ настроек поиска).
    • restore: восстановление старой версии (action: "version", те же факты, что при сохранении) или возврат записи из корзины (action: "trash"). При восстановлении версии вызываются и политики save.
    • delete: запись или язык перемещены в корзину (action: "trash") или удалены окончательно (action: "purge"). Вы получаете весь черновик каждого языка.
    • publish: полная версия каждого языка, который публикуется. unpublish: версия каждого языка, которая снимается с публикации и больше не выдаётся.
    • transition: каждый переход между этапами работы, с transition: { from, to }. Проверка вызывается и для релизов по расписанию, и для переходов из других плагинов; caller.via указывает, кто выполняет переход.
  • Ответьте { "decision": "allow" }, { "decision": "warning", "message", "fields"? } или { "decision": "refuse", "code", "message", "fields"? }. fields — ключи полей; редактор показывает ваше сообщение у них. Предупреждение пропускает запись; второй раз отправлять не нужно.
  • Политика save или restore может вместо этого ответить { "decision": "transform", "message"?, "values"?, "links"?, "media"?, "seo"? }. Это заменяет указанные части записи и языка, о которых вас спросили, по ключу поля. Список заменяет весь список. Общие связи и медиа берутся только из ответа для основного языка; восстановление принимает только values и seo. Перед сохранением Dee Wan проверяет результат по всем правилам, действующим для пользовательского ввода. Если проверка не пройдена, сохранение отклоняется с plugin_policy_transform_invalid и причиной. Если две политики по-разному меняют одно поле, сохранение отклоняется с plugin_policy_transform_conflict. Затем редактор показывает изменённые вашей политикой поля и ваше сообщение и заново загружает сохранённые значения. Повторное подтверждение не требуется.
{
"decision": "transform",
"message": "Trimmed spaces and normalized the title.",
"values": { "title": "Harbour tours" }
}
  • Отказ всегда останавливает запись. Поведение при тайм-ауте, сбое или недопустимом ответе определяется настройкой сайта в разделе Настройки → Плагины: по умолчанию запись проходит с предупреждением; с Блокировать изменения без ответа она отклоняется.
  • Проверка вызывается на всех путях записи: в редакторе, при сохранении страниц, восстановлении, работе с корзиной, массовых действиях, обработке электронных таблиц, импорте, через API управления и из других плагинов. Transform меняет только текущую запись; изменить этап процесса публикации он не может.
  • Исключение — плановая очистка записей, у которых истёк срок хранения в корзине. Она не выполняет действие от имени пользователя, поэтому политика не вызывается. Политика delete уже проверялась при перемещении записи в корзину и будет проверена снова при ручном окончательном удалении.
  • История версий показывает Changed by <plugin> · <policy> у версии, которую изменил ваш transform.

Столбцы списка контента и действия над строками

Заголовок раздела «Столбцы списка контента и действия над строками»

Операция page добавляет столбец в список контента модели, к которой плагину предоставлен доступ. Операция entry с соответствующей отметкой становится действием над выбранными пользователем строками. Обе используют описанный выше механизм вызова. Каждую ячейку и каждый результат отображает Dee Wan.

"operations": [
{
"id": "schedule-column",
"label": "Scheduled",
"endpoint": "https://plugin.example/dee-wan/operations/schedule-column",
"scope": "page",
"audience": "column",
"requires": ["content:read"],
"result": ["cells"],
"cell": "badge"
},
{
"id": "schedule-status",
"label": "Schedule status",
"endpoint": "https://plugin.example/dee-wan/operations/schedule-status",
"scope": "entry",
"audience": "interactive",
"requires": ["content:read"],
"result": ["rows"],
"list_action": true
}
]
  • У столбца scope: "page", audience: "column", один вид cell (text, badge или link), result: ["cells"], без input и без destructive. Его label — заголовок столбца.
  • Обмен возвращает $defs/column_request: модель и не более 50 идентификаторов строк, которые пользователь может читать. Значения не передаются; читайте нужные данные в пределах выданных прав. Ответьте одним блоком cells не более чем с одной ячейкой на каждый идентификатор, о котором вас спросили: { "id", "text" } плюс tone для метки или https-url для ссылки. Текст — не более 80 символов. Ячейка для другого идентификатора, повторный идентификатор, ссылка без https-url или url у метки делают весь ответ недействительным. Пустой список — допустимый ответ.
  • Столбец не создаёт делегирования, никогда не перечисляется и не запускается как обычная операция, не пишет строку аудита и не хранит повторяемый ответ. Ничего не сохраняйте, отвечая на него.
  • Действие над строками — интерактивная операция entry с list_action: true. Dee Wan запускает её по одному разу для каждой из не более 25 выбранных строк, у каждой свой вызов, делегирование, ключ идемпотентности и запись аудита, поэтому отвечайте на каждую как на операцию entry.

rich_blocks объявляет типы блоков, которые авторы могут вставлять в форматированный текст. Вы передаёте только данные. Dee Wan рисует поля, показывает превью и отрисовывает блок по декларативному дереву. В этом дереве нет исполняемого скрипта или удалённых вызовов. HTML/CSS/JS поставщика хранится в отдельном блоке snippet; его превью и публичный вывод работают в изолированном фрейме.

"rich_blocks": [
{
"name": "callout",
"version": 2,
"label": "Callout",
"description": "A boxed note",
"fields": [
{ "key": "tone", "control": "select", "label": "Tone", "required": true, "default": "info",
"options": [{ "value": "info", "label": "Info" }, { "value": "warning", "label": "Warning" }] },
{ "key": "title", "control": "text", "label": "Title", "required": true, "validation": { "max_length": 80 } },
{ "key": "image", "control": "media", "label": "Image", "media_kind": "image" }
],
"preview": { "tone": "info", "title": "Heads up" },
"render": {
"tone": "tone",
"nodes": [
{ "element": "strong", "children": [{ "field": "title" }] },
{ "element": "img", "src": "image", "alt": "title" }
]
},
"migrations": [{ "from": 1, "to": 2, "steps": [{ "op": "rename", "from": "heading", "to": "title" }] }]
}
]
  • Блок хранится как <your plugin id>/<name>, с версией, с которой он был сохранён, и своими значениями.
  • Элементы управления: text, textarea, number, boolean, select, url и media (media_kind: image, document, audio, video или file; file — загрузка, которую пропускают настройки медиа сайта). Правила: required, min_length/max_length, min/max/integer, варианты выбора. Шаблонов нет. Значение url — адрес http(s) или путь на сайте. Не больше 24 полей.
  • rich_text открывает встроенный редактор форматированного текста. Значение — { "richText": "<p>Текст</p>" }: канонический HTML хоста, до 10 000 символов. Привязка { "field": "body" } внутри блочного элемента выводит абзацы, списки, ссылки, выделение, изображения и файлы. В сохранённом блоке текст находится в <div data-rich-field="body">; обычные правила обхода медиа, закрепления файлов за версиями, импорта и экспорта действуют и здесь. Вложенные блоки плагинов, сниппеты и встраивания не поддерживаются. Строчный блок плагина не может объявлять rich_text. Приватные файлы разрешены только в приватной модели.
  • relation задаёт models и необязательный multiple; использует обычный выбор связей и актуальное право человека на чтение. group задаёт fields, необязательные repeatable и max_items: до трёх уровней групп и пятидесяти элементов в группе. Вложенные медиа сохраняют обычные привязки. { "group", "children" } и { "each", "children" } задают область дочерних связок рендера.
  • render.nodes — дерево из: { "element", "children" } из div section aside p span strong em small h2 h3 h4 blockquote ul ol li figure figcaption hr br; { "element": "a", "href": <url or media field> }; { "element": "img", "src": <image field>, "alt"?: <text field> }; { "text" }; { "field" } (select показывает подпись варианта); и { "when", "equals"?, "children" }. Не больше 64 узлов, глубина до 8 уровней. render.tone называет поле select, значение которого (neutral, info, success, warning, danger) окрашивает блок. Каждое значение экранируется; при небезопасном адресе дочернее содержимое выводится без ссылки.
  • Без render блок всё равно можно редактировать и хранить, но рендереры показывают его как неподдерживаемый: ничего на публичной странице, заглушка в предпросмотре.
  • Повышайте version, когда меняются поля, и добавляйте по одной миграции на шаг (to равно from + 1): rename, set_default и drop. Сохранённые блоки сохраняют свою версию. Рендереры и редактор мигрируют в памяти; сохранённый блок меняется, когда автор его обновляет.
  • Отключение или удаление вашего плагина никогда не удаляет блоки. Они остаются сохранёнными со своей версией и значениями, и ваш следующий релиз может их мигрировать.
  • Контент проверяется при сохранении только по активным плагинам. См. правила сохранения. API управления перечисляет определения через GET /api/manage/v1/rich-blocks (listRichBlockDefinitions); админка читает GET /api/plugins/rich-blocks; рендереры читают GET /v1/_rich-blocks.

media_sources превращает ваш сервис в библиотеку, которую люди просматривают в окне выбора медиа Dee Wan, в разделе Источники, рядом с собственной библиотекой сайта. Запросите media:source и объявите не больше четырёх источников:

"requested_capabilities": ["media:source"],
"media_sources": [
{
"id": "library",
"label": "Reference images",
"description": "Seven self-made CC0 files.",
"endpoint": "https://media-source.example/dee-wan/sources/library",
"modes": ["copy", "reference"],
"delivery_origins": ["https://media-source.example"]
}
]

modes обязателен. copy позволяет копировать файл в собственную библиотеку сайта; reference позволяет вместо этого привязать его, а байты хранит ваш сервис. Источник объявляет один режим или оба, а Dee Wan никогда не угадывает режим по URL или имени. delivery_origins (от одного до четырёх чистых origin, origin вашего манифеста может быть одним из них) обязателен с reference и отсутствует без него.

  • Ядро вызывает endpoint через тот же обмен вызова, что и операцию: оно отправляет POST с конвертом, вы обмениваете код своим токеном, а связка содержит kind: "media_source", id вашего источника и не содержит делегирования. Связанный вход — $defs/media_source_request: { "action": "browse", "query", "cursor", "limit" } или { "action": "item", "item_id" }.
  • Отвечайте на просмотр { "status": "completed", "items": [...], "next_cursor": "..." | null }, а на элемент — { "status": "completed", "item": {...} }. У каждого элемента есть id, title, file_name, mime_type, thumbnail_url, download_url (обязателен, когда источник объявляет copy), delivery_url (обязателен, когда он объявляет reference) и attribution (source_name, license и необязательные author, author_url, license_url, source_url). Добавьте width, height и byte_size, если знаете их. description изображения (не больше 125 символов) становится его альтернативным текстом на языке сайта по умолчанию. Отказывайте через { "status": "refused", "code", "message" }; окно выбора показывает ваше сообщение.
  • thumbnail_url и download_url должны быть на origin вашего манифеста, а delivery_url — на одном из delivery_origins источника. Окно выбора загружает превью прямо у вас без referrer и без учётных данных Dee Wan. Когда человек добавляет элемент, ядро снова спрашивает item и само выполняет GET download_url: без учётных данных, без перенаправлений, 20 секунд, лимит медиафайла. Подписывайте download_url, если ваши файлы не публичны.
  • Ядро проверяет байты по обычным правилам загрузки: допустимые виды, форматы и лимиты размера сайта, а затем выполняет проверку дубликатов. Копия — обычный медиафайл, принадлежащий сайту. Он сохраняет ваш attribution и id вашего плагина, источника и элемента, которые детали медиа показывают как его авторство, а копии сайта переносят. Опубликованные страницы получают только credit (author, source, license, url), показанный под изображением. После копирования сайт больше ничего не загружает из вашего сервиса, поэтому страницы продолжают работать, даже если сервис станет недоступен.
  • Режим reference добавляет ссылку на файл вместо копирования. Ядро ничего не загружает: строка библиотеки хранит ваш delivery_url как адрес, id вашего плагина, источника и элемента как идентификаторы, а также объявленные вами метаданные и авторство; тот же элемент, добавленный снова, подчиняется on_duplicate. Опубликованные страницы, копии сайта и генерируемые сайты используют delivery_url как есть, поэтому адрес должен оставаться стабильным и доступным, пока его используют сайты. Dee Wan не проксирует его, не может заменить или отредактировать его байты (media_external_bytes_unavailable) и никогда не обращается к вам, когда человек удаляет строку библиотеки. Если ваш плагин удалён или отключён, существующие страницы продолжают использовать сохранённый адрес, сведения о медиа сообщают, что источник недоступен, а новые привязки из него отклоняются. Добавляйте режим через обязательное поле запроса mode; режим, которого источник не объявил, отклоняется с 422 media_source_mode_unsupported.
  • Просмотр ничего не меняет и не аудируется. Человеку нужен media:upload. Отключение установки плагина или отзыв media:source сразу скрывает источник.
  • API управления предлагает то же: listMediaSources, browseMediaSource и importMediaSourceItem (область media:write, тело item_id и mode). См. API управления.
  • plugins/media-source-reference — небольшой полный пример с тестами. Набор conformance проверяет источник с его subject mediaSource (см. Тестирование плагина).

hooks позволяют вашему сервису принять решение до того, как Dee Wan примет данные. Объявите не более одного хука каждого вида: upload_metadata вызывается перед сохранением нового файла библиотеки, comment_moderation — перед сохранением комментария посетителя.

"hooks": [
{
"id": "upload-check",
"label": "Upload check",
"kind": "upload_metadata",
"endpoint": "https://plugin.example/dee-wan/hooks/upload",
"requires": []
},
{
"id": "comment-check",
"label": "Comment check",
"kind": "comment_moderation",
"endpoint": "https://plugin.example/dee-wan/hooks/comment",
"requires": []
}
]

Хук выключен, пока администратор сайта не включит его для инсталляции в разделе Настройки → Плагины. Объявление ничего не запрашивает и ничего не запускает. Неподдерживаемый kind отклоняется при проверке манифеста.

  • Core вызывает endpoint через тот же обмен вызовами, что и операцию. В привязке kind: "hook", id вашего хука, caller: null и нет делегирования. Входные данные — только то, что нужно для решения: для загрузки $defs/hook_upload_facts (имя файла, MIME-тип, размер, папка и альтернативные тексты, но не байты); для комментария $defs/hook_comment_facts (запись, id комментария, родитель, имя автора, сайт и текст, но не адрес почты). Кто спрашивает, не называется.
  • Отвечайте на загрузку через $defs/hook_upload_reply: { "decision": "accept" }, при желании с normalized (новое filename, сохраняющее расширение, и альтернативные тексты только на языках, которые загрузка уже назвала), либо { "decision": "refuse", "code", "message" }. Загружающий видит ваше сообщение (422 upload_hook_refused).
  • Отвечайте на комментарий через $defs/hook_comment_reply: { "decision": "approve" }, { "decision": "hold" } (комментарий ждёт модератора) или { "decision": "reject" } (сохраняется как спам или, с "as": "refused", как отклонённый).
  • Отвечайте в течение 5 секунд. Если хук не уложился в срок, недоступен, вернул ответ вне разрешённого набора или был отключён во время работы, одобрения не будет: загрузка отклоняется (503 upload_hook_unavailable, ничего не сохраняется), а комментарий остаётся на модерации.
  • У запроса есть стабильный идентификатор повтора. При повторной загрузке или повторной отправке комментария в течение 24 часов используется ваш первый принятый ответ без нового вызова; неудавшийся запрос выполняется снова.

При установке Dee Wan передаёт плагину учётные данные. Поэтому до первой установки в вашем сервисе уже должен работать маршрут activation_url. Выпустите одноразовый код активации, передайте его администратору по отдельному каналу и ожидайте запрос. Код должен соответствовать ^[A-Za-z0-9_-]{16,128}$. Всё остальное Dee Wan отклоняет как invalid_activation_code ещё до вызова вашего сервиса. Когда администратор выполнит установку, Dee Wan отправит POST с этим телом на activation_url:

{
"kind": "activate",
"installation_id": "plg_0000000000000000000000000000d1ee",
"site_id": "cdocssite00000000000000001",
"dee_wan_base_url": "https://cms.example",
"protocol_version": 1,
"token": "dwp_ExampleTokenNotRealExampleTokenNotRealExamp",
"activation_code": "activation-code-0123456789"
}

Что должен сделать ваш обработчик:

  1. Израсходовать activation_code ровно один раз и отклонить неизвестный, использованный или просроченный код. Это единственное, что доказывает, что вызывающий — тот администратор, которому вы дали код.
  2. Проверить, что protocol_version — одна из поддерживаемых вами версий, а dee_wan_base_url — origin, который вы принимаете.
  3. Сохранить token как свой секрет для этой инсталляции — в зашифрованном виде, никогда не в логах, никогда в URL или теле ошибки.
  4. Ответить 2xx в течение 5 секунд, с телом не более 32 КБ. Только тогда ядро помечает инсталляцию как active и сохраняет лишь SHA-256-хеш токена. Показать токен снова оно уже никогда не сможет.

Пока ваш обработчик не вернул ответ, инсталляция всё ещё pending, а токен ожидающей инсталляции отвечает plugin_unauthorized. Поэтому не вызывайте /api/plugin/v1 изнутри обработчика активации — сохраните токен, ответьте и сделайте первый вызов после этого.

Куда обращаться: каждый запрос протокола идёт на dee_wan_base_url, за которым следует /api/plugin/v1, например <dee_wan_base_url>/api/plugin/v1/context. Храните базовый URL вместе с токеном и принимайте только ожидаемые origin.

kind, равный rotate, — та же передача для существующей инсталляции: замените сохранённый токен. Оба вида несут код, потому что ротация — это передача, а не сброс.

Когда активация не удаётся. Ответ не 2xx, редирект, тело больше 32 КБ или отсутствие ответа в течение 5 секунд — всё это приводит к сбою установки: ядро удаляет свою ожидающую инсталляцию и показывает администратору причину. Однако ядро не видит, что сделала ваша сторона. Если ваш обработчик сохранил токен и затем ответил слишком поздно, у вас теперь есть токен для инсталляции, которой больше не существует, а ваш код активации уже израсходован. Учитывайте это:

  • Обработчик должен завершаться быстро, с запасом до тайм-аута.
  • Считайте сохранённую инсталляцию неподтверждённой до первого успешного вызова с её токеном и удаляйте ту, чей токен ни разу не сработал и теперь отвечает plugin_unauthorized.
  • Попросите администратора выпустить для повторной попытки новый код активации. Повторная попытка — это новая установка с новым installation_id, а не вторая попытка старой.

Администратор с site:plugins сначала проверяет манифест. Проверка ничего не сохраняет — она нужна для того, чтобы одобрение было осознанным.

Запрошенные плагином права и модели после проверки манифеста, до активации

Проверка манифеста

Окно терминала
curl -X POST "https://cms.example/api/plugins/review" \
-H "X-Site-Id: cdocssite00000000000000001" \
-H "Content-Type: application/json" \
-b "__Secure-better-auth.session_token=$SESSION" \
--data '{"manifest_url":"https://plugin.example/dee-wan/manifest.json"}'
{
"data": {
"manifest": {
"manifest_version": 1,
"id": "com.example.myplugin",
"name": "My Plugin",
"version": "0.1.0",
"protocol_versions": [1],
"base_url": "https://plugin.example",
"activation_url": "https://plugin.example/dee-wan/activate",
"management_url": "https://plugin.example/installations/{installation_id}",
"requested_capabilities": [
"content:read",
"workflow:read",
"workflow:publish",
"workflow:unpublish"
],
"content_actions": [{ "id": "schedule", "label": "Schedule" }]
},
"protocol_version": 1,
"review_digest": "5ad97eed147d0a0f9f8934fa20485f287e656d72499fc739c831d2748680e49a"
}
}

Затем администратор выполняет установку: одобренные возможности, одобренные id моделей и ваш одноразовый код. Установка снова получает и валидирует манифест, и сохраняется именно эта вторая копия. Одобренный набор должен быть подмножеством того, что запросил этот манифест. Пустой model_ids означает полное отсутствие доступа к контенту — а не все модели. Передайте полученный review_digest в expected_review_digest, чтобы установить именно проверенный манифест с проверенного URL. При изменении сервер вернёт 409 manifest_changed_since_review до активации.

Установка, которая выполняет активацию

Окно терминала
curl -X POST "https://cms.example/api/plugins" \
-H "X-Site-Id: cdocssite00000000000000001" \
-H "Content-Type: application/json" \
-b "__Secure-better-auth.session_token=$SESSION" \
--data '{"manifest_url":"https://plugin.example/dee-wan/manifest.json","activation_code":"activation-code-0123456789","expected_review_digest":"5ad97eed147d0a0f9f8934fa20485f287e656d72499fc739c831d2748680e49a","capabilities":["content:read","workflow:read","workflow:publish","workflow:unpublish"],"model_ids":["cdocsmodel0000000000000001"]}'
{
"data": {
"id": "plg_0000000000000000000000000000d1ee",
"plugin_id": "com.example.myplugin",
"plugin_name": "My Plugin",
"plugin_version": "0.1.0",
"protocol_version": 1,
"state": "active",
"capabilities": [
"content:read",
"workflow:read",
"workflow:publish",
"workflow:unpublish"
],
"requested_capabilities": [
"content:read",
"workflow:read",
"workflow:publish",
"workflow:unpublish"
],
"model_ids": ["cdocsmodel0000000000000001"],
"content_actions": [{ "id": "schedule", "label": "Schedule" }],
"manage_views": [],
"ui_extensions": [],
"operations": [],
"policies": [],
"policy_modes": {},
"hooks": [],
"hook_modes": {},
"manifest_url": "https://plugin.example/dee-wan/manifest.json",
"management_origin": "https://plugin.example",
"created_by_email": "[email protected]",
"created_at": "2026-01-01T00:00:00.000Z",
"updated_at": "2026-01-01T00:00:00.000Z"
}
}

Ни токен, ни хеш не появляются в этом ответе, в любом последующем чтении или в записи аудита, которую создаёт установка. Единственная копия — у вашего сервиса.

Выданные права можно изменить в любой момент, но только в пределах запроса из сохранённого манифеста. Исключение возможности или модели действует уже со следующего запроса: ждать сброса кеша не нужно.

Отзыв возможности, которая этому плагину оказалась не нужна

Окно терминала
curl -X PUT "https://cms.example/api/plugins/plg_0000000000000000000000000000d1ee/grant" \
-H "X-Site-Id: cdocssite00000000000000001" \
-H "Content-Type: application/json" \
-b "__Secure-better-auth.session_token=$SESSION" \
--data '{"capabilities":["content:read","workflow:read","workflow:publish"],"model_ids":["cdocsmodel0000000000000001"]}'
{
"data": {
"id": "plg_0000000000000000000000000000d1ee",
"plugin_id": "com.example.myplugin",
"plugin_name": "My Plugin",
"plugin_version": "0.1.0",
"protocol_version": 1,
"state": "active",
"capabilities": ["content:read", "workflow:read", "workflow:publish"],
"requested_capabilities": [
"content:read",
"workflow:read",
"workflow:publish",
"workflow:unpublish"
],
"model_ids": ["cdocsmodel0000000000000001"],
"content_actions": [{ "id": "schedule", "label": "Schedule" }],
"manage_views": [],
"ui_extensions": [],
"operations": [],
"policies": [],
"policy_modes": {},
"hooks": [],
"hook_modes": {},
"manifest_url": "https://plugin.example/dee-wan/manifest.json",
"management_origin": "https://plugin.example",
"created_by_email": "[email protected]",
"created_at": "2026-01-01T00:00:00.000Z",
"updated_at": "2026-01-01T00:00:00.000Z"
}
}

Запрос возможности за пределами сохранённого манифеста возвращает 400 invalid_capabilities. Позже выданные права можно снова расширить, но только в пределах запроса из сохранённого манифеста.

Публикация нового манифеста по вашему URL не меняет существующую установку плагина. Для этого администратор должен явно выполнить два действия:

  1. Принять релиз через POST /api/plugins/:id/upgrade с телом {"expected_version": "<the version they reviewed>"}. Ядро снова получает манифест по сохранённому URL манифеста и отклоняет его как manifest_changed_since_review, если его version не та, что названа, как manifest_identity_changed, если изменился его id или origin его base_url, и как protocol_changed, если изменилась версия протокола, которую согласовало бы ядро. При успехе ядро сохраняет новый манифест и оставляет только те выданные возможности, которые новый манифест всё ещё запрашивает. Принятие релиза может сузить выданные права, но никогда не расширяет их.
  2. Расширить права, если релиз запрашивает новую возможность, через PUT /api/plugins/:id/grant, как выше. Теперь это разрешено, потому что сохранённый манифест её запрашивает.

Поэтому релиз, которому нужна новая возможность, должен сказать об этом в собственных примечаниях к релизу: пока администратор не выполнит оба шага, ваш сервис продолжает работать с прежними правами, а команда, которой нужна новая возможность, отвечает capability_refused.

5. Открытие вашего UI через делегированный запуск

Заголовок раздела «5. Открытие вашего UI через делегированный запуск»

Обычный делегированный запуск открывает вашу страницу управления в новой вкладке. Объявленный и проверенный frame открывает изолированный редактор или представление управления внутри админки; действия проверяет хост. Фрейм не получает сессию администратора или код запуска. Cookie сессии Dee Wan остаются в Dee Wan. Делегированный запуск выпускает одноразовый код, действительный 60 секунд, хранящийся хешированным и привязанный к инсталляции, сайту, пользователю, действию и — для действия над контентом — к одному экземпляру.

Есть два вида запуска. manage без instance_id открывает ваш интерфейс для всей установки плагина и требует site:plugins. Запуск manage с объявленным представлением предназначен для редакционной работы: право site:plugins ему не нужно. Достаточно одной модели, к которой плагину предоставлен доступ и которую пользователь может читать — все записи или только свои. Действие над контентом использует один из id content_actions манифеста и instance_id; ему нужно право чтения выбранной записи. Если её модель не входит в права установки плагина, запуск отклоняется с model_not_granted. Административный интерфейс показывает действие только для записей моделей, к которым плагину предоставлен доступ.

Запуск manage может также содержать "view", одно из значений manage_views вашего манифеста. Тогда URL запуска получает dee_wan_view=<view> рядом с dee_wan_launch. Это лишь подсказка о целевой странице: обменяйте код как обычно, затем откройте эту страницу в созданной сессии. Подсказка ничего не разрешает, поэтому неизвестное значение считайте отсутствием подсказки. View, который ваш манифест не объявляет, отклоняется как invalid_launch_action.

Запуск может начаться и с агента. Учётные данные управления или подключённое приложение с областью plugins:use создают ту же ссылку через API управления или MCP (см. Запуск плагинов). Ядро выполняет те же проверки, и ссылку всё равно открывает человек. Ваша страница видит обычный запуск.

Запрос URL запуска

Окно терминала
curl -X POST "https://cms.example/api/plugins/plg_0000000000000000000000000000d1ee/launch" \
-H "X-Site-Id: cdocssite00000000000000001" \
-H "Content-Type: application/json" \
-b "__Secure-better-auth.session_token=$SESSION" \
--data '{"action":"schedule","instance_id":"cdocsinstance00000000000001"}'
{
"data": {
"url": "https://plugin.example/installations/plg_0000000000000000000000000000d1ee?dee_wan_launch=dwl_ExampleLaunchCodeExampleLaunchCodeExampleLa"
}
}

Браузер переходит туда. Ответ запуска от Dee Wan содержит Referrer-Policy: no-referrer, но страница, на которую попадает браузер, — ваша, как и остальная часть работы:

  • Сами отправляйте Referrer-Policy: no-referrer на своей странице управления, чтобы код в её URL не мог утечь через referrer.
  • Не загружайте никаких сторонних ресурсов до обмена.
  • Как только обмен прошёл успешно, перенаправьте на ту же страницу без dee_wan_launch в URL, чтобы код не оставался в адресной строке или истории браузера. Эталонный плагин отвечает 303 See Other на чистый URL.

Ваш БЭКЕНД обменивает код, используя ваш токен инсталляции:

Обмен кода на делегирование

Окно терминала
curl -X POST "https://cms.example/api/plugin/v1/launch/exchange" \
-H "Authorization: Bearer dwp_ExampleTokenNotRealExampleTokenNotRealExamp" \
-H "Content-Type: application/json" \
--data '{"code":"dwl_ExampleLaunchCodeExampleLaunchCodeExampleLa"}'
{
"data": {
"delegation_id": "pld_0000000000000000000000000000d1ee",
"installation_id": "plg_0000000000000000000000000000d1ee",
"site_id": "cdocssite00000000000000001",
"action": "schedule",
"instance_id": "cdocsinstance00000000000001",
"model_ids": ["cdocsmodel0000000000000001"],
"user": { "id": "cdocsuser00000000000000001", "email": "[email protected]" }
}
}

Теперь создайте СВОЙ cookie сессии для этого браузера и свяжите с ним delegation_id. Делегирование не является учётными данными и само по себе не предоставляет прав. Оно указывает пользователя, запустившего плагин. Передавайте его с каждой последующей командой, чтобы ядро могло проверить актуальные полномочия этого пользователя в момент выполнения.

Для чего можно использовать делегирование:

  • Делегирование действия над контентом (с instance_id) действует только на элементы из той же группы переводов, что и этот экземпляр. Указание его для любого другого элемента — это requester_unauthorized.
  • Делегирование manage (instance_id равен null) действует на элементы моделей из его model_ids.
  • Каждое делегирование содержит model_ids: модели из выданного набора, которые пользователь мог читать при запуске — все записи или только свои. Для действия над контентом это модель выбранной записи. Показывайте данные и выполняйте действия только в этих пределах. Запрос к записи другой модели или к записи, которую пользователь больше не может читать, возвращает requester_unauthorized.
  • GET /api/plugin/v1/delegations/<delegation_id> возвращает текущую область делегирования: каждую модель, доступные пользователю виды переходов и entries. Выполняйте запрос перед показом каждой страницы. Предлагайте перенос или отмену запланированной работы только для указанного вида.
  • entries: "all": человек читает каждый элемент этой модели, и виды переходов действуют для каждого. entries: "checked": человек читает или изменяет там только свои элементы (read:own, update:own). Спросите GET /api/plugin/v1/delegations/<delegation_id>/entries?instance_id=… (до 100 id), какие элементы показывать и что разрешено для каждого. Не показывайте то, что он не вернул. Не копируйте, кто чем владеет, в своё хранилище; спрашивайте заново при каждом запросе.
  • Делегирование, созданное через учётные данные управления, ограничено возможностями установки плагина, которые разрешены областями доступа этих учётных данных. Команда вне этих пределов возвращает capability_refused.
  • Ядро не проверяет action делегирования, когда вы отправляете команду. Оно нужно для того, чтобы ваш собственный UI знал, какой экран показать.
  • У делегирования нет ограничения по времени, потому что запланированная работа может выполниться намного позже того, как человек открыл ваш UI. Оно перестаёт работать, когда у этого человека больше нет полномочий, нужных команде, — он потерял право доступа, свою роль на сайте или учётную запись, — а также когда инсталляция отключена, удалена или её токен больше не действителен. Текущие полномочия человека перечитываются при каждой команде.

См. Безопасность плагинов.

6. Чтение контента и фиксация версии перед сменой статуса

Заголовок раздела «6. Чтение контента и фиксация версии перед сменой статуса»

Прежде чем запланировать операцию, прочитайте запись с делегированием. Блок workflow показывает, что пользователь вправе сделать сейчас, и возвращает значения, которые нужно зафиксировать.

Чтение элемента требует content:read. Блок workflow — а с ним и workflow.revision, который должна отправлять каждая команда перехода, — возвращается, только если инсталляция также имеет workflow:read. Без неё вы получаете только сводку, и закреплять нечего. Поэтому плагин, который отправляет команды, запрашивает обе возможности.

Делегированное чтение также возвращает saved с учётом текущих прав пользователя на поля и языки. Ниже показаны поля сводки.

Чтение одной записи от имени пользователя, запустившего плагин

Окно терминала
curl "https://cms.example/api/plugin/v1/content/cdocsinstance00000000000001?delegation_id=pld_0000000000000000000000000000d1ee" \
-H "Authorization: Bearer dwp_ExampleTokenNotRealExampleTokenNotRealExamp"
{
"data": {
"instance_id": "cdocsinstance00000000000001",
"primary_instance_id": "cdocsinstance00000000000001",
"translation_group_id": "cdocsgroup0000000000000001",
"model_id": "cdocsmodel0000000000000001",
"model_slug": "article",
"lang": "en",
"label": "Hello",
"slug": "hello",
"current_version_id": "cdocsversion00000000000001",
"published_version_id": null,
"workflow": {
"state": "draft",
"revision": 0,
"transitions": [{ "to": "published", "kind": "publish" }]
}
}
}

Сохраните в своей записи:

  • primary_instance_id — экземпляр, к которому обращается переход. Публикация применяется ко всей группе переводов, а первичный экземпляр — это дескриптор группы.
  • current_version_id для публикации обычного черновика или published_version_id для снятия с публикации. Это и есть закрепление.
  • workflow.revision.
  • Выбранные вами to и kind, которые должны быть одним из предложенных transitions.

Независимая будущая версия создаётся через POST /api/plugin/v1/content/:instanceId/futures и читается через GET /api/plugin/v1/content/:instanceId/futures/:futureId. Для публикации закрепите current_version_id этой ветки вместе с future_draft_id и expected_future_revision. Ядро связывает ветку с установкой плагина, выпуском и записью, а при начале команды публикации фиксирует её точную ревизию. Проверяются текущие права, этап работы, обязательные поля и задачи проверки именно этой версии; обычный черновик не меняется. Если результат неизвестен, повторяйте ту же команду с той же версией и ключом идемпотентности. В примере ниже публикуется обычный черновик.

Отправьте одну операцию изменения со всеми зафиксированными условиями и ключом идемпотентности, который можно повторно вычислить.

Публикация закреплённой версии

Окно терминала
curl -X POST "https://cms.example/api/plugin/v1/content/cdocsinstance00000000000001/transitions/published" \
-H "Authorization: Bearer dwp_ExampleTokenNotRealExampleTokenNotRealExamp" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: sched:plg_0000000000000000000000000000d1ee:sch_0001:r1" \
--data '{"kind":"publish","expected_current_version_id":"cdocsversion00000000000001","expected_workflow_revision":0,"delegation_id":"pld_0000000000000000000000000000d1ee"}'
{
"outcome": {
"code": "transition_applied",
"message": "The transition was applied.",
"retry": false
},
"data": {
"instance_id": "cdocsinstance00000000000001",
"primary_instance_id": "cdocsinstance00000000000001",
"from": "draft",
"to": "published",
"published": true,
"published_changed": true,
"published_version_id": "cdocsversion00000000000001",
"revision": 1,
"redirect": {
"status": "none",
"reason": "no_public_address"
},
"policy_results": []
}
}

redirect присутствует, когда переход публикует. Он сообщает об автоматическом перенаправлении, которое ядро сделало для изменённого адреса, или о причине, по которой его нет. Повтор той же команды возвращает это же тело.

Выводите ключ из своей собственной записи и её ревизии, никогда не из часов. Эталонный плагин использует sched:<installation_id>:<record_id>:r<revision> — это форма ключа выше. Ядро принимает любые от 1 до 128 печатных символов ASCII без пробелов. Тогда:

  • Точный повтор возвращает первый результат как idempotent_replay. Повтор после таймаута безопасен.
  • Публикация, которая меняет адрес, записывает своё перенаправление после фиксации перехода. Пока этот исход не записан, команда и её повторы отвечают temporary_failure с сообщением, что переход применён. Повторите с тем же ключом и телом. Результата без redirect вы никогда не получите.
  • Тот же ключ с другими входными данными — это idempotency_key_reused. Перепланирование — это новая ревизия, а значит, и новый ключ.
  • Для каждого отказа указан код. Все отказы окончательны, кроме rate_limited и temporary_failure. Прочитайте таблицу в Protocol v1 и обработайте каждый. Не превращайте неизвестный сбой в успех и не повторяйте бесконечно.

Журнал аудита записывает это как действие плагина, а не пользователя: событие workflow указывает инсталляцию, id, имя и принятую версию вашего плагина, версию протокола, дайджест ключа идемпотентности и человека, чьё делегирование это авторизовало. Админ-интерфейс показывает имя вашего плагина в качестве действующего лица.

Удаление сначала выполняет отзыв. Состояние становится revoked, оба хеша токена удаляются, и этот токен уже никогда не может стать действительным.

Удаление

Окно терминала
curl -X DELETE "https://cms.example/api/plugins/plg_0000000000000000000000000000d1ee" \
-H "X-Site-Id: cdocssite00000000000000001" \
-b "__Secure-better-auth.session_token=$SESSION"
{ "data": { "id": "plg_0000000000000000000000000000d1ee", "state": "revoked" } }

Каждый последующий запрос, включая уже находящийся в вашей очереди

Окно терминала
curl "https://cms.example/api/plugin/v1/context" \
-H "Authorization: Bearer dwp_ExampleTokenNotRealExampleTokenNotRealExamp"
{
"outcome": {
"code": "plugin_unauthorized",
"message": "The installation token is missing, unknown, disabled or revoked.",
"retry": false
}
}

Из этого следуют две вещи, и обе должны быть в вашей собственной документации:

  • Атрибуция в аудите сохраняется. Событие workflow хранит снимок вашей инсталляции, а не внешний ключ, поэтому удаление не может стереть, кто что опубликовал.
  • Dee Wan не утверждает, что удалил данные, которые хранятся у вас. За удаление в вашем сервисе отвечаете вы. В документации укажите, какие данные вы храните, как долго и как оператор может их удалить. Ответ эталонного плагина — Планирование от начала до конца.

Protocol v1 намеренно не даёт вам ни планировщика, ни очереди, ни потока событий. Если ваш плагин действует позже, на вас лежат:

  • управление временным правом на обработку (lease), чтобы два ваших воркера не могли одновременно выполнить одно задание, — Idempotency-Key — это вторая защита ядра, а не ваша первая.
  • ограниченные повторы с джиттером, лимитом попыток и лимитом возраста, а также соблюдение Retry-After в пределах выбранного вами максимума.
  • таблица окончательных отказов, которую могут прочитать ваши пользователи, потому что каждый условный отказ означает, что человеку нужно что-то решить.
  • ваша собственная авторизация для вашего собственного UI. Ядро проверяет запуск. После этого сессия ваша.

Исходный код плагина Scheduling (plugins/scheduling в репозитории Dee Wan) — полный рабочий пример всех четырёх пунктов. Его поведение описано на странице Плагин планирования.

Поля связей хранят ID записей. При публичном чтении цель определяется по обычным правилам доступа к опубликованным связям и выбора языка. { "field": "target" } показывает текущий опубликованный заголовок или имя. { "field": "target", "property": "address" } показывает доступное скалярное поле цели. Ссылка с href: "target" использует опубликованный адрес Page, Blog или управляемого маршрута либо публичное поле url цели. Если публичного адреса нет, дочернее содержимое ссылки выводится без элемента ссылки. Отсутствующие, черновые, снятые с публикации, перемещённые в корзину или недоступные цели не раскрывают ни подписей, ни адресов. Приватные поля не выдаются. Подписи — результат чтения; при обычном сохранении принимаются ID, а не объекты с присланными подписями и URL.

Копия сайта и история содержат проверяемый по количеству и контрольной сумме rich-relations.json. Он хранит версию, указатель на rich HTML, указатель на ID внутри блока, ID цели и модели. Вложенные и повторяемые ссылки переназначаются после создания записей. Отсутствующая текущая цель блокирует импорт; пропущенная цель старой истории получает обычное именованное предупреждение. Для сниппетов владелец — snippet:<source id>. Если подходящей исходной декларации нет, экспорт отклоняется с именованной ошибкой. Восстановите исходную декларацию и повторите экспорт. Декларация назначения не определяет ID источника.

Укажите inline: true в объявлении rich_blocks, чтобы элемент можно было вставить внутрь абзаца. Поля, значения, ссылки на файлы и записи, версии и миграции используют существующий контракт блоков. Хранимый элемент — <span data-inline="plugin"> с атрибутами data-type, data-version и data-values. Объявленные файлы остаются пустыми ссылками внутри него. Не передавайте готовый HTML.

Дерево отображения допускает только span, strong, em, small, a, img и br, в том числе внутри условий и групп. Блочные элементы и тон блока отклоняются. Встроенный элемент нельзя помещать внутрь существующей ссылки. Диалог использует те же поля Dee Wan, а публичное представление — тот же экранирующий рендерер, что и обычный блок.

Объявите ограниченные дополнения в renderer_metadata и запросите renderer:metadata. Каждое объявление содержит key, label, meta_names, link_rels и schema_types. Доступ предоставляется при проверке установки; новые права после обновления остаются выключенными до проверки. Дополнение может передавать только объявленные имена метаданных, виды ссылок (alternate, related, author, license) и типы JSON-LD.

Используйте делегированный POST /content/:instanceId/renderer-metadata с заголовком Idempotency-Key и телом { delegation_id, locale, expected_version_id, contributions }. У каждого дополнения есть объявленный key и необязательные meta, links, json_ld. Запись заменяет дополнения только этого плагина на указанном языке, создаёт обычную версию контента и остаётся черновиком до публикации. Устаревшая версия, отсутствие права или ограничение полей/языков отклоняют запись целиком.

Данные входят в принятую версию контента. Публичная выдача не обращается к плагину. Заголовки и описания SEO, canonical/robots, подтверждения сайта и опубликованные языковые адреса имеют приоритет. Дополнения объединяются по ID плагина и ключу; одинаковые имена и языковые ссылки разрешаются детерминированно. Разметка, исполняемые адреса, произвольные контексты и неверные ссылки отклоняются. Управляемый и генерируемый сайты и подписанный предпросмотр используют общую сериализацию SEO; предпросмотр по-прежнему запрещён для индексации.

Объявите mail_templates и запросите mail:send. Администратор проверяет текст и правило выбора получателя в Настройки → Плагины → Шаблоны писем. Плагин не передаёт адреса или произвольный текст.

Для шаблона с entry_author или entry_assignee вызовите POST /api/plugin/v1/mail/send с Idempotency-Key и {delegation_id, template, instance_id, variables}. Запись должна входить в доступные инсталляции и делегированию модели и записи; инициатор должен видеть её и иметь site:plugins. Подходит делегирование действия над соответствующей записью. Для заданных адресов по-прежнему нужно делегирование manage без instance_id.

entry_author выбирает аккаунт, создавший выбранную запись в CMS (content_instance.created_by_id), а не публичного автора блога, связанный профиль или последнего редактора. entry_assignee выбирает текущего назначенного пользователя из процесса согласования (content_workflow.assigned_to_id). Аккаунт должен быть активным, иметь текущий доступ к сайту и право читать запись; активному суперадминистратору отдельная роль на сайте не нужна. Отсутствие подходящего получателя даёт validation_failed; ответ не содержит адреса. При неопределённом результате повторите тот же запрос с тем же ключом. Система использует сохранённые письмо и получателя и снова проверяет исходный аккаунт; смена рецензента не перенаправляет повтор. Ограничения и результаты см. в протоколе.


Стабильная версия ещё не выпущена (pre-MVP)
Коммит исходного кода: e207e63239a627fbd2ae9c414ef7c6eb19fcfe5a (с локальными изменениями)
Дата сборки (UTC): 2026-10-21T09:28:24.214Z
Коммит для обновления: не определён: в рабочей копии есть изменения. Примечания к выпуску