joetjen.net
EN DE
Application

Architecture

How Lictor is put together: applications that each run and scale on their own, a service for identity, and the site on WebGlue. Code is quoted from the 0.2.0 sources, shortened where marked.

In development · v0.2

Applications, released as one

Every part of Lictor — the site, identity, moderation, platform integration, the chatbot, and the OAuth2 authorization and token servers — is an OTP application with its own supervision tree, its own data and its own stated interface. They ship in one release, versioned together; a node starts the applications its role names, and in development one node starts them all. On Kubernetes that is one image and one Deployment per role, each with its own replica count. The nodes form one BEAM cluster and talk by distributed Erlang, behind each application’s interface.

ApplicationNamed afterWhat it is
lictor_elysiumElysium, the IllusionThe site, and the supervision treesince 0.1
lictor_ketherKetherIdentity: invites, accounts, verificationsince 0.2
lictor_geburahGeburah, the Archon of LawThe OAuth2 authorization serverplanned, M37
lictor_chesedChesed, the Archon of SafetyThe OAuth2 token serverplanned, M37

The supervision tree

prx run compiles the suite, builds the stylesheet, reads the configuration and calls lictor/main, which checks it and starts the tree. In development every service runs on one node, each started once the one before it is running (shortened):

apps/lictor_elysium/src/lictor.prax
[(supervisor/child :pg nil {:id :lictor-scope ...})               ; where services are found
 (supervisor/child webglue.session.memory nil)                    ; the sessions
 (supervisor/child posthorn.mailbox nil {:id :mailbox ...})       ; the development mailbox
 (supervisor/child lictor.kether.service nil {:id :kether ...})   ; identity
 (server/child bandit/child (elysium/app) {:port (port)})]        ; the site

Kether is advertised in a pg group, so the site finds it with lictor.kether/locate exactly as it would on a cluster.

Kether, identity as a service

Kether is one process holding a store, asked through lictor.kether by its pid. A pid reaches across a cluster as it reaches across a node, so the site calls Kether the same way whether they share a node or run in pods of their own. The accounts live behind a store protocol whose first implementation is in memory.

(def k (kether/memory-store))
(kether/invite k {:for "Ada" :now now})   ; #(:ok token) -- the link is /invite/<token>

(kether/register k {:invite token :username "ada" :email "ada@example.com"
                    :password "correct horse battery" :now now
                    :mailer box :from "lictor@localhost"
                    :base-url "http://localhost:4000"})

(kether/verify k verify-token now)        ; #(:ok "ada")
  • A token is the only copy there is: Kether keeps its SHA-256 digest, so a copy of the store is not a handful of live links, and a token brought back is compared in constant time.
  • A password is kept as a shibboleth hash, PBKDF2-SHA256 at 600,000 iterations. A new account stays unverified until its mailed link is used.
  • The mail goes first: a mail that cannot be sent is a registration that did not happen. A refusal keeps nothing and leaves the invite good.
  • Nothing inside Kether reads the clock; every moment is handed in.

The site

The site is a WebGlue application: a function from a request map to a response map, the server only how a request reaches it. Around the routes, outermost first, sit static files, the session, the form body and the CSRF check:

apps/lictor_elysium/src/lictor/elysium.prax
(let [routed (-> (router/handler (routes))
                 (csrf/wrap)
                 (params/wrap)
                 (session/wrap {:store (sessions/store)})
                 (in-world world))]
  (static/wrap routed {:root (static-root) :at "/assets"}))

(elysium/app world) is the application over a world — the Kether handle, the mailbox and a clock — so a test builds one it controls. As the tree starts it, it finds the running Lictor’s world on every request, so a Kether its supervisor restarted is the one asked.

Every class name is written in one namespace, lictor.elysium.page: the layout, a card, a heading, a notice, a field, a button. A page says *a notice that something failed*; how it looks is decided there. The look is daisyUI, vendored and built by Tailwind through webglue_tailwind on every prx compile, and rebuilt beside prx run. Two themes come from the brand palette, and the seal follows the theme.

project.pxn
:prepare   ["tailwind build"]
:alongside ["tailwind watch" "posthorn"]
:tailwind  {:input   "apps/lictor_elysium/assets/lictor.css"
            :output  "apps/lictor_elysium/priv/static/assets/lictor.css"
            :rebuild :always}

Plugins in Logos

Plugins are written in Logos, the language Lictor’s customers will extend it with. That turns Logos in Praxis from a nice-to-have into a product decision: one runtime rather than two. Only the part of Logos the plugin runtime needs is ported when that journey pulls it. Safety controls — bounded execution, rate limits and failure isolation — come with the first plugin, not after the first incident.