Написание плагина
Плагин — это отдельно развёртываемый сервис. Dee Wan не загружает код плагинов, не выполняет JavaScript плагинов в своём админ-интерфейсе и не выдаёт плагину никакой привязки к базе данных — плагин общается с Dee Wan по HTTPS и никак иначе. Ниже плагин строится от начала до конца: манифест, установка, грант, запуск, условная команда публикации и удаление.
Вам нужно место, где запустить HTTPS-сервис, и место, где хранить его состояние. Эталонный плагин
(plugins/scheduling/) использует Cloudflare Worker с базой данных D1, и этот документ исходит из
такой конфигурации, но protocol v1 ничего из этого не требует.
Каждый запрос и ответ ниже выполняется против настоящих маршрутов тестом
backend/test/plugin-docs.test.ts. Идентификаторы и секреты синтетические и намеренно
недействительные.
- Protocol v1 — каждый маршрут, заголовок, тело, исход и правило повтора.
- Безопасность плагинов — токены, делегирование, область действия и модель угроз.
- Тестирование плагина — чек-лист контракта и локальная конфигурация из двух сервисов.
- Машиночитаемые:
schemas/manifest-v1.schema.json,schemas/protocol-v1.schema.json.
За что отвечаете вы
Заголовок раздела «За что отвечаете вы»| Вам принадлежит | Dee Wan принадлежит |
|---|---|
| Ваш сервис, его база данных, его часы и его повторы | Контент, версии, правила workflow, права доступа, аудит |
| Ваши собственные браузерные сессии и ваш собственный UI | Допустим ли переход и его атомарное применение |
| Решение, когда спрашивать | Решение, будет ли ответ «да» |
| Удаление ваших собственных данных | Отзыв вашего доступа |
Dee Wan никогда не доверяет утверждению плагина о том, что переход разрешён, что версия текущая или что снятие с публикации безопасно. Всё это перепроверяется внутри мутации.
1. Отдайте манифест
Заголовок раздела «1. Отдайте манифест»Манифест — это один HTTPS-документ, описывающий ваш плагин и то, что ему нужно. Dee Wan получает его один раз, при проверке, и сохраняет принятый снимок; при обычных запросах он повторно не запрашивается.
{ "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— стабильный идентификатор в формате обратного домена, минимум из трёх меток. Он никогда не меняется между релизами;version— это ваш релиз, и он меняется свободно.manifest_version,protocol_versionsиversion— три разные вещи.- Каждый URL — HTTPS, не содержит учётных данных и фрагмента, и все они — плюс сам URL манифеста —
должны иметь ОДИН origin. Второй origin — это
manifest_origin_mismatch. - Неизвестные ключи верхнего уровня отклоняются. Манифест не может полагаться на поле, которое это ядро игнорирует, поэтому нельзя выпустить ключ сегодня в надежде, что его начнут учитывать позже.
- Редиректы отклоняются при получении манифеста и при активации. Отдавайте оба напрямую.
- Приватные, loopback, link-local и зарезервированные адреса назначения отклоняются. Для локальной разработки см. Тестирование плагина.
content_actions— не более четырёх ограниченных пар{ id, label }: без разметки, без скриптов, без CSS, без URL иконки. Ядро рендерит собственную кнопку в собственном стиле.management_urlможет содержать{installation_id}и служит только для навигации. Он никогда не получает долгоживущих учётных данных в URL.
Полная схема, сгенерированная из объекта, по которому валидирует ядро, —
schemas/manifest-v1.schema.json.
2. Примите передачу активации
Заголовок раздела «2. Примите передачу активации»Инсталляция — это передача между двумя сторонами, поэтому вашему сервису нужен один маршрут ещё до
того, как кто-либо сможет его установить: activation_url. Выпустите одноразовый код активации,
передайте его администратору по отдельному каналу и ждите. Когда он выполнит установку, 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"}Что должен сделать ваш обработчик:
- Израсходовать
activation_codeровно один раз и отклонить неизвестный, использованный или просроченный код. Это единственное, что доказывает, что вызывающий — тот администратор, которому вы дали код. - Проверить, что
protocol_version— одна из поддерживаемых вами версий, аdee_wan_base_url— origin, который вы принимаете. - Сохранить
tokenкак свой секрет для этой инсталляции — в зашифрованном виде, никогда не в логах, никогда в URL или теле ошибки. - Ответить 2xx. Только тогда ядро помечает инсталляцию как
activeи сохраняет лишь SHA-256-хеш токена; показать токен снова оно уже никогда не сможет.
kind, равный rotate, — та же передача для существующей инсталляции: замените сохранённый токен.
Оба вида несут код, потому что ротация — это передача, а не сброс.
Если ваш обработчик завершится сбоем, ни на одной из сторон ничего не останется: ядро удаляет ожидающую инсталляцию, и администратор видит причину. Повторная попытка не создаёт второй инсталляции.
3. Проверка, затем установка
Заголовок раздела «3. Проверка, затем установка»Администратор с 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 }}Затем администратор выполняет установку: одобренные возможности, одобренные id моделей и ваш
одноразовый код. Одобренный набор должен быть подмножеством того, что запросил манифест. Пустой
model_ids означает полное отсутствие доступа к контенту, а не все модели.
Установка, которая выполняет активацию
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","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" }], "manifest_url": "https://plugin.example/dee-wan/manifest.json", "management_origin": "https://plugin.example", "created_by_email": "admin@example.com", "created_at": "2026-01-01T00:00:00.000Z", "updated_at": "2026-01-01T00:00:00.000Z" }}Ни токен, ни хеш не появляются в этом ответе, в любом последующем чтении или в записи аудита, которую создаёт установка. Единственная копия — у вашего сервиса.
4. Сужение гранта
Заголовок раздела «4. Сужение гранта»Грант можно изменить в любой момент, и только в пределах того, что запросил сохранённый манифест. Удаление возможности или модели вступает в силу при вашем следующем запросе — ждать сброса кеша не нужно.
Отзыв возможности, которая этому плагину оказалась не нужна
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" }], "manifest_url": "https://plugin.example/dee-wan/manifest.json", "management_origin": "https://plugin.example", "created_by_email": "admin@example.com", "created_at": "2026-01-01T00:00:00.000Z", "updated_at": "2026-01-01T00:00:00.000Z" }}Запрос возможности за пределами сохранённого манифеста — это 400 invalid_capabilities, и именно
поэтому обновление плагина не может расширить существующий грант: принятие нового манифеста —
отдельное явное действие (POST /api/plugins/:id/upgrade), и оно пересекает, а не расширяет.
5. Открытие вашего UI через делегированный запуск
Заголовок раздела «5. Открытие вашего UI через делегированный запуск»Ваш интерфейс управления — это ваша собственная страница, открываемая в новой вкладке верхнего уровня. Dee Wan её не встраивает, а cookie сессии Dee Wan никогда не покидают Dee Wan. Запуск выпускает одноразовый код, действительный 60 секунд, хранящийся хешированным и привязанный к инсталляции, сайту, пользователю, действию и — для действия над контентом — к одному экземпляру.
Запрос 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" }}Браузер переходит туда. Ваша страница не должна загружать никаких сторонних ресурсов до обмена, а
ответ запуска содержит Referrer-Policy: no-referrer, чтобы код не мог утечь через referrer. Затем
ваш БЭКЕНД обменивает код, используя ваш токен инсталляции:
Обмен кода на делегирование
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", "user": { "id": "cdocsuser00000000000000001", "email": "admin@example.com" } }}Теперь создайте СВОЙ cookie сессии для этого браузера и сохраните к нему delegation_id.
Делегирование — это не учётные данные, и само по себе оно ничего не разрешает: оно указывает
человека, который попросил, и каждая команда, которую вы отправите позже, несёт его, чтобы ядро могло
заново прочитать его полномочия в момент выполнения. См.
Безопасность плагинов.
6. Чтение контента и закрепление ровно того, что вы собираетесь перевести
Заголовок раздела «6. Чтение контента и закрепление ровно того, что вы собираетесь перевести»Прежде чем фиксировать какое-либо намерение, прочитайте элемент с делегированием. Блок workflow
сообщает, что этот человек действительно может сделать сейчас, и даёт три значения для закрепления.
Чтение одного элемента от имени человека, который попросил
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": "First draft", "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.
7. Отправка команды
Заголовок раздела «7. Отправка команды»Одна мутация, обусловленная всем, что вы закрепили, с ключом идемпотентности, который вы можете вывести повторно.
Публикация закреплённой версии
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 }}Выводите ключ из своей собственной записи и её ревизии — эталонный плагин использует
<installation>:<record>:r<revision>, — но никогда не из часов. Тогда:
- Точный повтор возвращает первый результат как
idempotent_replay. Повтор после таймаута безопасен. - Тот же ключ с другими входными данными — это
idempotency_key_reused. Перепланирование — это новая ревизия, а значит, и новый ключ. - Каждый отказ именован и терминален, кроме
rate_limitedиtemporary_failure. Прочитайте таблицу в Protocol v1 и обработайте каждый; не превращайте неизвестный сбой в успех и не повторяйте бесконечно.
Журнал аудита записывает это как действие плагина, а не пользователя: событие workflow указывает инсталляцию, id, имя и принятую версию вашего плагина, версию протокола, дайджест ключа идемпотентности и человека, чьё делегирование это авторизовало. Админ-интерфейс показывает имя вашего плагина в качестве действующего лица.
8. Удаление
Заголовок раздела «8. Удаление»Удаление сначала выполняет отзыв. Состояние становится 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. Ядро проверяет запуск; после этого сессия ваша.
plugins/scheduling/ — полный рабочий пример всех четырёх пунктов.