Skip to content
Pre-MVP. The 1.0 codebase is not a released product — see the 1.0 product boundary.

Protocol v1

Every route, header, body, outcome code and retry rule of the remote plugin protocol. A plugin is a separately deployed service: it speaks HTTP to Dee Wan and runs no code inside it. See also authoring.md for a first plugin end to end, security.md for the trust boundary, and testing.md for how to test one.

Supported protocol versions: 1. The version is negotiated once, at install, and pinned on the installation. A breaking change to a route, a meaning or an error code requires protocol v2; additive response fields are permitted only because clients are required to ignore response keys they do not know. Request shapes are closed — an unknown key is refused, never ignored.

Every plugin route takes Authorization: Bearer <token>, where the token is dwp_ followed by 43 URL-safe characters of 256-bit randomness. Core stores only its SHA-256 hash and can never show it again; the plugin receives it once, at activation.

The routes live under /api/plugin/v1 and are mounted before the browser-facing origin and session checks, because a server sends neither an Origin header nor a cookie. Their own requirePlugin middleware resolves the installation and the site BEFORE any request body is read, in this order: refuse a caller-named site or database, resolve the token to one active installation, spend the rate limit, resolve the site, route to the tenant database.

Consequences worth stating plainly:

  • The site is derived from the token. A plugin cannot name one, and sending X-Site-Id at all is invalid_request.
  • A user cookie is not plugin authentication, and a plugin token is not accepted on any administrative route.
  • pending, disabled and revoked installations answer plugin_unauthorized before any tenant access happens.
Header Where Meaning
Authorization: Bearer <token> every plugin request The installation token. Required.
Idempotency-Key every mutation Required. Up to 128 printable ASCII characters. A bearer token is authentication, not replay control.
Content-Type: application/json requests with a body The only body format.
X-Site-Id never Refused. The site comes from the token.
Retry-After on a 429 response Seconds. Honour it, within your own maximum.
Referrer-Policy: no-referrer on a launch response Set by core so a launch code cannot leak through a referrer.
Limit Value
Request body 16384 bytes
Manifest body 32768 bytes
Idempotency key 128 characters
Content page 50 items, default 20
Requests per installation 120 per 60s window, keyed by installation
Launch code lifetime 60s, single use, stored hashed
Token rotation overlap at most 10 minutes
Capability What it permits
content:read The content read routes, within the granted models.
workflow:read The workflow block on a read: state, revision and the boundary transitions.
workflow:publish A kind: publish command.
workflow:unpublish A kind: unpublish command.

A grant is the intersection of these with a per-site list of content-model ids. An empty model list means no content access — not every model. Capabilities outside the stored manifest request cannot be granted, so a plugin update can never widen an existing grant.

pending, active, disabled, revoked. Only active may call the plugin API. revoked is terminal: its token never becomes valid again, and uninstall revokes before anything else.

What this installation is. The installation id, the site it is bound to, the accepted protocol version and the grant. Read it at start-up to learn what this installation may ask for; it names no user, no token hash, no database and no infrastructure.

  • Caller: the plugin, with its installation token
  • Requires: none
  • No query, no body.
  • Answers: { "data": <context> }$defs/context.

Outcomes:

  • plugin_unauthorized
  • rate_limited
  • temporary_failure

What this installation may do

Terminal window
curl "https://cms.example/api/plugin/v1/context" \
-H "Authorization: Bearer dwp_ExampleTokenNotRealExampleTokenNotRealExamp"
{
"data": {
"installation_id": "plg_0000000000000000000000000000d1ee",
"site": {
"id": "cdocssite00000000000000001",
"name": "docs-site",
"default_lang": "en"
},
"protocol_version": 1,
"capabilities": [
"content:read",
"workflow:read",
"workflow:publish",
"workflow:unpublish"
],
"model_ids": [
"cdocsmodel0000000000000001"
]
}
}

An unknown, disabled or revoked token

Terminal window
curl "https://cms.example/api/plugin/v1/context" \
-H "Authorization: Bearer dwp_UnknownTokenNotRealUnknownTokenNotRealUnkno"
{
"outcome": {
"code": "plugin_unauthorized",
"message": "The installation token is missing, unknown, disabled or revoked.",
"retry": false
}
}

One answer for all four. Nothing tells a caller which of them applies.

A plugin cannot name a site

Terminal window
curl "https://cms.example/api/plugin/v1/context" \
-H "Authorization: Bearer dwp_ExampleTokenNotRealExampleTokenNotRealExamp" \
-H "X-Site-Id: cotherdocsite00000000001"
{
"outcome": {
"code": "invalid_request",
"message": "A plugin request cannot name a site.",
"retry": false
}
}

The site comes from the token. Sending the header at all is refused, even with the right value.

An unknown plugin route

Terminal window
curl "https://cms.example/api/plugin/v1/schedules" \
-H "Authorization: Bearer dwp_ExampleTokenNotRealExampleTokenNotRealExamp"
{
"outcome": {
"code": "content_not_found",
"message": "No such plugin route.",
"retry": false
}
}

Protocol v1 has the routes above and nothing else. A 404 here is not a temporary failure.

List content in one granted model. One page of primary instances in one model. model_id is mandatory: there is no “all models” read. The filter set is closed — any other query parameter is invalid_request rather than ignored — and the page is a narrow summary, not a content snapshot. Only translation-group primaries appear; the group is the unit a transition moves.

  • Caller: the plugin, content:read
  • Requires: content:read
  • model_id (required) — a model id from the grant.
  • publishedtrue or false.
  • after — the previous page’s last instance_id.
  • limit — 1..50, default 20.
  • Answers: { "data": [<summary>], "next": <instance_id|null> }$defs/content_page.

Outcomes:

  • capability_refused — no content:read, or a model outside the grant
  • invalid_request — missing model_id, an unknown filter, a limit out of range
  • plugin_unauthorized
  • rate_limited
  • temporary_failure

One page of one model

Terminal window
curl "https://cms.example/api/plugin/v1/content?model_id=cdocsmodel0000000000000001&published=false&limit=1" \
-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
}
],
"next": null
}

A filter outside model_id, published, after, limit is invalid_request, not ignored.

The filter set is closed

Terminal window
curl "https://cms.example/api/plugin/v1/content?model_id=cdocsmodel0000000000000001&sort=title" \
-H "Authorization: Bearer dwp_ExampleTokenNotRealExampleTokenNotRealExamp"
{
"outcome": {
"code": "invalid_request",
"message": "model_id is required; filters are model_id, published, after, limit.",
"retry": false
}
}

Read one item, and what it may do next. Ids, label, slug, the current and published version ids, and — with workflow:read — the workflow state, its revision and the transitions that cross the publication boundary. Transitions are narrowed to the capabilities this installation holds. Pass delegation_id and they are narrowed again to what that person may do NOW, re-read from control; a delegation whose person has lost the authority answers requester_unauthorized rather than a quietly shorter list. An item outside the site or the model grant is content_not_found, which is also what a cross-site id answers.

  • Caller: the plugin, content:read
  • Requires: content:read, plus workflow:read for the workflow block
  • delegation_id — a consumed launch delegation. No other query parameter is accepted.
  • Answers: { "data": <summary> }$defs/content_summary.

Outcomes:

  • capability_refused
  • content_not_found
  • requester_unauthorized — the named delegation is unknown, or its person no longer qualifies
  • invalid_request
  • plugin_unauthorized
  • rate_limited
  • temporary_failure

One item, with the transitions the requester may take now

Terminal window
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"
}
]
}
}
}

Pin what this answers: current_version_id for a publish, published_version_id for an unpublish, and workflow.revision for both.

POST /api/plugin/v1/content/:instanceId/transitions/:targetState

Section titled “POST /api/plugin/v1/content/:instanceId/transitions/:targetState”

Publish or unpublish, conditionally. The one mutation protocol v1 has. It is conditional in four ways at once, all checked inside the same atomic lifecycle mutation: the pinned version must still be the one the transition would move, the workflow revision must still be the one the schedule was made against, targetState must be a transition available from the current state, and that transition’s publication meaning must match kind. The delegating person’s authority is re-read from control before the write. A newer draft is never published silently; a changed workflow is never traversed silently. Publication applies to the whole translation group, which is why the result names the primary instance.

  • Caller: the plugin, workflow:publish or workflow:unpublish
  • Requires: workflow:publish for kind: publish, workflow:unpublish for kind: unpublish
  • Idempotency-Key (required) — up to 128 printable ASCII characters. Derive it from the schedule, not from the clock: an exact replay returns the first result, and the same key with different input is idempotency_key_reused.
  • Body — $defs/publish_command or $defs/unpublish_command. The shape is CLOSED: an unknown key, or the other kind’s pin, is invalid_request.
  • Answers: { "outcome": {...}, "data": <transition_result> }$defs/transition_result.

Outcomes:

  • transition_applied / idempotent_replay
  • invalid_request
  • capability_refused
  • requester_unauthorized
  • content_not_found
  • scheduled_target_stale
  • workflow_conflict
  • dependent_content
  • idempotency_key_reused
  • transition_unavailable / transition_refused
  • rate_limited
  • temporary_failure

Publish the pinned draft

Terminal window
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
}
}

The same command again

Terminal window
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": "idempotent_replay",
"message": "This command was already applied. The first result is returned.",
"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
}
}

A retry after a network failure is safe: the first result comes back, and nothing runs twice.

The same key, a different command

Terminal window
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": 7,
"delegation_id": "pld_0000000000000000000000000000d1ee"
}'
{
"outcome": {
"code": "idempotency_key_reused",
"message": "This idempotency key was already used for a different command.",
"retry": false
}
}

Derive the key from the schedule and its revision. Rescheduling is a new revision, so a new key.

Unpublish the pinned live version

Terminal window
curl -X POST "https://cms.example/api/plugin/v1/content/cdocsinstance00000000000001/transitions/draft" \
-H "Authorization: Bearer dwp_ExampleTokenNotRealExampleTokenNotRealExamp" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: sched:plg_0000000000000000000000000000d1ee:sch_0002:r1" \
--data '{
"kind": "unpublish",
"expected_published_version_id": "cdocsversion00000000000001",
"expected_workflow_revision": 1,
"delegation_id": "pld_0000000000000000000000000000d1ee"
}'
{
"outcome": {
"code": "transition_applied",
"message": "The transition was applied.",
"retry": false
},
"data": {
"instance_id": "cdocsinstance00000000000001",
"primary_instance_id": "cdocsinstance00000000000001",
"from": "published",
"to": "draft",
"published": false,
"published_changed": true,
"published_version_id": null,
"revision": 2
}
}

A newer draft is never published silently

Terminal window
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_0003:r1" \
--data '{
"kind": "publish",
"expected_current_version_id": "cdocsversion00000000000001",
"expected_workflow_revision": 2,
"delegation_id": "pld_0000000000000000000000000000d1ee"
}'
{
"outcome": {
"code": "scheduled_target_stale",
"message": "The pinned version is no longer the one this transition would move.",
"retry": false
}
}

Somebody saved a newer draft after the schedule was made. Terminal: a person decides what to do.

A changed workflow is never traversed silently

Terminal window
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_0004:r1" \
--data '{
"kind": "publish",
"expected_current_version_id": "cdocsversion00000000000002",
"expected_workflow_revision": 0,
"delegation_id": "pld_0000000000000000000000000000d1ee"
}'
{
"outcome": {
"code": "workflow_conflict",
"message": "The content moved through its workflow after this was requested.",
"retry": false
}
}

The request shape is closed

Terminal window
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_0005:r1" \
--data '{
"kind": "publish",
"expected_current_version_id": "cdocsversion00000000000002",
"expected_workflow_revision": 2,
"delegation_id": "pld_0000000000000000000000000000d1ee",
"override_dependents": true
}'
{
"outcome": {
"code": "invalid_request",
"message": "The request is not valid for protocol v1.",
"retry": false
}
}

There is no override in protocol v1. An unknown key is refused rather than dropped.

A mutation without an idempotency key

Terminal window
curl -X POST "https://cms.example/api/plugin/v1/content/cdocsinstance00000000000001/transitions/published" \
-H "Authorization: Bearer dwp_ExampleTokenNotRealExampleTokenNotRealExamp" \
-H "Content-Type: application/json" \
--data '{
"kind": "publish",
"expected_current_version_id": "cdocsversion00000000000002",
"expected_workflow_revision": 2,
"delegation_id": "pld_0000000000000000000000000000d1ee"
}'
{
"outcome": {
"code": "invalid_request",
"message": "Idempotency-Key is required.",
"retry": false
}
}

Exchange a launch code for a delegation. The browser arrives at management_url carrying dee_wan_launch. The plugin BACKEND sends that code here, with its own bearer token, within 60 seconds. The code is consumed once. What comes back is the binding — installation, site, action, optional content instance — and a display-safe identity for the plugin’s own session. It grants no Dee Wan capability the installation token does not already have, and a code minted for another installation is refused.

  • Caller: the plugin, with its installation token
  • Requires: none
  • Body — $defs/launch_exchange_request.
  • Answers: { "data": <delegation> }$defs/launch_delegation.

Outcomes:

  • invalid_request — unknown, already used, expired, or minted for another installation
  • plugin_unauthorized
  • rate_limited
  • temporary_failure

Turn a launch code into a delegation

Terminal window
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"
}
}
}

Keep delegation_id. Every later command carries it, and core re-reads that person’s authority.

A launch code is single use

Terminal window
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"
}'
{
"outcome": {
"code": "invalid_request",
"message": "The launch code is unknown, used or expired.",
"retry": false
}
}

Every plugin route answers a machine code and a human message:

{
"outcome": {
"code": "transition_applied",
"message": "…",
"retry": false
},
"data": {}
}

Branch on code. The message is for a person and is not stable. retry is core’s own statement about whether trying again can help, and it is the only field a runner needs to decide.

HTTP Code Retry Meaning
200 transition_applied no The transition was applied.
200 idempotent_replay no This command was already applied. The first result is returned.
400 invalid_request no The request is not valid for protocol v1.
401 plugin_unauthorized no The installation token is missing, unknown, disabled or revoked.
403 capability_refused no This installation was not granted that capability or model.
403 requester_unauthorized no The person who requested this no longer holds the authority it needs.
404 content_not_found no No such content in this installation’s scope.
409 scheduled_target_stale no The pinned version is no longer the one this transition would move.
409 workflow_conflict no The content moved through its workflow after this was requested.
409 dependent_content no Other content depends on this item. A person must review the unpublish in Dee Wan.
409 idempotency_key_reused no This idempotency key was already used for a different command.
422 transition_unavailable no That transition is not available from the content’s current state.
422 transition_refused no The lifecycle refused this transition. Nothing was changed.
429 rate_limited yes Too many requests for this installation.
503 temporary_failure yes Dee Wan could not complete the request. Nothing is known to have changed.
  • rate_limited — retry, and respect Retry-After. Refuse the schedule instead of waiting if the wait exceeds your own maximum.
  • temporary_failure and a transport failure (timeout, connection reset, no response) — retry with bounded exponential backoff and jitter, up to a documented attempt and age cap.
  • Everything else is TERMINAL. Do not retry it, and do not turn it into success.
  • A reply whose HTTP status and outcome.code disagree, or whose body is not the envelope, is untrusted: treat it as a terminal refusal rather than guessing.
  • A retry after an unknown outcome is safe because of Idempotency-Key: an exact replay returns the first result and applies nothing twice. It is not a substitute for your own lease — two of your workers must still not execute one schedule at once.
  • Nothing here is ever an infinite retry, and no unknown failure is ever reported as applied.

These are the browser-facing routes behind a user session, the site header, and the site:plugins permission — the surface that installs, grants, rotates and removes. They are not part of what a plugin calls; they are here so a plugin author knows what an administrator does. Each one that mutates writes an audit entry, and refuses (503 audit_unavailable) rather than mutating unaudited.

List installations. Every non-revoked installation on the site, redacted: no token, no hash.

  • Caller: a user with site:plugins
  • Requires: site:plugins
  • No body.
  • Answers: { "data": [<installation>] }$defs/installation.

Outcomes:

  • 200
  • 403 missing_permission

Models that can be granted. Id, name and slug of every content model on the site, for the grant screen. The GRANT stores ids, so a rename keeps it and a deleted model makes that part of it inert.

  • Caller: a user with site:plugins
  • Requires: site:plugins
  • No body.
  • Answers: { "data": [{ "id", "name", "slug" }] }

Outcomes:

  • 200
  • 403 missing_permission

Content actions to render. The bounded { id, label } pairs active installations declare, so the content screen can render a core-owned button. No markup, no script, no CSS, no icon URL ever comes from a plugin.

  • Caller: any member of the site
  • Requires: none beyond site access
  • No body.
  • Answers: { "data": [{ "installation_id", "plugin_name", "actions": [{ "id", "label" }] }] }

Outcomes:

  • 200

Fetch and validate a manifest. Fetches the manifest URL, validates it and returns what it asks for. Nothing is stored and no installation exists afterwards; this is the review step that makes approval informed.

  • Caller: a user with site:plugins
  • Requires: site:plugins
  • Body — { "manifest_url": "https://…" }
  • Answers: { "data": { "manifest": <manifest>, "protocol_version": 1 } }

Outcomes:

  • 200
  • 400 with a reasonmanifest_invalid, protocol_unsupported, manifest_origin_mismatch, url_not_https, url_credentials, url_fragment, url_private_destination, remote_redirect_refused, remote_body_too_large, remote_unreachable, manifest_unavailable

Install. Re-fetches and re-validates the manifest, records a pending installation with the approved capabilities and model ids, mints a token, and hands the raw token plus the one-time activation code to the manifest’s activation_url. The installation becomes active only after a 2xx from the plugin; core keeps the hash and never the token. A failed activation leaves nothing behind, and a second install of the same plugin on the same site is a conflict rather than a second installation.

  • Caller: a user with site:plugins
  • Requires: site:plugins
  • Body — { "manifest_url", "activation_code", "capabilities": […], "model_ids": […] }. The capabilities must be a subset of the manifest request; an empty model_ids means no content access, not every model.
  • Answers: { "data": <installation> }, 201.

Outcomes:

  • 201
  • 400 invalid_activation_code / invalid_capabilities / invalid_model_ids / a manifest reason
  • 409 installation_exists / backend_url_unset / activation_superseded
  • 502 with the remote reason — the plugin refused or could not be reached
  • 503 audit_unavailable

Change the grant. Replaces capabilities and model ids. It can only narrow within what the STORED manifest requested — a capability outside it is refused, so a changed manifest cannot widen a grant. A reduction takes effect on the next plugin request.

  • Caller: a user with site:plugins
  • Requires: site:plugins
  • Body — { "capabilities": […], "model_ids": […] }
  • Answers: { "data": <installation> }

Outcomes:

  • 200
  • 400 invalid_capabilities / invalid_model_ids
  • 404
  • 409 installation_state_changed

Accept a new manifest version. Re-fetches the stored manifest URL and accepts the release an administrator has just reviewed — expected_version must match what is served, or the review is stale. The plugin id, the origin and the accepted protocol version must not have moved. Capabilities are INTERSECTED with the new request; an upgrade never widens a grant.

  • Caller: a user with site:plugins
  • Requires: site:plugins
  • Body — { "expected_version": "0.2.0" }
  • Answers: { "data": <installation> }

Outcomes:

  • 200
  • 400 a manifest reason
  • 409 manifest_identity_changed / protocol_changed / manifest_changed_since_review

Rotate the token. Needs a fresh one-time code from the plugin, because rotation is a hand-off and not a reset. Core mints a token, sends it to the pinned activation origin, and only then swaps the hash. If the plugin refuses, the old token keeps working. The previous hash stays valid for at most 10 minutes, and the first request carrying the NEW token ends that overlap immediately.

  • Caller: a user with site:plugins
  • Requires: site:plugins
  • Body — { "activation_code": "…" }
  • Answers: { "data": { "rotated": true } }

Outcomes:

  • 200
  • 400 invalid_activation_code
  • 409 installation_not_active / backend_url_unset / installation_state_changed
  • 502 the remote reason — and the OLD token keeps working

Disable. Keeps the configuration and the audit trail and stops the plugin API dead: every request answers plugin_unauthorized before any tenant access.

  • Caller: a user with site:plugins
  • Requires: site:plugins
  • No body.
  • Answers: { "data": { "id", "state": "disabled" } }

Outcomes:

  • 200
  • 404
  • 409 installation_state_changed

Enable. Returns a disabled installation to active, with the same token and the same grant.

  • Caller: a user with site:plugins
  • Requires: site:plugins
  • No body.
  • Answers: { "data": { "id", "state": "active" } }

Outcomes:

  • 200
  • 404
  • 409 installation_state_changed

Uninstall. Revokes first: the state becomes revoked, both hashes are dropped, and the token can never become valid again. Audit attribution survives, because the workflow event stored a snapshot of the installation rather than a foreign key. Dee Wan does NOT claim this deleted anything the plugin holds — remote deletion is the plugin’s own action.

  • Caller: a user with site:plugins
  • Requires: site:plugins
  • No body.
  • Answers: { "data": { "id", "state": "revoked" } }

Outcomes:

  • 200
  • 404
  • 409 installation_state_changed

Open the plugin, delegated. Mints a one-time code, valid for 60 seconds, stored hashed, bound to installation, site, user, action and optional instance, and returns the management_url with the code on it. The response carries Referrer-Policy: no-referrer and Cache-Control: no-store. action must be manage with no instance, or a content_actions id from the stored manifest with one. Dee Wan session cookies never leave Dee Wan.

  • Caller: a site member for a content action; site:plugins for manage
  • Requires: read access to the named item, for a content action
  • Body — { "action": "manage" } or { "action": "<action id>", "instance_id": "…" }
  • Answers: { "data": { "url": "https://…?dee_wan_launch=…" } }$defs/launch.

Outcomes:

  • 200
  • 400 invalid_launch_action
  • 403 missing_permission (for manage) / model_denied
  • 404 not_found
  • 409 installation_not_active

Both are written by npm run docs:plugins and checked in. A test compares the checked-in copies to what the code renders, so they cannot describe a protocol this core does not speak.