platform
Infrastructure layer for HTTP routing/interceptors, database integration, CLI/runtime wiring, and shared platform adapters used by feature libraries.
Key namespaces
| Namespace | Purpose |
|---|---|
|
HTTP interceptor composition and execution |
|
Compiles every module’s route contribution into the Ring handler |
|
Database setup, context, and shared persistence infrastructure |
HTTP interceptors
Declarative cross-cutting concerns (auth, rate limiting, audit):
;; errors = wagoe.platform.core.http.errors
;; Define an interceptor
(def require-admin
{:name :require-admin
:enter (fn [ctx]
(if (admin? (get-in ctx [:request :session :user]))
ctx
;; To short-circuit you MUST set :halt? true — setting :response
;; alone does not stop the pipeline, so the downstream handler
;; would run and overwrite this response. See "Interceptor phases".
(assoc ctx :halt? true
:response (errors/response 403 :forbidden "Admin role required"))))
:leave (fn [ctx] ctx) ; optional response processing
:error (fn [ctx err] ; optional error handling
(assoc ctx :response (errors/response 500 :internal-error "Internal Server Error")))})
;; Attach interceptors to routes
[["/admin"
{:post {:handler handlers/create-resource
:interceptors ['auth/require-admin
'audit/log-action]
:summary "Create admin resource"}}]]
Built-in interceptors
The default HTTP stack (wagoe.platform.shell.http.interceptors/default-http-interceptors)
is applied to every route unless the route opts out with :skip-interceptors? true
(used only for internal endpoints such as health checks). In :enter/:leave order:
-
http-web-error-pages— renders an error on a/webroute as a page, or a toast for htmx -
http-request-logging— request entry/completion logging with timing -
http-request-tracing— a tracing span per request (no-op without a tracer) -
http-request-metrics— timing and status-code metrics -
http-error-reporting— captures exceptions to the error-tracking service -
http-correlation-header— addsX-Correlation-IDto the response -
http-rate-limit-protection— fixed-window limit from:wagoe/http :rate-limit(:enabled?, default false;:limit100;:window-ms60000), shared across replicas through:wagoe/cache -
http-csrf-protection— validates/issues CSRF tokens (see CSRF protection) -
http-security-headers— CSP, HSTS,X-Frame-Options,X-Content-Type-Options, … -
http-error-handler— maps an exception’s:typeto an HTTP status
(http-rate-limit limit window-ms cache) builds a fixed limit to attach per route.
|
Note
|
:skip-interceptors? is independent of :no-doc. :no-doc only excludes a
route from the OpenAPI document; it does not affect interceptors. All /web routes are
:no-doc yet still run the full stack so that security interceptors apply to the UI.
|
Error responses
Every JSON error the framework sends has one shape, whatever produced it — the exception mapping, the default-deny 401/403, CSRF, the rate limiter, a malformed body, an unknown route or method:
{"error": {"type": "validation-error",
"message": "Request validation failed",
"details": {"email": ["invalid"]},
"correlation-id": "5b1c9a52-6f0e-4d7a-9a57-2d9e3c1b8f40"}}
type and message are always there. details and correlation-id are
there when the error has them, and dev (the WGE code, dev only) sits beside
them. Everything is inside error, so a client reads one key. The correlation
id is also the X-Correlation-ID response header.
type is the :type of the thrown ex-info, looked up in
problem-details/default-error-mappings for its status. Anything unmapped is a
500 whose type is internal-error and whose message says nothing more. Build
a body of your own with wagoe.platform.core.http.errors/body or response.
The types the framework answers with:
| Status | Type | Meaning |
|---|---|---|
400 |
|
The request was read, and its input refused. |
400 |
|
The body does not parse as its |
400 |
|
Thrown by application code; the framework maps them. |
401 |
|
No valid credential. |
403 |
|
Known caller, not allowed. |
404 |
|
Nothing there. |
405 |
|
The path exists; the method does not. |
409 |
|
It already exists, or changed underneath. |
422 |
|
Well-formed, and still refused. |
429 |
|
Too many requests. |
500 |
|
Our fault; the log has the rest. |
500 |
|
Dev only: an |
501 |
|
Not on this database or configuration. |
503 |
|
The server has no handler yet. |
A write that a unique or foreign key refuses is a 409 conflict, on H2,
SQLite, PostgreSQL and MySQL alike: wagoe.platform.database throws it, so every
generated repository gets it. details names the constraint and the key’s
columns: field for one, fields for several. Nothing else of the driver’s
text is sent. Where the driver does not name the columns (MySQL, or PostgreSQL
with lc_messages other than English) they are read from the catalog, once per
key; a key on an expression names none. On PostgreSQL a refusal in a transaction
reads them on a separate connection, outside the pool; if none opens within a
second, it names no columns.
{"error": {"type": "conflict",
"message": "Another record already has this number",
"details": {"constraint": "unique", "field": "number"}}}
db/upsert! writes a row by its key on every engine: it updates the row, or
inserts it, and retries a writer that lost the race to insert. Outside a
transaction it also retries a deadlock. db/ensure-binary-collation! makes a
MySQL column compare case-sensitively, as the other engines do; use it for a
key such as a file name or a token.
A handler that knows only its status gets its type from errors/status→type:
validation-error for a 4xx with no type of its own, internal-error for a 5xx.
Interceptor phases
-
:enter— request processing (auth, validation, transformation) -
:leave— response processing (audit, metrics, transformation) -
:error— exception handling (custom error responses)
Enter phases run in order; leave phases run in reverse order.
To reject a request from :enter, set :halt? true in the context (alongside the
:response). The pipeline short-circuits only on :halt? — setting :response by
itself does not stop forward execution, so a later interceptor or the route handler
would overwrite it. :leave still runs for interceptors that already executed.
CSRF protection
http-csrf-protection enforces CSRF for session-authenticated, state-changing
requests and issues tokens for rendering. Pure token functions live in
wagoe.platform.core.csrf.
A state-changing request (POST/PUT/DELETE/PATCH) is validated — 403 on a missing or
invalid token — when CSRF is enabled, the path is not exempt, and the request is either
session-authenticated (session-token cookie / X-Session-Token header) or a /web
route. This covers /web, /web/admin, and any session-authenticated /api route.
Token-auth API clients that send no session cookie are not CSRF-vulnerable and are not
checked; safe methods (GET/HEAD/OPTIONS) and :exempt-paths are skipped.
;; resources/conf/<env>/config.edn under :active
:wagoe/http
{:security
{:csrf {:enabled? true ; OPT-IN: lib default is false
:secret #or [#env CSRF_SECRET #env JWT_SECRET] ; defaults to JWT_SECRET
:exempt-paths ["/api/v1/payments/webhook"]}}} ; trailing /* = segment-prefix
Enforcement is opt-in: the library default is :enabled? false, so upgrading the
framework cannot start rejecting requests from a consumer that does not yet emit
tokens. An app turns it on with the block above (after emitting tokens in its /web
forms); the secret falls back to JWT_SECRET. Startup fails loud — the system wiring
throws and the app refuses to boot if CSRF is enabled with a blank secret, rather than
letting the interceptor fail open (run unvalidated).
Tokens are emitted with no per-handler wiring. For HTMX, either merge
(csrf/hx-headers) onto an element’s attributes (e.g. <body>) so all inherited
hx-* requests carry the X-CSRF-Token header, or rely on the shared page layout’s
<meta name="csrf-token"> tag plus the global htmx:configRequest listener that
attaches the header to every HTMX request. Plain (non-HTMX) forms include a hidden
field:
(require '[wagoe.platform.core.csrf :as csrf])
[:form {:method "post" :action "/web/logout"}
(csrf/hidden-field) ; reads the token bound for the current request
[:button "Logout"]]
See the authentication guide for the request flow and the unauthenticated (login) pre-session token.
Adding components and middleware to an app module
Discovery builds a scaffolded module’s four keys (:wagoe/<name>, -repository,
-service, -routes). To add to them, define ig-config in the module’s
module_wiring.clj and return only what you add:
(defn ig-config [_settings _ctx]
{:components {:wagoe/orders-ip-filter {:allow ["10.0.0.0/8"]}
;; merged into the scaffold's service, which keeps :repository
:wagoe/orders-service {:filter (ig/ref :wagoe/orders-ip-filter)}}
:http {:extra-middleware (ig/ref :wagoe/orders-ip-filter)}})
-
A component the scaffold also builds is merged, your keys winning.
-
Wire
:wagoe/<name>yourself and the module owns its whole graph: no scaffold keys. -
:extra-middlewareis a middleware fn or a seq of them, and every module may set one. They run outermost first: framework modules, then app modules, each group sorted by module key, then the handler’s own:extra-middleware. -
Any other
:httpkey is a single slot; two modules setting it fail the boot.
Permission registry
Every permission a group can hold is in one registry, built at boot from what
the enabled modules declare. A module declares under :authorization in its
ig-config; a scaffolded module does so with a def authorization in its
wiring namespace:
{:permissions {:orders/refund {:label "Refund orders"}}
:roles {:orders/manager {:description "Everything in orders"
:permissions #{:orders/read :orders/write :orders/refund}}}
:scopes {:domain-id {:label "Domain" :entity :domains}}}
A module never declares <module>/read and <module>/write: every module has
them, and the code roles <module>/viewer and <module>/editor holding them
unless it declares its own. Every admin entity has admin.<entity>/read and
admin.<entity>/write.
The :permissions enum under :wagoe/user :groups is read in as well, until
2.0. bb scaffold gives a new module three code roles: <module>/viewer,
<module>/editor and <module>/manager.
The boot fails when two modules declare the same permission or role, or one
scope key differently, when a role or a route’s :permission names something
outside the registry, and when a code role outside the admin module holds an
admin.* permission. Every route a module contributes carries its module as
:wagoe/module in its route data.
Read the registry through wagoe.platform.ports.authorization:
(require '[wagoe.platform.ports.authorization :as authz])
(def registry (:wagoe/permission-registry (wagoe.platform.shell.system.wiring/system)))
(authz/permissions registry) ; {:orders/read {:label "Read orders" :module :wagoe/orders :source :derived} …}
(authz/roles registry)
(authz/permission? registry :orders/refund)
Scopes
A scope narrows a right to part of the data: {:domain-id 42}. Declare each
scope key under :scopes; it replaces the :scope schema under
:wagoe/user :groups, which is read in as scope keys until 2.0.
{:scopes {:domain-id {:label "Domain" :entity :domains :schema :int}
:department-id {:label "Department"
:sources #{:user/department}
:expand with-descendants}}
:scope-sources {:user/department {:label "The member's department"
:resolve (fn [user] #{(:department-id user)})}}}
-
A right with no scope holds everywhere. Rights add up: a permission held in two scopes is held in both.
-
A value may come from the user:
{:department-id :user/department}. Each member resolves to their own value, per request. A resolver that returns nothing gives no rights. A key naming a source nothing resolves fails boot. -
:expandturns values into them plus everything beneath them, after a source is resolved, so a department added later is covered.
A route says how its scope is checked. A scoped right never passes where nothing checks the scope:
["/orders" {:get {:handler list-all}}] ; unscoped holders only
["/domains/:domain-id/orders" {:scope {:domain-id :path/domain-id} ; the platform checks the parameter
:get {:handler list-domain}}]
["/my-orders" {:get {:scope :handler :handler mine}}] ; the handler filters
A parameter outside the caller’s scope is a 404. A :scope :handler handler
reads the scopes from :wagoe/scopes on the request and filters with
scope-filter, a HoneySQL WHERE:
(require '[wagoe.platform.core.scope :as scope])
{:select [:*] :from [:orders]
:where (scope/scope-filter (:wagoe/scopes request)
{:domain-id :domain_id
;; a scope on a related record
:category-id (fn [ids] [:in :product_id {:select [:id] :from [:products]
:where [:in :category_id ids]}])})}
A scope key scope-filter is not given a field for admits nothing.
scope/in-scope? checks one record.
Route authorization
:wagoe/authorization {:mode …} turns the checks on:
-
:off, the default in 1.x: nothing is checked beyond sign-in. -
:report: nothing is refused; each refusal:enforcewould make is logged with the user, route and permission. -
:enforce: refusals hold. A missing permission is a 403:forbidden, a web page for a browser; a parameter outside the scope is a 404.
Each route falls under the first rule that matches:
["/ping" {:public true :get {:handler ping}}] ; nothing checked
["/profile" {:authorization :signed-in :get {:handler me}}] ; any signed-in user
["/users/:id" {:authorization :self :get {:handler user}}] ; the handler checks
["/refunds" {:permission :orders/refund :post {:handler refund}}] ; that permission
["/orders" {:get {:handler list-all} :post {:handler create}}] ; orders/read, orders/write
The last falls back on the module the route came from: <module>/read for GET
and HEAD, <module>/write otherwise. A route with no rule and no module is
refused. A platform admin passes every rule. The boot logs, outside :off,
every route no rule covers. clojure -M:run authz-report prints them without
serving, and every route with its rule and permission, in any mode. The user module supplies each user’s rights, read
once per request.
Data export and retention
A module exports the data it holds about a person, and expires what it keeps,
through wagoe.platform.ports.privacy. It contributes implementations by id from
its ig-config; no module reads another’s tables.
{:components {:wagoe/orders-exporter {:repo (ig/ref :wagoe/orders-repository)}
:wagoe/orders-retention {:repo (ig/ref :wagoe/orders-repository)}}
:data-export {:orders (ig/ref :wagoe/orders-exporter)} ; IDataExporter
:retention {:orders/drafts (ig/ref :wagoe/orders-retention)}} ; IRetentionPolicy
:wagoe/data-export (IDataExport) collects every exporter’s data about a subject,
{:type :user :id …} or {:type :tenant :id …}; an exporter returns nil for a
subject it holds nothing about. The user module serves it to a signed-in user at
GET /api/v1/me/export and /web/profile/export, and to platform admins at
GET /api/v1/users/:id/export, which is recorded in the user’s audit log. The
tenant module serves a tenant’s export to platform admins at
GET /api/v1/tenants/:id/export, recorded in tenant_audit_log. Either admin
export can also run as a background job through IExportJobs, which the
storage module provides when the jobs module is on. Sections are keyed by exporter id with its
namespace, so :orders and :billing/orders do not collide.
Each route answers JSON. With ?format=zip or Accept: application/zip it
answers a zip of data-export.json and the files exporters hold, such as the
storage module’s uploads, each under its exporter id (storage/<key>). An
exporter with files also implements IFileExporter. A file that is gone is
listed under missingFiles in the JSON. For a subject too large to zip during
a request, the storage module builds a user’s export as a background job and
serves it from file storage (see the storage library).
Retention is off until you name a policy. It then runs as the recurring job
:wagoe/retention, so :wagoe/jobs must be on:
:wagoe/privacy {:retention {:user/audit-log {:retain "P2Y"}} ; ISO-8601 period
:retention-schedule {:cron "0 3 * * *" :zone "UTC"}} ; the default
A policy no enabled module provides or can run, or a period that is not positive, fails the boot.
| Policy | Deletes |
|---|---|
|
audit entries older than the period |
|
dead-lettered jobs that finally failed before the period |
|
uploads stored before the period, file and record |
wagoe.platform.database/delete-in-batches! deletes in batches of 1000, so a
first purge of years of rows does not hold one long lock.
Testing
clojure -M:test:test/pg :platform