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

Testing a plugin

What a plugin has to prove before it is allowed near real content, how to prove it against a fake core in your own suite, how to run both services on one machine, and how to produce each failure on purpose.

See protocol-v1.md for the surface under test and security.md for the invariants the cases below exist to protect.

These are the cases a plugin must have. Each one is a property of the protocol rather than of any particular plugin, so each one belongs in your suite whatever your plugin does.

Case What must happen
Activation A one-time code is consumed exactly once; an unknown, used or expired code is refused; the raw token is stored encrypted and never logged
Rotation kind: rotate replaces the stored token; the old one stops being used; a refused rotation leaves the old token in place
Launch A code is exchanged once, through the plugin’s backend; a second exchange fails; a code minted for another installation fails
Session The plugin’s own browser session is created at exchange, is its own cookie, and carries no Dee Wan credential
Pinning Publish pins current_version_id, unpublish pins published_version_id, both pin workflow.revision, and the chosen (kind, to) came from the delegated read
Idempotency The key is derived from the record and its revision, is identical on every retry, and changes when the record is rescheduled
At most once Two workers cannot execute one record: a lease with compare-and-set, plus the key as core’s second guard
Replay idempotent_replay settles as success, not as a new effect
Terminal refusals invalid_request, plugin_unauthorized, capability_refused, requester_unauthorized, content_not_found, scheduled_target_stale, workflow_conflict, dependent_content, idempotency_key_reused, transition_unavailable and transition_refused each settle without a retry, under their own name
Retries rate_limited honours Retry-After within a maximum; temporary_failure and transport failures back off with jitter; both stop at an attempt cap AND an age cap
Untrusted responses A status that disagrees with outcome.code, a non-envelope body, a 3xx or an unknown status is a terminal refusal, never a success and never an infinite retry
Isolation Every table and every query is scoped by installation; one installation cannot read, cancel or execute another’s records
Revocation After uninstall, pending work refuses with plugin_unauthorized and the plugin says so rather than retrying
Secrets No test fixture, log line, error body or screenshot contains a token, a launch code or an activation code

The reference plugin’s suite is a worked example of every row: plugins/scheduling/test/ (activation, launch, schedules, runner, time, isolation, flow).

Do not point your suite at a running Dee Wan. A fake core is deterministic, can inject failures a real one will not produce on demand, and runs in milliseconds.

plugins/scheduling/test/helpers/fake-core.ts is one, and is the intended starting point for a plugin in another repository. It is a fetch implementation that:

  • authenticates a bearer token per installation, and answers plugin_unauthorized otherwise;
  • answers /context, /content/:id and /launch/exchange with protocol-shaped bodies, including the workflow block and the transitions a delegation permits;
  • checks the pin and the workflow revision on a command, and answers scheduled_target_stale or workflow_conflict when they no longer hold;
  • keeps an idempotency record per key, so an exact replay answers idempotent_replay and a reused key with different input answers idempotency_key_reused;
  • consumes a launch code once and refuses it for another installation;
  • injects failures on demand — a network error, any status, any outcome.code, a Retry-After header, or a deliberately malformed body;
  • records every command it received, so a test can assert that nothing was sent twice.

Two things it is NOT: it is not core, and its message strings are placeholders. Assert on outcome.code and HTTP status, never on the human message — core’s messages are for people and are not stable.

Alongside it, plugins/scheduling/test/helpers/d1.ts is a 50-line better-sqlite3 shim with D1’s prepare/bind/first/all/run/batch surface, so the plugin’s real SQL — including its compare-and-set claims and its partial unique index — runs in the suite.

Terminal window
cd plugins/scheduling
npm test # vitest
npm run typecheck

Two JSON Schema documents are generated from the definitions core validates with, and checked in:

  • schemas/manifest-v1.schema.json — validate your manifest in your own CI, before an administrator ever fetches it.
  • schemas/protocol-v1.schema.json — request and response shapes under $defs, plus the machine-readable outcomes table (HTTP status and retry flag per code), capabilities, installation_states and limits.

The outcomes table is the one worth wiring in: a plugin that keeps its own copy — the reference plugin does, in plugins/scheduling/src/protocol.ts, because it cannot import from core — should assert that its copy equals the published one. backend/test/plugin-contract.test.ts does exactly that for the reference plugin, and fails if the two ever disagree on a status or a retry flag.

Regenerate both documents, and protocol-v1.md, with:

Terminal window
npm run docs:plugins

backend/test/plugin-docs.test.ts fails if the checked-in copies drift, so the command is only ever needed after changing the protocol.

Both services on one machine, with no Cloudflare account and no real credential.

Dee Wan. From the repository root:

Terminal window
node scripts/install-dependencies.mjs
npm run local:migrate
npm run local:seed # prints the sign-in it creates
npm run local:api # admin Worker on http://localhost:8787 (leave running)
npm run local:admin # admin UI on http://localhost:5173 (leave running)

The admin Worker needs three settings before it will talk to a plugin on http://, because every plugin URL is HTTPS-only in production. In backend/.dev.vars:

ENVIRONMENT=development
PLUGIN_DEV_ORIGINS=http://localhost:8788
BACKEND_URL=http://localhost:8787

PLUGIN_DEV_ORIGINS is an exact-origin allowlist — no wildcard, no private-range shortcut — and it is read only when ENVIRONMENT=development. BACKEND_URL is what core sends as dee_wan_base_url, so your plugin knows where to call back.

Your plugin. Serve it on the origin you just allowed, e.g. http://localhost:8788, and accept that base URL in return: the reference plugin has its own ENVIRONMENT=development and DEE_WAN_DEV_ORIGINS=http://localhost:8787 for exactly the same reason. For a Worker:

Terminal window
cd plugins/scheduling
npx wrangler d1 migrations apply dee-wan-scheduling --local
npx wrangler dev --port 8788 --test-scheduled

Then, in the admin UI: Settings → Plugins → install, with http://localhost:8788/dee-wan/manifest.json and a one-time code your plugin minted. Grant a capability set and pick the models. Open a content item and use the action your manifest declared.

Do the installation through the UI rather than by hand: the administrative routes want a real session, the site header and site:plugins, and the UI already has all three.

Two local gotchas, both real:

  • A __Host-/Secure session cookie needs HTTPS or localhost exactly — not 127.0.0.1.
  • A cron trigger does not fire in wrangler dev unless you ask it to. With --test-scheduled, curl "http://localhost:8788/__scheduled?cron=*+*+*+*+*" runs one tick.

How to produce each outcome on purpose. The left column is what your test does to a fake core; the right column is how the same state arises against a real one, when you want to confirm it end to end.

Outcome Against a fake core Against a local Dee Wan
invalid_request Send an unknown body key, or omit Idempotency-Key Same
plugin_unauthorized Use an unknown token Disable or uninstall the installation, then let a record come due
capability_refused Remove the capability from the installation Narrow the grant in Settings → Plugins
requester_unauthorized Mark the delegation’s person as no longer permitted Remove the person’s role, or deactivate them, after a record is created
content_not_found Ask for an unknown instance Use an id from another site, or remove the model from the grant
scheduled_target_stale Change the content’s current_version_id Edit and save the draft after the record is created
workflow_conflict Bump the content’s revision Publish or unpublish the item by hand after the record is created
dependent_content Answer dependent_content by injection Relate another published item to it, then try to unpublish
idempotency_key_reused Reuse a key with a different body Same
transition_unavailable Ask for a to the state does not offer Ask to unpublish something that is not published
rate_limited Inject 429 with a Retry-After, in seconds and as an HTTP date 120 requests in a minute for one installation
temporary_failure Inject 500, 502, 503 and 504 Hard to force; trust the injected case
transport failure Inject a network error, and a timeout Stop the Dee Wan Worker mid-run
untrusted response Inject a 200 whose outcome.code is a 409 code, a non-JSON body, and a 3xx Not producible; that is the point of injecting it
lease recovery Leave a record running with an expired lease Kill the plugin mid-command and let the next tick reclaim it

Assert two things on every refusal: the record settled under the right name, and nothing was sent twice — the recorded-command list is what proves the second.

Run from backend/:

Terminal window
npx vitest run test/plugin-manifest.test.ts test/plugin-installation.test.ts \
test/plugin-transition.test.ts test/plugin-schema.test.ts test/plugin-contract.test.ts \
test/plugin-docs.test.ts
File What it pins
plugin-manifest.test.ts The manifest parser, the URL policy, the body cap, token shape and hashing
plugin-installation.test.ts Install, activation failure, redirect refusal, state machine, rotation overlap and expiry, grant narrowing, request boundary, rate limit, launch single use and expiry
plugin-transition.test.ts Group-wide publication, plugin attribution, replay, concurrency, authority re-read, and every named terminal refusal
plugin-schema.test.ts The published schemas agree with the hand-rolled runtime checks, case by case
plugin-contract.test.ts The reference plugin’s copy of the protocol agrees with core’s
plugin-docs.test.ts These four documents: the generated file matches, and every example is what the live routes answer
  • There is no published contract package. SPEC-PLUGINS.md §14 records the same gap. What exists today is the two JSON Schemas, the fake core in plugins/scheduling/test/helpers/, and the core-side test that keeps the reference plugin’s protocol table honest. A plugin in another repository copies the fake core and validates against the schemas; it cannot yet npm install a conformance suite.
  • The cross-service acceptance run in §16 is not automated here. The reference plugin’s suite and core’s suite each use a double for the other side. Running both processes against each other is the local setup above, driven by hand.
  • No browser journey is included in these files. Screen-level gates for the core Plugins UI are repository policy (SPEC.md §12 D-S) and belong to the orchestrator, not to a plugin author.