Liking cljdoc? Tell your friends :D

platform

Infrastructure layer for HTTP routing/interceptors, database integration, CLI/runtime wiring, and shared platform adapters used by feature libraries.

Key namespaces

NamespacePurpose

wagoe.platform.shell.http.interceptors

HTTP interceptor composition and execution

wagoe.platform.shell.http.reitit-router

Compiles every module’s route contribution into the Ring handler

wagoe.platform.shell.adapters.database.*

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 /web route 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 — adds X-Correlation-ID to the response

  • http-rate-limit-protection — fixed-window limit from :wagoe/http :rate-limit (:enabled?, default false; :limit 100; :window-ms 60000), 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 :type to an HTTP status

(http-rate-limit limit window-ms cache) builds a fixed limit to attach per route.

: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:

StatusTypeMeaning

400

validation-error

The request was read, and its input refused.

400

malformed-request

The body does not parse as its Content-Type. Nothing else.

400

business-rule-violation, invalid-request

Thrown by application code; the framework maps them.

401

unauthorized, auth-failed

No valid credential.

403

forbidden, csrf-validation-failed, deletion-not-allowed, hard-deletion-not-allowed

Known caller, not allowed.

404

not-found, user-not-found, resource-not-found

Nothing there.

405

method-not-allowed

The path exists; the method does not.

409

conflict, user-exists, resource-exists

It already exists, or changed underneath.

422

unprocessable; a workflow transition’s refusal (transition-not-found, insufficient-permissions, guard-rejected, guard-not-registered)

Well-formed, and still refused.

429

rate-limit-exceeded

Too many requests.

500

internal-error

Our fault; the log has the rest.

500

missing-error-type

Dev only: an ex-info without :type reached the boundary.

501

not-supported

Not on this database or configuration.

503

unavailable

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-middleware is 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 :http key 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.

  • :expand turns 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 :enforce would 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.

PolicyDeletes

:user/audit-log

audit entries older than the period

:jobs/dead-letter

dead-lettered jobs that finally failed before the period

:storage/uploads

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

Can you improve this documentation? These fine people already did:
thijscreemers & Thijs Creemers
Edit on GitHub

cljdoc builds & hosts documentation for Clojure/Script libraries

Keyboard shortcuts
Ctrl+kJump to recent docs
←Move to previous article
→Move to next article
Ctrl+/Jump to the search field
× close