Skip to content
LarsArtmannPublic

About

Go library for wiring go-cqrs-lite with HTMX, templ, and Casbin authorization — framework-agnostic HTTP handler builder

Topics

Resources

Contributing

Security policy

Stars

2 stars

Watchers

0 watching

Forks

Latest commit

 

History

3,819 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

cqrs-htmx

Go Reference CI License: MIT Go 1.27+ GOTH stack

Wire go-cqrs-lite commands and queries to net/http in one line per endpoint. HTMX responses, Casbin authorization, CSRF, rate limiting, SSE, and event-sourced user management come built-in — but nothing is forced on you. Bring your own router (net/http, Chi, Gin, etc.), your own templ components, your own persistence. The library never picks your stack for you.

mux.Handle("POST /items", app.Command("CreateItem",
    cqrshtmx.DecodeJSON(mapToCmd),          // decode body → command
    cqrshtmx.Authorize("items", "create"),  // Casbin check
    cqrshtmx.WithSuccessStatus(201),        // configure response
    cqrshtmx.PushURL("/items")))            // HTMX: update address bar

One endpoint, four concerns, in declarative order. The same shape works for queries, form posts, and SSE handlers.

Part of the GOTH stack — pair with templ-components (UI components for templ + HTMX + Tailwind v4) and go-cqrs-lite (event sourcing core) for a complete server-rendered Go web stack with zero framework lock-in.

Features at a Glance

  • Command & Query dispatch — app.Command() / app.Query() return http.HandlerFunc that decode, authorize, dispatch, and respond
  • Casbin authorization — Authorize(resource, action) checks policies before dispatch; RequireAuth() for auth-only guards
  • HTMX-aware responses — fluent response builder with HX-Trigger, HX-Redirect, HX-Push-Url, swap strategies, and notifications
  • templ integration — duck-typed TemplComponent renders templ components without importing templ
  • User identity propagation — HTTP request → context → CQRS event metadata, with strongly-typed UserID and CorrelationID (ULID-backed)
  • Request validation — ValidateCommand/ValidateQuery wrap decoders with validation logic
  • Dispatch timeout — Config.Timeout wraps dispatch with context.WithTimeout
  • Lifecycle hooks — BeforeDispatchHook/AfterDispatchHook for tracing, logging, metrics
  • Error classification — CQRS error families automatically map to HTTP status codes
  • JSON or plain-text error handlers — pluggable ErrorHandler with HTMX-aware auth redirects
  • Notification system — NotifySuccess/NotifyError/NotifyWarning/NotifyInfo with standard {level, message} payload; custom event names via NotifyWithEvent
  • CSRF protection — double-submit cookie pattern with HTMX-aware X-CSRF-Token header validation
  • Rate limiting — per-key token-bucket with min-heap eviction, configurable burst, and hook callbacks
  • Security headers — automatic X-Content-Type-Options, X-Frame-Options, Referrer-Policy, plus optional CSP/HSTS/Permissions-Policy
  • Request logging — plain-text or structured JSON logging with status, duration, and context IDs
  • SSE streaming — built on go-sse (sse.Stream, sse.Event); cqrs-htmx adds Broadcaster (thread-safe fan-out with CQRS hooks), JournalSSEStore (production durable replay via event.SeekableJournal), CQRS bridge via BroadcastOnSuccess/BroadcastOnError, Heartbeat for proxy keepalive, ACK protocol (BroadcastOnAck — opt-in command confirmation via X-Command-Id header)
  • Offline sync — SyncPullHandler (cursor-based, permission-filtered event pull WITH payloads, ETag/304, backend-reset detection) + App.SyncPushHandler() (batched push of queued commands, per-command classified outcomes — the server re-decides everything) + an embedded sync client (IndexedDB queue + event cache, offline reads via window.cqrsSync.getEvents()). See Frontend Sync (ADR-0056)
  • Pagination — DecodePagination(r) + RenderPaginatedJSON[T]() with go-cqrs-lite
  • Embedded HTMX JS — HTMXScriptHandler() serves embedded HTMX v2.0.10 (minified) with ETag/caching. Opt-in, zero CDN dependency. Embedded HTMX extensions (SSE + idiomorph) also available via HTMXExtensionHandler/HTMXExtensionsHandler (the WS extension was removed in v5 alongside the WS transport — see ADR 0046)
  • User management — optional usermgmt submodule with RBAC, sessions, account lockout, and HTTP auth handlers. Auth strategies (WebAuthn/Passkeys, TOTP MFA, OAuth2/OIDC) are optional sub-modules — import only what you need, zero auth deps in core
  • Async projection startup — AsyncStartup: true lets the HTTP server bind while projections replay the journal in the background; ProjectionReadinessCheck returns 503 until every worker is live, so load balancers hold traffic for zero-downtime restarts. See Async Projection Startup
  • Actor + audit trail — every event, command, and query carries who acted (user/bot/system/service), impersonation chains, and correlation/request IDs — auto-derived from the HTTP session. See Actors and the Audit Trail
  • SQL event store — Postgres, SQLite, and MySQL backends via go-cqrs-lite/storage/v4. Auto-migrating schema, automatic error classification (Conflict/Transient), one-call setup templates for SQLite/Postgres/MySQL, plus MySQL session/snapshot/checkpoint stores and ReadModelDialect for Postgres/MySQL read models. See MySQL Setup Guide

Why

Every Go backend that combines CQRS + HTMX ends up writing the same glue:

  • HTTP request → CQRS command/query dispatch
  • User identity from cookie/session → event metadata
  • Casbin policy check before dispatch
  • CQRS error families → HTTP status codes (with HTMX-aware auth redirects)
  • HTMX partial vs full-page rendering
  • HTMX response headers (HX-Trigger, HX-Redirect, HX-Push-Url)
  • templ component rendering for query results
  • CSRF protection, rate limiting, security headers

This library handles all of it. If your endpoint doesn't need all of it, you can opt out piece by piece — every layer (auth, CSRF, rate limit, HTMX) is middleware or a HandlerOption you choose to apply.

If you are not using go-cqrs-lite, this library is not for you — it is the HTMX/HTTP binding for that specific CQRS core, not a generic web framework.

Install

go get github.com/larsartmann/cqrs-htmx/v4

For the user management submodule:

go get github.com/larsartmann/cqrs-htmx/usermgmt/v4

Auth strategies (WebAuthn, TOTP, OAuth2) are now optional sub-modules in v4 — import only what you need:

go get github.com/larsartmann/cqrs-htmx/usermgmt/webauthn/v4  # Passkeys
# go get github.com/larsartmann/cqrs-htmx/usermgmt/totp/v4     # TOTP MFA
# go get github.com/larsartmann/cqrs-htmx/usermgmt/oauth2/v4   # OAuth2/OIDC

Upgrading from v3? See the v3→v4 Migration Guide. Upgrading from v2? See the v2→v3 Migration Guide.

Quick Start

package main

import (
    "net/http"

    cqrshtmx "github.com/larsartmann/cqrs-htmx/v4"
    "github.com/larsartmann/go-cqrs-lite/command/v4"
    "github.com/larsartmann/go-cqrs-lite/query/v4"
    "github.com/casbin/casbin/v3"
)

func main() {
    cmdDisp := command.NewDispatcher()
    qryDisp := query.NewDispatcher()
    enforcer, _ := casbin.NewEnforcer("model.conf", "policy.csv")

    app, _ := cqrshtmx.New(cqrshtmx.Config{
        Commands:        cmdDisp,
        Queries:         qryDisp,
        Enforcer:        enforcer,
        UserIDExtractor: func(r *http.Request) (cqrshtmx.UserID, error) {
            return cqrshtmx.ParseUserID(r.Header.Get("X-User-ID"))
        },
    })

    mux := http.NewServeMux()

    // Command dispatch with auth + HTMX
    mux.Handle("POST /users", app.Command("CreateUser",
        cqrshtmx.Authorize("users", "create"),
        cqrshtmx.DecodeJSON(func(req CreateUserRequest) (command.Command, error) {
            return NewCreateUserCmd(req.Email, req.Name), nil
        }),
        cqrshtmx.NotifySuccess("User created"),
        cqrshtmx.PushURL("/users"),
    ))

    // Query dispatch with templ rendering
    mux.Handle("GET /users", app.Query("ListUsers",
        cqrshtmx.Authorize("users", "read"),
        cqrshtmx.DecodeJSONQuery(func(req ListUsersRequest) (query.Query, error) {
            return &ListUsersQuery{}, nil
        }),
        cqrshtmx.RenderTemplResult(func(result []*User) cqrshtmx.TemplComponent {
            return userListPage(result)
        }),
    ))

    // Apply middleware stack
    handler := cqrshtmx.Chain(
        httputil.SecurityHeaders(httputil.DefaultSecurityHeadersConfig()),
        httputil.CSRFMiddleware(httputil.CSRFConfig{}),
        cqrshtmx.HTMXMiddleware,
        app.Middleware(),
    )(mux)

    http.ListenAndServe(":8080", handler)
}

Want the full stack in one call? The setup/v4 module creates shared stores, the user management service, admin dashboard, observability dashboard, and login page with correct middleware ordering in a single setup.New() call. Adopt your own service (Config.Service), override the full usermgmt.ServiceConfig (Config.ServiceConfig — MaxUsers, TokenPepper, SecurityHooks, ...), or stay with the flattened convenience fields. See the Full-Stack Wiring Guide.

Config

New(Config) creates an App. Commands or Queries must be non-nil:

app, err := cqrshtmx.New(cqrshtmx.Config{
    Commands:        cmdDisp,          // *command.Dispatcher
    Queries:         qryDisp,          // *query.Dispatcher
    Enforcer:        enforcer,         // Enforcer interface (Casbin or custom)
    UserIDExtractor: extractFunc,      // func(r *http.Request) (UserID, error)
    LoginRedirect:   "/auth/signin",   // default: "/login"
    Timeout:         5 * time.Second,  // wraps dispatch only; 0 = no timeout
    ErrorHandler:    myErrorHandler,   // custom ErrorHandler; default uses plain text + LoginRedirect
    BeforeDispatch:           beforeHook,       // func(ctx, r) context.Context — tracing, request IDs
    AfterDispatch:            afterHook,        // func(ctx, r, err) — logging, metrics
    IncludeRequestIDInErrors: true,             // prefix errors with request_id for log correlation
    MaxBodySize:              10 << 20,         // max request body (default 10 MB)
    ServiceName:              "my-service",     // identifies the service in event metadata
})

Handler Options

Authorization

Option Description
Authorize(resource, action) Check Casbin policy before dispatch
RequireAuth() Require authenticated user (no specific permission)

Request Decoding

Option Description
DecodeJSON[T](mapper) Decode JSON body into a command via mapper function
DecodeJSONQuery[T](mapper) Decode JSON body into a query via mapper function
DecodeForm[T](mapper) Decode form data into a command via mapper function
DecodeFormQuery[T](mapper) Decode form data into a query via mapper function

All decoders enforce Config.MaxBodySize when set (returns ErrRequestTooLarge / 413 when exceeded).

Validation

Wrap your decoder with validation. Must be applied after the decoder in the option list:

app.Command("CreateUser",
    cqrshtmx.DecodeJSON(createUserMapper),
    cqrshtmx.ValidateCommand(func(cmd command.Command) error {
        return validateStruct(cmd)
    }),
)
Option Description
ValidateCommand(validator) Validate decoded command; errors → 400 Bad Request
ValidateQuery(validator) Validate decoded query; errors → 400 Bad Request

Response

Option Description
Render(fn) Render query result with custom function
RenderTempl(component) Render a fixed templ.Component (duck-typed)
RenderTemplResult[T](mapper) Map query result to a templ.Component and render
RenderJSON[T]() Render query result as JSON (200 OK)
RenderJSONStatus[T](status) Render query result as JSON with custom status
Redirect(url) Redirect after success (HX-Redirect for HTMX, HTTP redirect otherwise)
Trigger(event) Fire HTMX client-side event on success
TriggerWithDetail(event, detail) Fire HTMX event with JSON detail data
PushURL(url) Push URL into browser history

Notifications

Convenience wrappers around TriggerWithDetail with a standard {level, message} payload:

Option Description
NotifySuccess(message) Success notification
NotifyError(message) Error notification
NotifyWarning(message) Warning notification
NotifyInfo(message) Info notification

All use the default "showMessage" event. For custom event names, use the NotifyWithEvent builder:

app.Command("CreateUser",
    cqrshtmx.NotifyWithEvent("showToast").Success("User created"),
)

Client-side JS:

document.body.addEventListener("showMessage", function (evt) {
	showNotification(evt.detail.level, evt.detail.message);
});

Per-Handler Timeout

Override the global Config.Timeout per handler:

app.Command("SlowOperation",
    cqrshtmx.WithTimeout(30*time.Second),
    cqrshtmx.DecodeJSON(mapper),
)

Per-Handler Options

Option Description
WithMaxBodySize(n) Override Config.MaxBodySize per handler
WithSuccessStatus(code) Custom success status (default 204; e.g., 201 Created)
RequireMethod(method) Reject wrong HTTP methods with 405
OnError(fn) Per-handler error callback after App-level handler

Convenience Helpers

Helper Description
App.HealthHandler() 200/503 JSON health check for load balancers
App.HasCommands() / App.HasQueries() Report dispatcher availability
MustNew(cfg) Panics on error — for init-time setup
IsAuthenticated(r) Checks for non-zero UserID in context

HTMX Request Context

HTMXMiddleware parses all HTMX headers once and stores them in context:

handler := cqrshtmx.Chain(
    cqrshtmx.HTMXMiddleware,
    app.Middleware(),
)(mux)

HTMXRequest Struct

h := cqrshtmx.HTMXFromContext(r.Context())
h.IsHTMX           // bool
h.IsBoosted        // bool
h.IsHistoryRestore // bool
h.Target           // string
h.TriggerID        // string
h.TriggerName      // string
h.Prompt           // string
h.CurrentURL       // string
h.RenderPartial()  // bool — IsHTMX && !IsHistoryRestore

Standalone Accessors (work with or without middleware)

cqrshtmx.IsHTMXRequest(r)    // bool
cqrshtmx.IsBoosted(r)        // bool
cqrshtmx.IsHistoryRestore(r) // bool
cqrshtmx.RenderPartial(r)    // bool — partial vs full page
cqrshtmx.HTMXTarget(r)       // string
cqrshtmx.HTMXTrigger(r)      // string
cqrshtmx.HTMXTriggerName(r)  // string
cqrshtmx.HTMXPrompt(r)       // string
cqrshtmx.HTMXCurrentURL(r)   // string

Partial Rendering (HTMX)

Every HTMX handler needs the same branching: render a fragment for HTMX requests, a full page for regular navigation. These helpers eliminate that boilerplate.

For CQRS Query Handlers (templ)

app.Query("ListUsers", decoder,
    cqrshtmx.RenderPartialOrFull(
        func(users []*User) cqrshtmx.TemplComponent { return userListPartial(users) },
        func(users []*User) cqrshtmx.TemplComponent { return usersPage(users) },
    ),
)

History-restore requests (HX-History-Restore-Request) automatically render the full page.

For CQRS Query Handlers (non-templ)

app.Query("ListUsers", decoder,
    cqrshtmx.RenderPartialOrFullFunc(
        func(w http.ResponseWriter, _ *http.Request, result any) error { /* partial HTML */ },
        func(w http.ResponseWriter, _ *http.Request, result any) error { /* full page */ },
    ),
)

For Non-CQRS Routes

mux.HandleFunc("GET /items", func(w http.ResponseWriter, r *http.Request) {
    items := loadItems()
    _ = cqrshtmx.RenderTemplComponent(w, r, itemsPartial(items), itemsPage(items))
})

Custom Predicates

app.Query("GetProfile", decoder,
    cqrshtmx.RenderIf(
        func(r *http.Request) bool { return cqrshtmx.HTMXTarget(r) == "#avatar" },
        avatarPartial,
        fullProfile,
    ),
)

Out-of-Band Swaps

html := cqrshtmx.OOBHTML("notifications", "<div>1 new</div>")
// <div id="notifications" hx-swap-oob="true"><div>1 new</div></div>

html := cqrshtmx.OOBHTML("list", "<li>item</li>", cqrshtmx.SwapBeforeEnd)
// <div id="list" hx-swap-oob="beforeend"><li>item</li></div>

HTMX Response Builder

Build HTMX-aware responses with fluent chaining:

resp := cqrshtmx.NewResponse(w, r)
resp.Trigger("userCreated").
    PushURL("/users/1").
    Retarget("#user-list").
    Reswap(cqrshtmx.SwapInnerHTML).
    NotifySuccess("User created").
    Apply()

Methods

Method Header Set
PushURL(url) HX-Push-Url
ReplaceURL(url) HX-Replace-Url
Redirect(url) HX-Redirect (HTMX) or HTTP redirect
Refresh() HX-Refresh
Location(url) HX-Location
Reswap(strategy) HX-Reswap
Retarget(selector) HX-Retarget
Reselect(selector) HX-Reselect
Trigger(event) HX-Trigger
TriggerAfterSwap(event) HX-Trigger-After-Swap
TriggerAfterSettle(event) HX-Trigger-After-Settle
TriggerWithDetail(name, detail) HX-Trigger (JSON)
NotifySuccess(message) HX-Trigger (JSON notification)
NotifyError(message) HX-Trigger (JSON notification)
NotifyWarning(message) HX-Trigger (JSON notification)
NotifyInfo(message) HX-Trigger (JSON notification)

Swap Strategies

SwapInnerHTML, SwapOuterHTML, SwapBeforeBegin, SwapAfterBegin, SwapBeforeEnd, SwapAfterEnd, SwapDelete, SwapNone

SSE (Server-Sent Events)

First-class SSE support for the HTMX SSE extension (hx-ext="sse"). Protocol types (Event, Stream, EventID) come from go-sse; cqrs-htmx adds the CQRS-coupled Broadcaster and JournalSSEStore.

import "github.com/larsartmann/go-sse"

// Start an SSE stream (go-sse)
stream := sse.NewStream(w, r)
_ = stream.Send(sse.Event{
    Event: "todoUpdated",
    Data:  "<div id='todos'><ul><li>Buy milk</li></ul></div>",
})

// Fan-out to multiple clients (cqrs-htmx Broadcaster)
broadcaster := cqrshtmx.NewBroadcaster()
ch := broadcaster.Subscribe()
broadcaster.Broadcast(sse.Event{Event: "update", Data: html})

// Bridge to CQRS: broadcast SSE on successful command dispatch
app, _ := cqrshtmx.New(cqrshtmx.Config{
    AfterDispatch: broadcaster.BroadcastOnSuccess("itemUpdated", ""),
})

// Broadcast StructuredError (RFC 7807) on dispatch failure
errApp, _ := cqrshtmx.New(cqrshtmx.Config{
    AfterDispatch: broadcaster.BroadcastOnError("commandError"),
})

// Prevent reverse proxies from killing idle connections
go stream.Heartbeat(stream.Context(), 15*time.Second)

// Reconnection support (SSE spec — go-sse)
lastID := sse.LastEventIDFromRequest(r)
sse.Replay(stream, eventStore, lastID)

Client-side:

<div hx-ext="sse" sse-connect="/events" sse-swap="todoUpdated">
	<!-- content swapped here -->
</div>

SSE API

Protocol types come from go-sse; cqrs-htmx adds the CQRS-coupled building blocks.

go-sse (github.com/larsartmann/go-sse):

Type / Function Description
sse.Event Event struct: Event, Data, ID, Retry
sse.WriteEvent(w, event) Write a single SSE event to any io.Writer
sse.NewStream(w, r) Create a managed SSE connection (correct headers, flush, context-aware lifecycle)
stream.Send(event) Send event to connected client
stream.SendData(name, data) Shorthand for raw string content events
stream.LastEventID() Client's last event ID (for reconnection)
stream.Close() Graceful shutdown
stream.Heartbeat(ctx, interval) Send SSE comment-frame pings to prevent proxy idle kills
stream.OnDisconnect(fn) Register cleanup callback fired on Close()
sse.LastEventIDFromRequest(r) Extract Last-Event-ID from request
sse.EventStore Interface for reconnection replay (EventsSince(id))
sse.Replay(stream, store, lastID) Replay missed events to reconnecting client

cqrs-htmx (CQRS-coupled):

Type / Function Description
NewBroadcaster() Thread-safe fan-out hub
broadcaster.Subscribe() Get a receiver channel
broadcaster.Unsubscribe(ch) O(1) unsubscribe via channel identity
broadcaster.Broadcast(event) Non-blocking send to all subscribers (drops to slow consumers)
broadcaster.SubscriberCount() Active subscriber count
BroadcastOnSuccess(event, data) AfterDispatchHook that broadcasts on successful dispatch
BroadcastOnSuccessFunc(fn) AfterDispatchHook with dynamic event generation
BroadcastOnError(eventName) AfterDispatchHook that broadcasts StructuredError on dispatch failure
BroadcastOnErrorFunc(fn) AfterDispatchHook with dynamic error event generation
NewStructuredError(err, r) RFC 7807 error payload with type/title/status/detail/instance. .JSON() for SSE
NewJournalSSEStore(...) Production sse.EventStore backed by a go-cqrs-lite event journal

Note: The WebSocket helpers were removed in v5 (ADR 0046). The library is SSE-only now. Consumers needing bi-directional transport should use SSE for server→client and POST endpoints for client→server, or integrate a dedicated WebSocket library directly (the library intentionally avoids pulling in gorilla/websocket or similar).

Embedded HTMX JavaScript

Embed HTMX v2.0.10 (minified, ~51KB) directly in your binary — no CDN dependency:

// Serve embedded HTMX JS
mux.Handle("/static/htmx.js", cqrshtmx.HTMXScriptHandler())

// Generate script tag for templates
cqrshtmx.HTMXScriptTag("/static/htmx.js")
// => <script src="/static/htmx.js"></script>

// Check version
cqrshtmx.HTMXVersion() // "2.0.10"

HTMXScriptHandler sets Content-Type: text/javascript, long-lived Cache-Control (1 year, immutable), and ETag with If-None-Match support for 304 responses.

Embedded HTMX Extensions

The library embeds the 2 HTMX extensions that pair with its server-side building blocks (the WS extension was removed in v5 alongside the WS transport — see ADR 0046):

Extension Version Server-side counterpart
HTMXExtSSE htmx-ext-sse 2.2.4 sse.Stream, Broadcaster, JournalSSEStore
HTMXExtIdiomorph idiomorph 0.7.4 Morph-swap for SSE partial updates
// Serve individual extension
mux.Handle("/ext/sse.js", cqrshtmx.HTMXExtensionHandler(cqrshtmx.HTMXExtSSE))

// Or serve both as a single bundle (one HTTP request)
mux.Handle("/ext/bundle.js",
    cqrshtmx.HTMXExtensionsHandler(cqrshtmx.HTMXExtSSE, cqrshtmx.HTMXExtIdiomorph))

Same caching headers (ETag, Cache-Control 1yr immutable, 304). Load after htmx core in your layout.

templ Integration

First-class support for templ without importing it as a dependency. Uses duck-typing — any type with Render(ctx context.Context, w io.Writer) error works.

// Render a fixed component
app.Query("GetPage",
    cqrshtmx.RenderTempl(myPageComponent()),
)

// Map query result to a component
app.Query("ListUsers",
    cqrshtmx.DecodeJSONQuery(mapper),
    cqrshtmx.RenderTemplResult(func(result []*User) cqrshtmx.TemplComponent {
        return userListView(result)
    }),
)

Works seamlessly with templ-components.

Context Propagation

User identity, correlation IDs, and request IDs flow automatically from HTTP → CQRS metadata. All ID types are strongly-typed wrappers around ULID — collision-free, lexicographically sortable, and impossible to confuse with raw strings.

// User ID
userID := cqrshtmx.MustParseUserID("01HK1549P84T9XF8R94E960633")
ctx := cqrshtmx.WithUserID(r.Context(), userID)
retrieved := cqrshtmx.UserIDFromContext(ctx)

// Correlation ID (auto-extracted from X-Correlation-ID header)
cid := cqrshtmx.MustParseCorrelationID("01HK154ANGZHV2ZW0X3SKSNEN2")
ctx = cqrshtmx.WithCorrelationID(ctx, cid)
corrID := cqrshtmx.CorrelationIDFromContext(ctx)

// Request ID
reqID := cqrshtmx.NewRequestID()
ctx = cqrshtmx.WithRequestID(ctx, reqID)

// Build event options from context (includes user ID + correlation ID)
opts := cqrshtmx.EventOptionsFromContext(ctx)
evt, _ := event.NewEvent("UserCreated", aggID, "User", 1, payload, opts...)

// Generate new random IDs
newID  := cqrshtmx.NewUserID()
newCID := cqrshtmx.NewCorrelationID()
newRID := cqrshtmx.NewRequestID()

Why ULID?

  • Collision-free keys — 48-bit timestamp + 80-bit randomness
  • Lexicographic sortability — time-ordered without a central coordinator
  • Type safety — impossible to pass an arbitrary string where a valid ID is required
  • Consistency — event metadata, context values, and Casbin subjects all use the same representation

ContextEnrichmentMiddleware silently drops non-ULID values from X-Correlation-ID headers (and UserIDExtractor outputs that fail parsing). Use NewUserID() / NewCorrelationID() to generate valid IDs, or ParseUserID() / ParseCorrelationID() for safe parsing from external input.

Error Mapping

CQRS error families automatically map to HTTP status codes:

CQRS Family HTTP Status
Rejection 400 Bad Request
Conflict 409 Conflict
Corruption 500 Internal Server Error
Transient 503 Service Unavailable
Infrastructure 503 Service Unavailable

Auth errors map specially:

  • ErrUnauthorized → 401
  • ErrForbidden → 403

Error Handlers

The default error handler writes plain text and redirects HTMX auth errors to the login page. For JSON APIs:

app, _ := cqrshtmx.New(cqrshtmx.Config{
    ErrorHandler: cqrshtmx.JSONErrorHandlerWithRedirect,
    LoginRedirect: "/auth/signin",
})
Handler Format Login Redirect
DefaultErrorHandler Plain text /login
DefaultErrorHandlerWithRedirect Plain text Custom
DefaultErrorHandlerWithRequestID Plain text /login (includes request_id when present)
DefaultErrorHandlerWithRedirectAndRequestID Plain text Custom (includes request_id when present)
JSONErrorHandler JSON /login
JSONErrorHandlerWithRedirect JSON Custom
MapError(err) — Returns HTTP status code for any CQRS error

Login Redirect

Configure per-App. For HTMX requests with auth errors, the handler sets HX-Redirect instead of returning an error body:

app, _ := cqrshtmx.New(cqrshtmx.Config{
    LoginRedirect: "/auth/signin",
})

Middleware

// Context enrichment (applied once to your router)
mux := app.Middleware()(router)

// HTMX header parsing (applied once, stores in context)
mux := cqrshtmx.HTMXMiddleware(mux)

// Panic recovery (catches panics, logs stack trace, writes 500)
mux := cqrshtmx.RecoveryMiddleware(mux)
// Or use App.RecoverHandler() for App-configured error handling:
mux := app.RecoverHandler()(mux)

// Standalone Casbin authorization middleware
mux.Handle("/admin", cqrshtmx.AuthorizeMiddleware(
    enforcer, "admin", "access",
    userIDExtractor,
)(handler))

// Chain multiple middleware
chained := cqrshtmx.Chain(
    httputil.SecurityHeaders(httputil.DefaultSecurityHeadersConfig()),
    cqrshtmx.RecoveryMiddleware,
    httputil.CSRFMiddleware(httputil.CSRFConfig{}),
    cqrshtmx.HTMXMiddleware,
    app.Middleware(),
)(mux)

Request Logging

Log every request with method, path, status, duration, and context IDs:

// Plain text or custom format
handler := cqrshtmx.RequestLogging(nil, func(line string) {
    log.Println(line)
})(mux)

// Structured JSON
handler := cqrshtmx.RequestLogging(cqrshtmx.JSONLogFormatter, func(line string) {
    log.Println(line)
})(mux)

// slog integration (recommended for production)
handler := cqrshtmx.RequestLoggingSlog(slog.Default())(mux)

CSRF Protection

Double-submit cookie CSRF protection with HTMX awareness. Uses justinas/nosurf internally for token generation and origin validation. Validates X-CSRF-Token header (or form field) on state-changing methods:

handler := httputil.CSRFMiddleware(httputil.CSRFConfig{
    CookieName: "csrf_token",
    HeaderName: "X-CSRF-Token",
    MaxAge:     24 * time.Hour,
    Secure:     true,
    SameSite:   http.SameSiteLaxMode,
})

CSRFConfig fields:

Field Default Description
CookieName "csrf_token" Cookie name
HeaderName "X-CSRF-Token" Header/field name to validate
FieldName "csrf_token" Form field name
MaxAge 24h Token lifetime
Secure false Set true in production (HTTPS only)
SameSite Lax Cross-site cookie policy
Domain "" Cookie domain (host-only by default)
Path "/" Cookie path
TrustedOrigins nil Additional trusted origins
ErrorHandler 403 plain text Custom error handler for CSRF failures

HTMX Integration:

<!-- Set token globally for all HTMX requests -->
<body hx-headers='{"X-CSRF-Token":"{{ .CSRFToken }}"}'></body>
// Pass token to templates from handler
token := httputil.CSRFTokenFromContext(r.Context())

// Or use Response builder
resp := cqrshtmx.NewResponse(w, r)
resp.CSRFToken(token).Apply()

Template Helpers (for HTML/templ):

data := map[string]any{
    "CSRFMeta":      httputil.CSRFTokenHTMLMeta(r),     // <meta name="csrf-token" content="...">
    "CSRFHXHeaders": httputil.CSRFTokenHXHeaders(r),    // hx-headers='{"X-CSRF-Token":"..."}'
    "CSRFFormField": httputil.CSRFTokenFormField(r),    // <input type="hidden" name="..." value="...">
    "CSRFToken":     httputil.CSRFTokenFromContext(r.Context()),
}

Auto-inject token (no handler changes needed):

handler := cqrshtmx.Chain(
    httputil.CSRFMiddleware(httputil.CSRFConfig{}),
    httputil.CSRFResponseHeaderMiddleware, // auto-sets X-CSRF-Token on every response
    cqrshtmx.HTMXMiddleware,
    app.Middleware(),
)(mux)

Per-handler CSRF (instead of global middleware):

app.Command("CreateUser",
    cqrshtmx.CSRFProtect(httputil.CSRFConfig{}),
    cqrshtmx.DecodeJSON(...),
)

Rate Limiting

Token-bucket rate limiter per key. Global rate limit when KeyExtractor is nil:

handler := cqrshtmx.RateLimiterMiddleware(cqrshtmx.RateLimiterConfig{
    Limit:        100,          // requests per Window
    Window:       time.Minute,
    Burst:        10,           // max burst (zero defaults to Limit)
    KeyExtractor: cqrshtmx.KeyExtractorFromRemoteAddr(),
})(mux)

RateLimiterConfig fields:

Field Default Description
Limit 100 Requests per Window
Window 1 minute Rate window
Burst = Limit Max burst size
KeyExtractor nil (global) func(*http.Request) string; nil = single global bucket
TTL 10 minutes Idle limiter eviction time
MaxKeys 0 (unbounded) Cap on tracked keys; oldest evicted via min-heap
OnAllowed nil Hook called on allowed requests
OnRejected nil Hook called on rejected requests (includes retry-after)
RejectionHandler default 429 Custom handler for rejected requests

Per-IP rate limiting — use KeyExtractorFromRemoteAddr. Behind reverse proxies, use ClientIP(r) which respects X-Forwarded-For and X-Real-IP.

Exempt requests — return "" from KeyExtractor to skip rate limiting for that request.

Security Headers

Defense-in-depth headers on every response. Recommended for all production deployments:

handler := httputil.SecurityHeaders(httputil.DefaultSecurityHeadersConfig())(mux)

Defaults: X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Referrer-Policy: strict-origin-when-cross-origin.

For custom headers:

handler := httputil.SecurityHeaders(httputil.SecurityHeadersConfig{
    ContentSecurityPolicy:   "default-src 'self'",
    StrictTransportSecurity: "max-age=31536000; includeSubDomains",
    PermissionsPolicy:       "camera=(), microphone=(), geolocation=()",
    Custom: map[string]string{
        "X-Custom-Header": "value",
    },
})(mux)

Utilities

WriteJSON

Encode and write JSON responses in one call:

if err := cqrshtmx.WriteJSON(w, http.StatusOK, user); err != nil {
    // handle encode error
}

ClientIP

Extract client IP from X-Forwarded-For → X-Real-IP → RemoteAddr:

ip := cqrshtmx.ClientIP(r)

Warning: trusts proxy headers without validation. Only use behind a trusted reverse proxy that strips/overwrites these headers.

User Management (usermgmt)

An independent submodule with passwordless authentication (WebAuthn/Passkeys), event-sourced CQRS, RBAC via Casbin, and session management:

go get github.com/larsartmann/cqrs-htmx/usermgmt/v4

Setup (v4 Provider Injection)

In v4, auth strategies are injected as providers. Import only the sub-modules you need:

import (
    "log/slog"
    "time"

    "github.com/larsartmann/cqrs-htmx/usermgmt/v4"
    "github.com/larsartmann/cqrs-htmx/usermgmt/webauthn/v4" // optional: passkeys
)

// Create a WebAuthn provider
wa, err := webauthn.New(webauthn.Config{
    RPID:          "example.com",
    RPDisplayName: "My App",
    RPOrigins:     []string{"https://example.com"},
})
if err != nil {
    log.Fatal(err)
}

// Create service with provider injected
svc, err := usermgmt.NewService(usermgmt.ServiceConfig{
    WebAuthn: wa, // inject the provider — nil if not needed
    SessionTTL: 24 * time.Hour,
    Logger:     slog.Default(),
    Lockout:    usermgmt.NewAccountLockout(usermgmt.LockoutConfig{
        MaxAttempts: 5,
        Duration:    15 * time.Minute,
    }),
})
defer svc.Stop()

// Create HTTP handlers
authHandler := usermgmt.NewAuthHandler(svc, usermgmt.HandlerConfig{
    SessionMaxAge: 86400,
})

// Register routes
mux := http.NewServeMux()
authHandler.RegisterRoutes(mux)
// Routes: POST /auth/register, POST /auth/webauthn/register/begin|finish (owner-session-gated),
//         POST /auth/webauthn/login/begin|finish, POST /auth/logout, GET /auth/me,
//         GET /auth/credentials, DELETE /auth/credentials/{id}
//         (session-dependent routes are self-wrapped: an enrich-only session
//          pass runs per route, so they work with or without external session
//          middleware and fail closed with 401 without a valid cookie)

// Optional external session middleware (validates cookie, loads user into
// context for YOUR handlers; the /auth/* routes above enrich themselves):
handler := usermgmt.NewSessionMiddleware(svc, "session_token")(mux)

Registration & Login Flow

Users register with email only (no password). Authentication is via WebAuthn/Passkeys:

1. POST /auth/register              → create account (email only), get session cookie
2. POST /auth/webauthn/register/begin  → get credential creation challenge (owner-session-gated)
3. POST /auth/webauthn/register/finish → verify attestation, persist credential (owner-session-gated)
4. POST /auth/webauthn/login/begin     → get assertion challenge
5. POST /auth/webauthn/login/finish    → verify assertion, create session

The enrollment ceremonies (2–3) enforce the owner-match rule: 401 without a session, 403 when the user_id they name is not the session user. The flow above works because step 1 already set the session cookie (same-origin fetches carry it). Headless/admin enrollment uses the service-level API (BeginRegistration/FinishRegistration); there is no HTTP opt-out.

Finish endpoints read user_id from the URL query param (?user_id=...), since the request body contains the WebAuthn attestation/assertion response.

Service Methods

Method Description
Register(ctx, RegisterRequest) → (*RegisterResponse, error) Create user (email only), assign default roles, create session
BeginRegistration(ctx, userID) → (*BeginRegistrationResponse, error) Start WebAuthn credential registration
FinishRegistration(ctx, userID, r, credentialName) → error Complete credential registration
BeginLogin(ctx, email) → (*BeginLoginResponse, error) Start WebAuthn login ceremony
FinishLogin(ctx, userID, r) → (*FinishLoginResponse, error) Complete login, create session
Logout(ctx, token) → error Delete session
Authenticate(ctx, token) → (*User, error) Validate session, return user
Authorize(ctx, sub, dom, obj, act) → error Check RBAC policy
GetUser(ctx, id) → (*User, error) Find user by ID
DisplayName(ctx, userID string) → string Render label: display name → email → ""; accepts bare and user:-prefixed ids
UpdateRoles(ctx, userID, roles, domain) → error Atomically update user's roles
ChangeEmail(ctx, userID, newEmail) → error Change user's email
ChangeDisplayName(ctx, userID, newName) → error Change user's display name
DeleteUser(ctx, userID, reason) → error Soft-delete user (tombstone), revoke sessions
AddCredential(ctx, userID, cred) → error Add WebAuthn credential
RemoveCredential(ctx, userID, credID) → error Remove WebAuthn credential
Authz() → *Authz Access underlying RBAC engine
ReadModel() → *UserReadModel Access read model directly
Stop() Gracefully shutdown background resources

Input Validation

RegisterRequest has a Validate() method:

req := usermgmt.RegisterRequest{
    ID:          usermgmt.NewUserID(ulid),
    Email:       "user@example.com",
    DisplayName: "Alice",
}
if err := req.Validate(); err != nil {
    // handle validation error
}

Checks: valid email, required ID, display name length ≤ 100. Trims whitespace on Email and DisplayName in-place.

Domain Events

Optional EventHandler callback receives events for audit logging, analytics, or side effects:

usermgmt.EventHandler(func(userID usermgmt.UserID, evt any) {
    switch e := evt.(type) {
    case usermgmt.UserRegisteredEvent:
        slog.Info("user registered", "email", e.Email)
    case usermgmt.RolesUpdatedEvent:
        slog.Info("roles updated", "roles", e.Roles, "domain", e.Domain)
    }
})

Events are panic-safe — handler errors are logged but never propagate to the caller.

Authz (RBAC Engine)

Domain-aware Casbin RBAC with typed constants:

authz, _ := usermgmt.NewAuthz()

// Check permission
allowed, _ := authz.Enforce(userID.String(), "acme", "documents", "read")

// Query roles
roles, _ := authz.RolesForUser(userID.String(), "acme")

// Atomic policy update
authz.Apply(usermgmt.PolicyUpdate{
    AddPolicies:    []usermgmt.Policy{{...}},
    RemovePolicies: []usermgmt.Policy{{...}},
    AddGroups:      []usermgmt.GroupPolicy{{...}},
    RemoveGroups:   []usermgmt.GroupPolicy{{...}},
})

// Bridge to parent cqrs-htmx Enforcer interface
enforcer := authz.AsEnforcer()

Predefined Constants

Type Values
Action ActionExecute, ActionRead, ActionAll
Effect EffectAllow, EffectDeny
Role RoleAdmin, RoleUser, RoleViewer, RoleOwner

Account Lockout

Configurable failed-login tracking with automatic expiry:

lockout := usermgmt.NewAccountLockout(usermgmt.LockoutConfig{
    MaxAttempts: 5,
    Duration:    15 * time.Minute,
})

// Integrated into Service via ServiceConfig.Lockout
// Automatic cleanup: lockout.EvictStale() removes expired entries

Session Store

type SessionStore interface {
    Create(ctx context.Context, userID UserID, ttl time.Duration) (*Session, error)
    Find(ctx context.Context, token string) (*Session, error)
    Delete(ctx context.Context, token string) error
    DeleteByUserID(ctx context.Context, userID UserID) error
}

Default: InMemorySessionStore (with EvictExpired() cleanup). Implement this interface for Redis, SQL, or other backends.

CSRF & Rate Limiting on Auth Endpoints

Wire cqrs-htmx middleware around the usermgmt auth handler:

handler := cqrshtmx.Chain(
    httputil.CSRFMiddleware(httputil.CSRFConfig{Secure: true}),
    cqrshtmx.RateLimiterMiddleware(cqrshtmx.RateLimiterConfig{
        Limit:        10,
        Window:       time.Minute,
        Burst:        3,
        KeyExtractor: cqrshtmx.KeyExtractorFromRemoteAddr(),
    }),
)(mux)

This protects WebAuthn ceremony endpoints from CSRF attacks and credential stuffing.

Strongly-Typed UserID

All user IDs in the submodule use usermgmt.UserID — a branded type via go-branded-id (brandid.ID[userBrand, string]):

uid := usermgmt.NewUserID("01HK1549P84T9XF8R94E960633")
uid.String() // "User:01HK1549P84T9XF8R94E960633" (brand-prefixed)
uid.Get()    // "01HK1549P84T9XF8R94E960633"       (raw ULID, for cqrs-htmx bridge)

User Context

// Set user in context (done by SessionMiddleware)
ctx := usermgmt.WithUser(r.Context(), user)

// Retrieve
user, ok := usermgmt.UserFromContext(ctx)
user = usermgmt.UserFromContextOr(ctx, fallback)

// Bridge to cqrs-htmx UserIDExtractor
func extractor(r *http.Request) (cqrshtmx.UserID, error) {
    return cqrshtmx.MustParseUserID(usermgmt.UserIDFromRequest(r)), nil // reads from context, returns string
}

Architecture

cqrs-htmx/
├── app.go              # App builder, Config, Command(), Query(), lifecycle hooks
├── handler.go          # Command/query dispatch handlers
├── options_types.go    # HandlerOption type, TemplComponent interface
├── options_decode.go   # DecodeJSON[T], DecodeJSONQuery[T], DecodeForm[T] decoders
├── options_htmx.go     # HTMX-aware error handling, response application
├── options_json.go     # RenderJSON[T], RenderJSONStatus[T], pagination, RequireMethod
├── options_render.go   # Render, RenderTempl, Redirect, Trigger, PushURL
├── options_validate.go # ValidateCommand, ValidateQuery, WithTimeout, WithMaxBodySize
├── response.go         # HTMX response builder (fluent API), ContentType constants
├── authz.go            # Enforcer interface, Authorize, RequireAuth, AuthorizeMiddleware
├── context.go          # UserID, CorrelationID, RequestID — strongly-typed context helpers
├── errors.go           # CQRS error → HTTP status mapping, sentinels, error handlers
├── htmx.go             # HTMXRequest struct, accessors, swap strategies
├── htmx_embed.go       # Embedded HTMX v2.0.10 JS (minified)
├── htmx_serve.go       # HTMXScriptHandler, HTMXScriptTag, HTMXVersion, serveJS helper
├── htmx_extensions.go  # Embedded HTMX extensions (SSE/WS/idiomorph), HTMXExtensionHandler, HTMXExtensionsHandler
├── notify.go           # Notification HandlerOptions, NotifyWithEvent builder
├── middleware.go        # ContextEnrichmentMiddleware, HTMXMiddleware, Chain
├── csrf_config.go      # CSRFConfig, defaults, Validate()
├── csrf_context.go     # Token context helpers (get/set)
├── csrf_middleware.go  # CSRFMiddleware, double-submit validation
├── csrf_handler.go     # CSRFProtect (per-handler CSRF)
├── csrf_helpers.go     # CSRFTokenHTMLMeta, CSRFTokenHXHeaders, CSRFTokenFormField
├── decoder.go          # Body reading, form/JSON decoding, MaxBodySize
├── httputil.go         # WriteJSON, ClientIP (delegates to larsartmann/httputil)
├── logging.go          # RequestLogging, RequestLoggingSlog, StatusRecorder
├── ratelimit_config.go     # RateLimiterConfig
├── ratelimit_middleware.go # RateLimiterMiddleware, token bucket, min-heap eviction
├── security.go         # SecurityHeadersMiddleware, SecurityHeadersConfig
├── recovery.go         # RecoveryMiddleware (package-level), App.RecoverHandler()
├── sse_broadcaster.go  # Broadcaster, ServeSSE, BroadcastOnSuccess/OnError CQRS bridge
├── sse_event.go        # Deprecated re-exports of go-sse types (sse.Event, WriteEvent, etc.)
├── sse_store.go        # Deprecated re-exports (SSEEventStore, ReplayEvents → sse.EventStore, sse.Replay)
├── event_store_sse.go  # JournalSSEStore — production sse.EventStore backed by event journal
├── usermgmt/           # User management submodule (independent Go module)
│   ├── id.go               # Branded UserID type (go-branded-id)
│   ├── authz_types.go      # Authz wrapper, AsEnforcer bridge
│   ├── authz_policies.go   # Apply, AddGroupPolicy, AddPolicy
│   ├── authz_roles.go      # RolesForUser, ImplicitRolesForUser
│   ├── es_constants.go     # Event-sourced aggregate + event + command type constants
│   ├── es_events.go        # 22 event payload types (re-exported from identity-model)
│   ├── es_commands.go      # 19 command types (re-exported from identity-model)
│   ├── es_state.go         # UserState + foldUser() pure function
│   ├── es_decide.go        # 19 pure decide functions
│   ├── es_dispatch.go      # RegisterCommands — wires commands to decider.Repository
│   ├── es_setup.go         # DefaultEventSourcedSetup, UserDecider
│   ├── es_readmodel.go     # UserReadModel projection + email index
│   ├── es_casbin_projection.go  # CasbinProjection — derives policies from events
│   ├── es_projection_setup.go   # StartProjections orchestration
│   ├── service_core.go     # Service struct, ServiceConfig, NewService
│   ├── service_register.go # Service.Register
│   ├── service_login.go    # Service.Logout/Authenticate/Authorize
│   ├── service_misc.go     # GetUser, UpdateRoles, ChangeEmail, etc.
│   ├── credential.go       # WebAuthnCredential type
│   ├── credential_http.go  # Credential listing/removal HTTP handlers
│   ├── webauthn_service.go # BeginRegistration/FinishRegistration/BeginLogin/FinishLogin (provider-based)
│   ├── webauthn_http.go    # HTTP handlers for WebAuthn ceremony endpoints
│   ├── webauthn_adapter.go # Adapts domain User → webauthn.User interface
│   ├── webauthn_session.go # WebAuthnSessionTTL + in-memory challenge store
│   ├── user.go             # Immutable User read model
│   ├── store.go            # SessionStore interface + InMemorySessionStore
│   ├── events.go           # EventHandler callback + notification event structs
│   ├── http.go             # AuthHandler (register, logout, me, webauthn endpoints)
│   ├── middleware.go       # NewSessionMiddleware, user context helpers
│   ├── lockout.go          # AccountLockout (configurable attempts + duration)
│   ├── errors.go           # Sentinel errors
│   ├── totp/               # TOTP MFA sub-module (pquerna/otp)
│   ├── webauthn/           # WebAuthn sub-module (go-webauthn)
│   └── oauth2/             # OAuth2/OIDC sub-module (golang.org/x/oauth2 + coreos/go-oidc)
├── identity-model/     # Pure domain types — IDs, events, commands, fold functions, Authz engine (ADR 0043)
├── adminui/             # Admin Dashboard UI (templ + HTMX, independent Go module)
├── loginpage/          # Ready-made passwordless login page (templ + HTMX, independent Go module)
├── health/             # Optional go-health bridge: K8s probes + dashboard over projection health (independent Go module)
├── dashboardui/        # CQRS/ES observability dashboard (HTMX + Tailwind v4, independent Go module)
├── datastar/           # Optional Datastar frontend adapter (script serving, signals, SSE, replay, event bridge)
├── integration_test/   # Cross-module integration tests (independent Go module)
└── examples/
    ├── basic/           # Minimal cqrs-htmx consumer example
    ├── datastar-demo/   # Real-time todo app using the datastar adapter module (SSE + signals)
    ├── catalog-demo/    # Catalog doc-server example
    ├── admin-demo/      # Runnable admin panel showcase
    └── dashboard-demo/  # CQRS/ES observability dashboard demo

Ready-Made UI Modules

Three ready-made UIs ship as independent modules. They answer different questions, so most apps mount several:

Module Question it answers Write ops Wired to
adminui "Who uses my app?" Yes (role-gated) *usermgmt.Service
dashboardui "How is my event store doing?" Read-only by default go-cqrs-lite introspection interfaces
loginpage "How do users sign in?" No (auth UX) *usermgmt.Service

adminui = identity operations (users, tenants, members, audit log); dashboardui = event-store observability (journal, aggregates, projections, DLQ, snapshots). They are complementary, not alternatives: setup/v4 mounts all three behind correct middleware in one call.

Optional Sub-Packages

API Documentation Generation

API documentation (OpenAPI, AsyncAPI, D2 diagrams, EventCatalog) is now provided by go-cqrs-lite/catalog v4 directly:

go get github.com/larsartmann/go-cqrs-lite/catalog/v4
import (
    "github.com/larsartmann/go-cqrs-lite/catalog/v4/docserver"
    "github.com/larsartmann/go-cqrs-lite/catalog/v4/simple"
)

b := simple.New("User Service", "1.0.0")
simple.Command[RegisterUserCmd](b, "register-user")
cat := b.Build()

mux.Handle("/docs/openapi.json", docserver.NewDocsServer(...).OpenAPISpec())

See go-cqrs-lite/catalog/README.md for full documentation.

Dependencies

Pinned versions live in go.mod; go-cqrs-lite ships per-module version trains, so exact versions are intentionally not repeated here.

Dependency Purpose
go-cqrs-lite CQRS command/query dispatch, event sourcing, pagination
casbin/casbin/v3 Authorization
go-error-family Error classification
go-sse SSE protocol writer, broadcaster, replay
larsartmann/httputil CSRF, Server-Timing, rate limiting, ClientIP, security headers, compression, CORS
go-datastar DataStar frontend adapter protocol (optional datastar module)
go-branded-id Branded types (usermgmt)
go-playground/form/v4 Form decoding
templ-components UI component library (adminui, dashboardui)

Optional sub-module dependencies (only import the auth strategies you need; versions live in each sub-module's go.mod):

Sub-module Dependency Purpose
usermgmt/totp/v4 pquerna/otp TOTP MFA (RFC 6238)
usermgmt/webauthn/v4 go-webauthn WebAuthn/Passkey authentication
usermgmt/oauth2/v4 golang.org/x/oauth2 OAuth2 authorization code + PKCE
usermgmt/oauth2/v4 coreos/go-oidc/v3 OIDC discovery + ID token verification

Contributing

See CONTRIBUTING.md for build/test/lint commands, code style, and PR checklist.

A pre-push hook enforces CI parity at the push boundary (release-train strict + version-drift strict). Install it once per clone: bash scripts/tools/install-git-hooks.sh. If a push is blocked, the hook output names the gate and its fix — resolve it rather than pushing with --no-verify.

License

MIT

About

Go library for wiring go-cqrs-lite with HTMX, templ, and Casbin authorization — framework-agnostic HTTP handler builder

Topics

Resources

Contributing

Security policy

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages