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.
Authentication
Section titled “Authentication”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-Idat all isinvalid_request. - A user cookie is not plugin authentication, and a plugin token is not accepted on any administrative route.
pending,disabledandrevokedinstallations answerplugin_unauthorizedbefore any tenant access happens.
Headers
Section titled “Headers”| 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. |
Limits
Section titled “Limits”| 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 |
Capabilities
Section titled “Capabilities”| 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.
Installation states
Section titled “Installation states”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.
Plugin routes
Section titled “Plugin routes”GET /api/plugin/v1/context
Section titled “GET /api/plugin/v1/context”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_unauthorizedrate_limitedtemporary_failure
What this installation may do
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
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
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
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.
GET /api/plugin/v1/content
Section titled “GET /api/plugin/v1/content”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.published—trueorfalse.after— the previous page’s lastinstance_id.limit— 1..50, default 20.- Answers:
{ "data": [<summary>], "next": <instance_id|null> }—$defs/content_page.
Outcomes:
capability_refused— nocontent:read, or a model outside the grantinvalid_request— missingmodel_id, an unknown filter, alimitout of rangeplugin_unauthorizedrate_limitedtemporary_failure
One page of one model
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
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 }}GET /api/plugin/v1/content/:instanceId
Section titled “GET /api/plugin/v1/content/:instanceId”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, plusworkflow:readfor theworkflowblock delegation_id— a consumed launch delegation. No other query parameter is accepted.- Answers:
{ "data": <summary> }—$defs/content_summary.
Outcomes:
capability_refusedcontent_not_foundrequester_unauthorized— the named delegation is unknown, or its person no longer qualifiesinvalid_requestplugin_unauthorizedrate_limitedtemporary_failure
One item, with the transitions the requester may take now
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:publishorworkflow:unpublish - Requires:
workflow:publishforkind: publish,workflow:unpublishforkind: 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 isidempotency_key_reused.- Body —
$defs/publish_commandor$defs/unpublish_command. The shape is CLOSED: an unknown key, or the other kind’s pin, isinvalid_request. - Answers:
{ "outcome": {...}, "data": <transition_result> }—$defs/transition_result.
Outcomes:
transition_applied/idempotent_replayinvalid_requestcapability_refusedrequester_unauthorizedcontent_not_foundscheduled_target_staleworkflow_conflictdependent_contentidempotency_key_reusedtransition_unavailable/transition_refusedrate_limitedtemporary_failure
Publish the pinned draft
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
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
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
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
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
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
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
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 }}POST /api/plugin/v1/launch/exchange
Section titled “POST /api/plugin/v1/launch/exchange”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 installationplugin_unauthorizedrate_limitedtemporary_failure
Turn a launch code into a delegation
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
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 }}Outcomes
Section titled “Outcomes”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. |
Retry rules
Section titled “Retry rules”rate_limited— retry, and respectRetry-After. Refuse the schedule instead of waiting if the wait exceeds your own maximum.temporary_failureand 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.codedisagree, 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.
Administrative routes
Section titled “Administrative routes”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.
GET /api/plugins
Section titled “GET /api/plugins”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
GET /api/plugins/models
Section titled “GET /api/plugins/models”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
GET /api/plugins/actions
Section titled “GET /api/plugins/actions”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
POST /api/plugins/review
Section titled “POST /api/plugins/review”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
reason—manifest_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
POST /api/plugins
Section titled “POST /api/plugins”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 emptymodel_idsmeans 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
PUT /api/plugins/:id/grant
Section titled “PUT /api/plugins/:id/grant”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
POST /api/plugins/:id/upgrade
Section titled “POST /api/plugins/:id/upgrade”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
POST /api/plugins/:id/rotate
Section titled “POST /api/plugins/:id/rotate”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
POST /api/plugins/:id/disable
Section titled “POST /api/plugins/:id/disable”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
POST /api/plugins/:id/enable
Section titled “POST /api/plugins/:id/enable”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
DELETE /api/plugins/:id
Section titled “DELETE /api/plugins/:id”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
POST /api/plugins/:id/launch
Section titled “POST /api/plugins/:id/launch”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:pluginsformanage - 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(formanage) /model_denied - 404
not_found - 409
installation_not_active
Machine-readable schemas
Section titled “Machine-readable schemas”schemas/manifest-v1.schema.json— the manifest, generated from the object core validates every fetched manifest with.schemas/protocol-v1.schema.json— requests, responses, outcomes, limits and secret shapes, generated from the same definitions.
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.