Skip to content
Dee Wan 1.0 is not yet released (pre-MVP). See the 1.0 product boundary.

Standalone backend: API reference

This page covers the API, authentication, deployment and rollback of a generated standalone backend. It describes emitted source, not a Cloudflare deployment that has passed acceptance. For what a standalone backend is and how to generate one, see the overview. For files, dependencies and table mappings, see Implementation details.

Generated IDs such as Article.findMany identify operations. HTTP routes use the lower-case model name, for example /api/blogpost. The customizable catalogue includes only operations that have generated security.

Method/path below /api/<model> Operation Result
GET / findMany Array of rows
GET /paginated findManyPaginated { data, total, hasMore }
GET /unique findUnique Row or null
POST / create Row, status 201
PUT / update Row
DELETE / delete Row

Junctions expose only the three reads; a list relation is written through its owner (below). Translation tables also allow create, update and delete operations for editors. Bulk operations, aggregate/count/groupBy, upsert, updateEach and other unconfigured operations are absent. Disabling an emitted operation removes it from execution and OpenAPI while keeping its path reserved against the raw owned mount. Reads have no POST alias.

Read arguments use query parameters, with JSON for structured values. Writes use Prisma argument objects in JSON bodies. URL-encode structured query values.

GET /api/blogpost?take=25&skip=25
GET /api/blogpost/unique?where={"id":"item-id"}
POST /api/blogpost {"data":{"id":"new-post","featured":false}}
PUT /api/blogpost {"where":{"id":"new-post"},"data":{"featured":true}}
DELETE /api/blogpost {"where":{"id":"new-post"}}

This is not the CMS /v1 public API: consumers receive Prisma-shaped results, not the CMS { data, page } response contract.

A to-one id is written on the row, like any column. A missing target returns 422 relation_target_missing.

A hierarchical parent link (a to-one relation to its own model marked Hierarchy (parent/child)) never loops. A create or update that makes a row its own parent, or puts it under one of its own descendants, returns 422 relation_hierarchy_cycle with field (the <field>_id column) and targets. Generated create and update routes and trusted routes check it before writing. SQLite triggers in migrations/ refuse the same write inside the database, so two concurrent writes cannot both succeed; the refused one gets the same answer. Every migration drops and re-creates these triggers around its changes. Roots, reparenting and clearing the parent (null) are ordinary writes. An id from another model or site, or one that identifies no row, returns relation_target_missing. Locale rows never carry the parent.

A list relation has one route per field on its owner: PUT /api/<owner>/relations/<field>. The body names the owner row and either the whole ordered list or the changes to it.

PUT /api/article/relations/tags {"where":{"id":"post-1"},"set":["tag-2","tag-1"]}
PUT /api/article/relations/tags {"where":{"id":"post-1"},"connect":["tag-3"],"disconnect":["tag-1"]}
POST /api/articletranslation {"data":{"article_id":"post-1","lang":"fr","title":"Bonjour"}}
  • set replaces the list in the given order. connect appends; disconnect removes, and disconnecting an id that is not linked is treated as already complete. set cannot be combined with the other two.
  • The resulting list is validated against the CMS rules: no duplicates (relation_duplicate_target), required and minItems (relation_too_few), maxItems (relation_too_many), existing targets (relation_target_missing). Item counts are checked on every request. Deleting a target removes its link, so a list can fall below minItems; it then answers relation_too_few until a write brings it back within its limits. A list equal to the stored one writes nothing and answers with the stored list.
  • A one-to-many target already held by another owner is refused with 409 relation_target_taken.
  • The links are rewritten in one database batch. If the list changed after it was read, nothing is written and the answer is 409 relation_changed_concurrently.
  • The route inherits <Owner>.update: its access mode, permission, operation and group hooks. Disabling <Owner>.update disables the route; its path stays reserved.

A locale row is created with its row key and language. The language must be one the site was generated with (422 unknown_locale): a Blog’s configured languages, or a language used by published content. A Website pins no language list, so a language with no published content yet needs a regeneration. The base row must exist (422 translation_row_missing), and a row has one locale row per language (409 locale_exists). An update changes only the translated values. Translation writes inherit <Model>.update, and deletes <Model>.delete.

A relation field can allow more than one model. Such a field is stored in one join table per allowed model, for example ArticleRelatedTag and ArticleRelatedPerson, each with a foreign key to its model that deletes the link with the target. The rows of all these tables share one list order through position, so the list can mix models freely. A mixed to-one field (manyToOne, oneToOne) is stored the same way, not in a column. A parent link (hierarchy) allows one model only.

  • A reference is {"model":"<model slug>","id":"<row id>"}. In set, connect, disconnect and relations of the atomic create, a bare id is refused with 422 relation_target_model_required on a field that allows several models. A model the field does not allow answers 422 relation_target_model_not_allowed, and an id that identifies no row of the named model, or no row at all, answers 422 relation_target_missing. A field that allows one model still takes bare ids and also takes { model, id } with its own model.

  • The answer lists { "model", "id" } references in order. In targets of an error, a reference of such a field is written model:id.

  • The same rules as for any list apply to the whole mixed list: no duplicates, minItems and maxItems, a one-to-many target held by one owner (409 relation_target_taken), one write in one batch. A to-one field holds one reference across all its models, also against a racing write: the write that read an older list answers 409 relation_changed_concurrently.

  • GET /api/<model>/files/<field>/<row id> serves a private file when the row’s <field>_url holds a private-file: marker. The Worker reads it from the PRIVATE_FILES R2 binding; the Node server (npm run serve:local) reads it from the absolute directory named by PRIVATE_FILES_DIRECTORY, which must exist, in which a file’s content type is kept in a <key>.meta.json beside it. It inherits <Model>.findUnique: its access mode, permission, hooks and availability, and the caller’s own guarded read of the row. It answers 404 for a row without a marker, an object the bucket lacks, or a row the caller cannot read. With neither the binding nor the directory it answers 503 private_files_unavailable, and never falls back to public storage. A response is private, no-store; images and PDFs are displayed inline, anything else downloads.

  • Rich-text media use the same protected route with ?media=<media id>. It selects a private-file: marker actually held by the row’s rich field, not a storage key supplied by the caller. &variant=thumb and &variant=preview read that parent’s private WebP variants; a missing preview falls back to its original. The exported privateRichText(html, endpoint, rowId) helper maps canonical rich HTML to these same-origin file URLs for display. Use it after reading the row; the file route still checks live row access on every request.

  • GET /api/<owner>/relations/<field>?id=<owner id> reads the ordered list with each reference’s model (add &locale= for a list with links in each language). It inherits <Owner>.findUnique, and a private field answers editors only. Reading one join table, for example GET /api/articlerelatedtag, returns only that model’s rows; position is the place in the whole list.

  • A published item that links to a model the field does not allow blocks generation (invalid_reference), and a model that is not on the site blocks it (relation_target_absent).

An owner with at least one list relation also has POST /api/<owner>/with-relations. It creates the row and its list memberships together. The ordinary POST /api/<owner> still creates a row without lists, and PUT /api/<owner>/relations/<field> still changes a list later.

POST /api/article/with-relations {"data":{"id":"post-1","title":"Hello","author_id":"person-1"},"relations":{"tags":["tag-2","tag-1"],"topics":["topic-1","topic-2"]}}
POST /api/note/with-relations {"data":{"id":"note-1"},"relations":{"related":{"en":["tag-1"],"fr":["tag-2","tag-3"]}}}
  • data is the body of the ordinary create, validated against the same create guard shape and hooks as POST /api/<owner>. A missing id is generated. relations names list fields; a shared list is an array of ids in order, a list with its own links in each language is an object from language to array. Anything else in the body answers 400 invalid_request.
  • Every list field of the owner is checked, and a field you leave out counts as empty. A required list (minItems) must be given, or the answer is 422 relation_too_few. For a list with links in each language only the languages you name are checked.
  • Before anything is written the route checks the CMS rules: no duplicates (relation_duplicate_target), minItems and maxItems (relation_too_few, relation_too_many), existing targets (relation_target_missing), a one-to-many target held by another owner (409 relation_target_taken), a parent link that names the new row (relation_hierarchy_cycle), an id that is already a row (409 record_exists), and to-one ids in data (relation_target_missing).
  • The row and all its links are written in one database batch on D1 and in one transaction on SQLite. If any statement fails, none of them is kept: there is no row without its links and no link without its row. Two creates that claim the same one-to-many target at the same time cannot both succeed. One answers 201; the others answer 409 relation_target_taken and leave nothing behind. A failure the preliminary checks could not detect, such as a unique value another write took first, answers 409 write_conflict.
  • A success answers 201 with { "id", <columns>, <field>: [ids] }. Private fields are left out, as in every write answer.
  • The route inherits <Owner>.create: its access mode, permission, and operation and group hooks. It answers 403 forbidden to a caller that may not create. Disabling <Owner>.create removes the route. If the owner’s create has an operation override, the route answers 501 atomic_create_unavailable, because the override replaces the write this route performs. Without a batch runner it answers 503 relation_writes_unavailable. OpenAPI marks the route x-atomic-create.
  • It does not write translation rows. Create those with POST /api/<model>translation.

Deployment runs scripts/check-relation-ids.mjs before it applies migrations. A database migrated by an earlier generator can hold relation ids that name no row, and the migration that adds their foreign keys would fail as a whole. The check lists each one (model, column, row id, missing target) with the remedy and refuses with relation_ids_dangling before anything is migrated. npm run db:init and npm run data:load -- --database <file> run the same check, with the same database, before local migrations. Generating the standalone backend locally into an existing target database refuses the same way.

Readiness refuses relation shapes no generated write can satisfy: limits no list can meet (relation_bounds_unsatisfiable), a target model the site does not have (relation_target_absent), and required to-one fields that lead back to their own model (relation_required_cycle). Published items whose parent links already loop are refused with relation_hierarchy_cycle, naming the items. Model Builder shows the same note on the relation field.

Generated text fields retain the declared minLength, maxLength and pattern rules. Patterns support Unicode property escapes such as \p{L} and \p{N}; an intentionally ASCII-only pattern keeps its restriction. Regenerate and redeploy after changing these rules. Generation refuses executable custom text validation with unsupported_text_validation instead of running or discarding it. Malformed text rules, including invalid patterns or a minimum above the maximum, refuse generation with invalid_field_validation.

Generated list and paginated-list routes accept filter[location][within_radius]=13.75,100.5,1000 (latitude, longitude, metres) and filter[location][within_bbox]=13,100,14,101 (south, west, north, east). Bounds are inclusive; west greater than east crosses the antimeridian. Nearest order uses sort=location&origin=13.75,100.5; sort=-location reverses distance. Missing points sort last, followed by stable row ID order. Radius uses Haversine distance in metres.

These options pass through the existing generated route, authentication, row guard and effective field-query permissions. A disabled point field is refused with 403 spatial_field_denied. They do not enable a field or operation removed by an owned customization or saved query. sort cannot be combined with orderBy; invalid coordinates or syntax are refused with 400 spatial_query_invalid.

The emitted local SQLite and D1 runtimes supply a driver adapter. A spatial request without an adapter returns 501 spatial_adapter_required. SQL translation also covers PostgreSQL and MySQL; those providers require their own runtime acceptance before deployment. Spatial filtering and ordering run before SQL pagination and use the same predicates for counts.

Live shared references require source-site grant custody. Generation returns shared_references_require_live_delivery for effective shared slots; remove or replace them before generating a detached backend. Ordinary same-site relations remain supported.

Media identity remains <field>_id; <field>_url remains the default transferred address. Media-bearing fields also carry <field>_originals, a JSON map from served locale to media ID to the exact retained original’s facts and transferred URL. Shared component and rich fields keep one authored value and retain originals for each served language. Private fields and components with private nested fields keep this metadata private.

Import mediaOriginal from src/media-original.ts. mediaOriginal(row, 'cover', 'fr') reads the original for the row’s cover_id; pass a fourth media ID for a component or rich field. An absent locale or asset returns null. Generation uses the published version’s recorded fallback result; it does not choose a newer library file. Exit transfers each retained object independently and rewrites its address while preserving the asset identity.

Access policy decides whether a route accepts anonymous, session or service callers. Missing model policy is service. Read tokens are digests scoped by model in ACCESS_CONFIG. Unscoped routes and /api/openapi.json require wildcard scope. Service writes without a writer authenticator return 503 writes_unavailable. Absent credentials return 401 and denied permission returns 403. Optional Better Auth supplies the artifact session for both local and Worker runtimes. Local serving and the Worker accept the same API_BEARER_TOKEN service bearer.

Managed deployment builds ACCESS_CONFIG on every deploy from two sources: the CMS-issued tokens in the generated file access/read-tokens.json, and your own vars.ACCESS_CONFIG in wrangler.jsonc. It deploys their union. With neither, it deploys a valid empty list (tokens: []). Both are checked by one rule module, src/access-config-rules.js, which the Worker also uses at startup. The rule requires version 1, this site, every token naming this site, and lowercase SHA-256 hex digests. It also requires model scopes, no write capability, no duplicate token id and no duplicate digest. An invalid owned value is rejected with owned_access_config_invalid, and an invalid generated file is rejected with generated_access_tokens_invalid. The same id in both is rejected with access_token_id_conflict, and the same digest in both with access_config_union_invalid, all before any database or Worker changes. Your own entries are never removed. A value set only in the Cloudflare dashboard is replaced by the next deploy, so keep yours in wrangler.jsonc. Commit digests of long random secrets only, never the secrets. Outside the generated deployment flow, missing or invalid ACCESS_CONFIG refuses boot.

Settings → Source & delivery → Deployed API read tokens, with the site:infra permission.

  • Issue. Give a name and one or more models. The secret, dw_art_ and 43 base64url characters, is shown once and stored nowhere. The CMS keeps its SHA-256 digest. Callers send Authorization: Bearer <secret>. The CMS never issues a * token. A wildcard token stays in your own vars.ACCESS_CONFIG.
  • Deliver. Nothing reaches a deployed backend until you regenerate and deploy. Issuing or revoking marks the site out of date. Regeneration writes the active tokens, digests only, to access/read-tokens.json with each scope mapped to the model’s generated name (and its translation table).
  • Revoke. Revoking does not deploy. The deployed backend accepts a revoked token until the next regeneration and deploy. A detached backend is out of the CMS’s reach.
  • Rotate. Issue a replacement, deploy, move the caller to the new secret, revoke the old token, deploy again. There is no in-place swap.
  • Models. Scopes are model ids, so renaming a model changes nothing. Deleting a model, or undoing the import that created it, removes it from every token. A token left with no model is revoked, never widened.
  • CMS site API tokens (site_api_token) are never copied into a generated backend.
  • Generation refuses a token whose scope no longer maps to a generated model (token_scope_not_emitted), and single-model generation refuses while active tokens exist (read_tokens_need_site_graduation).

Generated reader shapes permit id equality. Editor shapes permit equality on the generated row columns. A model’s private fields are left out of reader selects (anonymous reads and read tokens) and of every write response; a component column holding a private field anywhere below is left out whole, and a private list relation’s join table responds only to editors. A single-entry model’s table holds one row: a unique index refuses a second, and the create route answers 409 single_entry_exists. Normal lists order by id and use default 25/max 100. Translation lists order by language then id. Junction lists order by position then id with default 100/max 200. Unsupported selectors/operators are refused. D1 is isolated per site. The artifact refuses a mismatched SITE_ID rather than claiming row-level tenancy.

In the generated backend, a component field is one embedded JSON value, nested up to 3 levels below a root component, the same limit the CMS enforces when you build the definition. A chain deeper than that is refused before generation with component_nesting_too_deep, naming the component and the path of components that reaches it. Media references inside nested components are found at every level.

Customization cannot remove generated authentication, permissions, variant selection, guards or error protection. The generated composition layer resolves guards before hooks, validates resolved shapes and disables the environment guard bypass regardless of owned settings. The installation’s other supported guard choices remain generation inputs. allowE2EGuardBypass is still deprecated upstream, but is not an override escape hatch here.

Generated router errors retain upstream normalization. Errors disclosing SQL, Prisma calls or local paths are replaced by request_refused while preserving status. Unhandled owned failures return request_failed. A configured commit is returned in x-artifact-commit.

Settings → Source & delivery → Generated API guards remains the installation input for generator policy. The owned composition layer fixes the following security settings. Owned declarations cannot weaken them. The existing reader/editor variants remain the only variant dispatcher.

Setting Generated customization pipeline
requireGuardShape true
validateGuardShapes true
requireDefaultVariantOptIn Retains the installation’s selected policy
enableUpdateEach false
guardResolutionOrder before-hooks
allowE2EGuardBypass false
validateResolvedShapes true

Authenticated GET /api/openapi.json combines upstream generated schemas and enabled operations with schema-validated declarative routes. Same-contract overrides retain the generated request/result schema. Custom routes declare their access operation and optional model scope. Their supported schema subset is enforced for input and output. Raw Hono routes have no generated OpenAPI contract. Documentation does not make an anonymous route public.

Optional artifact authentication and access

Section titled “Optional artifact authentication and access”

Source and delivery settings with standalone authentication, cache and model exposure

In Settings → Source & delivery → Standalone authentication, cache and exposure, enable Better Auth once for the backend. Then select Password, Magic link (Resend), Google or GitHub. Disabled is the default: no Better Auth dependency, runtime, routes, tables, migration, provider configuration or secrets are emitted. CMS accounts, sessions, invitations, password hashes and provider credentials never enter a standalone backend. Application content named User stays separate from the artifact identity tables.

Access mode Accepted caller
public Accepts an explicit typed anonymous principal without a credential.
session A current session from this artifact’s Better Auth instance.
service API_BEARER_TOKEN, or an eligible scoped token for a read.
session_or_service Either the artifact session or service authentication.

Each model’s access mode and operation overrides are set on its model page → Standalone API. Saving needs site:infra. The policy is stored against the model id, so it follows a slug rename and is deleted with the model. A save names the revision it replaces. A stale screen gets 409 policy_changed_concurrently. Slugs that generate the same model name (blog-post and blog_post) are refused when a model is created or renamed. A missing model configuration explicitly uses service. Missing operation overrides inherit the model default. Overrides use exact generated IDs such as User.findMany, User.findManyPaginated, User.findUnique, User.create, User.update and User.delete. Validation rejects removed operations. Generated policy types also reject stale IDs. Operation availability remains in the owned backend registry. Access settings do not re-enable disabled operations. Policies name sessions, never particular login providers.

A model’s standalone API page with default and per-operation access modes

Authentication and permission checks run before hooks and overrides. Guard resolution, existing ownership boundaries and error protection remain mandatory. A public create rejects protected system, owner, tenant, role, administrator, password, verification and provider-link fields, including nested input. Public create makes application content. Signup happens only through Better Auth routes. Raw routes default to service. Declarative routes must state access.mode and access.operation, with optional model scope. OpenAPI shows each operation’s access mode.

Optional install root Package Block Exact version
root, only when enabled better-auth dependencies 1.6.25

Enabled artifacts emit src/better-auth.ts, provider verification and the existing auth limit/lockout rules, plus a separate migration for _artifact_auth_* infrastructure tables. These tables have no generated CRUD endpoints. Both the Node/SQLite server and Worker/D1 use the same selected methods, password bounds, verified OAuth identities and hashed verification tokens. They also use the same database session revocation, seven-day session limit and mutation-origin checks. Provider access, refresh and ID tokens are discarded. Auth responses suppress provider internals.

Run npm run auth:init to create a fresh random secret in ignored .dev.vars. It preserves an existing artifact secret. The Node server can load this file with node --env-file=.dev.vars src/serve.local.mjs. Wrangler loads it directly. Apply db:init first. Set ARTIFACT_ORIGIN to the API origin, for example http://127.0.0.1:8787 locally. Remote origins must use HTTPS. Deployment derives the origin from the reviewed target hostname, never from the CMS origin.

Selected method Required runtime values Target callback
Password BETTER_AUTH_SECRET Password routes under /api/auth
Magic link BETTER_AUTH_SECRET, RESEND_API_KEY, MAIL_FROM <ARTIFACT_ORIGIN>/api/auth/magic-link/verify
Google BETTER_AUTH_SECRET, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET <ARTIFACT_ORIGIN>/api/auth/callback/google
GitHub BETTER_AUTH_SECRET, GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET <ARTIFACT_ORIGIN>/api/auth/callback/github

Register callbacks in adopter-owned OAuth applications. Resend needs a verified sender domain. Only selected methods require credentials. Target preflight fails before deployment when a selected method lacks them. Repository Actions secrets prefix runtime names with ARTIFACT_. This also avoids GitHub’s reserved GITHUB_ prefix. Delivery generates a new artifact secret and seals it to the target public key. Existing target secret names are preserved without rotation. npm run backend:deliver reads provider inputs only from ARTIFACT_* environment variables. Secret values never enter source, manifests, generation inputs, digests, checked-in vars, logs or deployment ledgers.

Regeneration preserves owned/shared files. Enabling auth in an existing shared package or changing selected methods requires regeneration to update dependencies and caller workflow bindings. Disabling auth on an existing destination is rejected: use a new destination, preserving the old identities and migration history. Access-only changes participate in generation-source and manifest digests.

verify:deployment respects access policy. Public reads are checked anonymously. Service reads use the bearer. Session-only reads require a current target session in ARTIFACT_VERIFY_SESSION_COOKIE. Without it, verification refuses with verify_session_required. It never reports an unperformed content check as passed. For first deployment, initialize the target, create an account through its auth routes, and rerun verification with that session. Keep the short-lived session out of source and evidence. Browser and live deployment acceptance remain separate gates.

Off by default. When Better Auth is enabled, the artifact can also expose the Better Auth admin plugin for the application’s own accounts. Accounts, roles and sessions stay in the application’s database, and only an application administrator can use these operations. The Dee Wan CMS holds no copy of the accounts, takes no part in a request, and need not be running. With the integration off, no plugin, route, migration, column or binding is emitted.

Turn it on. Add "accountAdmin": true to the authentication setting of the site, then regenerate and deploy: PATCH /api/sites/<site-id>/standalone-auth with {"authentication":{"enabled":true,"methods":["password"],"accountAdmin":true}} and the site:infra permission. A save that leaves accountAdmin out keeps its current value. In the admin it is the switch “Account administration in the generated application” under Settings → Source & delivery → Standalone authentication, cache and exposure; it is disabled while Better Auth is off. The setting adds the migration migrations/<number>_artifact_auth_admin.sql: it adds the role, banned, banReason and banExpires columns to _artifact_auth_user and impersonatedBy to _artifact_auth_session. The number is the next free one in the migration lineage the artifact already has when the setting is first enabled (0004 on a new artifact). Regeneration keeps that file and number, and an applied migration is never renumbered. Turning the setting off later removes the routes and the page and leaves the columns and the migration file; turning it on again reuses the same file.

Who is an administrator. A user whose role is admin, or whose id is in the optional runtime binding ARTIFACT_ADMIN_USER_IDS (comma-separated, at most 50 ids). New accounts use the configured default application role unless an administrator selects another role. list-users and get-user add access (admin or user) and adminSource (role, configuration, both or null) to each account, computed on every request from the stored role and the current binding. The binding never changes the stored role; the account page shows both. To create the first administrator, sign up through /api/auth/sign-up/email, read your id from GET /api/auth/get-session, set the binding as a Worker secret of the target (or in the environment of the Node server) and restart. Later administrators are promoted with set-role. The database refuses deletion, demotion or banning of the last active administrator. Keep the binding as an independent recovery path. The generated deployment workflow does not manage this binding.

All calls use the administrator’s own session cookie, and mutations must come from ARTIFACT_ORIGIN like every other auth call. The paths are under /api/auth/admin/.

Operation Method and path Body or query Effect
List accounts GET /api/auth/admin/list-users limit (at most 100, default 100), offset, searchValue, searchField, sortBy { users, total }
Create an account POST /api/auth/admin/create-user { name, email, role?, password? }; a configured role; password 12–128 characters Creates the account without public sign-up or mail delivery
Delete an account POST /api/auth/admin/remove-user { userId } Ends sessions and removes credentials; self-deletion, last-administrator deletion and linked application records are refused
Read one account GET /api/auth/admin/get-user id The account
Edit a profile POST /api/auth/admin/update-user { userId, data }, data holds name, image or email only The updated account
Set application access POST /api/auth/admin/set-role { userId, role }, role is a configured application role (default: admin or user) The updated account
Ban POST /api/auth/admin/ban-user { userId, banReason?, banExpiresIn? } (seconds) Marks the account banned and ends all its sessions
Unban POST /api/auth/admin/unban-user { userId } Clears the ban
List an account’s sessions POST /api/auth/admin/list-user-sessions { userId } { sessions }
Revoke one session POST /api/auth/admin/revoke-user-session { sessionToken } { success: true }
Revoke all sessions POST /api/auth/admin/revoke-user-sessions { userId } { success: true }

Create or delete. Open /admin/accounts, choose New account, enter a name, email, initial role and password when password sign-in is enabled. Creation uses the same Better Auth service and hashes credentials; no sign-up request or email transport is needed. Open an account and choose Delete account to confirm irreversible deletion and session revocation. A 409 ACCOUNT_ADMIN_LINKED_RECORDS response lists the model/table and field to reassign or remove first. Application foreign keys are checked at deletion; related application records are never silently cascaded. 400 YOU_CANNOT_REMOVE_YOURSELF and 409 ACCOUNT_ADMIN_LAST_ADMINISTRATOR protect administrator access.

A banned account cannot sign in until it is unbanned or the ban duration specified by banExpiresIn expires; its sign-in answers 403 authentication_failed.

Refusals. Every refusal answers { "error": "account_admin_refused", "code": "<code>" } with the status below.

Status code When
401 no code, or Better Auth’s No valid session
403 YOU_ARE_NOT_ALLOWED_TO_… The signed-in user is not an administrator; or the origin is foreign
404 USER_NOT_FOUND No such account
404 ACCOUNT_ADMIN_OPERATION_UNSUPPORTED Any other path or method under /api/auth/admin/: set password, impersonate, stop impersonating, has permission
400 ACCOUNT_ADMIN_PROFILE_FIELD_UNSUPPORTED update-user with a field other than name, image, email, or with no field
400 YOU_CANNOT_BAN_YOURSELF An administrator bans their own account
400 YOU_ARE_NOT_ALLOWED_TO_SET_NON_EXISTENT_VALUE set-role with an unknown application role

The administrator role carries only the permissions these operations use, so an operation outside the table is unavailable even if a request reached the plugin. The integration never writes the _artifact_auth_* tables itself and adds no generic CRUD over them.

Account page. The artifact serves a page for these operations at /admin/accounts on its own origin. It uses the application’s own administrator session, in the browser, with the page’s own cookie, and calls only the operations in the table plus the artifact’s own get-session, sign-in/email and sign-out. Signed out, it shows a sign-in form when the password method is selected; with another method, sign in through the application first. A signed-in user who is not an administrator sees a refusal. Each account shows its stored role and, separately, “Administrator (role)”, “Administrator (configured)” or “Administrator (role and configured)” when it has administrator access. An administrator can search accounts by email, edit a profile, set the role, ban and unban an account, and list and revoke its sessions. The page holds no account data of its own and names no CMS address, so it works with the CMS offline. With the integration off the route does not exist. In the Dee Wan admin, once the switch is saved on and the deployment has recorded a hostname (Settings → Source & delivery → Advanced — build webhook and deployment → Standalone backend), “Open account administration” under the switch opens https://<hostname>/admin/accounts in a new tab; until a hostname is recorded the admin shows “Deploy the application to record its address.” The CMS never proxies the application’s cookies: a page served by the CMS cannot call these operations, because the artifact’s session cookie belongs to the artifact origin and its trusted origin list holds only that origin. curl or a client of your own on the artifact origin also works.

In Settings → Source & delivery, enable application sign-in and account administration. Application roles lets you add roles such as customer or staff, choose the signup role, and select each role’s generated API operations by model. Save, then regenerate and deploy the application. /admin/accounts offers those roles when an administrator changes an account.

Up to 32 roles, including admin and user, are supported. Names use 1–64 lowercase Latin letters, digits, hyphens or underscores and start with a letter. The signup role must exist and cannot be admin. The administrator role retains all operations. All operations is explicit; otherwise only the selected operation IDs are permitted. Empty grants permit none. Without this optional configuration, the existing admin/user behavior is unchanged.

The stored backend_policy.authentication.roles configuration is:

{"default":"customer","grants":{"admin":["*"],"user":[],"customer":["Article.findMany","Article.findUnique"]}}

This constrains application sessions on generated API operations. Public operations remain public; service credentials keep their existing scopes. Trusted reads and translation writes inherit the operation they already name. CMS roles and accounts remain separate. Assignment changes are checked on subsequent requests; they do not depend on the CMS being online. A configured recovery administrator retains access. Removed or unknown role assignments grant no application operations. The existing regeneration and deployment process carries the policy without overwriting owned code.

Snapshot, migrations, deployment and rollback

Section titled “Snapshot, migrations, deployment and rollback”

data/published-snapshot.json is generated and digest-checked. db:init applies local migrations before loading it. data:load loads a declared local SQLite or D1 target. Loading the same snapshot again changes nothing. Loading is rejected for a database that holds another site’s or artifact’s snapshot, for edited snapshot bytes or for an incomplete load. A snapshot with different content replaces every table it owns: the load deletes each table’s rows and writes the snapshot’s rows. A row the snapshot does not hold is gone, whoever wrote it, and the load does not refuse. Tables the generator did not create are not touched. Store application-owned rows in application-owned tables. The observed_row_set_mismatch refusal belongs to local generation into its own disposable --db database, not to data:load.

Terminal window
npm run db:init -- --database ./standalone.db

Regeneration validates source ownership, emits schema/app/security/site files, regenerates Prisma and guard/router output, removes generation-machine paths, and checks customization. Migration emission appends changes and preserves history. Destructive or incompatible shape changes require their explicit review path. The final ownership manifest must describe the completed tree.

npm run regenerate runs scripts/regenerate.mjs, then prisma:generate, portable and customization:check. A whole-site artifact needs two inputs:

  • definitions.json in the repository root: the site definitions as the CMS serves them. Git ignores the file, and the managed build deletes it before it commits. Without it, regeneration refuses with definitions_missing.
  • GENERATION_SOURCE_VERSION: the version those definitions describe. Without it, regeneration refuses with generation_source_absent. A value that differs from data.generationSourceVersion in the file refuses with generation_source_version_mismatch.

Connected. The CMS serves definitions only to a build token. Signed in to the admin with site:deploy, call POST /api/sites/<site-id>/build/token. It answers build_token and generation_source_version. The token is short-lived and reads that one version’s definitions. DEE_WAN_URL is the same CMS origin the managed workflow uses. A change to the site after you mint the token makes the pull answer 409 source_moved.

Terminal window
curl -sS -o definitions.json -H "Authorization: Bearer <build_token>" "$DEE_WAN_URL/api/sites/<site-id>/definitions"
export GENERATION_SOURCE_VERSION=<generation_source_version>
node scripts/install-dependencies.mjs
npm run regenerate
npm run migrate:emit
npm run typecheck
npm run build
npm run files:manifest
rm definitions.json

Detached. Without the CMS, regeneration works only from a definitions.json you pulled earlier, for example with the connected steps before retirement. Keep that copy outside the repository: it is the site’s whole content model. Regenerating from it rebuilds the generated files for that same version. It brings no newer content.

Terminal window
cp <kept-definitions.json> definitions.json
export GENERATION_SOURCE_VERSION="$(node -p "require('./definitions.json').data.generationSourceVersion")"
node scripts/install-dependencies.mjs
npm run regenerate
npm run migrate:emit
npm run typecheck
npm run build
npm run files:manifest
rm definitions.json

Regeneration refuses an edited generated file. If you edit generated files after detaching, stop regenerating.

A --model artifact needs MODEL_VERSION_ID and that model’s definitions instead. Without the variable it refuses with model_version_absent. The CMS /definitions route serves site definitions only. The single-model manifest reader reads the payload, and records and re-checks the artifact’s own generator guard choices.

deploy-from-repository.yml checks out an exact commit, installs verified dependencies, checks/builds, resolves D1, and applies migrations/ with wrangler d1 migrations apply. It then deploys the API, sets its writer secret, loads the committed snapshot and invalidates the response cache, verifies content/media, and deploys the site. verify:deployment compares served rows and commit identity. verify:media checks custody. An older git_sha selects an earlier code revision. It is not automatic database or media restoration: retain compatible migration history, backups and adopter objects before rollback.

The response cache and its invalidation are described in Response cache.

D1 does not support the interactive-transaction path. Paginated rows and totals use separate reads and can differ during concurrent writes. Raw Hono handlers must apply explicit guard shapes to their own Prisma queries. Merely using an extended client does not constrain every query, and raw SQL is not guarded. Local Worker acceptance and deployment acceptance are separate gates. Source code and authored checks alone do not prove either has passed.

For the files, dependencies and table mappings behind this API, see Implementation details.


Unreleased / pre-MVP; no stable version
Source revision: fe23db481c422b40e317b92fc98116b4c0c26a87 (modified checkout)
Build date (UTC): 2026-10-18T09:22:34.630Z
Upgrade target: unavailable: modified checkout. Release notes