editor
import { saveRoute, settingsRoute, blobSettingsRoute, pagesRoute, versionsRoute, searchRoute, mediaRoute, listMediaRoute, formRoute, inquiriesRoute, seedRoute, statusRoute, d1Check, ageCheck, runEditorRoute,} from "louise-toolkit/editor";The server-side counterpart to the Louise Settings: framework-generic
api/louise/* request→response handlers. Each factory returns a
WorkerRoute—(request, env, ctx) => Response | undefined—that composeWorker composes, so a site wires the ones
it needs, passes its own Drizzle tables, and keeps bespoke resource routes
(products, artworks…) per-site. Sites not on composeWorker (Astro, Nitro…) run
the same handlers with runEditorRoute.
The framework panels (Pages/Media/Settings) and the default Inquiries panel
call these endpoints. Peer: drizzle-orm.
Composing the routes
Section titled “Composing the routes”import { composeWorker } from "louise-toolkit/worker";import { pagesRoute, mediaRoute, settingsRoute, saveRoute, inquiriesRoute,} from "louise-toolkit/editor";import { getLouiseAuth, resolveEditorSession } from "louise-toolkit/auth";import { pages, media, siteSettings, inquiries } from "./db/schema";
// Bridge the site's auth once; every route reuses it. `getLouiseAuth` and// `resolveEditorSession` come from louise-toolkit/auth — see the auth reference.const resolveEditor = async (request: Request, env: Env) => resolveEditorSession(await getLouiseAuth(env, request.url, authConfig), request);
export default composeWorker({ routes: [ pagesRoute({ table: pages, resolveEditor }), mediaRoute({ table: media, resolveEditor, referenceSources: [/* … */], }), settingsRoute({ table: siteSettings, resolveEditor, columns: ["siteName", "navLinks" /* … */], }), saveRoute({ resolveEditor, collections: {/* … */}, }), inquiriesRoute({ table: inquiries, resolveEditor }), ], // …your bespoke routes + SSR fallthrough.});Auth: resolveEditor + the guard
Section titled “Auth: resolveEditor + the guard”Every route is decoupled from any one auth wiring by a site-supplied
ResolveEditor:
type ResolveEditor<Env> = ( request: Request, env: Env,) => EditorSession | null | Promise<EditorSession | null>;Returning null means “not an editor” and the route answers 401/403. Internally
each route calls guardEditor, which runs requireEditor:
it verifies the session and, on mutations, enforces a same-origin (CSRF)
check. Reads skip the same-origin check; writes require it.
Adapting to Astro (or any non-Worker host)
Section titled “Adapting to Astro (or any non-Worker host)”The handlers are composeWorker WorkerRoutes—(request, env, ctx). A site on
an Astro (or Nitro/Nuxt/…) adapter resolves the editor session in middleware and
has no ExecutionContext to hand the route, so wrap it with runEditorRoute:
it supplies a no-op ctx and turns a path fall-through (undefined) into a 404.
Because the session is already resolved, resolveEditor just hands it back—no
second auth path:
import { inquiriesRoute, runEditorRoute } from "louise-toolkit/editor";import { inquiries } from "../../../db/schema";import { env } from "cloudflare:workers";
export const ALL: APIRoute = (ctx) => runEditorRoute( inquiriesRoute({ table: inquiries, resolveEditor: () => ctx.locals.editor }), ctx.request, env, );runEditorRoute(route, request, env): Promise<Response>.
Routes
Section titled “Routes”| Factory | Endpoint | Methods |
|---|---|---|
pagesRoute |
/api/louise/pages (+ /:id) |
GET list · POST create · GET/PATCH/DELETE one |
versionsRoute |
/api/louise/pages/:id/… |
GET/POST versions · POST publish · POST unpublish |
searchRoute |
/api/louise/pages/{search,reindex} |
GET search?q= · POST reindex |
mediaRoute |
/api/louise/media |
GET list · POST upload · PATCH alt/caption · DELETE (reference-scanned) |
listMediaRoute |
/api/louise/media |
registry-less variant: lists R2 directly, per-request scope |
settingsRoute |
/api/louise/settings |
GET · POST/PATCH (structured base + custom) |
blobSettingsRoute |
/api/louise/settings |
GET · POST/PATCH—single-JSON-blob variant |
saveRoute |
/api/louise/save |
POST (inline field save) |
formRoute |
/api/louise/forms/<name> |
public POST capture (same-origin + spam guard) |
inquiriesRoute |
/api/louise/inquiries |
GET list · DELETE one |
submissionsRoute |
/api/louise/submissions/<form> |
GET list · DELETE one—a form’s rows in the shared table |
seedRoute |
/api/louise/seed |
seeds the site_settings singleton (idempotent) |
statusRoute |
/api/louise/status |
public GET · HEAD: 200 or 503 from the site’s checks |
pagesRoute—content pages CRUD. Create/update are allowlisted tofields(defaultsDEFAULT_PAGE_FIELDS) and rich fields (body) are run throughsanitizeRichHtmlbefore store. An optionalvalidate(data, ctx)hook runs after allowlisting and before the write—throwLouiseValidationError(for example, viaassertValidSections) to reject with a422carrying the per-fieldviolations. An optionalafterWrite(editor, { operation, id })hook runs after each create, update, and delete, and a throw from it never fails the write. Plain CRUD writes don’t touch the search index, so passafterWrite: (_editor, { id }) => reindexDoc(db(env.DB), pages, pagesCollection, id)to keep that one row searchable, rather than rebuilding the whole index withreindexSearchafter every edit. For a versioned collection, passversionsTableanddrafts: { config, bufferKv? }, so an update also lands in the page’s pending draft and the next publish doesn’t undo it; see Writing to the live row directly.versionsRoute—the draft/publish + version history surface for aversionscollection:GET/POST /api/louise/pages/:id/versions(list / save a draft),POST …/:id/publish({ versionId? }, default the latest draft; aversionIdthat isn’t a version of page:idgets a404),POST …/:id/unpublish. Only an absentversionId(an empty body or{}) means the latest draft. AversionIdthat’s present but isn’t a positive JSON integer, such as"7",1.5, ornull, gets a400, and so does a body that isn’t a JSON object. A save merges the edit over the current row and stores a full snapshot in${slug}_versions; publish promotes it onto the live row and setspublished_version_id. Takes{ table, versionsTable, config, resolveEditor, validate? }; mount it beforepagesRouteso its/:id/versionspaths aren’t claimed bypagesRoute’s/:idmatcher. The versionsGETreturns the current field revisions asrevs, a save returns the revisions of the fields it stored, and a save whose$baseis stale for a field someone else changed gets a409withconflicts; see When someone else saved first. PasssoftLocks(realtimeSoftLocksfromlouise-toolkit/realtime) and a save that changes a field another editor holds in the realtime session gets a423withlocked; see When someone else is editing.searchRoute—full-text search over a collection with asearchconfig:GET /api/louise/pages/search?q=…&limit=…returns ranked (published) rows from the FTS5 index;POST …/reindexrebuilds it from the table. Ajsonfield insearch.fieldsis indexed by flattening every string leaf, so structuredsectionscontent is searchable. Also mount beforepagesRoute. Passvector: { index, ai }to add a semantic layer: the route embeds the query, queries Vectorize, and merges both result lists withfuseRankings. Without the bindings it stays FTS-only.vector.minScoredrops semantic matches below a score floor before the merge; it has no default.mediaRoute—wrapslouise-toolkit/media: magic-byte- sniffed uploads (recording intrinsicwidth/height), the registry list,PATCHto set an asset’salt/caption(only those two columns are writable), and a delete-safety reference scan (a409 in_useunless?force=1). Its env widensEditorRouteEnvwith the R2 bindings (MediaRouteEnv:MEDIA,MEDIA_URL).listMediaRoute—the same GET/POST/DELETE contract asmediaRoutebut with nomediaregistry table: GET lists the R2 bucket directly vialistMedia, and POST reads an allowlisted uploadscopefrom the form (scopes, first is the default) rather than a fixed one. SameMediaRouteEnv(delete-safety still scans D1 content tables).formRoute—the public capture companion toinquiriesRoute, built from adefineFormdefinition. POST only, same-origin-guarded but not session-gated (anyone may submit): validates + coerces against the form’s fields (422with per-fieldviolations), enforces the declared spam guard (KV rate limit viarateLimitKv, Turnstile viaturnstileSecret, and silent honeypot/timing heuristics), inserts the row, and fires the form’snotify(webhook + email via amailer) off the response path. PassgenericTableto store into the sharedsubmissionstable ({ form, data }) so an ad-hoc form needs no migration. Mounted at/api/louise/forms/<name>.submissionsRoute—the editor-gated review companion for a genericformRoute(genericTable): GET lists oneform’s rows from the sharedsubmissionstable newest-first (parsingdataback onto each row), DELETE removes one by?id=. Gives each catalog form its own review tab over one table.settingsRoute—GET/PATCH thesite_settingssingleton. Extensible, not a closed set: it patches an allowlisted structured base (columns, the frameworksiteSettingsColumns) and merges site-declaredcustomKeysinto thecustomJSON. A key in neither allowlist is ignored, never written—this is what backs the Settings panel’s extension groups. DeclareimageKeys(withmediaBase= yourMEDIA_URL) to reject an image setting (logo, favicon, share image…) that isn’t a media asset—a422.blobSettingsRoute—the variant for sites that keep all config in a single JSON blob column (not the structuredsiteSettingsColumns), paired with the Settings’settingsBaseGroups: []+ render fields.allowis a{ key: sanitize }map (only listed top-level keys are merged into the blob, each through its sanitizer; anything else is ignored); an optionalreadtransforms the blob on GET (for example, seed-merge). GET returns{ settings: <blob> }.saveRoute—the inline edit-on-the-page save endpoint. Each collection declares its editable fields (SaveCollectionConfig); values are resolved and sanitized before write.inquiriesRoute—read-mostly: list submissions newest-first, delete one by?id=.statusRoute—the public route an outside probe reads. See The status route.
The status route
Section titled “The status route”statusRoute answers whether the site works, for something outside Cloudflare
to poll. It answers GET and HEAD at /api/louise/status with 200 when every
check passes and 503 when any check fails, throws, or times out. It’s a
publicRoute,
so the API gate lets an anonymous probe through, and the
@louise-toolkit/astro middleware exempts its default path too.
import { ageCheck, d1Check, statusRoute } from "louise-toolkit/editor";import { readHealthSummary } from "louise-toolkit/health";
const HOUR = 60 * 60 * 1000;
statusRoute<Env>({ checks: { db: d1Check((env) => env.DB), // A daily scan, so 36 hours leaves room for one late run. healthScan: ageCheck(async (env) => (await readHealthSummary(env.KV))?.checkedAt, 36 * HOUR), // Anything else that means "working" for your site: return true or false. content: async (env) => !(await readsSeedContent(env)), }, reuseMs: 10_000,});{ "ok": false, "checks": { "db": { "ok": true }, "healthScan": { "ok": false, "ageMs": 190800000 }, "content": { "ok": true } }}You supply the checks. Only your site knows what “working” means, so the
route has none of its own. A check is (env, signal) => boolean | { ok, ageMs? },
sync or async, and anything other than true or { ok: true } counts as a
failure. With no checks, the route answers 200 whenever the Worker runs. Two
builders cover the generic cases:
d1Check(db)passes when the database answersSELECT 1. It reads no rows, so it costs next to nothing. A missing binding fails it.ageCheck(read, maxAgeMs)passes when the timestampreadreturns (an ISO string, epoch milliseconds, or aDate) is no older thanmaxAgeMs, and reports the age: the last health scan, a catalog snapshot, any scheduled job’s last success. A missing or unparseable timestamp fails with no age. A future one (clock skew) passes with an age of 0.
The body carries booleans and ages, never an error’s text. A check that
throws is logged with console.error and reports { ok: false }; the message
and stack stay in your logs. The check names appear in the body, so don’t put
anything in a name you wouldn’t publish. Every response, including a 405, has
Cache-Control: no-store, so no cache between the probe and the Worker can
answer for it.
Every check has a timeout, timeoutMs (default STATUS_CHECK_TIMEOUT_MS,
2 seconds). The checks run at once, so a hung dependency makes a 503 in about
that long, not a hung probe. The check’s signal aborts at the timeout; pass
it to a fetch so the request stops too.
Keep the checks cheap: anyone can make them run. Set reuseMs to reuse a
finished result for that long, within one Worker isolate, so a burst of
requests costs one run of the checks per isolate. It defaults to 0: every
request runs them.
runStatusChecks(env, checks, { timeoutMs }) is the same run without the
route, for a scheduled job that wants the same answer. path changes the mount
from /api/louise/status. If you move it, a framework middleware gate no
longer exempts it by default: add the new path to apiGate.isPublic.
Resuming a draft
Section titled “Resuming a draft”resumeDraft(d1, { versionsTable, collection, bufferKv? }, row) returns the
editor’s work-in-progress for a versioned row ({ id, publishedVersionId }), or
null: the KV buffer first, then the newest draft newer than the live pointer.
That is the same base applySaveDraft layers a save onto, so edit mode shows what
the next save builds on. See Drafts → Rendering.
Pure helpers
Section titled “Pure helpers”The security-sensitive logic is factored into pure, testable functions you can reuse or unit-test:
pickFields(input, fields, richFields, sanitize)—allowlist + sanitize a create/update payload (pagesRoute).partitionSettingsPatch(patch, columns, customKeys)—split a settings patch into base-column updates,customupdates, and ignored keys (settingsRoute).mergeBlobPatch(blob, patch, allow)—merge an allowlisted{ key: sanitize }patch into a settings blob, returning{ blob, ignored, changed }without mutating the input (blobSettingsRoute).resolveFieldValue(...)—resolve one inline-save field against its collection config (saveRoute).
Also exported: runEditorRoute, guardEditor, json, matchPath, ident,
tableMeta, and the EditorRouteEnv / ResolveEditor types.
Mounting a write on your own transport
Section titled “Mounting a write on your own transport”Three helpers are the route-free cores of the write paths, for a host that mounts
its own endpoint rather than using the WorkerRoutes above—an Astro Action, say:
applyFieldSave(env, config, session, input)—one inline field write, the body ofsaveRoute.applySaveDraft(env, deps, session, id, snapshot, { base?, softLocks? })—a versioned draft write, the body of the draft route.idis aPageId.baseis the field revisions the save started from; a stale one is a409withconflicts.softLocksis where the held soft-locks come from; changing a held field is a423withlocked. This is also what the realtime Durable Object calls, so there is exactly one write path rather than two that can drift.fieldRev(value)andfieldRevs(data, keys)compute the revisions, andDRAFT_BASE_KEYis the$basebody key.applySettingsPatch(env, config, session, patch)—a settings write, the body ofsettingsRoute.
They run the same validation, sanitization and access checks the routes do; the route is only the transport. Reach for these when the transport is yours and the behaviour should not be.