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.
The contract
Section titled “The contract”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).
Prove it against a fake core
Section titled “Prove it against a fake core”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_unauthorizedotherwise; - answers
/context,/content/:idand/launch/exchangewith protocol-shaped bodies, including theworkflowblock and the transitions a delegation permits; - checks the pin and the workflow revision on a command, and answers
scheduled_target_staleorworkflow_conflictwhen they no longer hold; - keeps an idempotency record per key, so an exact replay answers
idempotent_replayand a reused key with different input answersidempotency_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, aRetry-Afterheader, 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.
cd plugins/schedulingnpm test # vitestnpm run typecheckValidate against the published schemas
Section titled “Validate against the published schemas”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-readableoutcomestable (HTTP status and retry flag per code),capabilities,installation_statesandlimits.
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:
npm run docs:pluginsbackend/test/plugin-docs.test.ts fails if the checked-in copies drift, so the command is only ever
needed after changing the protocol.
A local two-service setup
Section titled “A local two-service setup”Both services on one machine, with no Cloudflare account and no real credential.
Dee Wan. From the repository root:
node scripts/install-dependencies.mjsnpm run local:migratenpm run local:seed # prints the sign-in it createsnpm 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=developmentPLUGIN_DEV_ORIGINS=http://localhost:8788BACKEND_URL=http://localhost:8787PLUGIN_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:
cd plugins/schedulingnpx wrangler d1 migrations apply dee-wan-scheduling --localnpx wrangler dev --port 8788 --test-scheduledThen, 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-/Securesession cookie needs HTTPS orlocalhostexactly — not127.0.0.1. - A cron trigger does not fire in
wrangler devunless you ask it to. With--test-scheduled,curl "http://localhost:8788/__scheduled?cron=*+*+*+*+*"runs one tick.
Failure fixtures
Section titled “Failure fixtures”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.
What core’s own suite covers
Section titled “What core’s own suite covers”Run from backend/:
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 |
Gaps, stated plainly
Section titled “Gaps, stated plainly”- 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 yetnpm installa 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.