Scriptorium is a single WFL program. This note explains how the pieces fit and, importantly, the WFL constraints that shaped the design — so the structure reads as deliberate rather than accidental.
This describes Scriptorium as built, not the house standard. The layout a new WFL project should adopt is
PROJECT-LAYOUT.md. The constraints catalogued below — especially #2, includes forming a tree so diamonds break — are what that policy is built around; its composition-root rule exists to stop a new project inheriting the 51 KBmain.wflthis one ended up with. Scriptorium is grandfathered and is not being retrofitted.
Browser
│ HTTP (127.0.0.1:8080)
▼
main.wfl ── listen on port ── main loop: wait for request ── route ──┐
│ │
├─ /assets/* → serve_asset (read binary + mime_type) │
│ └─ /assets/uploads/* = media uploaded via admin │
├─ /, /post/:slug, /page/:slug → dispatch_public → Scribe → HTML │
└─ /admin/* → dispatch_admin (session + CSRF + role gate) │
│ │
▼ │
SQLite (scriptorium.db) ◄───────────────┘
users · sessions · posts · pages · settings · media · login_attempts
Library layers, each pulled in with include from (see the include rule below):
render.wfl ── auth.wfl ── db.wfl ── util.wfl
└──────── lib/scribe/src/scribe.wfl (git submodule)
main.wfl ── render.wfl (+ defines the router and every request handler)
- util.wfl —
slugify,to_int,field_or,truncate_text,file_ext,file_stem. Form/cookie parsing is not here: WFL's stdlib already shipsparse_form_urlencodedandparse_cookies(both percent-decode), so we use those. - db.wfl — the SQLite schema (idempotent
CREATE TABLE IF NOT EXISTS) and everyquery/executethe app runs. Helpers take the connection handle as a parameter, so the data layer is testable againstsqlite::memory:. - auth.wfl —
hash_password/verify_password, session create/lookup/delete (each session carries a CSRF token, validated bycsrf_ok), and theis_admin/can_editrole checks. - render.wfl — builds the shared
sitecontext (title, nav, current user) and wraps Scribe'sscribe_render_filewith the theme/admin template dirs. - main.wfl — boot (open DB, migrate, seed the first admin), the request loop,
the
route-based dispatcher, and all handler actions.
WFL's web server is pull-based and single-threaded: the loop blocks on
wait for request, handles exactly one request, responds, and loops. There is
no middleware and no built-in session support, so the loop does the plumbing:
wait for request comes in on web_server as req
store req_method as method
store req_path as path
store req_body as body
store req_ip as client_ip # feeds the login rate limiter
store cookie_hdr as header "Cookie" of req
call dispatch with db and req and req_method and req_path and req_body and cookie_hdr and req_ip
dispatch resolves the current user from the cookie, then routes with WFL's
route construct and path_params. Handlers are ordinary actions that call
respond to req …. Since WFL 26.7.26, header "X" of req, body_bytes of req
and friends also resolve inside actions from a passed request object — the
media-upload handler uses that to read the multipart body and its
Content-Type itself instead of having the loop thread them through.
These are real properties of WFL that the structure works within. The first MVP targeted v26.7.25; WFL#597 (fixed in 26.7.26) lifted several of them — noted inline:
- Request values used to resolve only in the top-level request loop.
Lifted in 26.7.26:
header "X" of req,path of req,method of req,body of req,body_bytes of reqandquery of reqnow work inside actions that receivereq. The loop still threadsmethod/path/body/client_ipinto handlers as plain parameters (it reads them once anyway), but new code — like the media-upload handler — reads what it needs straight fromreq. - Includes form a tree, not a flat namespace — diamonds break. A file only
sees definitions from files it includes (transitively), and an include that
was already pulled in elsewhere is skipped. → The libraries form a strict
chain
util ← db ← auth ← render,mainincludes onlyrender, and the router + handlers live inmain.wfl(so they share one scope). Scribe is included exactly once (byrender.wfl). - No query strings. Lifted in 26.7.26 (
query/query of req+parse_query_string). Scriptorium's URLs still keep state in the path (/blog/page/2) — they predate the fix and are stable, shareable URLs. - No transactions / no migration engine. → Schema is idempotent DDL run at
boot; every write is a single statement. (The one post-MVP column addition,
sessions.csrf_token, is atry-guardedALTER TABLEat boot.) - No CSRF/session helpers. → Sessions are a random
secure_random_bytesid in anHttpOnly; SameSite=Laxcookie, stored in asessionstable with a SQL-checked expiry. CSRF tokens ride the same table — see Security below. - Request bodies were capped at 1 MB with no multipart parser. Lifted in
26.7.26:
.wflcfgsetsweb_server_max_body_size = 10485760(10 MiB) and the upload handler parsesmultipart/form-datawithparse_multipart. - Reserved words.
found,missing,undefined,data,content,status,count,header,one, … are keywords, so identifiers avoid them (the_status,password_hash,svalue,media_row, …).
- CSRF. Every admin
<form method="post">carries a hiddencsrf_token. For signed-in users the token is minted per session (generate_csrf_tokenat login, stored insessions.csrf_token) and validated centrally indispatch_admin: any admin POST whose token doesn't match the session's — compared withconstant_time_equals— is rejected with 403 before any handler runs. The multipart upload route is the one exception: its token travels as a form part and is checked insidehandle_media_upload. The login form has no session yet, so it uses a double-submit cookie: the rendered form mints a token, sets it as a short-livedcsrfcookie and embeds it as the hidden field; the POST must present both, matching. Because the gate only inspects POSTs, every mutating route — updates, deletes, logout — is POST-only; otherwise a plain GET (a prefetcher, a crawler, an<img src=…>) would bypass the token check entirely. Update and delete routes answer a non-POST with 405./admin/logoutinstead redirects a GET to/adminwithout touching the session — same guarantee (the GET cannot log anyone out), friendlier to a stale bookmark. - Login rate limiting. Failed logins are recorded per client IP in
login_attempts(timestamps are SQL-side, like everything else). More than 10 failures from one address inside 15 minutes →429for that address, before credentials are even checked. A successful login clears the counter; stale rows are purged at boot. This is the crude in-app limiter the single-threaded server allows — a robust one still wants upstream concurrency/middleware support. - Media uploads. Only
png/jpg/jpeg/gif/webpextensions are accepted (no SVG — it can carry scripts). Stored names are re-derived, never trusted:slugify(original stem)+ a random hex suffix + the vetted extension, so path separators or oddball characters in the client's filename never reach the filesystem. Bytes land in the uploads directory (see Storage below —static/uploads/by default), served by the existing/assets/uploads/*route, which already rejects.., and amediarow records size/type/uploader. Deleting is uploader-or-admin (can_edit). The file content is not decoded or signature-checked: WFL currently has no image decoder and no byte-level access to binary values, so the extension allowlist is the only content gate. The exposure is contained — uploads are always served with an extension-derivedimage/*content type, nevertext/html— so mislabelled bytes render as a broken image rather than executing. Revisit if the WFL stdlib grows binary inspection primitives.
All mutable state lives in one place so a container or CI deploy can mount a
single volume and never lose content on redeploy. main.wfl resolves this at
boot: it reads an optional data_dir key from .wflcfg (with the pure,
unit-tested config_value_from helper in util.wfl — WFL programs cannot read
their own .wflcfg through the runtime, so the app parses the file itself) and,
when set, keeps the database at <data_dir>/scriptorium.db and uploads at
<data_dir>/uploads/, creating the directory if needed. The /assets/uploads/*
route reads from the same directory, so media stays reachable wherever it lives.
The two path globals (db_path, uploads_dir) are declared up front with their
legacy defaults (scriptorium.db, static/uploads) and only re-pointed when
data_dir is set — so an existing install with no data_dir behaves exactly as
before. Backward compatibility is preserved; the new layout is strictly opt-in.
| Table | Columns |
|---|---|
users |
id, username (unique), password_hash, role (admin/author), created_at |
sessions |
id (random hex), user_id, created_at, expires_at, csrf_token |
posts |
id, slug (unique), title, body_markdown, status (draft/published), author_id, created_at, updated_at |
pages |
id, slug (unique), title, body_markdown, status, author_id, created_at, updated_at |
settings |
skey (PK), svalue |
media |
id, filename (unique, on disk under the uploads dir — static/uploads/ by default, <data_dir>/uploads/ when configured), original_name, content_type, size, uploader_id, created_at |
login_attempts |
id, ip, attempted_at |
Timestamps are filled by SQLite (DEFAULT (datetime('now')) and explicit
datetime('now') in updates), so the app never formats dates itself.
Scribe is a Twig-style engine, tracked as a git submodule at lib/scribe
(upstream: WebFirstLanguage/Scribe);
render.wfl includes it from ../lib/scribe/src/scribe.wfl. A submodule pins
one exact Scribe commit, so a checkout is reproducible — bumping that pin to a
newer Scribe is a deliberate step (scripts/update-scribe.sh, or the weekly
update-scribe workflow), not something that happens on its own. Template paths
resolve relative to the process working directory, which is why Scriptorium
must be run from the repo root and templates reference each other by root paths
({% extends "themes/base/templates/skeleton.html" %}). Every {{ … }} is
auto-escaped; post/page bodies go through the markdown filter, which escapes
input first and neutralises dangerous link schemes.
- A new content type = a table + helpers in
db.wfl, templates, handlers inmain.wfl, and route arms indispatch_admin/dispatch_public. - A new theme = a second folder under
themes/and anactive themesetting (therender_publicwrapper is the single place that maps names to paths). - A new protected form = include
<input type="hidden" name="csrf_token" value="{{ site.csrf }}">in the form; the POST gate indispatch_adminvalidates it before your handler is reached — nothing else to do.