Перейти к содержимому
Pre-MVP. Кодовая база 1.0 не является выпущенным продуктом — см. границы продукта 1.0.

Написание плагина

Плагин — это отдельно развёртываемый сервис. Dee Wan не загружает код плагинов, не выполняет JavaScript плагинов в своём админ-интерфейсе и не выдаёт плагину никакой привязки к базе данных — плагин общается с Dee Wan по HTTPS и никак иначе. Ниже плагин строится от начала до конца: манифест, установка, грант, запуск, условная команда публикации и удаление.

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

Каждый запрос и ответ ниже выполняется против настоящих маршрутов тестом backend/test/plugin-docs.test.ts. Идентификаторы и секреты синтетические и намеренно недействительные.

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

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

Манифест — это один 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.

Инсталляция — это передача между двумя сторонами, поэтому вашему сервису нужен один маршрут ещё до того, как кто-либо сможет его установить: 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"
}

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

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

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

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

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

Settings → Plugins после Review: запрошенные возможности и модели сайта, каждая с флажком, над кодом активации

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

Окно терминала
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"
}
}

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

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

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

Окно терминала
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.

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

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

Окно терминала
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, имя и принятую версию вашего плагина, версию протокола, дайджест ключа идемпотентности и человека, чьё делегирование это авторизовало. Админ-интерфейс показывает имя вашего плагина в качестве действующего лица.

Удаление сначала выполняет отзыв. Состояние становится 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/ — полный рабочий пример всех четырёх пунктов.