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 barOne 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.
- Command & Query dispatch —
app.Command()/app.Query()returnhttp.HandlerFuncthat 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
TemplComponentrenders templ components without importing templ - User identity propagation — HTTP request → context → CQRS event metadata, with strongly-typed
UserIDandCorrelationID(ULID-backed) - Request validation —
ValidateCommand/ValidateQuerywrap decoders with validation logic - Dispatch timeout —
Config.Timeoutwraps dispatch withcontext.WithTimeout - Lifecycle hooks —
BeforeDispatchHook/AfterDispatchHookfor tracing, logging, metrics - Error classification — CQRS error families automatically map to HTTP status codes
- JSON or plain-text error handlers — pluggable
ErrorHandlerwith HTMX-aware auth redirects - Notification system —
NotifySuccess/NotifyError/NotifyWarning/NotifyInfowith standard{level, message}payload; custom event names viaNotifyWithEvent - CSRF protection — double-submit cookie pattern with HTMX-aware
X-CSRF-Tokenheader 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 addsBroadcaster(thread-safe fan-out with CQRS hooks),JournalSSEStore(production durable replay viaevent.SeekableJournal), CQRS bridge viaBroadcastOnSuccess/BroadcastOnError,Heartbeatfor proxy keepalive, ACK protocol (BroadcastOnAck— opt-in command confirmation viaX-Command-Idheader) - 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 viawindow.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 viaHTMXExtensionHandler/HTMXExtensionsHandler(the WS extension was removed in v5 alongside the WS transport — see ADR 0046) - User management — optional
usermgmtsubmodule 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: truelets the HTTP server bind while projections replay the journal in the background;ProjectionReadinessCheckreturns 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 andReadModelDialectfor Postgres/MySQL read models. See MySQL Setup Guide
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.
go get github.com/larsartmann/cqrs-htmx/v4For the user management submodule:
go get github.com/larsartmann/cqrs-htmx/usermgmt/v4Auth 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/OIDCUpgrading from v3? See the v3→v4 Migration Guide. Upgrading from v2? See the v2→v3 Migration Guide.
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/v4module creates shared stores, the user management service, admin dashboard, observability dashboard, and login page with correct middleware ordering in a singlesetup.New()call. Adopt your own service (Config.Service), override the fullusermgmt.ServiceConfig(Config.ServiceConfig— MaxUsers, TokenPepper, SecurityHooks, ...), or stay with the flattened convenience fields. See the Full-Stack Wiring Guide.
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
})| Option | Description |
|---|---|
Authorize(resource, action) |
Check Casbin policy before dispatch |
RequireAuth() |
Require authenticated user (no specific permission) |
| 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).
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 |
| 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 |
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);
});Override the global Config.Timeout per handler:
app.Command("SlowOperation",
cqrshtmx.WithTimeout(30*time.Second),
cqrshtmx.DecodeJSON(mapper),
)| 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 |
| 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 |
HTMXMiddleware parses all HTMX headers once and stores them in context:
handler := cqrshtmx.Chain(
cqrshtmx.HTMXMiddleware,
app.Middleware(),
)(mux)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 && !IsHistoryRestorecqrshtmx.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) // stringEvery HTMX handler needs the same branching: render a fragment for HTMX requests, a full page for regular navigation. These helpers eliminate that boilerplate.
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.
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 */ },
),
)mux.HandleFunc("GET /items", func(w http.ResponseWriter, r *http.Request) {
items := loadItems()
_ = cqrshtmx.RenderTemplComponent(w, r, itemsPartial(items), itemsPage(items))
})app.Query("GetProfile", decoder,
cqrshtmx.RenderIf(
func(r *http.Request) bool { return cqrshtmx.HTMXTarget(r) == "#avatar" },
avatarPartial,
fullProfile,
),
)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>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()| 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) |
SwapInnerHTML, SwapOuterHTML, SwapBeforeBegin, SwapAfterBegin, SwapBeforeEnd, SwapAfterEnd, SwapDelete, SwapNone
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>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/websocketor similar).
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.
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.
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.
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()- 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.
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→ 401ErrForbidden→ 403
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 |
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",
})// 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)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)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(...),
)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.
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)Encode and write JSON responses in one call:
if err := cqrshtmx.WriteJSON(w, http.StatusOK, user); err != nil {
// handle encode error
}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.
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/v4In 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)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.
| 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 |
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.
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.
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()| Type | Values |
|---|---|
Action |
ActionExecute, ActionRead, ActionAll |
Effect |
EffectAllow, EffectDeny |
Role |
RoleAdmin, RoleUser, RoleViewer, RoleOwner |
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 entriestype 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.
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.
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)// 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
}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
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.
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/v4import (
"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.
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 |
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.
MIT