Headless EDN-over-HTTP daemon: one JVM owns one KB and serves it to remote clients
(vaelii.impl.client). A thin reitit-ring + jetty layer over vaelii.core, the
network dual of the in-process API.
Wire format is EDN. A sentence is a symbol s-expression — (dog Fido), ?x,
(genl dog animal) — which EDN round-trips losslessly; JSON would mangle the symbols.
The body of every call is {:op <keyword> :args [...]}, and the reply is
{:ok true :result …} or {:ok false :error "…"}. EDN is read with
clojure.edn/read-string (never clojure.core/read-string), so an untrusted body
cannot evaluate code — EDN has no reader-eval.
A refusal's :type is a plain keyword — :body-too-large, :not-edn,
:cross-origin, :bad-host — and so is the :type an engine ex-info carries
through. The protocol is what a client written against another build discriminates
on, so it cannot be qualified by the namespace that happens to serve it: a
::-qualified keyword names this namespace, and a client matching on it would be
matching on where the daemon's code lives.
The daemon is the single writer (docs/storage.md, the single-writer contract): it owns the one process allowed to mutate the store, so it serializes every op through one monitor. Concurrent client writes therefore apply one at a time and cannot interleave; reads pay the same lock, which is conservative but keeps the contract simple.
Only the allowlisted ops are reachable (ops). Each is a vaelii.core fn with
the KB supplied by the daemon — the client sends only the op and the remaining args —
so no client can reach an arbitrary var. Sentex records in a result are projected to
plain maps before they hit the wire (the sentex-map contract), so the client reads
them back without the impl record class.
Nothing authenticates a caller, so vaelii.impl.guard stands in for the session
the daemon does not have: POST /op requires Content-Type: application/edn, refuses
a cross-origin Origin, and answers only to a Host naming the interface it was
started on. Together those stop a page the operator happens to visit from driving
the KB over loopback — which binding to loopback alone does not.
Headless EDN-over-HTTP daemon: one JVM owns one KB and serves it to remote clients
(`vaelii.impl.client`). A thin reitit-ring + jetty layer over `vaelii.core`, the
network dual of the in-process API.
**Wire format is EDN.** A sentence is a symbol s-expression — `(dog Fido)`, `?x`,
`(genl dog animal)` — which EDN round-trips losslessly; JSON would mangle the symbols.
The body of every call is `{:op <keyword> :args [...]}`, and the reply is
`{:ok true :result …}` or `{:ok false :error "…"}`. EDN is read with
`clojure.edn/read-string` (never `clojure.core/read-string`), so an untrusted body
cannot evaluate code — EDN has no reader-eval.
**A refusal's `:type` is a plain keyword** — `:body-too-large`, `:not-edn`,
`:cross-origin`, `:bad-host` — and so is the `:type` an engine `ex-info` carries
through. The protocol is what a client written against another build discriminates
on, so it cannot be qualified by the namespace that happens to serve it: a
`::`-qualified keyword names *this* namespace, and a client matching on it would be
matching on where the daemon's code lives.
**The daemon is the single writer** (docs/storage.md, the single-writer contract): it
owns the one process allowed to mutate the store, so it serializes every op through
one monitor. Concurrent client writes therefore apply one at a time and cannot
interleave; reads pay the same lock, which is conservative but keeps the contract
simple.
**Only the allowlisted ops are reachable** (`ops`). Each is a `vaelii.core` fn with
the KB supplied by the daemon — the client sends only the op and the remaining args —
so no client can reach an arbitrary var. Sentex records in a result are projected to
plain maps before they hit the wire (the `sentex`-map contract), so the client reads
them back without the `impl` record class.
**Nothing authenticates a caller**, so `vaelii.impl.guard` stands in for the session
the daemon does not have: `POST /op` requires `Content-Type: application/edn`, refuses
a cross-origin `Origin`, and answers only to a `Host` naming the interface it was
started on. Together those stop a page the operator happens to visit from driving
the KB over loopback — which binding to loopback alone does not.(-main & args)Run the daemon in the foreground. Args: [port [dir]] [--listen ADDR] — dir
selects the durable :disk backend (recovered on open, so it persists across
restarts); with no dir the KB is in-memory and lives only as long as the process.
lein run -m vaelii.impl.serve 4200 /var/lib/vaelii lein run -m vaelii.impl.serve 4200 /var/lib/vaelii --listen 0.0.0.0 ; opt-in
It binds loopback unless --listen says otherwise, for the reason on loopback
above: POST /op writes, and nothing authenticates it. Put a reverse proxy that does
in front of it before naming an address — and note that naming one also drops the
Host allowlist (guard/allowed-hosts), since the name you reach it by is then
yours to know; set VAELII_ALLOWED_HOSTS to keep the check.
Run the daemon in the foreground. Args: `[port [dir]] [--listen ADDR]` — `dir` selects the durable `:disk` backend (recovered on open, so it persists across restarts); with no `dir` the KB is in-memory and lives only as long as the process. lein run -m vaelii.impl.serve 4200 /var/lib/vaelii lein run -m vaelii.impl.serve 4200 /var/lib/vaelii --listen 0.0.0.0 ; opt-in It binds **loopback** unless `--listen` says otherwise, for the reason on `loopback` above: `POST /op` writes, and nothing authenticates it. Put a reverse proxy that does in front of it before naming an address — and note that naming one also drops the `Host` allowlist (`guard/allowed-hosts`), since the name you reach it by is then yours to know; set `VAELII_ALLOWED_HOSTS` to keep the check.
(app kb)(app kb {:keys [host] :or {host loopback}})The ring handler for a KB — pure request -> response, so it is tested without a
socket. One monitor per handler serializes the ops (the single-writer contract).
:host names the interface this handler will be served on, which fixes the Host
values it answers to (guard/allowed-hosts). On the loopback default that refuses
a rebound DNS name, the one attack same-origin? cannot see.
The ring handler for a KB — pure `request -> response`, so it is tested without a socket. One monitor per handler serializes the ops (the single-writer contract). `:host` names the interface this handler will be served on, which fixes the `Host` values it answers to (`guard/allowed-hosts`). On the loopback default that refuses a rebound DNS name, the one attack `same-origin?` cannot see.
The reachable operations, keyed by op keyword. Reads, writes, and introspection —
the working set a remote caller needs; extend by adding a vaelii.core fn here.
The reachable operations, keyed by op keyword. Reads, writes, and introspection — the working set a remote caller needs; extend by adding a `vaelii.core` fn here.
(port server)The actual TCP port a started Server is listening on — the ephemeral one when it
was started with :port 0, read off its first connector.
The actual TCP port a started `Server` is listening on — the ephemeral one when it was started with `:port 0`, read off its first connector.
(start kb {:keys [port host] :or {port 4200 host loopback}})Start the daemon over kb and return the running jetty Server (:join? false, so
the caller controls its lifetime — a test stops it in a finally). :port 0 binds
an ephemeral port; read the actual one with port.
:host defaults to loopback; pass an address ("0.0.0.0") to bind publicly, and
read the note on loopback before doing so.
Start the daemon over `kb` and return the running jetty `Server` (`:join? false`, so the caller controls its lifetime — a test stops it in a `finally`). `:port 0` binds an ephemeral port; read the actual one with `port`. `:host` defaults to loopback; pass an address (`"0.0.0.0"`) to bind publicly, and read the note on `loopback` before doing so.
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |