TutorialsThe example applicationsEdit on GitHub
blog: both layers#
| artifact | "io.eezo" %% "eezo" (the umbrella) and "io.eezo" %% "eezo-auth" |
| entry trait | io.eezo.EezoApp |
| layers | database and http |
One case class with all three derivations. models/Post.scala carries derives Table, Form, Resource: the table, the HTML form, and the seven CRUD routes over it. AppSchema.scala registers
the table with a unique title, and Main.scala names that schema and the generated route table.
Because Post carries a Table, the generated table hands its routes a JdbcStore, so the posts
live in Postgres and survive a restart.
The derived half of the table is then mounted under /admin: Main.scala splits the routes on
where they came from and wraps only the derived ones in Route.under("/admin"), so the handwritten
app/Index.scala keeps answering GET / and the posts live at /admin/posts. The mount moves the
routes and the URLs the pages emit together; app/Index.scala explains the one link that has to
name /admin by hand.
The blog keeps its table in a Postgres schema named blog (databaseSchema plus the databaseInit
hook), so it shares the dev database with the other examples.
docker run -d --name eezo-pg -p 5442:5432 \
-e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=eezo postgres:17 # skip if it already runs
sbt publishLocalForExample # once, at the repository root
cd examples
sbt "blog/run sync --apply" # the two tables have to exist before the first requestThe first user comes next, since there is no sign up page. Run this line on its own and type the
password at the prompt. It is a separate block on purpose: pasted together with the lines after it,
read would take the next line as the password.
printf 'password: '; IFS= read -rs EEZO_BLOG_PASSWORD; echo; export EEZO_BLOG_PASSWORD[email protected] sbt "blog/runMain CreateUser"
unset EEZO_BLOG_PASSWORD # the server below has no use for it
sbt blog/run # http://localhost:8080, posts at /admin/posts
sbt "blog/run routes" # 11 routes: GET /, the three the guard carries, and the seven
sbt "blog/run status" # in sync ✓An existing blog database predates the users table, so sync --apply is not optional on one:
without it the first sign in fails on a table that is not there.
Signing in and out#
/admin is guarded, and each post belongs to whoever wrote it. models/Post.scala says both in
one line, given Owned[Post, User] = User.guard.required[Post].owning(_.author).except(Action.Index, Action.Show), and that line is also what mounts /admin/login and /admin/logout: a declaration
carries the guard's own routes with it, so the page a refusal redirects to cannot be forgotten.
app/Index.scala says the opposite in the same shape, given Guarded[Index.type] = Guarded.public, because this application has a guard and so every route it mounts has to say who
may reach it. Leaving one out is a compile error naming the type, in the generated table.
Each author edits their own posts#
required is who has to be signed in; except(Index, Show) is whose rows each route reads. Every
signed in author sees the whole blog on /admin/posts and can open anybody's post, and the five
remaining routes read and write that author's rows alone. Opening somebody else's post shows its
fields and offers neither an Edit link nor a Delete button; asking for /admin/posts/<id>/edit on
one answers 403, and a PUT or DELETE at a hand-typed address answers the same. An id that names
no post at all answers 404, so the two are told apart.
author is a field of Post like any other, and it is on no page: the derived form emits no input
for it, the index heads no column with it and the show page prints no row. The handler fills it from
who is signed in, on create and on update both, so a hand-crafted POST carrying
author=<somebody else> writes a post attributed to whoever sent it.
Adding author is drift against a blog database that predates it, and on one already holding posts
sync --apply alone is not enough: author is not null with no default, and Postgres refuses to
add such a column to a table that already has rows. On a database with nothing worth keeping, sbt "blog/run reset" drops every table and recreates them from the model in one pass, at the price of
every user row too, so create the first user again afterward. To keep the existing posts, add the
column nullable first and give each one an owner by hand, then let sync narrow it: alter table blog."post" add column "author" uuid; then update blog."post" set "author" = (select "id" from blog."user" limit 1); then sbt "blog/run sync --apply --force". The update hands every existing
post to one user, whichever the database returns first, and it needs a user to be there: on an empty
user table it writes null into every row and the forced sync then fails on the nulls, so create
the first user before running it. --force is what lets the not null through, since narrowing a
column is a change sync blocks on purpose, but it is not selective: it applies every change sync
was blocking, dropped tables and columns included, so read what sbt "blog/run sync" lists before
forcing it. Either way, a second user is what it takes to see the effect.
Visiting /admin/posts while signed out answers a 303 to /admin/login and remembers where you
were going, so signing in lands on the page you asked for.
/admin/login is unthrottled: it answers every attempt, and every attempt that decodes costs one
bcrypt at strength 12, about a quarter of a second of CPU, whether the password is right, wrong, or
typed against an email no row has; a body that does not decode, an empty password or one past the 72
bytes bcrypt reads, is refused before any hashing. That is deliberate on both counts. Equal cost is
what stops the clock from saying which emails have accounts, and no counter lives in the application
because refusing an address after so many tries belongs where the addresses already are: put this
behind a reverse proxy and rate limit POST /admin/login there, with nginx's limit_req or your
platform's equivalent, before anybody but you can reach it.
POST /admin/logout empties the session, and the front page is where the blog offers it:
app/Index.scala ends with User.guard.logoutForm(request, Url.Absolute("/admin/logout")), which
renders a Sign out button for a browser that is signed in and nothing at all for one that is not.
The guard builds the form rather than the application, because a POST has to carry the CSRF token
and a hand written form that forgot it would be refused with a 403 at the route it posts to.
The button is on / and not on the editing screens because those are derived: a derived page comes
back in a plain envelope with nowhere to put a control, and whether an application can replace that
envelope is still an open question. The address is written out because / is outside the mount, the
same reason the "All posts" link on that page names /admin by hand; a page inside a mount takes
logoutForm's default and never spells the prefix.
The first user, without a sign up page#
There is no registration: User derives Table alone, with no Form and no Resource, so nothing
mounts a page that writes one. CreateUser.scala is the operator's tool instead, a second DbApp
in the same project, and the commands above are the whole of it. It reads the email and password
from the environment rather than from arguments, because an argument list is visible to every
process on the machine through ps. The environment alone does not keep a secret out of the
shell's history file, which records the whole command line, so the password is typed at a prompt
that does not echo instead of being written into the command.
It hashes through Password.hash, which is the only door: User.password is a Password, and the
only way to make one from text is to hash it.
sbt "blog/run dev" (or blog/eezoDev) runs the drift check before serving and answers the drift
page while the drift is dangerous; ../todo/README.md walks through that loop and the rest of the
schema commands.
Why both derivations compile here#
Table is the database layer's derivation and Form and Resource are the http layer's. This
application depends on the umbrella, so both layers are on its classpath and all three derive.
../hello has the http layer only and shows derives Table failing to compile; ../reminders has
the database layer only and shows derives Form failing. Opting out of a layer is one changed line
in build.sbt.
eezo-auth is named in build.sbt beside the umbrella, even though the umbrella already carries
it, and naming it is what turns the "every route must declare who may reach it" rule on. ../todo
depends on the same umbrella and does not name it, so its routes declare nothing and every one of
them is public. Demanding a declaration is a feature an application asks for, not something it
inherits from depending on eezo. Honouring one is not optional either way: the generated table
looks a Guarded up for every route it mounts, so a guard written in an application that never
named eezo-auth still guards.