WebFluid Documentation
Beta 3 is the second stabilisation release, and it does two jobs. First it works through the
known-issues list beta 2 published and closes everything that could be closed without moving the
shape of the framework — a mail client that talked plaintext, a URI rewriter that rewrote
too much, a url_for that was None off request, a translation that was
skipped whenever it happened to equal its source. Then it goes looking for what nobody had
reported yet, and finds five security defects in a default configuration. The largest of them is
the reason this release is not, as planned, one with no breaking changes at all: Jinja
autoescaping is on now, and that is the one line every application rendering HTML has
to read before upgrading.
New Features
A socket that knows who is asking
The events websocket handler now runs inside a FluidContext carrying the
connection, so a query answered over /ws/events sees the same request
surface an HTTP handler does. FluidContext.current().request is the
WebSocket, and because both it and Request are Starlette
HTTPConnections, the signed-in user, the browser locale and
url_for all resolve against it unchanged. A public query no longer has to
take a principal id as payload — which was the only way to personalise a socket
answer before, and one no server should ever have accepted.
A gate for addresses nobody confirmed
A new EmailVerifiedGate sits between the default gate and
require_2fa. Every guard past require_user —
require_2fa, require_admin, every role and permission guard
— answers 401 EMAIL_NOT_VERIFIED for a user whose email
is unset or whose email_verified is False. Bare
require_user is the only guard left untouched, which is what makes a
verification flow reachable. The predicate is exported as
UserService.email_verified, next to has_2fa and
is_admin.
Bundles, and a channel that means “newest”
wf ocean install learned three options. --bundle/-b takes a
bundle id and is exactly equivalent to naming every package in it; leading zeros are
optional, a package requested twice is installed once, and an explicit
id==version pin beats the bundle's plain id. --pre/-p
resolves the highest prerelease of any channel, so a package at
1.1rc1 installs the candidate while one still at 1.1b2
installs the beta. --prefer-stable/-ps takes the latest stable and only
falls back to a prerelease when there is none. wf ocean search now prints
the bundle id as its first column, zero padded to six digits, which is how bundles are
addressed everywhere else.
Migrations that can alter a SQLite table
Both generated env.py templates now pass
render_as_batch whenever the target dialect is SQLite, online and offline.
SQLite has no real ALTER TABLE, so a column rename or a type change used
to fail on exactly the database every project develops against. Alembic's batch mode
rebuilds the table instead. Existing migration environments keep the old
env.py — it is a template, generated once by
wf migrate init, so an established project has to copy the two lines in
by hand.
Connections that get closed
Database engines were created lazily and then never disposed, so every pool stayed
open until the process died. SQLAlchemy.dispose() is registered as a
shutdown hook and closes the sync and the async engine of every bind;
Bind.dispose is idempotent and lets a disposed bind be used again. On the
same theme, SocketManager no longer keeps a dead subscription entry per
disconnect — and every browser refresh is a disconnect.
A skill, shipped in the package
webfluid/.agents/skills/webfluid ships inside the distribution: a
SKILL.md plus six reference files covering project setup, the runtime,
the batteries, Additives, the surface and the current list of live defects. An agent
working in your repository picks it up from the installed package, and it points at
these docs for anything exhaustive. It is the same material as the Markdown edition of
this site, cut for working in a codebase rather than reading one.
244 tests, and a file named after the audit
Up from 160. tests/test_hardening.py is new and pins all five security
fixes: an unregistered or traversing vite_ns, a kid outside
the rotation's shape, a forwarded header the limiter must ignore, every offsite
redirect shape, and a CSRF payload of the wrong type.
test_rendering.py covers escaping in both directions, and
test_websocket.py runs six malformed message shapes and a binary frame
against a real uvicorn and asserts the socket is still usable afterwards.
Fix Fixes
Templates escape what you put in them
The Jinja environment was built without autoescape, so every
{{ value }} in every template of every application was
interpolated verbatim — an XSS hole anywhere user input reached a page.
Autoescaping is on now through
select_autoescape(("html", "htm", "xml", "xhtml", "svg")), which also
covers every render_string source. That the framework already wrapped its
own injected HTML in markupsafe.Markup is what made the switch safe to
throw. Templates with any other suffix — .txt, .md,
.json — are not escaped, so plain-text mail bodies are unaffected.
See the breaking change below for what this costs you.
A cookie that could read your config
asset_catch is registered as a catch-all GET /{path:path}
whenever a Vite frontend exists, in production as well as debug, and it resolved the
requested file under the vite_ns cookie — which the client
controls and which nothing validated. No traversal was even required: an empty
vite_ns rooted the lookup at project_root, so
GET /app_configs/<app>.ini returned the application's config,
SECRET_KEY included. The namespace is checked against the frontends
actually registered now, and the resolved path must be a file under
project_root.
A key id that named any cache entry
Decoder interpolated the unverified kid header straight into
cache.get(f"jwt:{kid}") and used whatever came back as the HMAC
secret. kid: "current" therefore resolved to jwt:current,
whose value is the key id — a uuid published in the header of every
token the application issues. Anyone holding one valid token could read that id and
sign their own tokens with it, for any sub and any grant. A
kid that is not the 32 lowercase hex characters a rotation produces is
rejected before any lookup now.
A rate limit you could not hit
The limiter used slowapi's get_ipaddr, which prefers an
X_Forwarded_For request header over the real peer. Since nothing verified
it, a client could send a different value on every request and never hit a limit;
behind a real proxy the same function put every client in one bucket, because that
header spelling is not the one proxies send. The key is get_remote_address
now, and PROXY_FIX with PROXY_TRUSTED_HOSTS is the supported
— and only — way a forwarded header changes request.client.
A login that could land anywhere
?redirect= was stored in the session by the OAuth service and handed to
RedirectResponse unchanged, so a crafted login link sent the user to any
origin after a successful sign-in. Only a same-site absolute path survives now; an
absolute URL, a protocol-relative //host and a /\host all
collapse to /.
Drivers inserted once, not everywhere
database_uris inserted the driver with a plain str.replace,
so every occurrence of the scheme word in a URI was rewritten rather than the leading
one. sqlite:///data/sqlite/app.db came back as
sqlite+aiosqlite:///data/sqlite+aiosqlite/app.db, and a MySQL database
named mysql_prod, a user named postgresql or a password
containing the scheme word got the same treatment. Only the scheme prefix is replaced
now.
Mail that actually uses TLS, and says so when it cannot
SyncManager stored MAIL_USE_TLS and then opened a plain
smtplib.SMTP connection, so mail.send() against a server
configured for implicit TLS talked plaintext with the credentials in it. It opens an
SMTP_SSL connection now. Both clients also connected and logged in
before the try that wraps SMTP errors, so a refused greeting or a
rejected password escaped as a raw library error instead of the documented
FrameworkException. And a fake_async=True send ran in a bare
thread nobody joined, so a failure vanished with no entry in the log; the thread body
reports through the framework logger now and the thread is a daemon.
Sockets that survive what you send them
Neither framework socket survived a message that was not a JSON object — a
number, a boolean, null or a binary frame tore the connection down for
the visitor who sent it — and WebSocketDisconnect escaped the
receive loop of both, so every closed tab surfaced as an unhandled exception. Each
socket decodes through a _receive that answers a named error now, and
wraps dispatch so a handler that raises answers an error and leaves the socket usable.
A query that fails answers {"error": "Query '<name>' failed."}
with the exception logged, rather than {"data": null}.
A subscription registry that shrinks again
SocketManager.leave released the socket but not its subscriptions, so
every disconnect left a dead sid behind and the registry grew for the life of the
process — one entry per reconnect, and every browser refresh is a reconnect. It
drops the sid from every event now. unsubscribe, in the same corner,
checked whether anyone was subscribed rather than the caller, so a client that
never subscribed was answered true as long as someone else had.
url_for outside a request
Both the framework and the additive context processor built url_for from
the request in the current context and handed back None when there was
none, so rendering a template from a startup hook, a scheduled job or a mail routine
failed with NoneType is not callable rather than a missing name.
Off-request url_for resolves through the application's route table now,
and external=True prefixes the configured BASE_URL.
A translation that is allowed to equal its source
The domain escalation accepted a catalog's answer only when it differed from the
string it was given, so a deliberate translation that happens to equal its source
— an English catalog translating Save to Save —
was read as a miss and the next domain in the chain answered instead.
MergedTranslations reports hit and miss separately through a new
findtext family, and the escalation tests for a miss rather than comparing
strings.
Signals you can declare at import time
events.create_signal and @events.event created their
broadcaster's consumer loop eagerly with asyncio.create_task, so calling
either before the application's event loop was running — the ordinary case for a
signal declared at module level, right after Fluid(...) — raised
RuntimeError: no running event loop. An event registered outside a running
loop is queued now and wired up from a startup hook once the loop exists. Registering a
genuinely new event off-loop after the application has started is the
one case this cannot paper over, and it raises a FrameworkException that
says so.
Smaller sharp edges
resolve_bearer looked its principal up with int(sub), so a
uuid or an email sub came back as a 500 instead of a 401; a token whose
kid is not in the cache passed None to the signing library
and raised a raw TypeError rather than InvalidTokenError; a
CSRF token carrying a validly signed payload of the wrong shape reached
.get("csrf") and answered 500 instead of
403 INVALID_CSRF; wf create app wrote
app_configs/<name>.ini in the interpreter's locale encoding, which
breaks the moment a config is written on Windows and read anywhere else; and the
websocket proxy cancelled its idle pump task without awaiting it.
Bug Known issues
The i18n socket translates in the wrong language
The events socket got a FluidContext in this release;
/ws/i18n did not. Its cache request resolves the connection's
locale by hand and answers correctly, but its translate request calls the
agettext family with no context and no forced locale, so
get_locale() finds nothing to read and falls back to
BABEL_DEFAULT_LOCALE every time. The two halves of the same socket
disagree. Use the cached catalog (_(), the synchronous client API), which
is loaded once per locale and is correct; treat live._() as English-only
for now.
live._p and live._np pass their arguments in the wrong order
In window.wf.ext.i18n, live._p(context, string) sends
args: [string, context] while the server reads
apgettext(context, string), and live._np sends
[singular, plural, num, context] against
anpgettext(context, singular, plural, num). Both come back untranslated
or wrong. The non-live, cache-backed _p and _np are correct.
ngettext raises without EXT_BABEL
With Babel off, the framework installs a no-op pair into Jinja so _() and
ngettext() still exist. The plural fallback interpolates with
n while the real implementation interpolates with num, so the
conventional '%(num)d items' placeholder renders fine with
EXT_BABEL = 1 and raises KeyError: 'num' — a 500 on the
page — with it off. If your templates must work in both configurations, write the
count into the string yourself rather than relying on the implicit variable.
A bearer grant walks past the email gate
requirement_or_grant and requirement_and_grant try the
session chain first and fall back to the token when it raises an
HTTPException — and the new
401 EMAIL_NOT_VERIFIED is one. The bearer path then checks only the
token's grant list, so a user with an unverified address who presents a valid token
gets through a route the session half would have refused. That a grant also bypasses
CSRF and 2FA is by design; the email check was meant to be universal. Mint grant tokens
only for principals you have already verified.
The styled error page throws away the response it replaces
The after_request processor that swaps in errors/<status>.html
builds a brand new HTMLResponse for 400, 401, 403, 404, 405, 429, 500,
502 and 503 whenever the client accepts HTML. Every header the handler set on the
response it replaces is dropped with it — a Set-Cookie, a
WWW-Authenticate, the Retry-After on a 429. Session cookies
survive, because the session middleware sits outside this one. Return a
JSONResponse, or a status the table does not cover, when the headers
matter.
WF_PROCESSING buffers every response
The request middleware streams a response straight through when no
after_request processor is registered, and buffers the whole body when one
is. WF_PROCESSING registers one unconditionally — the error-page
swap — so every application that renders HTML pays for buffering on every
response, even though that processor reads nothing but the status code. Streaming
responses are still detected and released unbuffered, so the escape hatch works; there
is simply no configuration in which a normal response is not buffered.
The Vite catch-all serves the workspace, not the build
With the traversal hole closed, asset_catch resolves under
<vite_ns>/<path> and
<vite_ns>/public/<path> — that is, under
fluid/frontend/, not under dist/. The framework sets the
vite_ns cookie itself when it serves the Vite index, so in production any
visitor can fetch any file inside a registered frontend workspace:
GET /package.json, GET /src/main.ts. Keep nothing in a
frontend workspace you would not publish, and put secrets in the app config, which is
now out of reach.
An event registered from a request handler keeps that request
create_loop starts the broadcaster's consumer with
asyncio.create_task, which copies the context it was called in. Register
an event from inside a request and the loop task carries that request's
FluidContext for the life of the process, so every handler on that channel
afterwards sees a long-dead Request — and because the wrapper then
re-raises rather than logging, a failing handler disappears into the gather without a
line in the log. Register events from a startup hook or an Additive's
before_enable, which is what every example here does anyway.
A half-registered event survives its own error
When registration off-loop after startup raises the new
FrameworkException, the broadcaster has already been put in the registry,
so events.has_event(name) answers True and
trigger() succeeds quietly into a channel nothing will ever drain. Catch
that exception and you have an event that looks registered and delivers nothing; let it
propagate, which is the right thing to do with it.
Additive request processors skip the Additive's websockets
additive.before_request and additive.after_request are
installed as HTTP middleware on additive.api and additive.app
only. A route on additive.ws is registered without them, so a guard you
wrote as a before_request does not protect your Additive's websocket
endpoints. Check the connection inside the endpoint.
Additive after-request processors do not receive a response
fluid.after_request receives a real Response, because it runs
in the ASGI middleware after the app has produced one.
additive.after_request runs inside the router's endpoint wrapper, before
FastAPI serialises anything, so what it receives is whatever your handler returned
— a dict, a model, a string. Write additive after-request processors against the
return value, not against a response object.
Streaming responses skip after-request processors
The request middleware buffers the response so after_request can rewrite
it, and gives that up the moment the app announces more body to come. A
StreamingResponse therefore passes straight through untouched. A buffered
one keeps the headers the app produced, so a processor that changes the body has to
return a new response rather than mutating the one it was handed.
RATELIMIT_DEFAULT never applies
slowapi evaluates application-wide default limits only from its own middleware, and the
framework installs the exception handler without that middleware. So a route without a
fluid.limit decorator is never checked at all. Installing the middleware
would start rate limiting every route of every existing application on upgrade, which
is not something a stabilisation release gets to do. Put the limit you want on the
route and read RATELIMIT_DEFAULT as intent.
JWT keys live in the cache
Key rotation writes the signing keys into whatever CACHE_TYPE points at,
and a rotation also runs as a startup hook. With the in-process legacy cache every
restart mints a new key and forgets the old ones, so every token issued before it stops
decoding. Run the JWT extension against Redis. A real fix is a key store that is not
the response cache.
Migrate template selection
wf migrate init chooses the single- or multi-database template from
SQLALCHEMY_BINDS in your config, at init time. Binds attached at runtime
through Model.set_bind — which is how
SECURITY_MODELS_DB_BIND, BABEL_DATABASE_BIND and most
Additives do it — are not detected and need the template picked by hand.
Event backpressure
Broadcasts use a bounded per-listener buffer sized by
EVENTS_EVENT_QUEUE_SIZE. A consumer that falls behind still loses its
oldest events — it is logged as a warning rather than happening silently, but
delivery stays best-effort by design.
Hooks live in mix(), not in the lifespan
Startup and shutdown hooks run from fluid.mix() rather than through the
ASGI lifespan protocol, so they do not run when an external ASGI server imports and
serves your app. Run your apps through wf run.
Uncached translations still block
A key you explicitly opted out of the cache is read through a synchronous session on
the gettext path. agettext offers a non-blocking alternative,
but the callables installed into Jinja stay synchronous on purpose.
?lang= accepts locales you do not ship
?lang= and the lang cookie accept any locale Babel can parse,
not only the ones in BABEL_SUPPORTED_LOCALES — only the
Accept-Language path is restricted to them. A request naming a locale you
do not ship gets the untranslated source strings, and a Domain keeps one
loaded catalog per locale it has been asked for, for the life of the process. The set of
locales Babel knows is finite, so this is bounded rather than a leak, but a client can
still make an application hold a few hundred empty catalogs it will never use.
Configs written before beta 3 keep their old encoding
app_configs/<name>.ini is written as UTF-8 and read with a locale
fallback now, so old files keep working. But a non-ASCII value written on a non-UTF-8
console before this release still cannot be recovered on a machine with a different
locale encoding. Rewrite the affected configs once, or keep their values ASCII and move
secrets behind the *_FILE indirection.
HMR relies on a proxied websocket that can die
Hot reload across the main app and multiple Additive frontends rides on the websocket
proxy in front of the shared dev server. That connection can drop on its own — a
Vite restart is the usual trigger — and when it does, the affected frontend
simply stops picking up changes. A browser refresh re-establishes the proxy
immediately; there is no need to restart wf run for it.
Break Breaking Changes
Templates autoescape
This is the one that costs you reading time. A template of yours that
deliberately interpolates an HTML string now shows the tags. Wrap the value in
markupsafe.Markup at the point you build it, or add | safe at
the point you render it — and never do either to something that came from a
request. An existing | e keeps working and does not double escape. The
escaped suffixes are .html, .htm, .xml,
.xhtml and .svg, plus every render_string source;
anything else, .txt and .md included, is untouched. If you
assemble HTML in Python and hand it to a template, remember that joining
Markup pieces with a plain str separator gives a plain
str back. Rendering costs about 12 % more, measured on an
interpolation-dense page; it stays well inside the budget, and it is the whole price of
the fix.
MAIL_USE_TLS and MAIL_USE_STARTTLS swapped defaults
MAIL_USE_TLS now defaults to False and
MAIL_USE_STARTTLS to True, matching the
MAIL_PORT default of 587. The old pair — 587 with implicit TLS
— described a server neither client could reach. If yours really does answer with
implicit TLS, set MAIL_PORT = 465 and MAIL_USE_TLS = True
explicitly. Setting both flags now raises at startup instead of being resolved
differently by each client.
SocketManager takes the application first
SocketManager(fluid, events, queries). It is constructed by the events
extension, so this only concerns code that built one by hand.
has_subscriptions(event, sid) now means what it says on the
unsubscribe path.
Sources.rendered is Markup, and Vite counts namespaces
Sources.rendered is a Markup rather than a str,
and Markup("") rather than "" before the freeze — it has
to be, or autoescaping would escape the framework's own script tags.
Vite._instances, a counter, is replaced by Vite._namespaces,
the set of registered frontend namespaces, and asset_catch takes it as a
second argument.
MergedTranslations gained a findtext family
findtext, nfindtext, pfindtext,
npfindtext and their four a-prefixed pendants answer
None when neither the database nor the compiled catalog carries the
message. The eight gettext methods are unchanged and still answer with the
source string on a miss, so this only breaks a subclass that overrode them expecting
the escalation to call gettext.