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

Plugin security

What a Dee Wan installation token is, how it is stored and rotated, how a person’s authority reaches a plugin command, what a grant actually bounds, and what someone who takes over a plugin can and cannot do with it.

Every example here is executed against the real routes by backend/test/plugin-docs.test.ts. The ids and secrets are synthetic.

See also protocol-v1.md for the route-by-route surface and authoring.md for the installation flow.

browser ── user session ──► Dee Wan admin Worker
│ control database: installations, token hashes, grants, launch codes
│ tenant database: content, versions, workflow, audit
│ HTTPS + installation bearer token
plugin service ◄──────────────┘
│ its own database, its own clock, its own sessions
└── HTTPS + installation bearer token ──► /api/plugin/v1 ──► the same lifecycle service a person uses

Three properties hold that boundary up:

  1. The plugin API is not the admin API. It lives under /api/plugin/v1, has its own middleware, and shares only the lifecycle service — there is no second implementation of a transition that could drift from the one a person drives.
  2. No plugin code, no plugin markup. Core loads no module, evaluates no script, renders no remote HTML or CSS, and embeds no iframe. A plugin’s only influence on the admin UI is a bounded { id, label } action and a link.
  3. No plugin database access. A plugin gets no Prisma client, no D1 binding, no R2 credentials and no Cloudflare account access. Everything it can read or change goes through five routes.
Shape dwp_ plus 43 URL-safe characters — 256 bits of randomness from crypto.getRandomValues
Given to the plugin Once, in the activation hand-off, over HTTPS to the manifest’s own origin
Stored by core SHA-256 hash only. There is no code path that can print the token again
Sent by the plugin Authorization: Bearer <token>, on every request
Never in A URL, a query parameter, an error body, a log line, an audit entry or a fixture
Scope One installation, which is one plugin on one site

The prefix is there so a leaked string is recognisable as a Dee Wan plugin credential; the entropy is what makes it a credential. A malformed bearer value is rejected on shape before any database read, so a guessing attempt costs an attacker a lookup they never reach.

Rotation is a hand-off, not a reset, and it needs a fresh one-time code from the plugin — otherwise anybody who could reach the admin route could silently replace a credential.

  1. The administrator gets a new activation code from the plugin.
  2. Core mints a new token and sends it, with that code, to the pinned activation_url from the stored manifest. Not to a URL supplied at rotation time.
  3. Only on a 2xx does core swap the hash. If the plugin refuses or cannot be reached, the old token keeps working — a failed rotation must never lock a working installation out.
  4. The previous hash stays valid for at most 10 minutes, so a request already in flight is not killed mid-rotation.
  5. The first request carrying the NEW token ends that overlap immediately. A plugin that adopts its new token promptly narrows its own exposure window.

The activation code is never stored and never logged, on either side.

disabled and revoked both stop the plugin API at the door — before tenant access, before the body is read. revoked is terminal: the hashes are dropped and that token can never be valid again. Uninstall revokes first, and Dee Wan makes no claim about data it cannot see.

A plugin’s management UI is a separate page, and Dee Wan session cookies never leave Dee Wan. The link between the two is a launch code:

  • random, one-time, stored hashed, and valid for 60 seconds;
  • bound to installation, site, user, action and — for a content action — one content instance;
  • exchanged by the plugin’s BACKEND, with its own bearer token, for a delegation record;
  • a code minted for one installation cannot be exchanged by another;
  • the launch response carries Referrer-Policy: no-referrer, and the remote page must load no third-party resource before the exchange.

Core checks, before minting: the requester has a session and access to the site, manage requires site:plugins, and a content action requires read access to that specific item.

The delegation is not a credential. It carries no capability. What it carries is identity — and that is what makes the authority re-read possible:

Every plugin command names a delegation_id, and core re-reads that person’s CURRENT authority from the control database before it writes anything. Authority recorded when a schedule was created is never carried forward.

So all of these refuse, at execution, even though the delegation was valid when it was made: the person lost their role on the site, their account was deactivated, their role no longer carries the publish permission, or the delegation belongs to another installation. A read with delegation_id narrows the offered transitions the same way, and refuses rather than quietly returning a shorter list.

The person who asked no longer holds the authority it needs

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_0009:r1" \
--data '{"kind":"publish","expected_current_version_id":"cdocsversion00000000000001","expected_workflow_revision":0,"delegation_id":"pld_0000000000000000000000000000d1ee"}'
{
"outcome": {
"code": "requester_unauthorized",
"message": "The person who requested this no longer holds the authority it needs.",
"retry": false
}
}

Nothing was written. This is terminal: a plugin must not retry it, and must show its user that the schedule needs a person with the right authority.

The one deliberate exception is a replay. An Idempotency-Key that was already applied returns the first result even if the requester has since lost their authority — the work is already done, and re-answering it is not a new grant of permission.

The site comes from the token and from nothing a caller sends. There is no X-Site-Id on a plugin request, no site in a body, and no database name anywhere: both are refused outright rather than ignored, so a bug in a plugin cannot become a cross-tenant read.

Naming a database

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

Model access is the intersection of a capability and a stored list of content-model ids. Out of scope answers content_not_found — the same answer as “no such content”, so a plugin cannot map the shape of a site by probing.

An id from another site

Terminal window
curl "https://cms.example/api/plugin/v1/content/cotherinstance0000000000001" \
-H "Authorization: Bearer dwp_ExampleTokenNotRealExampleTokenNotRealExamp"
{
"outcome": {
"code": "content_not_found",
"message": "No such content in this installation’s scope.",
"retry": false
}
}

A narrowed grant applies to the very next request:

After an administrator removes the model from the grant

Terminal window
curl "https://cms.example/api/plugin/v1/content/cdocsinstance00000000000001" \
-H "Authorization: Bearer dwp_ExampleTokenNotRealExampleTokenNotRealExamp"
{
"outcome": {
"code": "content_not_found",
"message": "No such content in this installation’s scope.",
"retry": false
}
}

Grants store model ids, so renaming a model keeps the grant and deleting one makes that part of it inert. The administrative UI shows the current slugs beside the ids.

A user cookie is not plugin authentication. Plugin routes read Authorization and nothing else, so a stolen browser session cannot be replayed against the plugin API:

A session cookie on a plugin route

Terminal window
curl "https://cms.example/api/plugin/v1/context" \
-b "__Secure-better-auth.session_token=$SESSION"
{
"outcome": {
"code": "plugin_unauthorized",
"message": "The installation token is missing, unknown, disabled or revoked.",
"retry": false
}
}

The reverse holds too, and is covered by backend/test/plugin-installation.test.ts: a plugin token presented to an administrative route is refused as unauthenticated. Those routes want a user session, the site header, and site:plugins; a plugin has none of the three.

A disabled installation, before any tenant access

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

One answer covers missing, unknown, disabled and revoked. Nothing tells a caller which applies.

Requests are limited per installation — 120 per 60-second window — using the same enforcement primitive as the rest of the product, and answered as the protocol’s own envelope with Retry-After. These are abuse controls. Nothing here is measured, reported or retained as telemetry.

Over the limit

Terminal window
curl "https://cms.example/api/plugin/v1/context" \
-H "Authorization: Bearer dwp_ExampleTokenNotRealExampleTokenNotRealExamp"
{
"outcome": {
"code": "rate_limited",
"message": "Too many requests for this installation.",
"retry": true
}
}

Respect Retry-After, and refuse your own record rather than waiting if the delay exceeds a maximum you chose in advance.

Core calls a plugin exactly twice: the manifest fetch and the activation or rotation hand-off. Both are bounded and pinned.

  • HTTPS only. Credentials in the URL, fragments, loopback, link-local, private and reserved destinations are all refused, as are IP literals and names with no dot.
  • Redirects are refused, not followed. A 3xx is remote_redirect_refused.
  • The response body is capped at 32 KB and the call at 5 seconds.
  • Every URL — manifest, base, activation, management — must share one origin, so activation cannot be pointed somewhere the reviewed manifest did not name.
  • The remote response is parsed as untrusted input against the manifest schema. An unknown key is a refusal, not something to ignore.
  • Workers cannot resolve DNS before fetch, so this is what the URL itself names. A public name that resolves to a private address is outside what the check can see, and is stated as such in url-policy.ts.
  • Local HTTP is possible only when ENVIRONMENT=development and the exact origin is listed in PLUGIN_DEV_ORIGINS. No wildcard, no private-range shortcut, no production fallback.

A plugin is an actor, not a user. No fake user is created, and no installation id is written into a user foreign key.

A plugin transition records the installation id, the accepted plugin id, name and version, the protocol version, a digest of the idempotency key — never the key itself — the resulting state and publication pointer, and the email of the person whose delegation authorised it. Installation identity lives in the control database and workflow audit in the tenant database, with no cross-database foreign key: the tenant event stores an immutable snapshot. That is also why uninstall cannot erase attribution.

Administrative acts — install, grant change, manifest acceptance, rotation, disable, enable, uninstall — are written to the admin audit ledger by the acting user, and refuse (503 audit_unavailable) rather than mutating unaudited.

If a plugin is fully compromised, an attacker holds one installation token for one site, and can:

  • read the narrow content summary for the granted models — ids, label, slug, version ids, workflow state and boundary transitions;
  • publish or unpublish content in those models, but only by naming a still-valid delegation_id whose person still holds the authority, pinning the exact version, and matching the current workflow revision;
  • open the exchange route with codes it already holds.

It cannot:

  • reach another site, or another model, or another installation’s delegations;
  • read or write field values, media, users, roles, sessions, settings, models or audit;
  • run code, render markup, or reach the database, R2 or any Cloudflare credential;
  • publish a newer draft than the one pinned, traverse a changed workflow, override dependent content, or use any override route — there are none in protocol v1;
  • act for a person who has lost their authority, or forge one: delegation_id is a lookup, not an assertion;
  • replay a mutation into a second effect, or hide one from the audit trail.

If a launch code leaks — a referrer, a log, a shared URL — it is one-time, 60 seconds old at most, stored hashed, and useless without the installation token that exchanges it.

If an administrator is compromised, they can install a plugin and grant it what they hold. That is the real risk in this design, and it is why site:plugins is a separate permission seeded only into the administrator role, is never implied by publish permission, and why every step of an install is audited and every grant is chosen by hand from an explicit manifest request.

What Dee Wan cannot defend against is the plugin’s own operation: its uptime, its clock, its retry policy, what it keeps in its database, and whether it deletes your data when you ask. The plugin’s own documentation has to answer those, and an administrator has to read it before granting anything. See plugins/scheduling/README.md for what that looks like.