editor
import { saveRoute, settingsRoute, blobSettingsRoute, pagesRoute, versionsRoute, searchRoute, mediaRoute, listMediaRoute, formRoute, inquiriesRoute, seedRoute, 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) |
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.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),POST …/:id/unpublish. 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.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.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=.
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)—a versioned draft write, the body of the draft route. This is also what the realtime Durable Object calls, so there is exactly one write path rather than two that can drift.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.