bezel
A web framework for Quarz, in the shape of
Phoenix: an immutable Conn flows through plugs, a router and a
controller action, and comes out with a response. Data goes through
rotor on PostgreSQL
(pg). Nothing is looked up by
reflection at run time and no template is interpreted.
# Cog.toml of an application
[dependencies]
Bezel = { git = "https://github.com/quarz-lang/bezel", branch = "main" }
The API reference is at https://bezel.api.quarz.dev.
Work in progress (the plan is issue #1). What exists: the connection,
parameters, plugs, endpoints, configuration, error pages, Bezel.start,
the built-in plugs, the router and controllers, and the HTML DSL with
forms over rotor's changesets, sessions, the flash, CSRF protection,
secure headers, host authorization, password hashes and signed tokens,
test helpers, the template compiler, and the bezel command.
class Shop::Endpoint < Bezel::Endpoint
def self.plugs -> Array[Bezel::Plug]
[Bezel::Plug::RequestId.new,
Bezel::Plug::Logger.new,
Bezel::Plug::Static.new(at: "/", from: "priv/static"),
Bezel::Plug::Parsers.new(parsers: [:urlencoded, :json], max_body: 1_048_576),
Bezel::Plug::MethodOverride.new,
Bezel::Plug::Head.new,
Bezel::Plug::Timeout.new(15.seconds),
Bezel.plug { |conn| conn.text("hello #{conn.params.fetch("name", "world")}") }]
end
end
# HOST, PORT, DATABASE_URL, SECRET_KEY_BASE... from the environment, over
# the application's settings for its BEZEL_ENV (prod when unset: run with
# BEZEL_ENV=dev when developing)
Bezel.start(Shop::Endpoint, {dev: Bezel::Settings.new(database_url: "postgres://localhost/shop_dev"),
prod: Bezel::Settings.new(port: 8080)})
Bezel::Connis adatavalue: the request, the merged parameters (Bezel::Params: query, body, path;params.int("id"),params["user"].fetch("email"), a missing or malformed one a400), assigns, session and flash, and the response being made (status, headers, body,halted?). Every change makes a new one.- A plug is a
Bezel::Plug(call(conn) -> Conn, andwrapfor one that acts around the rest, asLoggerandTimeoutdo) or a block (Bezel.plug { |conn| ... }). A plug that answers halts the pipeline; an exception a plug raises halts it too, kept in the connection (conn.error) with what the plugs before it did. - An endpoint lists its plugs in
def self.plugs. A request no plug answers is a404. An exception is answered with the error page:Bezel::NotFound,Bezel::Forbiddenand the otherHTTP::Errors with their status, anything else with500, reported to the endpoint'son_error. Indevandtestthe page shows the exception, its backtrace and the request (HTML, or JSON for a JSONAccept); inprodit shows the status only. Headers the plugs set (the request id) stay on the error page. Bezel::ConfigreadsBEZEL_ENV(dev,test,prod;prodwhen unset, so that a deploy that forgets it shows no backtraces),HOST,PORT,DATABASE_URL,POOL_SIZE,SECRET_KEY_BASE(required inprod, 64 bytes or more),TRUSTED_PROXIES,TLS_CERT/TLS_KEYandLOG_LEVELover the application's ownBezel::Settingsper environment.Bezel.startserves the endpoint withHTTP::Server, opens aPG::Poolwhen there is aDATABASE_URL, and stops gracefully onSIGTERMandSIGINT(a second signal ends the process at once).
A router declares its routes in one routes block, checked where it is
compiled. A controller declares its plugs, one plug or skip line
each (a controller without plugs of its own writes controller); the
class generates the dispatch to its actions, which runs the plugs:
class Shop::UsersController < Bezel::Controller
plug :require_login, except: [:index, :show]
def index(conn: Bezel::Conn) -> Bezel::Conn = text(conn, "users")
def show(conn: Bezel::Conn) -> Bezel::Conn = text(conn, "user #{conn.params.int("id")}")
# new, create, edit, update, delete ...
private def require_login(conn: Bezel::Conn) -> Bezel::Conn = ...
end
class Shop::Router < Bezel::Router
def self.browser -> Array[Bezel::Plug] = [Bezel::Plug::Accepts.new(["html"])]
def self.api -> Array[Bezel::Plug] = [Bezel::Plug::Accepts.new(["json"])]
routes do
scope "/", pipeline: :browser do
get "/", Shop::PageController, :home, as: "home"
resources "/users", Shop::UsersController do
resources "/posts", Shop::PostsController, only: [:index, :show]
end
end
scope "/api", pipeline: :api, as: "api" do
resources "/users", Shop::API::UsersController, except: [:new, :edit]
end
end
end
Shop::Router.user_path(id: 5) # => "/users/5"
Shop::Router.user_post_path(user_id: 5, id: 2) # => "/users/5/posts/2"
- A route whose controller lacks its action, a pipeline the router does not define, a controller plug that is not a method, and a route helper given a wrong keyword are compile errors.
resourcesmakes the seven routes (index,new,create,show,edit,updatewithPATCHandPUT,delete),only:/except:choose, and a block nests routes under/{user_id}.- Matching is the standard
HTTP::Router's: the most specific pattern,HEADbyGET,405withAllowfor another method's path.
Views are Quarz code: Bezel.html gives a builder with a method per
HTML element, every text and attribute escaped, attributes as typed
keywords (class_name: is the class attribute, since class cannot
name a parameter; class: waits for quarz-lang/quarz#382). A layout is
a function taking the inner HTML; a component includes
Bezel::Component; a form over a changeset shows its values and errors:
module UserView
def self.edit(conn: Bezel::Conn, cs: Rotor::Changeset[User]) -> Bezel::Html
Bezel.html(conn) do |h|
h.h1("Edit #{cs.data.name}")
h.form_for(cs, action: Shop::Router.user_path(id: cs.data.id), method: :patch) do |f|
f.label(:email)
f.email_field(:email)
f.errors(:email)
f.submit("Save")
end
h.link("Delete", to: Shop::Router.user_path(id: cs.data.id), method: :delete, confirm: "Sure?")
end
end
end
- A misspelt attribute or one of the wrong type is a compile error;
attrs:passes others (event handlerson...are refused),data:andaria:thedata-*andaria-*ones. - URL attributes, typed or given in
attrs:(andsrcset, a meta refresh), refusejavascript:and other schemes than http, https, mailto and tel (relative URLs are fine);srcdocis refused;<script>and<style>refuse content that could end them. Astyleis escaped but not checked: do not build one from input. - Forms carry the CSRF token (from
Bezel.html(conn), once the CSRF plug sets it) and_method, name their fieldsuser[email], and show the input as typed for a field with errors.link'sconfirm:writesdata-confirmfor the application's script.
Templates are HTML with Quarz expressions (*.html.bz), compiled ahead
of time into DSL code (Bezel::Templates; bezel templates writes
src/views/generated/, committed, with each template's digest in the
header). Nothing is interpreted at run time and the generated code is
type-checked and escaped as hand-written DSL code is:
{@args conn: Bezel::Conn, users: Array[User], banner: String}
<h1 class="title">Users</h1>
<ul :if={!users.empty?}>
<li :for={u <- users}><a href={Shop::Router.user_path(id: u.id)}>{u.name}</a></li>
</ul>
<.card title="Help">{users.size} users.<:footer>{raw(banner)}</:footer></.card>
{expr}writes a value escaped (aBezel::Htmlas it is, nil as nothing);{raw(expr)}, likeBezel::Html.rawin the code, writes a string unescaped, and nothing else does.:for={pattern <- expr}and:if={expr}repeat or guard an element;<.card>is a component (Card.new(title: "Help"), the content itscontent),<:footer>a slot (afooter:argument, aBezel::Html).- Code in braces is one expression the compiler can delimit as Quarz
will:
/and%are operators with a space on each side (a.size / 2) or start a literal after(,[, a comma, the opening brace, or a spaced=~,=,=>,&&,||or keyword argument (gsub(/\s/, ""),s =~ /x/); no#comments or heredocs. - An unknown element or attribute, a boolean attribute given a string,
an unclosed tag: an error naming the template's line.
<!-- -->is a comment of the template, left out of the page.
A browser pipeline adds sessions and protection:
def self.browser -> Array[Bezel::Plug]
[Bezel::Plug::HostAuthorization.new(["shop.example"]),
Bezel::Plug::SecureHeaders.new, # first: error pages get them too
Bezel::Plug::Session.new(key: "_shop"), # an encrypted cookie (AES-256-GCM)
Bezel::Plug::Flash.new,
Bezel::Plug::CSRF.new] # forms from Bezel.html(conn) carry the token
end
- The session holds strings (
conn.put_session("user_id", "7"),conn.session["user_id"]), encrypted under a key derived fromSECRET_KEY_BASE; its cookie isHttpOnly,SameSite=Lax, andSecureoutsidedev. A cookie that does not decrypt is an empty session. - The flash lives one request:
conn.put_flash(:info, "Saved")before a redirect,conn.flash[:info]after it. - CSRF: unsafe methods need the session's token (masked per request) in
_csrf_tokenorX-CSRF-Token, and a request from another origin (Origin,Sec-Fetch-Site) is refused. Bezel::Password.hash/verify?/needs_rehash?(argon2id,Crypto::Argon2, with the cost ofSettings'spassword_params:) andBezel::Token.sign/verify(HMAC, expiring, bound to a purpose, and to afingerprintsuch as the password hash so that a reset link stops working once used) for links sent by mail.conn.session.renewafter a login drops the CSRF secret;dropthenputstarts a new session.
An application is tested through its endpoint, in the test's task,
without sockets (Bezel::Testing):
res = Bezel::Testing.get(Shop::Endpoint, "/users")
page = Bezel::Testing.html_response(res, 200) # a parsed page
page.has_element?("form[action=\"/users\"] input[name=\"user[email]\"]")
page.has_text?("Users")
c = Bezel::Testing::Client.new(Shop::Endpoint, session_key: "_shop") # keeps cookies
c.login_as({"user_id" => "7"}) # the session cookie, as a login writes it
c.post("/users", params: {"user[email]" => "ann@example.com"})
c.flash[:info] # => "Welcome", for the next request
c.follow_redirect # which shows it (and takes it)
Bezel::Testing.json_response(c.get("/api/users"), 200)
Requests in tests skip the CSRF check (a flag of the test application,
refused in prod); Bezel::Testing.app(skip_csrf: false) tests it.
The bezel command
cli/ is the bezel executable (cd cli && quarz build -o bezel, then
put it on the PATH):
bezel new shop # an application: endpoint, router, a page, layout, test, CI
cd shop
bezel server # serves http://localhost:4000, restarting on changes
bezel gen controller about index team
bezel gen model tag name:string
bezel gen migration add_index_to_tags
bezel gen job send_welcome user_id:integer # a divider job (src/jobs/), in Shop::Jobs.all
bezel jobs # runs the jobs: a divider worker (BEZEL_ENV=dev)
bezel gen resource post title:string body:text # model, migration, controller, views, routes
bezel templates [--check] # src/views/*/*.html.bz -> src/views/generated/ (Shop::PageView.home)
bezel migrations [--check] # src/migrations.qz, the list of src/migrations/
bezel routes # the routes of Shop::Router
bezel migrate | rollback | status # with DATABASE_URL (and BEZEL_ENV) set
bezel test
- The application's
src/main.qzcallsBezel.main(ARGV, Shop::Endpoint, settings, router:, migrations:): without arguments it serves;routes,migrate [--to V] [--allow-out-of-order] [--lock-timeout MS] [--table NAME],rollback [--steps N | --to V] [--lock-timeout MS] [--table NAME]andstatus [--table NAME]are whatbezelruns in it (quarz run -- routes); the migration commands need no SECRET_KEY_BASE. - Migrations are
src/migrations/<version>_<name>.qz, each aShop::Migrations::<Name> < Rotor::Migration;genwrites them with a UTC timestamp and rewrites the listShop::Migrations.all. bezel genadds its routes before the router's# bezel gen: routesline.- Background jobs are divider's:
a new application depends on it and runs its migrations with its own.
bezel gen job NAME [FIELD:TYPE...]writessrc/jobs/<name>.qz(aShop::Jobs::<Name> < Divider::Job, withdata Argsandjob_args Argswhen it has fields) and the listShop::Jobs.all(src/jobs.qz).src/main.qzruns them onquarz run -- jobs(whatbezel jobsruns; a compiled application:./shop jobs): a worker with a queue of 5 at once for each queue the jobs name; enqueue withShop::Jobs::SendWelcome.enqueue(repo: conn.repo, user_id: 42). bezel serverruns the application withBEZEL_ENV=dev(unless set) and its output on the terminal. It looks for changes every half second: a changed source (src/**/*.qz), template (*.html.bz) orCog.tomlcompiles the templates again and restarts the application (TERM, then KILL after 5 seconds). One that exits on its own (a compile error) is started again at the next change. Ctrl-C stops both.
License
MIT No Attribution: see LICENSE.
Classes and modules
BezelBezel::AppWhat every request of an application shares: its configuration and, with a DATABASE_URL, the database's pool. Bezel.start makes it; a test makes one with App.new(config).Bezel::BadRequest400 Bad Request: a parameter missing or malformed, a body that does not parse.Bezel::ComponentA component: a class that renders a piece of HTML, given the HTML its caller's block made (Html.empty without a block), with HtmlBuilder#component.Bezel::ConfigAn application's settings, for one environment: what Config.load makes of the environment variables over the application's Settings.Bezel::ConfigErrorA setting is missing or wrong (Bezel::Config): raised when the configuration is loaded, before the server starts. Not an HTTP error.Bezel::ConnA connection: one request and the response being made for it, flowing through the plugs of an endpoint (Bezel::Plug). A Conn is a value: every change makes a new one (with, or the methods below), which a plug returns. A plug that answers (send_resp and friends) and stops the rest of the pipeline also calls halt.Bezel::ControllerThe base of an application's controllers. A controller's actions are its public methods taking and returning a Conn (but the helpers below), which the router calls for its routes. Its body declares its plugs, one plug (or skip) line each, or, without plugs of its own, writes controller; either generates dispatch over the actions, which runs the plugs before them:Bezel::ControllerPlugA controller's plug, as a plug line declares it, with the skips of later lines and subclasses' lines.Bezel::CookieOverflowA session that does not fit its cookie (Session::CookieStore): a 500, since the application put too much in it.Bezel::CookiesRequest cookies and Set-Cookie values.Bezel::ElementsThe HTML elements, as HtmlBuilder's methods: each writes its element with the attributes given (see HtmlBuilder).Bezel::EndpointAn application's entry: the plugs every request goes through, in order, the last of them usually the router. An endpoint is a class whose plugs the application writes; Bezel.start serves it, and a test calls it without a socket (MyApp::Endpoint.call(req)).Bezel::ErrorThe root of the errors an application raises to answer with a status: raise Bezel::NotFound in an action is a 404. The endpoint turns each into its status, with the error page (Bezel::ErrorPage); any other StandardError is a 500. Being an HTTP::Error, it is also answered with its status by a bare HTTP::Server.Bezel::ErrorPageThe answer to a request that raised: an HTTP::Error (Bezel::NotFound and the others) with its status, anything else with 500.Bezel::FlashThe flash: messages kept for the next request (put), and those the previous request left ([]). the flash plug (bezel#6) carries them in the session (kept is what goes there).Bezel::Forbidden403 Forbidden.Bezel::FormBuilderWrites the fields of a form over a rotor changeset (HtmlBuilder#form_for): each field is named <prefix>[<field>] (user[email]), with the id <prefix>_<field>, and shows the value the changeset holds: its change, else the model's; for a permitted field with errors and no change (a value that did not cast, or a blank required one), the input as given, so that it shows as typed. errors writes the changeset's errors on a field.Bezel::HtmlHTML that is safe to send: what Bezel.html builds, every text in it escaped. (Html.new(source) is Html.raw: the caller vouches.) A value: it is concatenated with +, put in another page with HtmlBuilder#append, and sent with Conn#html or a controller's render.Bezel::HtmlBuilderWrites HTML: an element per method (h.div, h.a, ...; see HtmlBuilder in src/html/elements.qz), each taking its content as a text (escaped) or a block, its attributes as keywords, and the global ones (id:, class_name: for class, style:, title:, lang:, dir:, hidden:, tabindex:, role:, data: for data-*, aria: for aria-*, and attrs: for any other). (class cannot name a parameter; class: waits for quarz-lang/quarz#382.) A String attribute is escaped, nil leaves it out, true writes a boolean attribute and false none; an attribute given twice is an ArgumentError. A URL attribute (href, src, action, formaction, cite, poster, a srcset's candidates, a meta refresh's url=, and those names and the other URL attributes of AttrList::URL_NAMES in attrs:) must be relative or of the schemes http, https, mailto or tel (data:image/ for images): another (javascript:) is an ArgumentError. srcdoc is refused. A style is escaped but not checked: do not build one from input.Bezel::InvalidCSRFTokenThe request did not prove that it came from the application's own pages: Plug::CSRF refused it (a 403).Bezel::KeysKeys derived from the application's SECRET_KEY_BASE, one per purpose (the session cookie's, the tokens'): PBKDF2-SHA256 of the secret with the purpose as the salt. Derived once per secret and purpose, then kept for the process.Bezel::LogEventWhat Bezel::Plug::Logger reports of one request.Bezel::NotAcceptable406 Not Acceptable: no format the client accepts.Bezel::NotFound404 Not Found.Bezel::ParamOne parameter's value: a string, a list (tags[]=a&tags[]=b, a JSON array) or a nested map (user[email]=x, a JSON object).Bezel::ParamTree::ListA list.Bezel::ParamTree::MapA map.Bezel::ParamTree::NodeA node.Bezel::ParamTree::StrA string.Bezel::ParamsA request's parameters, merged: the query's, then the body's (a form or a JSON object, Bezel::Plug::Parsers), then the path's (the router's), a later one replacing an earlier one of the same name. Values are strings, lists and nested maps (Bezel::Param); the typed readers raise Bezel::BadRequest (a 400) for a parameter missing or malformed, so that an action reads what it needs in one call.Bezel::PasswordPassword hashes: argon2id (Crypto::Argon2), as PHC strings ($argon2id$v=19$m=19456,t=2,p=1$<salt>$<hash>) that name their parameters, with a random salt and a check in constant time.Bezel::PayloadTooLarge413 Content Too Large: a body over the parsers' max_body.Bezel::PipelineA plug running other plugs in order (a pipeline as one plug), halting when one of them does.Bezel::PlugA step of an endpoint's pipeline: takes the connection and returns it changed, as Elixir's Plug does. A plug that answers the request calls Conn#halt (or one of Conn#text, Conn#html, Conn#json, Conn#redirect, Conn#send_resp, which halt), and the plugs after it do not run.Bezel::Plug::AcceptsChooses the response's format among formats (["html", "json"]) and sets Conn#format (:html): the _format parameter when there is one, else the request's Accept (its media ranges by their quality; */* or no Accept takes the first of formats). A request that accepts none of them, or names another _format, is Bezel::NotAcceptable (406).Bezel::Plug::CSRFProtects a browser pipeline against cross-site request forgery (after Plug::Session):Bezel::Plug::FlashCarries the flash between requests in the session (after Plug::Session): the messages a request put (Conn#put_flash) are the next request's conn.flash[:kind], and then gone. Messages shown in the same request use Bezel::Flash#now.Bezel::Plug::HeadRuns a HEAD request as a GET, so that the routes and actions of GET answer it (the server sends no body for it).Bezel::Plug::HostAuthorizationRefuses a request whose Host header is not one of the application's hosts (a 403), against DNS rebinding and links built from a forged Host (Conn#url). A host is a name (shop.example), a name with a leading dot for it and its subdomains (.shop.example), or an address; the port is ignored and case does not matter.Bezel::Plug::LoggerLogs one line per request, once the rest of the pipeline has answered, with the status the client gets (Conn#final_status: 404 when nothing answered, the error's when a plug raised), the method and path as this plug received them (before Plug::Head or Plug::MethodOverride change the method), and the request's id when Plug::RequestId ran before it: to standard error, or to on_event when given (for structured logs). Lines are written at the info level, so a LOG_LEVEL of warn or error silences them. The path is written percent-decoded, as Conn#path; control characters in it are escaped, so that a request cannot forge a line.Bezel::Plug::MethodOverrideLets an HTML form send PUT, PATCH or DELETE: a POST whose body has _method set to one of them (in any case) gets that method. It reads the body's parameters, so it comes after Bezel::Plug::Parsers; the query's _method is not believed (a link could carry it).Bezel::Plug::ParsersReads the request's body into its parameters (Conn#params, and Conn#body_params alone) by its Content-Type:Bezel::Plug::RequestIdGives each request an id: the request's X-Request-Id when it is a sane one (20 to 200 characters of letters, digits, +, /, =, _ and -, as a proxy in front makes), else a new random one. The id is Conn#request_id and goes back in the response's X-Request-Id, so that a log line and a client's report can be matched.Bezel::Plug::SecureHeadersSets the response headers that keep browsers from misreading or framing the application's pages: X-Content-Type-Options: nosniff, X-Frame-Options (SAMEORIGIN by default), Referrer-Policy (strict-origin-when-cross-origin), and, when given, a Content-Security-Policy and Strict-Transport-Security (hsts:, a max-age in seconds; only for an application served over HTTPS). A header the application sets itself later in the pipeline wins.Bezel::Plug::SessionLoads the request's session from store (conn.session) and, once the rest of the pipeline has answered, saves it when it changed (a cookie store sets its cookie). Not saved when a plug raised: the error page leaves the session as it was (so a first visit's CSRF secret is made again on the next request).Bezel::Plug::StaticServes the files under the directory from at the path at (at: "/", from: "priv/static" serves priv/static/app.css as /app.css), for GET and HEAD; any other request, or a path with no such file, goes on to the next plug. A directory is never listed.Bezel::Plug::TimeoutGives the rest of the pipeline limit to answer a request; past it, the rest is cancelled (at its next wait: a computation that never waits runs to its end first) and the request is answered 503 Service Unavailable (Bezel::TimeoutError).Bezel::PlugSkipA skip of a plug: before the actions only names (all when it and except are empty), but those except names.Bezel::RouteOne route of a router's table: requests of method matching pattern (as HTTP::Router reads patterns: /users/{id}) go through the pipelines, then to action of controller. name is the route's name, for its helpers (user gives user_path), or nil.Bezel::RouteBuilderWhat a router's routes block runs on: it collects the routes. Paths are joined to the enclosing scopes' (scope "/admin" then get "/users" is /admin/users), and route names to their prefixes.Bezel::RouterAn application's router: a plug (the endpoint's last, usually) that matches a request to a route of its routes statement, runs the route's pipelines, and calls the route's action on its controller (Controller#dispatch, which runs the controller's plugs first).Bezel::RunnerAn endpoint's handler: its plugs and the application, made once and shared by the server's connections.Bezel::SessionThe session's values, which Plug::Session loads from its store (Session::Store) and writes back when they changed; empty, and not loaded, without that plug. Values are strings (an application stores ids, not objects).Bezel::Session::CookieStoreA session kept in a cookie: its values as JSON, encrypted and authenticated with AES-256-GCM under a key derived from SECRET_KEY_BASE (Keys), the cookie's name as associated data. A cookie that does not decrypt (another secret, tampered with, another cookie's) reads as an empty session. The cookie is HttpOnly, SameSite=Lax, Path=/ and Secure outside dev, by default; a session over MAX_BYTES once encoded is a Bezel::CookieOverflow.Bezel::Session::StoreWhere a session lives between requests: Plug::Session loads it for a request and saves it when it changed. Session::CookieStore keeps it in an encrypted cookie; a store on the server (rotor-backed) can come later behind the same interface.Bezel::SettingsWhat an application's config/<env>.qz gives for one environment (MyApp::Config.dev returns one): every setting optional, the environment variables taking precedence over it. Secrets are not here: SECRET_KEY_BASE comes from the environment only.Bezel::Tasks::UsageErrorA command or option Bezel.main does not take.Bezel::TemplatesThe template compiler: HEEx-like templates (*.html.bz) made into Quarz code over the HTML DSL (Bezel.html), which bezel templates writes to src/views/generated/ and the application compiles as its own code. Nothing is interpreted at run time; the generated code is type-checked as any other, and escapes as the DSL does.Bezel::Templates::ErrorA template that cannot compile: the message names its path and line (src/views/user/show.html.bz:3: <span> is not closed).Bezel::Templates::SourceA template: its path (named in errors and in the generated file's header), the name of the method it becomes, and its text.Bezel::TestingTesting an application through its endpoint, in the test's own task, without sockets: requests built in memory go through every plug, the router and the actions, and the responses come back to be checked.Bezel::Testing::ClientA browser for tests: requests to one endpoint (the Host www.example.com), keeping the cookies the responses set (a Max-Age of zero or less deletes one; Expires is not read), sending them with the next.Bezel::Testing::DocA parsed HTML page for tests to query (Testing.html_response): its elements as a tree, found by a small selector language:Bezel::Testing::NodeAn element of a Testing::Doc (or its root, without a tag), or a text.Bezel::TimeoutError503 Service Unavailable: the request ran out of the time the Bezel::Plug::Timeout plug gives it.Bezel::TokenSigned, expiring tokens for links sent by mail (confirm an address, reset a password): data (an id), the purpose and the expiry, signed with HMAC-SHA256 under a key derived from SECRET_KEY_BASE. A token is good for its purpose only, until it expires; its data can be read by anyone holding it (it is signed, not encrypted).Bezel::Unauthorized401 Unauthorized.Bezel::UnsupportedMediaType415 Unsupported Media Type: a body of a type no parser takes.