Dee Wan CMS

Dee Wan CMS (ดีหวาน 🇹🇭) is a multi-tenant, multilingual headless CMS that runs entirely on Cloudflare’s edge: a Hono Worker over D1 for the API, and a SvelteKit admin on Pages.
It is a CMS first: model content, author it, publish it, manage it. What separates it from other headless CMSs is what it can hand you at the end — Generate standalone backend turns a site into an adopter-owned repository with a normalized Prisma schema, reviewable append-only migrations, generated routes using familiar Prisma model/query semantics, its own database, deployment and credentials, and no runtime dependency on Dee Wan. Developer-owned code survives regeneration, and detaching from Dee Wan entirely is a supported outcome rather than an escape hatch.
Supporting strengths: Cloudflare-native deployment, a familiar Prisma development model, multi-site and multilingual operation, independent repositories and infrastructure, and safe schema evolution. Import/export, optional editorial workflow, AI assistance and portfolio migration tooling are supporting capabilities — none of them is the product definition.
Two delivery surfaces, and they are not the same thing. The CMS public API is the live publishing surface and updates the moment content is published. The generated backend is an independent schema-and-content release: later CMS changes reach it only through an explicit regeneration and deployment. It is not continuously synchronized.
Pre-MVP: the 1.0 codebase is not a released product. Local coverage is substantial, but real-Cloudflare, final browser/visual and four-site migration acceptance gates remain. See the release gates (
SPEC.md, in the repository) and 1.0 product boundary.
Content types are not hardcoded. You define content models and their fields in a model builder UI, and the CMS stores them as EAV data — so adding a “Recipe” type with the fields you want takes no code and no migration.
Database setup starts from one initial Prisma migration and its generated D1 mirror. The tenant bootstrap contains the current per-site schema. Future releases append migrations. See database migration instructions.
What it does
Section titled “What it does”-
Model builder — define content models and fields (text, rich text, number, boolean, select, date, media, JSON, relation, component) through the UI. Applying a change is diffed first, and edits that would invalidate existing content are flagged before they run.
-
Multi-site — one deployment serves many sites. Every admin request carries
X-Site-Id, and the data layer force-injectssite_idinto every query; the API refuses to infer a tenant rather than risk a cross-tenant read. -
Multilingual — configure languages in the UI; content is stored per language.
-
Versioning and publishing — content revisions with publish/unpublish. Editorial review is an optional per-site mode, off by default: a new site publishes straight from draft, and no review control is rendered until someone turns it on in Settings → General.
-
AI assistance — generate and translate copy, and generate images, through a server-side proxy. Provider keys never reach the browser.
-
Media — upload to R2 or Cloudflare Images, with automatic webp/avif conversion.
-
Auth — self-hosted sessions: a single-use claim link creates the first administrator, passwords are set by their owner, and every request carries a database session. Cloudflare Access is optional perimeter hardening, off by default. See SPEC-AUTH.md.
-
Remote plugins — a plugin is a separately deployed remote service. It runs no code inside Dee Wan: no module is loaded, no JavaScript reaches the admin UI, no database binding is handed out. It speaks one versioned HTTP protocol, under a per-site grant chosen by hand in Settings → Plugins, and can read a narrow content summary and ask for one conditional publish/unpublish. Scheduled publishing is the Scheduling plugin, not a core feature.
Developer documentation: writing a plugin · protocol v1 · security · testing. Machine-readable manifest and protocol schemas are generated from the definitions the routes validate with. The platform specification is SPEC-PLUGINS.md; the reference plugin is
plugins/scheduling/.
The built-in author role is owner-restricted, including workflow, restore and publish operations.
Translation-group edits use the primary instance’s owner. Full behavior and migration notes are in
SPEC-ACCESS.md §4.4.
Architecture
Section titled “Architecture”| Piece | Stack |
|---|---|
| Admin UI | SvelteKit (SPA, ssr=false), shadcn-svelte, TipTap — on Cloudflare Pages |
| API | Hono on a Cloudflare Worker |
| Database | D1, accessed through Prisma (@prisma/adapter-d1) |
| Media | R2 or Cloudflare Images |
| Auth | Better Auth sessions; Cloudflare Access optional as perimeter |
| Inference | DeepInfra |
The repo is an npm workspace. Shared libraries live in packages/ as git
submodules — clone with --recurse-submodules.
backend/ Hono Worker: API, auth, scoped CRUD, AI proxy, Prisma schemafrontend/ SvelteKit adminpackages/ schema-parser, prisma-guard, prisma-rbac, eav-to-prisma (submodules)plugins/ remote plugins built against protocol v1 (Scheduling); not workspacesscripts/ setup, deploy, migrate, health, teardownTry it locally, with no Cloudflare account
Section titled “Try it locally, with no Cloudflare account”Everything below this section provisions real Cloudflare resources. This one does not: it runs the whole product against a local D1 file on your machine, with no account, no API token and no DNS.
node scripts/install-dependencies.mjs # locked tree, scripts off, audited, then rebuiltnpm run local:migrate # apply the schema to the local D1 mirrornpm run local:api # admin Worker on http://localhost:8787 (leave running)npm run local:seed # the fixture rig, then a corpus that looks like real sitesnpm run local:admin # admin UI on http://localhost:5173 (leave running)local:seed prints the sign-in it just created — admin@localhost.test, password
local-dev-password-1234. It is idempotent: run it again and it reports what already exists rather
than making a second copy.
It seeds two things, and they are separate commands if you want only one of them:
| Command | What it makes |
|---|---|
npm run local:seed:fixture |
The Local Dev Site: first admin, an Article model, three drafts and one demo Page. This is what the real-backend e2e specs assert against, so its content does not move. |
npm run local:seed:corpus |
Two sites that read like real ones — Meridian Review, a magazine with an eighteen-month back catalogue, and Fern & Ash, a small studio site. |
The corpus deploys the shipped Blog System preset (Post, Article, Category, Keyword and six components), so the models are the ones a user gets from the model builder rather than a schema invented for seeding. It writes about fifty items across published, draft, in-review, approved and scheduled states, wires categories, keywords and related articles, uploads images with alt text in both locales, and translates two articles into German. Both sites are fiction and say so; no real organisation, person or building appears in them.
Dates are anchored to the earliest creation timestamp of the two corpus sites in the same local D1 target. A fresh corpus places its newest item five days before that day and preserves the authored intervals across both sites. Later reruns and missing-item repairs reuse that anchor; they do not move existing content forward. Timestamp initialization resumes after an interrupted pass and leaves later update/version timestamps alone once the instance is initialized. Existing authored article and post values are retained; missing relations may still be repaired. Unreadable or mismatched site anchors stop the run. This does not claim isolation from simultaneous manual edits.
Images come from backend/scripts/fixtures/media/ when that directory has any — drop photographs in
and the corpus uses them. With nothing there it generates deterministic duotone placeholders, which
are honest placeholders rather than photographs.
Then, in the admin UI: open Content, edit one of the seeded articles, and publish it. To read what you published the way a website would:
npm run local:public # public read Worker on http://127.0.0.1:8788 (leave running)curl -H "Host: localhost" http://127.0.0.1:8788/v1/articleThe Host header is not decoration. The public Worker resolves the site from Host and from
nothing else, so a request without it answers 404 — that is the same rule a deployment runs under.
The public cache is off until you turn it on. A deployment purges the edge cache tag on
publish, which needs CF_PURGE_ZONE_ID and a CF_PURGE_API_TOKEN with Zone → Cache Purge. A
default installation holds neither, so it ships with PUBLIC_CACHE_MAX_AGE=0 and the public worker
answers Cache-Control: no-store: nothing is stored and nothing is read, so a publish, update,
unpublish or delete is visible on the next read. Zero turns the cache off in both directions on
purpose — an entry outlives the setting that stored it, so a worker that stopped writing but kept
reading would serve the previous lifetime’s stale answers after being reconfigured to stop.
no-store rather than max-age=0 on purpose. A zone’s Browser Cache TTL rewrites the browser
directive whenever the origin’s is lower than the setting, so max-age=0 leaves the edge as
whatever the zone says — four hours on a zone left at its default, measured against an origin
asking for sixty seconds. Raising PUBLIC_CACHE_MAX_AGE opts into the edge cache and into that
zone setting for browsers: a tag purge clears Cloudflare and can never clear anybody’s browser, so
set the zone’s Browser Cache TTL to Respect Existing Headers before you rely on it.
From here, Settings → Data imports and exports content, and the dashboard’s Generate standalone
backend is the graduation path. Generating a backend writes a project; deploying it is the part
that needs an account.
This rig uses backend/wrangler.dev.jsonc, which is tracked and carries no credential and no
account id. It never touches Cloudflare.
Prerequisites
Section titled “Prerequisites”- A Cloudflare account (Workers, D1, Pages, and R2 or Cloudflare Images)
- Node.js 22.14+ or 24.10+ — even-numbered lines only (the repo ships an
.nvmrc;nvm usepicks it up) - npm exactly as
package.json#packageManagerdeclares (11.16.0), which is the npm the.nvmrcNode ships. Install withnode scripts/install-dependencies.mjs, never a barenpm installornpm ci: it refuses any other npm, extracts the locked tree with scripts disabled, refuses every dependency lifecycle scriptpackage.json#allowScriptsdoes not decide by exact version, and only then runsnpm rebuildfor the approved ones - A DeepInfra API key, if you want the AI features
Cloudflare Access (Zero Trust) is not required. Setup asks whether to enable it as an extra perimeter; declining it — the default — leaves session auth as the login path.
Quickstart
Section titled “Quickstart”Clone, then run the installer for the admin CMS. Public website delivery needs the additional configuration in the next section.
git clone --recurse-submodules https://github.com/your-username/dee-wan-cms.gitcd dee-wan-cmsnode scripts/install-dependencies.mjs
npm run setup # provisions D1 and media storage; offers DNS and optional Accessnpm run deploy # builds and deploys the admin worker and admin UInpm run health # checks the deployment answersThen open the claim link setup printed and set a password. That creates the
superadmin, and you are in. The link is single-use and expires in 15 minutes; if
you miss it, npm run claim:link -- --remote --email you@example.com mints
another.
setup offers to apply the database schema as part of the run (default yes), so
a fresh install needs no separate migrate step.
A fresh clone needs no manual build steps. packages/*/dist and the Prisma
client are build outputs, so a clone has neither — and setup, deploy and
migrate:d1 each build what is missing before they do anything else.
npm run doctor is an optional read-only diagnostic. On a fresh clone it reports the missing
generated packages and Prisma client and exits non-zero. Run it after setup, or build those outputs
first with npm run build:packages && npm run prisma:generate.
Serving published content (the public read worker)
Section titled “Serving published content (the public read worker)”Published content is served by a separate public read worker, with its own
backend/wrangler.public.jsonc.
npm run setup writes that file for you. It does it at the point where every value it needs is
settled — the account, the zone and the database id — and it never overwrites an existing one. If
any of those cannot be derived, setup refuses by name rather than guessing a route or a database
id, and says so; that is the case where you prepare it by hand:
cp backend/wrangler.public.example.jsonc backend/wrangler.public.jsonc# edit backend/wrangler.public.jsonc — account id, D1 id, routenpm run deploydeploy refuses to run when that file is absent, because a deployment with no public worker serves
published content to nothing and should not be mistaken for a finished one. An installation that
genuinely wants only the admin UI says so:
npm run deploy -- --admin-onlywhich deploys the admin side and reports the run as admin-only rather than as complete.
Deploying an alternate install. --config selects the admin worker’s wrangler config; the public
worker has its own, and one filename does not imply the other. A non-default --config therefore
requires --public-config:
npm run deploy -- --config wrangler.v3.jsonc --public-config wrangler.v3.public.jsoncWithout it the deploy refuses, rather than pairing your alternate admin worker with the default
install’s public worker. Both configs are read and their account_id values compared before anything
is deployed.
Credentials
Section titled “Credentials”setup needs a Cloudflare API token. It prompts for one if you have not set
it, and prints the exact permission list with a reason for each:
export CLOUDFLARE_API_TOKEN=… # optional — skips the promptexport CLOUDFLARE_ACCOUNT_ID=… # optional — skips the account pickernpm run doctor prints the same list in its fix line when the token is missing
or under-scoped. The two come from one source (scripts/cloudflare-token-scopes.ts),
so a token built from what doctor asked for is a token setup accepts.
Nothing else needs an environment variable. Application secrets are set with
wrangler secret put (below), never in a file.
Configure and provision
Section titled “Configure and provision”npm run setup is interactive. It provisions the D1 database and the media
bucket, offers to create DNS records, sets up a Cloudflare Access application
only when you opt in, then writes backend/wrangler.jsonc.
wrangler.jsonc is the configuration file — there is no wrangler.toml.
Secrets
Section titled “Secrets”Secrets are never stored in wrangler.jsonc or in the database. Set them with
wrangler secret put, from the backend/ directory:
npx wrangler secret put DEEPINFRA_API_KEY # AI text + image generationnpx wrangler secret put CF_IMAGES_API_TOKEN # only if STORAGE_PROVIDER=cf-imagesnpx wrangler secret put CF_PURGE_API_TOKEN # only if caching public reads; see PUBLIC_CACHE_MAX_AGEDeepInfra is the only supported inference provider. Chat, images, and the
structured-generation engine all run through it on one key. The admin UI never
calls a provider directly — everything goes through /api/ai/* on the Worker,
which holds the key and enforces a per-site hourly quota.
Migrate
Section titled “Migrate”A fresh install needs none of this — setup applies the schema. These are for
afterwards, when the schema changes:
npm run migrate:d1 # apply pending migrations to the deployed databasenpm run migrate:d1:local # …to the local mirrornpm run migrate:sync # regenerate backend/migrations-wrangler/ from Prismanpm run migrate:check # verify the two are in step (CI)
npm run migrate:d1:local -- --create-migration # author one from schema.prismaApplying and authoring are separate. Without --create-migration these commands
only apply what already exists — they will not write a migration from a drifting
schema.prisma, which is how a DROP TABLE ends up in the history unnoticed.
Migrations are Prisma migrations, replayed into D1 from
backend/migrations-wrangler/. There is no schema.sql. prisma/migrations/ is
the source of truth and the wrangler directory is generated from it — write a
Prisma migration, then run migrate:sync, and commit both halves.
Bringing content in, and taking it out
Section titled “Bringing content in, and taking it out”Both live in the admin under Settings → Data — no terminal, no JSON editing.
- In: a Strapi export archive (
.tar/.tar.gz, verified against Strapi 4.25.9 and 5.51.1), or a Dee Wan site copy (.json) exported from another install of this CMS. The wizard inspects the archive, shows what it found, maps types and languages, checks the plan, then runs it. Nothing is overwritten: an import creates content or skips it, and the whole job can be undone. - Out: Download site copy writes one file holding this site’s models, fields, languages, workflows, settings, current content, the published version wherever it differs, and the exact bytes of every image, including an earlier copy a carried version still shows. When any of those bytes cannot be read exactly from R2 or Cloudflare Images, the download is refused by name instead of writing a partial copy. That file is what the importer reads back.
- Spreadsheets update rows that already exist. That one starts from a content list for a single model, not from this wizard.
Archive import needs an IMPORT_BUCKET. npm run setup offers to provision it,
and npm run doctor reports whether it is enabled — everything else works
without it.
Status is honest in SPEC-IMPORT.md: implemented in the working tree, not yet 1.0-complete.
Run locally
Section titled “Run locally”npm run local:api # admin Worker on http://localhost:8787, tracked dev confignpm run local:admin # admin UI on http://localhost:5173npm run local:public # public read Worker on http://127.0.0.1:8788local:api is wrangler dev -c wrangler.dev.jsonc — the tracked local config, which a fresh clone
has. backend/wrangler.jsonc is written by setup and is gitignored, so nothing in the local flow
depends on it; to run an installed configuration instead, use npm run dev:install --workspace backend.
local:admin passes PUBLIC_BACKEND_URL on the command line, so the frontend needs no .env for
this path. A deployment still reads it from frontend/.env.
Deploy
Section titled “Deploy”npm run deploy # backend Worker + frontend Pages project (+ public worker when configured)npm run health # post-deploy checkshealth runs the same checks with or without Access; with Access enabled it
also verifies the perimeter using the service-token pair.
Setup prints a single-use claim URL at the end. Open it to set a password and create the superadmin — see below.
Cloudflare Access no longer creates anybody. It authenticates users who already
exist (which is what the migration window needs), and an Access identity with no
account gets user_not_provisioned. BOOTSTRAP_ADMIN_EMAIL names the address
the claim link is bound to; it grants nothing on its own.
Signing in, and getting back in
Section titled “Signing in, and getting back in”Registration is closed. There is no public sign-up and no public “forgot my password” form — an account exists because someone with authority created it. Passwords are set by their owner through a single-use link, never chosen by whoever did the inviting.
Every link below is single-use, expires in 15 minutes, and is shown exactly once. Only its hash is stored, so nothing can print it again; if you lose one, mint another, which invalidates the first. Minimum password length is 12 characters, enforced by the server.
Claiming a new installation
Section titled “Claiming a new installation”npm run setup prints a claim URL at the end of a fresh install. Open it, set a
password, and the superadmin account is created. The address it belongs to comes
from BOOTSTRAP_ADMIN_EMAIL, which must name exactly one address.
If the link expires before you use it:
npm run claim:link -- --remote --email you@example.comSomeone forgot their password
Section titled “Someone forgot their password”A superadmin generates a link from the People list and delivers it however they like — chat, phone, in person. Nothing is emailed, because a default install has no mail provider configured.
The last superadmin is locked out
Section titled “The last superadmin is locked out”This is the one case nobody else can fix, so it needs deployment credentials:
npm run claim:link -- --remote --email locked-out@example.comIt refuses unless that address is the only active superadmin. If there are two, the other one should mint a link from the People list instead — that path needs no Cloudflare access at all.
Development
Section titled “Development”cd backend && npm test # Vitest, against a local SQLite databasecd frontend && npm test # Vitestcd frontend && npx svelte-check # type checknpm run submodules:check # fail if a submodule has uncommitted workEvery command in one table
Section titled “Every command in one table”| Command | What it does |
|---|---|
npm run local:migrate |
Local only, no credential. Applies the schema to the local D1 mirror through backend/wrangler.dev.jsonc. Reads --local before it reads any credential, so it runs on a machine that has never seen a Cloudflare token. |
npm run local:api |
Admin Worker on the tracked local config. Nothing it touches leaves the machine. |
npm run local:public |
Public read Worker against the same local D1, from a throwaway config outside the repository. Resolve a site with -H "Host: localhost". |
npm run local:seed |
The fixture rig plus the realistic corpus, then prints the sign-in. Idempotent. |
npm run local:seed:fixture |
Just the fixture rig: first admin, one site, an Article model, three drafts and the demo Page. What the real-backend e2e specs assert against. |
npm run local:seed:corpus |
Just the corpus: two fictional sites with real-shaped content, media, translations and backdated timestamps. Needs the fixture rig’s admin to exist first. |
npm run local:admin |
Admin UI against the local Worker, with PUBLIC_BACKEND_URL supplied on the command line. |
npm run test:boot |
Starts both Workers under the pinned workerd and fails if either refuses. The only check that sees an invalid entrypoint export. |
npm run doctor |
Preflight. Changes nothing, names what is missing and how to fix it. |
npm run setup |
Provisions Cloudflare resources, offers to apply the schema, prints a claim link. |
npm run plan |
Operator, read-only. Prints every step an install would perform and the state each one is already in, deriving all of it from the config and ledger on disk. Touches no network, creates nothing, costs nothing. Add -- --json for a machine-readable plan with a planVersion. |
npm run install:cms |
Operator, mutating. The installer addressed by verb — install:cms plan, apply, resume, doctor, verify, teardown. apply and resume create billable Cloudflare resources; the other verbs do not. Exit codes distinguish “incomplete” from “broken” from “no credentials”, so CI can branch on them. |
npm run install:web |
Operator, mutating. The same installer with a browser interface, served from localhost on this machine so the Cloudflare token stays where it already lives. Locked to one caller by a single-use secret. A four-step wizard — Site (where it lives), Cloudflare (connect, choose the account, D1 database plan), Administrator (the one address the claim link is bound to), Install (a review of planned addresses and what will be created, with infrastructure names behind one “Customize infrastructure” disclosure). Continue saves each step; import infrastructure is off unless chosen. Writes what you decide into backend/wrangler.jsonc without disturbing its comments, asks Cloudflare for exactly the access those settings need, and refuses to start a run against settings you have not reviewed. Drives the same catalogue plan prints. |
npm run install:web:dry-run |
Reviewer, non-mutating. The same server and page as install:web for UI/UX review, with every external effect replaced: config and ledger live in a temporary folder removed on Ctrl-C, account discovery and the install run are scripted, and “Create administrator” opens a local notice instead of the configured admin address. Cloudflare sign-in completes when you open the sign-in link printed at start (it shows the real “Connected” page), or on its own with --connect-ms <n>; do not press Authorise, which opens Cloudflare with a placeholder client id. The run records the real ledger keys, so the plan and log move as they do in a real install. --outcome succeeded|incomplete|failed decides the first run only (failed stops at the health check, incomplete before the claim link) and a retry or resume then succeeds. Also --accounts one|several|none|unreadable, --cloudflare oauth|unavailable, --step-ms, --credential-seconds (60 or less is expired at once, because the installer drops a credential 60 seconds early). Port 8978 unless DEEWAN_INSTALL_PORT is set. |
npm run docs:api |
Contributor, local files only. Regenerates docs/PUBLIC-API.md from backend/src/public/api-docs.ts. Writes one file in the repo and nothing else; CI verifies the committed copy rather than rewriting it. |
npm run docs:plugins |
Contributor, local files only. Regenerates docs/plugins/protocol-v1.md and the two JSON Schemas under docs/plugins/schemas/ from backend/src/lib/plugins/. Writes three files in the repo and nothing else. backend/test/plugin-docs.test.ts verifies the committed copies and executes every example in the plugin documents against the real routes, so drift fails a test rather than misleading a plugin author. |
npm run provision:site -- --site <id|slug> |
Operator, mutating. Gives one site its own D1 database, in eight ledgered steps: create → migrate → seed → bind → deploy → preflight → activate → smoke. preflight asks what does not need the router — both deployed Workers bind this exact database UUID, read from the account rather than from this checkout, and the tenant answers a direct query — because registry.ts refuses a site whose binding is not active and a request made before activation would be measuring the refusal. smoke makes the real requests, once activation has made them answerable. Creates a billable resource and redeploys both Workers. Add --plan to print the sequence and what is already done — read-only, though it does READ the control database over the network, unlike npm run plan — or --repair to re-check the steps the ledger calls finished; a repair never re-runs create, migrate or seed against a tenant that is already in service, and a repair whose probes could not answer refuses without running anything rather than reporting success from rows nobody re-checked. A site that has once completed provisioning is recorded permanently, and no later failure may delete its database. Safe to run twice; unknown arguments are refused. Set DEE_WAN_ADMIN_COOKIE so smoke can prove admin-side routing; without it the run stops rather than claiming a check it did not make. If a previous run crashed holding the lease, recover it with --declare-dead <owner> --declared-by <who> --evidence <what> — all three parts, because a recovery nobody can attribute is one nobody can review. |
curl -H "X-Site-Id: <id>" -b "<session cookie>" <admin>/api/models/public-client.ts |
Emits a dependency-free TypeScript client for that site’s public read API — envelope, a type per model, a locale union, query types whose relation/tag keys are the model’s real relation fields, and methods named for the site’s models. Read-only. A private model is included with its methods gated on a site API token; an unknown field type is emitted as unknown, never any. Regenerate after a model change — nothing watches for drift. |
npm run acceptance -- plan |
Prints the ten-leg acceptance journey — doctor → plan → install → claim-link → provision-site → publish-read → public-query-matrix → tenant-upgrade → health → teardown-dry — with the SPEC.md gate each leg is evidence for. Runs nothing and touches no account. |
npm run acceptance -- run --account <id> --confirm-disposable |
Deployment owner, mutating, DISPOSABLE ACCOUNTS ONLY. Creates real, billable resources and leaves them — the last leg is a teardown dry run, so removing them is yours to do afterwards. Runs the journey in order against a real Cloudflare account and writes a ledger to .dee-wan/acceptance-<account>.json: the command, exit code, moment and target for every leg. The ledger is bound to a TARGET FINGERPRINT taken over content, not names: account, acceptance site, admin email, both verification URLs, a digest of every configuration the journey reads (including backend/wrangler.jsonc, which provision:site and tenant:upgrade read whatever --config says, and which is gitignored so its path says nothing about its contents), a digest of every staged blob, a digest of whatever the worktree holds beyond the index, and the exact recursive submodule pins. A resume whose target has moved is refused, naming the fields that changed, because nine legs green across four different targets describe no state of the world that ever worked. --restart discards such a ledger and begins a new journey; --allow-dirty is required to record evidence from a worktree that does not match its own commit. Stops at the first failure — a journey that continues past one is measuring a state nobody described. A leg whose required environment is missing is REFUSED before anything is created, and the run writes NO ledger at all — a refused run used to record a skipped row stamped with the very blank environment it had just refused, which the next correctly configured run then read as drift. Re-running repeats failed legs and skips passed ones; --from <leg> re-runs from that leg and is refused if it is not one. Every leg is pinned to the named account, so none can land in another. Exit 0 means the LEDGER records every leg as passed — not “everything this run attempted succeeded”; a partial run exits 2 and names what is outstanding. Needs DEEWAN_ACCEPTANCE_ADMIN_EMAIL, DEEWAN_ACCEPTANCE_SITE_ID, DEE_WAN_ADMIN_COOKIE, the VERIFY_* values, and DEEWAN_PUBLIC_HOST / DEEWAN_PUBLIC_STRANGER_HOST for the filter matrix. The runner is not evidence; the ledger it leaves behind is. |
npm run public:query-probe -- --confirm-disposable --site <id> --admin <url> --public <url> --host <domain> --stranger-host <domain> |
Deployment owner, mutating, DISPOSABLE ACCOUNTS ONLY. The R4.1 filter matrix: locale fallback, strict locale, relation equality, tag equality through the shipped json_each path, a multi-page cursor walk, unpublished exclusion and stranger-host isolation, each asserted against the result set rather than the status code, and each refusing an edge-cache hit as evidence. It reads the site’s own locale policy first, borrows it for the strict check, and restores that exact policy — plus deletes every model and instance it created — in a finally where each step is attempted independently, so a thrown deletion cannot stop the restoration. A successful create whose body carried no id is recovered by the run’s own unique slug, and reported as a stranded resource if it cannot be. It never creates a language: the second locale must already exist. A cleanup or restoration failure writes no artifact. It also reads the site’s append-only audit ledger (GET /api/sites/:id/audit, so the admin session needs audit:read) before reading the site and again after cleanup, and the artifact names the rows its own model applies, model deletes and settings writes left there. Any other administrative write touching a target the run changes — the site’s settings, which the restore would overwrite, or a model it created — fails the run with no artifact, as does an expected row that is missing or never settled. The route answers only its 200 most recent raw rows with no cursor, so a run whose first audited action falls outside them refuses. Each failure names its kind (precondition, fixture, matrix, cleanup, audit) and what it means for the site. Never point it at production, customer, shared or otherwise non-disposable infrastructure. It is for an explicitly authorized disposable deployment and nothing else — it creates, publishes and deletes real content and changes the site’s locale policy for the duration. No such run has been made: no external evidence exists yet. |
| D1 and Workers analytics | Capacity and performance evidence (R4.2, R4.7, R4.9). No repository harness measures them. After a normal import and normal traffic on the deployment, read Cloudflare’s own data: npx wrangler d1 insights <database> and the GraphQL Analytics API datasets d1StorageAdaptiveGroups / d1AnalyticsAdaptiveGroups for D1 size, rows and query latency, and the Workers metrics in the dashboard for CPU time and request latency. See performance evidence (docs/PERFORMANCE-EVIDENCE.md, in the repository). |
npm run acceptance -- report --account <id> |
Prints that ledger, with what each passed leg does NOT prove. A leg that never ran says so rather than reading as absent-and-fine. Nothing here ticks a release gate — a person reads it and decides. |
npm run prisma:drift |
Verifies the Prisma schema still describes what the migrations build. SQL guard tables and raw-D1-only tables have an explicit allowlist. Every other difference is drift. |
npm run tenant:schema |
Contributor, local files only. Regenerates backend/tenant-migrations/, the schema a freshly created per-site database is given, from the tracked migrations and lib/tenancy/placement.ts. Writes one file in the repo. |
npm run tenant:schema:check |
Verifies the checked-in tenant schema still matches the migrations (CI). A migration that adds a table fails this until somebody places it. |
npm run tenant:upgrade |
Operator, mutating. Applies the checked-in tenant deltas to every ACTIVE per-site database that has not seen them, ledgered per site through fanOutMigrations. Add --plan to ask each tenant what it is missing without writing, --site <id> for one, or --declare-dead <owner> --declared-by <who> --evidence <what> — the exact three-flag sequence — to recover a lease a dead runner is holding. Each tenant’s OWN delta ledger decides what it holds, not the control ledger: a delta whose file was edited after it was applied is refused, a tenant holding a delta this build does not ship is refused, and a delta recorded before digests existed is reported as unverifiable rather than as up to date. --site naming an id that is unknown, not active, or without a database id exits non-zero instead of reporting nothing to do. The bootstrap file covers NEW databases; this is what brings existing ones forward. |
npm run deploy |
Builds and deploys the admin worker and UI, plus the paired public-read worker. --config selects the admin wrangler config and --public-config its public counterpart; a non-default --config requires one, and no fallback to the default public config is ever taken. --admin-only deploys without a public worker and reports the run as admin-only. --skip-migration-check bypasses the pending/unverifiable-migration gate, which otherwise refuses. |
npm run health |
Post-deploy checks against the deployed URLs. |
npm run probe |
Anonymous, read-only reachability check — no credentials, touches nothing. |
npm run verify |
Drives a disposable deployment end to end — creates a site, a model and content. Never point it at production; use --read-only there. |
npm run migrate:d1 |
Applies pending migrations to the deployed database (:local for the mirror). |
npm run migrate:sync |
Regenerates the wrangler migration mirror from Prisma (migrate:check verifies). |
npm run claim:link |
Mints a single-use password link for one address. |
npm run revoke:sessions |
Break-glass: signs everybody out of the deployment. |
npm run graduate |
Regenerates one site’s static artifact (graduate:all for every site). What the adopter then owns and how they extend it: docs/EXTENDING-YOUR-BACKEND.md. |
npm run graduate:all |
Runs the graduation artifact build for every site. Deploys nothing. |
npm run test:e2e |
Runs the Cypress suite against a dev server it owns (frontend). |
npm run teardown |
Deletes what setup provisioned. Dry run by default. |
npm run prisma:generate |
Regenerates the Prisma client after a schema edit. |
npm run contract:embed |
Contributor, local files only. Regenerates backend/src/lib/graduate/site-contract-embedded.ts from backend/src/lib/page-contract/. The graduation generator is bundled so that it reads nothing at run time, so those bytes are committed as string literals rather than inlined by a build step — which is what keeps the vendored bundle reproducible by a plain npx esbuild. Run it after editing anything under page-contract/; a test compares the two byte for byte, so drift fails rather than silently shipping. |
npm run submodules:sync |
Initialises or updates the vendored packages/. |
npm run submodules:check |
Fails when a submodule pin or worktree has drifted. |
npm run lint |
Lints the backend and the installer scripts. Enforced in CI. |
npm run typecheck:scripts |
Typechecks scripts/, which no other config covers. |
npm run verify -- --read-only --public <url> |
Post-deploy check that writes nothing. Add --model <slug> to read one published model. |
npm run wire:github -- --repo <owner/name> [--project <dir>] |
Signs in to GitHub, creates or resolves the repo, seals the three secrets the build workflow reads, and pushes the graduated project. Prints the webhook settings to paste. |
npm run exit -- --site <id> --repo <owner/name> --source <cms.db> --dest <dir> --db <target.db> |
Hands the site over as a repository the adopter owns: graduates it with a committed lockfile and a published-data snapshot, commits and pushes, verifies the remote branch really points at that commit, seals the Cloudflare secrets, dispatches a repository-only deployment of that exact sha, and reports repository URL, branch, commit, artifact id, Worker origin and D1 id. Nothing reaches Cloudflare until the commit is remotely readable. With --media, published media moves into adopter-owned R2 (EXIT_MEDIA_DESTINATION_*). Source media held in R2 needs EXIT_MEDIA_SOURCE_ACCOUNT_ID, EXIT_MEDIA_SOURCE_BUCKET, EXIT_MEDIA_SOURCE_ACCESS_KEY_ID and EXIT_MEDIA_SOURCE_SECRET_ACCESS_KEY; source media held in Cloudflare Images needs EXIT_MEDIA_SOURCE_IMAGES_ACCOUNT_ID and EXIT_MEDIA_SOURCE_IMAGES_API_TOKEN. Only the group the published objects use is required, a missing group refuses before any destination write, and no credential is printed or stored. |
Contributing
Section titled “Contributing”PRs welcome. Two conventions worth knowing:
packages/are submodules — commit and push there first, then bump the pointer.- Site-scoped routes must go through the scoped CRUD layer, which injects
site_id. Bypassing it is how cross-tenant reads happen.
License
Section titled “License”MIT. Copyright (c) 2026 Danil Trapeznikov — authorized by the product owner on 2026-08-27, with the
complete text in LICENSE.
Built in Thailand 🇹🇭 for the global edge
Running the installer against a shared account
Section titled “Running the installer against a shared account”backend/wrangler.jsonc hardcodes dee-wan-cms-backend, dee-wan-cms-db and
friends, so two installs in one Cloudflare account collide by name — and a
teardown aimed at the wrong account would delete the other install’s database.
Set DEEWAN_RESOURCE_PREFIX to namespace everything a run provisions:
DEEWAN_RESOURCE_PREFIX=test-a1b2 npm run setup # creates test-a1b2-dee-wan-cms-db, …DEEWAN_RESOURCE_PREFIX=test-a1b2 npm run teardown # dry run; only touches test-a1b2-*The prefix must be lowercase letters, digits and dashes, at most 24 characters.
With it set, teardown skips and reports any resource whose name does not
carry it, whatever the config file says — so a misaimed config cannot reach a
production database. With it unset, behaviour is exactly as before.
Set it for a fresh install, not an existing one. wrangler resolves a D1
database by looking up the name in backend/wrangler.jsonc, so the prefix only
works once setup has provisioned the resources and written the prefixed names
into that file. Turning it on against an install whose config still holds
unprefixed names makes every command fail with
Couldn't find a D1 DB with the name or binding '<prefix>-…'. To move an
existing install under a prefix, re-run setup; do not just set the variable.
Verified so far: the prefix reaches every script through loadConfig, is
idempotent, is rejected when malformed, and teardown refuses resources it does
not own (backend/test/teardown.test.ts). The full setup → Cloudflare → config
round trip has not been exercised — that needs an account with D1 and R2.
Deployment smoke test
Section titled “Deployment smoke test”Every other suite runs against a local harness — Cypress stubs the admin API and
drives Vite’s dev server, the backend tests run on local SQLite. Neither can see
the deployed system, which is how /content/<id> came to answer with an API 404
while all 84 e2e tests stayed green.
DEEWAN_SMOKE_PAGES=https://admin.example.com \DEEWAN_SMOKE_HOST=https://cms.example.com \npm run smoke --workspace=dee-wan-cms-backendIt asserts two things a wiring mistake breaks first, and needs no session:
- the Pages origin serves the SPA for dynamic routes, not just
/— a deep link is the first thing a misbuilt_routes.jsonbreaks and the last thing anyone checks, because the app always works when you navigate to it from home; - on installs with Access enabled,
/api/*on the public hostname is behind Access — if that route or the Access application is removed, the perimeter is gone and nothing else in this repo checks that. On default installs (Access off) the API is protected by session auth instead, and this check does not apply.
Opt-in on purpose: with no origins supplied it skips rather than fails, so it cannot rot into a red suite people learn to ignore. Run it after every deploy.