Changelog
All notable changes to this project are documented here. The format follows Keep a Changelog, and the project follows Semantic Versioning.
Until 1.0.0, a minor bump may carry a breaking change. Each one is listed under Changed with the migration.
Unreleased
0.2.8 - 2026-09-29
This release is the result of four rounds of adversarial review of the framework, run as an attacker would against a service built on it, each later round also against the earlier rounds' fixes, and of testing on a real network and on Cloud Run. Every finding below was reproduced with a test before it was fixed, and each of those tests is now a regression test. Several fixes tighten a default; each one is listed under Changed with its migration.
Added
- Declare how a route authenticates, so the documentation can offer
Authorize. A guard is a function the framework can run but not read, so the
document could never say whether a route wants a bearer token, an API key or a
session cookie, and guarded operations looked public.
AppOptions.SecuritySchemesnow declares named schemes (muzak.BearerAuth("JWT"),BasicAuth(),HTTPAuth("digest"),APIKeyHeader/APIKeyQuery/APIKeyCookie,OAuth2(OAuthFlows{...}),OpenIDConnect(url)), andWithSecuritysays which a router or route sits behind, with scopes:muzak.WithSecurity(muzak.Require("oauth", "items:read")).Public()marks an exception, and is written as an emptysecuritylist. They are emitted ascomponents.securitySchemesand asecuritylist on each operation that declared one. A route's declaration replaces its router's, and several requirements are alternatives (aSecurityRequirementnaming several schemes needs all of them). Nothing changes for an application that declares none: the document carries neither. A scheme only describes. It is never consulted while a request is served, so naming one protects nothing; the route still needs theGuardthat refuses. Schemes are checked when the application is built: a name outside[A-Za-z0-9._-], a type OpenAPI does not define, anhttpscheme with noScheme, anapiKeywith no validInorName, anoauth2scheme with no flow or a flow without the URL its kind needs, anopenIdConnectURL, a flow URL that is nothttporhttps, a route naming a scheme that is not declared or a scope an OAuth 2.0 scheme does not offer, and aWithSecurity()naming nothing are all build errors. SSEOptions.MaxLifetimeandWSOptions.MaxLifetimeend a long-lived connection on purpose. The write timeout bounds one write and a keepalive is a few bytes, so a client that reads nothing while comments trickle in never fills a socket buffer, and a websocket peer that answers every ping and says nothing is alive as far as a keepalive can tell. Either held its slot, a goroutine and a file descriptor until the server stopped. When the lifetime has passed an event stream writes a closing comment and ends the way a finished response does, and a websocket is closed with1001going away, so a browser'sEventSourcereconnects on its own withLast-Event-IDand a handler that resumes fromstream.LastEventID()loses nothing. The handler is told through its context and the errors later sends report, and the slot is released when it returns. Both are unset by default, which leaves connections unbounded as before; a negative value removes a bound a wider scope set. They are worth setting on any route a client one does not control can open. Migration: none; a client that does not reconnect on its own (a script usingSSEDial) must be written to.WSOptions.AllowedHostssays whichHostvalues make an origin the server's own. The same-origin rule compared theOriginheader's host with the request'sHost, which is whatever the client wrote, so in a DNS-rebinding attack a page served from the attacker's name, made to resolve to the server, sent that name as both and read as same-origin. A route that lists the names it answers to refuses any otherHostwith 403 unlessAllowedOriginsorAllowOriginFuncnames the origin. Unset, the rule is what it was, so nothing changes until it is set; it is worth setting for a server on a loopback or private address, where such a page is a real threat. The option bounds the websocket handshake only: a plain HTTP route is not protected against rebinding by it, and an application that wants every request held to its own names checksHostin a middleware.- File a router under a category, and give a route a title, so a large API
stays readable in the documentation. With a hundred routers the tag tree in
a docs sidebar is unreadable, and a tag is the wrong tool: it names what an
operation is about, and several add up.
muzak.WithCategory("Billing")files a router, or a single route, under one heading. It is aSharedOption, so it works at both levels: every route beneath a router inherits its category, many routers may share one, and a router included into another, or a route itself, replaces the category it would have inherited, since a category is a single value and never a list.muzak.Title("Fetch User Profile")is aRouteOptionnext toSummarygiving a route a human name, for a documentation tool to list in place of the endpoint's path. Both reach the OpenAPI document as vendor extensions on the operation,"x-category"and"x-title", emitted only where one was set, and are exposed asRoute.CategoryandRoute.Title; generic, WebSocket and event stream routes carry them alike. A category is not a tag, and tags behave exactly as before. The document also carries a top-level"x-categories"array naming every category some operation in it has, each once, in the order they were first registered: a route counts where it was declared and a router included into another counts where theIncludecall was made, so a hundred routers list in the order the application mounted them and not in the sorted order of their paths. A category met only on a hidden route is left out. Nothing changes for an application that sets neither: the document carries neither extension nor the list. Both are checked when the application is built, and the error names the route or the router (by its mount prefix): after trimming surrounding whitespace the value must not be empty, must be valid UTF-8 of at most 64 characters for a category and 120 for a title, and must hold no control character, line break or bidirectional control. Titles need not be unique. Theexample/application now files its routers under categories and titles some routes. ServerOptions.UnencryptedHTTP2accepts HTTP/2 without TLS. It is for a server behind a platform that speaks HTTP/2 to the container in the clear, such as Cloud Run with anh2cport, where a request is cancelled the moment its client leaves; over HTTP/1 the platform's proxy keeps the connection to the container open and a handler runs on.App.RunandApp.RunSignalscould not serve it before. Seedocs/cloud-run.md, which records what was measured on Cloud Run: the proxy's peer address andTrustedProxies, HTTP/1 againsth2c, timeouts, streams and rollouts.
Changed
- A JSON body sent without a
Content-Typeis refused with 415. Accepting it let any page drive a JSON route cross-site without a CORS preflight, by posting an untypedBlob. A request with no body and noContent-Typeis unaffected. Migration: sendContent-Type: application/json(or a+jsontype) with every JSON body. - A location tag the binder cannot reach is a build error. A
path,query,header,cookie,formorfiletag inside an embedded pointer, a named nested struct, or a slice, map or pointer of one was silently ignored, so the field became JSON body content and a request could set, for example, the user ID a gateway puts in a header. A location tag on an unexported field is refused the same way. Migration: embed the struct by value, move the field to the top level of the input, or export it. - Guards and providers given to
muzak.Newalso cover the documentation./openapi.json,/docsand its assets used to be served before any guard, so an application-wide guard left every non-hidden route, header and schema readable by anyone. They now run the application's guards and providers first, and guarded docs are sentCache-Control: private, no-cache. Router and route guards do not apply to the docs. Migration: to keep the docs public while the API is private, declare the guard where routers are included rather than onNew(the example does this now), or setAppOptions.DisableDocs. - Frontend and Static mounts run their routers' providers and rate limits.
A mount ran only inherited guards, so a router authenticated with
Needs(...)served its files to anyone, and no quota counted a file request. Mounts now apply the rate limit, guards and every inheritedNeedsandSingletonprovider in route order. Migration: move assets meant to be public onto a router without those options, and useSkipRateLimit()to keep a mount outside a quota. - Frontend and Static mounts no longer serve dotfiles. A path with any
segment starting with
.(/.env,/.git/config) answers 404, with no SPA fallback. A leading/.well-known/is still served. Migration: setFrontendOptions.AllowDotfilesorStaticOptions.AllowDotfilesfor a directory whose dotfiles are meant to be public. - Every provider along a route's chain runs. Declaring the same type twice
(
app.Include(admin, Needs(RequireAdmin))with the admin router declaringNeeds(CurrentUser)of that type) kept only the innermost provider, so the outer authorization never ran. Every provider now runs, outermost first, and the first error aborts the request;FromandTryFromreturn the innermost value. Migration: an outer provider that was deliberately replaced now also runs and can fail the request; declare it only where it applies, or give the two values distinct types. - A request that fails after its response has started aborts the
connection. A panic, or an error returned after the first byte, was logged
and the response then ended cleanly, so a truncated export looked complete.
Muzak now logs the failure and panics with
http.ErrAbortHandler, whichnet/httpturns into an aborted connection without logging it again. Hijacked connections, WebSocket and SSE routes are unchanged. The access log marks such requestsaborted=true, and now also records a middleware panic as a 500. Migration: code that callsApp.ServeHTTPorRecoverydirectly should expecthttp.ErrAbortHandlerin this case. - A status coder error renders only its own message. Below 500 the client
gets the
StatusCoder's ownError()text; from 500 up it gets the standard internal message and the error is logged. Migration: put a 4xx explanation in theStatusCoder's ownError(), and use an*HTTPErrorfor a 5xx whose message is meant for the client. - A singleton caches only a successful value. An error or a panic fails
the request that triggered it and the next request retries. The provider now
sees a context without the triggering request's cancellation or deadline.
Migration: a provider that relied on the request deadline to bound its I/O
should apply its own with
context.WithTimeout. - A static route pattern must be validly encoded. A static segment that is
not (
/50%off) or that encodes a/(/a%2Fb) could never match and is now a build error, and two spellings of one path (/a,/%61) conflict. Migration: write%25for a literal percent sign, and use a parameter for a value that may contain/. - Validation reports at most 100 failures, and follows nested models at
most 32 levels deep. A full report ends with a detail of kind
too_many_problemsand stops evaluating, and a model nested past the limit is refused astoo_deep. The limits areMaxValidationDetailsandMaxNestedDepth.Nestednow validates the model when it is called, so code after it sees the nested model's transforms applied, and a model nested per element is named by its position (items[3].name). Migration: read the marker and resubmit to see further failures; a recursive model that must go deeper bounds its own depth; clients matching field names match the indexed ones. Timezone()accepts only zone names spelled as the IANA database spells them. Case variants, doubled or trailing slashes,Local,posix/andright/names, and zones newer than tzdata 2026c are refused. Migration: send canonical names.- A 422 no longer quotes the client's value or a parser's own error. A
rejected entry of a repeated parameter is named by position ("entry 2 must
be a valid integer"), and a failing
encoding.TextUnmarshalerreads "is not in the expected format" (muzak.binding.format). Migration: return an*HTTPErrorfromUnmarshalTextto send your own wording. - A urlencoded form body is bounded by
MaxBodySize(1 MiB by default), notMaxUploadSize(32 MiB). Multipart bodies keepMaxUploadSize. Migration: setMaxBodySizeon a form route that takes larger urlencoded bodies. - Error responses drop the headers set for the response they replace, and
are sent with
Cache-Control: no-store. Content-Type, Content-Length, Content-Disposition, Content-Encoding, Content-Range, ETag, Last-Modified, Cache-Control and Expires are removed, and Content-Language is reset to the request's locale. Vary, Retry-After, Allow, WWW-Authenticate, Set-Cookie and security headers are kept. Migration: a customErrorRenderersets any header it wants. - Routes and file mounts behind a guard or a request-scoped provider are
sent
Cache-Control: private, no-cache. A route keeps a value its handler or a middleware set; a mount replaces one. A singleton alone does not count. Event streams and WebSocket upgrades are unchanged. Migration: a handler serving public data sets its ownCache-Control. - A static mount serves HTML, SVG and other XML under
Content-Security-Policy: sandbox, and no mount guesses a type from content. A file with no extension or an unknown one isapplication/octet-stream. Migration: setStaticOptions.AllowActiveContentfor a static site whose scripts must run, and give files an extension. - The WebSocket and SSE per-client caps count an IPv6 client by its /56,
and the prefix is configurable. New
ClientIPOptions.ConnectionIPv6Prefix(default 56) andConnectionIPv4Prefix(default 32) are checked at build. Migration: setConnectionIPv6Prefix: 64to keep the previous grouping. SSEDialdoes not follow redirects. A 3xx is refused and the response returned with the error. Migration: dial theLocationyourself if it is trusted.- Configuring an application after it was built panics.
Use,Options,Include, route, WebSocket and SSE registration, andFrontendandStaticmounts afterBuild,ServeHTTP,Documentor a run method used to be dropped silently, so a guard added late left routes open. Migration: make every configuration call before the first build. - Shutdown is bounded by one
ShutdownTimeoutdeadline, and lifecycleStopgets what remains. The WebSocket, SSE and HTTP drains used to take a full timeout each, andStophad no deadline and could run under live handlers.Stopnow runs after in-flight handlers (hijacked ones included) return or a short grace past the deadline ends, with at least one second;Runreturns onceShutdownhas finished. Migration: makeStophonour its context, or raiseShutdownTimeout. - Log redaction matches keys that contain a sensitive term.
db_password,X-Api-Key,jwtandsession_idare now redacted, andjwt,bearer,signatureandcredentialjoin the defaults. A customRedactKeyslist is matched the same way. Migration: rename a non-secret key containing a term if it must be logged. - The documentation counts against the application rate limit. See
Security. Migration: exempt it with
SkipRateLimit, or disable the docs. HTTPError'sWithDetails,WithCode,WithMessageKeyandWrapreturn a modified copy and leave the receiver untouched. They edited the error in place, so a package-level error decorated per request handed one request's details to the next response, grew them with every request and raced between concurrent ones. Migration: use the returned value (every documented use already does); a call whose result is discarded, such aserr.WithCode("x")on its own line, no longer has any effect.- A certificate without a key, or a key without a certificate, is a build
error. With only one of
ServerOptions.CertFileandKeyFileset the server came up in plaintext on the port its operator believed was HTTPS, with nothing said. Migration: set both, or neither. required:"true"on a config field refuses an empty value.API_KEY=, which Compose and Kubernetes produce when they expand an unset variable, satisfiedrequiredand loaded an empty secret. An empty value is now treated as missing, so adefaultapplies if there is one and the load fails otherwise. Fields that are notrequiredare unchanged. Migration: droprequiredfrom a field whose empty value is legitimate, or give it adefault.- The context
Lifecycle.Startreceives stays live until the components are stopped. It was cancelled as soon asStartreturned, so a component that kept it for a background worker had the worker cancelled during boot. It is still cancelled when a sibling fails to start and when the context passed toStartLifecycleis; it is now also cancelled afterStophas run. - A route template that declares a parameter name twice is a build error.
/orgs/{id}/users/{id}used to build, and a handler bindingpath:"id"silently received the first segment, so a check on "the user's id" was made against the organisation's. Migration: give each parameter its own name. - Two static mounts at one path are a build error. A second
StaticorFrontendat a path used to lose to the first without a word, guards included. Migration: keep one mount per path, or give them different paths. Accept-Encoding: deflateis answered in the zlib format. RFC 9110 defines thedeflatecontent coding as zlib around DEFLATE, and the bare DEFLATE stream sent before was refused by strict decoders. A client that decodes it as raw DEFLATE, which was only ever a lenient guess, should decode zlib; every conforming client already does.- A version is read from
Acceptand from a version header more strictly. With media type versioning, each range ofAcceptis parsed as a media range: the parameter name is matched without regard to case, a quoted value is unquoted, the range with the highestqpicks the version (one refused withq=0is ignored), everyAcceptline is read, and a barev=1that is not a media range no longer names a version.Keymay be written with or without its=. With header versioning, a header sent on several lines is one list and names no version, so it matches no route rather than the first line's. A proxy that reads the same header the standard way now agrees with the application about which version, and so which guards, a request reached. Migration: send oneAcceptrange per version, in the formapplication/json;v=2, and one line of the version header. - A multipart body sent to a route that binds no file is bounded by
MaxBodySize, notMaxUploadSize. A login or sign-up form that binds onlyform:values took itsmultipart/form-databody under the 32 MiB upload limit, so net/http held up to 20 MiB of text values (about 49 MiB of heap per in-flight request) before any rule could run, and a file sent to it, which nothing reads, was spooled to disk first. The urlencoded encoding of the same route was already bounded byMaxBodySize. A route that declares afile:field keepsMaxUploadSize. Migration: a form-only route that legitimately receives more thanMaxBodySize(1 MiB by default) as multipart must raiseMaxBodySizefor it. Required()fails a nil pointer field. Every rule was skipped for a nil pointer,Required()included, sov.String(&in.Name).Required()on a*stringaccepted{}and{"name":null}while the generated document listed the field as required. A nil pointer now reports the field'sRequired()rule (is required, with its custom message if it has one); its other rules are still skipped. Migration: a pointer field that is meant to be optional must not declareRequired().Nestedthat would validate nothing is a build error.v.Nested(in.Addr)with a value instead of a pointer was skipped without a word, and a model whoseValidatehas a value receiver bound its rules to a copy, so{"addr":{"city":""}}passed aRequired()city and its transforms were lost. Both are now refused when the route is compiled, for the input and for every model it nests. Migration: pass the model's address,v.Nested(&in.Addr), and declareValidateon the pointer receiver.Unique()treats twotime.Timevalues as equal when they are the same instant. It usedreflect.DeepEqual, which compares zone pointers, so12:00Zand13:00+01:00were distinct. They are now a repeat, astime.Time.Equalhas it.[]time.Timeand[]netip.Addr,[]netip.AddrPortand[]netip.Prefixare checked in linear time (see Fixed).UUID()accepts only the canonical 8-4-4-4-12 form. It read a URN, braces and 32 bare digits as the same identifier, which the generated document'sformat: uuiddoes not describe and which let one UUID be held under several strings. Migration: normalise the spelling before validating, or useMatchesfor a wider set.Base64(),URL(),HTTPS(),URLWithSchemes()andNotBlank()are stricter.Base64()rejects a value with a CR or LF (Go's decoder skips them). The URL rules reject a port above 65535 and a value containing a control, format or separator character (bidirectional overrides, zero-width characters).NotBlank()treats a value of only invisible characters (zero width space, Hangul filler, braille blank, format characters) as blank.default:"..."on a member of a JSON body is applied, and documented as its type. The document listed the default and marked the member optional while the binder never filled it in. A top-level member (or one promoted from an embedded struct) that the client omits now takes the default; one it sends wins. A default that does not parse as its field's type is a build error, and a member whose type cannot be written from text (a struct, a map) still has no default. Defaults are now emitted as the JSON type of the member (10, not"10") for query, header, form and body, and are no longer emitted on a nested member or on a response type, where nothing applies them.- The OpenAPI document no longer describes a
Clampas a range.Clampmoves a value into range rather than rejecting one outside it, sominimumandmaximumtold a client that 500 is refused. - A WebSocket handshake's
Originis read strictly. The same-origin rule took the host out of any value a lenient URL parse could find one in, sohttp://attacker@host,http://host/path,http://host?xand//hostpassed as the server's own origin, and only the first of severalOriginheaders was looked at. An origin is now a scheme and a host with nothing after it, and a handshake with more than oneOriginheader is refused with 400. Values listed inAllowedOrigins, and the onesAllowOriginFuncsees, are still compared exactly as they arrived. Migration: none for browsers, which send neither shape. A non-browser client that sent a decoratedOriginmust send a barescheme://host[:port]or none at all. - A websocket connection that sends more than 65536 pings, pongs and empty
fragments in a minute is closed with 1008. The frame guard restarted with
every message, so a peer completing one empty message per 65535 pings was
answered without end while
MessageLimitssaw a single message per round. Migration: none for an honest peer, whose keepalive sends one ping every few seconds. - A negative
WSOptions.PongTimeoutmeans the default. It was read as zero, so the first keepalive ping closed every connection whose peer could not answer instantly. Migration: none. SSEDialbounds how long opening a stream may take. It removed the supplied client'sTimeoutso that it would not cut the stream short, and put nothing in its place, so a server that accepted the request and never answered, or trickled the body of a refusal, held the call for as long as a context without a deadline lasts. Opening is now bounded by the newSSEDialOptions.HandshakeTimeout, which defaults to theTimeoutof the suppliedHTTPClientwhen it has one and otherwise toDefaultSSEHandshakeTimeout(30 seconds); a negative value removes the bound. The bound ends when the stream is open and says nothing about how long the stream lasts or idles. Migration: a server that takes longer than 30 seconds to send response headers needsHandshakeTimeoutset higher, or negative.SSEReader.Retryis capped at an hour and accepts digits only. Aretry:field was parsed withParseIntand multiplied into a duration, so a server sending9223372036855madeRetry()negative, and a client that sleeps for it reconnected in a tight loop. A value with a sign, a space or an exponent is now ignored as the format requires, and a value beyond an hour is reported as an hour. Migration: none for a server that asks for a sensible delay.- Default log redaction covers more secret-bearing keys.
DefaultRedactedKeysnow also matchespassphrase,pwd,dsn,connection-string,access-key,signing-key,master-key,encryption-key,hmac,totp,otp-code,basic-auth,auth-headerandx-auth, in any case and with any of-,_,.or space between the words.auth,pass,sigandotpare left out on purpose, since they sit insideauthor,passenger,designandhot_path. Migration: a key that these now hide and that is not a secret can be kept by passingLoggerOptions.RedactKeyswithout the term; a secret under a shorter name is added the same way. - A recovered panic's value is cut to 4 KiB in the log. The value was
logged whole, so a panic message built from a request's input made a log
line as large as the input. It is now rendered with
fmtand cut, with a...(truncated)marker, in bothRecoveryand the router's own recovery. Thepanicattribute is a string in the JSON format for every kind of value; it used to be the marshalled value for one that was not a string or an error. - A stream that cannot carry write deadlines logs a warning. Middleware
that wraps the response writer, forwards
Flushand has noUnwrap() http.ResponseWriterhides the connection's deadlines, so the stream'sWriteTimeoutwas silently not enforced and a client that stopped reading held its stream indefinitely. The stream is still served, because a writer supplied by a test cannot carry deadlines either, but the first one on each route now logs which writer stopped the chain. Migration: give the wrapper anUnwrapmethod. - An IPv6 client is counted by its /56.
IPTrackerand the per-client connection caps (MaxConnectionsPerIP,MaxStreamsPerIP) keyed on the exact address, so one subscriber got a fresh budget from every address it held. A /56 is what providers delegate to one home or small site (RFC 6177): rotating the low bits of the fourth group of an address, all inside its own /56, turned a limit of 5 a minute into more than a thousand.ClientIPOptions.ConnectionIPv6Prefix(default 56) sets the prefix both count, so one subscriber has one budget however it is limited. IPv4 is still counted exactly and its keys are unchanged; IPv6 tracker keys are nowip:<prefix>/56.
Migration: a counter kept in a shared storage under an IPv6 client's old exact-address key is not carried over, so IPv6 clients start with a full budget once after the upgrade. Raise the limit, or setTracker: IPPrefixTracker(32, 128), if a proxy in front already bounds per-address traffic. Where many unrelated users share one /56 (a campus, or a carrier that gives each device a /64), setClientIPOptions.ConnectionIPv6Prefix: 64to keep the old grouping, which also loosens the connection caps to match; for one route only,IPPrefixTracker(32, 64)(any length from 0 to 128) still chooses its own. Where sites are delegated a /48, or the deployment is hostile to begin with, set 48 (anything from 32 to 128 is accepted). - A locale file that reaches one key two ways is refused. A key that
contains a dot (
"a.b": one) next to a namespace that spells the same path out (a: {b: two}) is the keya.btwice, and which of the two a lookup returned was decided by the order Go ranged the map in, so the same file answered differently after each restart and one translator's entry could silently shadow another's. Loading it is now an error naming the key, whether the two are in one file or in two files loaded into the same locale, and the locale is left as it was. A locale tree that nests more than 100 levels (the depth the YAML reader already stops at) is refused as well; see below. Migration: write each key one way. Dotted keys that collide with nothing keep working. - YAML aliases are bounded. The aliases of one document may copy at most
yaml.MaxAliasNodes(100,000) values between them, and an alias adds the depth of the value it names to its own, so it counts againstMaxDepth. A document that exceeds either is refused with a located syntax error. Sharing a block of defaults between locales, which is what aliases are for in a locale file, is far below either limit. Migration: none for a real file. - Strftime patterns, and
%<name>dinterpolation directives, are bounded. A strftime width is at most 64, a pattern's nesting through%cand%xat most four levels, one expansion reads at most 16,384 characters and writes at most 16 KiB (cut at a character boundary). A%<name>directive with a width or precision above 64, or a*width, is left as literal text rather than rendered. Migration: none for a real pattern or message. - A directory that has a mount of its own is reachable only through that
mount, under any name. When a mount's directory lies inside another
mount's, a request that reaches it through the outer mount is answered 404,
not only when the path differs in case but whenever a directory on the way
is the inner mount's directory: a symbolic link inside the outer directory
that leads to it, or a mount whose path differs from its directory's name
(a guarded mount at
/xoversite/staff, beside a public mount at/oversite, no longer serves the same files at/staff/...). The fallback page is not offered in its place. A request the inner mount already answers is unaffected, and so is every mount with no mount beneath it. Migration: none for a layout where the inner mount is mounted at its own directory name; to publish the same files at two paths, mount them at both. - WebSocket keepalive is on by default.
WSOptions.PingIntervaldefaults to 30 seconds andPongTimeoutto 10, so a peer that connects and goes silent, or whose network dropped without a word, no longer holds a connection slot for as long as it likes: only the per-IP and global caps bounded it before. A connection is closed for an unanswered ping only when its handler was reading during it, because a pong is consumed by a read like any other frame; a handler that only writes, or one busy with a message, is left alone, which also stops keepalive closing a healthy peer of a handler that does not read. Migration: none is needed for a peer that answers pings, as every browser does. A negativePingIntervalturns keepalive off. - The default
MaxHeaderBytesis 64 KiB, not 1 MiB. A client that never finishes a header block holds all it has sent for as long asReadHeaderTimeoutallows, and with no cap on connections as a whole a megabyte each adds up. An ordinary request, with its cookies and a token, needs a small fraction of the new limit. Migration: setServerOptions.MaxHeaderBytesfor a deployment that sends larger headers. - The document lists a JSON body member as
requiredonly when aRequired()rule refuses a body without it. Every non-pointer member withoutomitemptywas listed, but the decoder accepts a body that leaves any member out, so a client, or a gateway validating requests against the document, was told that a request the server accepts was malformed. A member of a request body is now required exactly when aRequired()rule is declared for it (and it has no default, which is written before the rules run). This changes the emitted document for existing applications: a request schema that listed every member now lists only those with a rule, or none. ARequired()on a pointer refusesnullas well as absence, so the member is required and no longer described as nullable. The response side is unchanged, since a member that is not a pointer and notomitemptyis always written. A type used both as a request and as a response therefore gets two schemas: the component keeps its shape for the response, and the request refers to a copy named for it withInputafter (ItemandItemInput); a type only requests use keeps its name. Types nested inside a body keep their shape unless their own rules speak for them, and are never split. Form values keep the binder's own rule, to which a rule can only add. Migration: a client generator will now type an unruled request member as optional; declareRequired()on the members that must be sent. format: durationis gone from atime.Duration. It is ISO 8601 (PT1.5S) in JSON Schema, while the binder and the JSON codec read1500ms, so a client that checks formats refused the value the document told it to send. The schema is a string with apatternthat agrees withtime.ParseDuration, and the description is kept.- A
[]bytequery, header, cookie or form value is described as an array of integers from 0 to 255, not a base64 string. The binder reads each value of a parameter as one element, so?data=65is[]byte{65}and?data=aGk=is a 422. A[]bytein a JSON body is still base64. The runtime is unchanged. Unique()is linear for every element type. Elements that cannot be hashed as they are, pointers, interfaces, slices, maps, and structs holding them, were compared pair by pair, so twenty thousand of them, a few hundred kilobytes of JSON, cost minutes of CPU unless the rule carried aMaxItemsbound the developer had to know to write. They are now keyed by a canonical encoding that is equal exactly whenreflect.DeepEqualis, with the two exceptionsUniquealready made: atime.Timeis the instant it names, wherever in an element it sits, and-0is0. A nil slice or map is still not an empty one, two pointers to equal values are a repeat, a map is the same map in any order, and aNaNequals nothing. A cyclic value is encoded once. Memory follows the size of the collection, so the body limit bounds it, and no build error or default cap is needed.MaxItemsandItemsstill short-circuit a collection that is already too long. Migration: none.WSDialrefuses a 101 that names an extension. The client never offers one, and RFC 6455 section 4.1 says a response that negotiates one it did not ask for fails the connection. It used to read on with frames whose meaning the server had changed. Migration: none for a server that does not negotiate extensions unasked.- Middleware installed with
App.Usenow runs inside the CORS policy, not outside it. Until now the policy was the innermost layer, so an authenticating middleware refused every preflight (a browser never sends credentials on one) and its own 401 or 429 carried noAccess-Control-Allow-Origin, which a browser reports as a network error rather than the status. The chain is now the built-in layers (request id, security headers, recovery, locale, access log), then CORS, thenUsemiddleware. A preflight is answered by the policy and no longer reachesUsemiddleware; a response such a middleware writes itself carries the policy's headers for an allowed origin and none for a denied one.
Migration: aUsemiddleware that relied on seeing anOPTIONSpreflight (to log it, or to answer CORS itself) no longer does whenAppOptions.CORSnames origins; without a CORS policy nothing changes. RateLimitStorage.Incrementnow runs under a timeout.RateLimitOptions.StorageTimeoutbounds each call, defaulting toDefaultRateLimitStorageTimeout(2 seconds); a negative value removes the bound. A store that stopped answering used to hold every request that reached it until its client gave up, andFailOpencould not help because no error ever came back. A call that outlives the bound has its context cancelled and counts as a storage failure: 503, or unmetered withFailOpen. The process-local storage is called without the deadline. A custom storage must return when its context ends, which any client library's context-taking call does.- A file larger than 8 MiB from a filesystem whose files cannot seek is
streamed, not read whole into memory. Smaller files are still buffered, so
range and conditional requests keep working for them; a streamed one is sent
with its size as
Content-Lengthwhen known andLast-Modified, without range support. - A configuration setting declared where it cannot be loaded is an error.
A tagged unexported field, or an unexported embedded pointer whose type holds
tagged fields, loaded nothing without a word, so a
requiredsetting was never enforced.LoadConfignow reports the field. Export it, embed the struct by value, or tag itenv:"-"to opt out.
Security
- A WebSocket message can no longer slip past the read limit. The limit
check added the bytes already received to the length the next frame
declared, so one byte followed by a continuation frame declaring
0x7FFFFFFFFFFFFFFFwrapped negative and passed, and the peer could stream as much as it liked into one message. With the default configuration and one unauthenticated connection this could exhaust the server's memory. The check cannot overflow now, and such a frame is refused with 1009 from its header alone.WSDialconnections were affected the same way and are fixed too. - Percent-encoding can no longer route a request around a static route's
guards.
GET /users/%61dminmissed a guarded/users/adminand was answered by a public/users/{id}withidset toadmin. Static segments are now compared percent-decoded, asnet/http.ServeMuxdoes, for prefixes, versions, HEAD, OPTIONS, 405, WebSocket and SSE routes alike. An encoded%2Fstill never matches a static segment. - A request can no longer set a located field from its body. See the build error under Changed: a tag the binder silently ignored left the field to the JSON body.
Uniqueis linear for hashable elements, and defers to a count bound. It compared every pair withreflect.DeepEqual, so a 228 KB body cost 24 seconds of CPU, and.Unique().MaxItems(100)still paid it because rules ran in declaration order. Strings, numbers, booleans and arrays or structs of them now use a hash set, and a collection over aMaxItemsorItemsbound is never searched for repeats. Other element types keep the pairwise search and should carry aMaxItemsbound.- A status coder error no longer sends its wrapped cause to the client.
DefaultErrorRendererrendered the outermosterr.Error()whenever aStatusCoderwas anywhere in the chain, so context added withfmt.Errorf("...: %w")-- queries, connection strings, tokens -- reached the response, 5xx included. - A singleton can no longer be poisoned for the life of the process. A provider that panicked once, or that failed because the first client disconnected and cancelled the request context, failed every later request until restart, which one aborted request could trigger.
- A float parameter refuses
NaNand infinity. Query, path, header, cookie and form fields acceptedNaN,InfandInfinity, which pass comparisons such asamount > balance. They now fail as "must be a valid number". The 0.2.6 fix covered onlyvalidate.Number. IPandIPv6refuse an address with a zone. The zone is free text to the parser, sofe80::1%followed by CRLF and a header, or by markup, passed as a valid address.- One rate limit quota can no longer evict another's counters. When the in-memory storage was full, eviction was global, so flooding one quota with new keys -- easily done from a single IPv6 allocation with the default tracker -- evicted and so reset a per-account login limit. It now evicts from the quota holding the most counters.
- Localized responses vary on the header or cookie that chose their
language. With
LocaleFromHeaderorLocaleFromCookieonlyVary: Accept-Languagewas added, so a shared cache could be made to serve an attacker-chosen language to everyone. ALocaleFromCustomextractor should add its ownVary. - CORS adds
Vary: Originto every response under a named-origin policy. It was added only when the origin was allowed, so a shared cache could serve the allowed origin a response without CORS headers. OPTIONS responses also vary onAccess-Control-Request-MethodandAccess-Control-Request-Headers. - Every console log record is exactly one line. The console format, the
default when stderr is a terminal, wrote the message (which carries the
decoded request path) and attribute values as they were, so
%0aforged log lines and ESC drove the operator's terminal. Control characters, DEL, C1 controls, U+2028, U+2029, bidi controls and invalid UTF-8 are now escaped. - A panic below
Compressis answered with a 500 instead of an empty 200. Its deferred finish sent the pending header while the panic unwound, so Recovery could no longer write its error. - A less specific mount no longer serves a path inside a more specific one
by case. On a case-insensitive filesystem
GET /ADMIN/secret.txtwas served by a publicStatic("/")instead of the guarded/adminmount. Such requests, and ones using\as a separator, now answer 404. Emailrefuses invisible characters. Runes in Unicode categories Cc and Cf and U+2028, U+2029 -- bidi overrides, zero-width characters, C1 controls -- were accepted. Internationalised addresses are still accepted.WSDialconnections applyReadTimeoutandWriteTimeout. The transportnet/httphands back is not anet.Conn, so no deadline was ever set, and a server that stopped reading or stalled mid-message held the client forever.- An env file parse error no longer repeats the offending line. A malformed
line, often part of an unquoted multi-line PEM key or a
KEY: valuetypo, was quoted into the error. Errors now name the file, the line number and, when it looks like one, the key. A value read by anysecret:"true"field is also hidden from every other field reading the same variable. - A path that is not valid UTF-8 gets its 404 instead of a 500. The "no
route matches" message quoted the decoded path, so
GET /%ffmade the error envelope impossible to encode. The path is quoted in its escaped form when it is not printable ASCII, and the method likewise in 404 and 405 messages. json:"-"on a located field no longer disables its binding. The header or query value, its default and its required check were all dropped; the tag now only keeps the field out of the JSON body and schema.- Validation work and output are bounded.
Each, orNestedcalled per element, reported every failing element, so a 1 MiB body produced a 22 MB 422 and half a gigabyte of allocation; a recursive model nested 10,000 deep cost a 300 MB report and gigabytes of allocation, and the pooled validator kept the tree alive. See Changed for the limits. - An event stream never writes into a response it has handed back. A send
still encoding when the handler returned held the write lock; the stream
gave up after
WriteTimeoutand returned the response anyway, which crashed the process or could write one client's event into the next request on the connection. - Static mounts no longer serve uploads as live pages. An upload stored
without an extension was sniffed as
text/html, and an.svgwas served inline, so client-written markup ran script on the API's origin. - An error response can no longer be served as HTML or cached publicly.
A handler that set
Content-Type: text/htmlor a publicCache-Controland then failed sent its JSON error, reflected input included, under those headers. - The documentation counts against the application rate limit. Once the
docs ran the application's guards but not its rate limit,
/openapi.jsoncould be used to guess the application's token without limit. - A failing singleton no longer makes requests queue for it. Each waiting request retried in turn under a lock and ignored its own cancellation, so a slow failure multiplied into queued latency and goroutines. One attempt is in flight at a time, its waiters share its outcome and leave when their own request ends, and only success is cached.
- Guarded responses are kept out of shared caches. Guarded routes and file
mounts sent per-user JSON and files with no
Cache-Control, so a CDN could hand one user's response to another. - A multipart form's temporary files are removed at the end of every
request, whoever parsed it. A guard or handler reading the form through
ctx.Request()parsed it on a copy of the request that net/http's cleanup never saw, and every such upload stayed on disk. - A malformed form body is no longer written to the error log. Its parse error, which quoted the bad part header whole, was logged at ERROR, so a 1 MiB request wrote a 5 MB log line.
Timezone()no longer grows a cache from client input, and an unknown name no longer costs a read of the zone database each time.- A path is quoted at most 1 KiB long. The 404 and 405 messages and the access, error and panic logs cut a longer path or method, so a request of invalid bytes can no longer write several times its size to the client and the log store.
SSEDialno longer sends credentials to a redirect's target. It sent custom headers such asX-Api-Keyto another host, andAuthorizationover an https-to-http downgrade.- One IPv6 allocation can no longer take every WebSocket or SSE slot. A home /56 held 256 separate allowances, one per /64, and now counts as one client.
- Log redaction covers groups. A redacted key whose value was a group,
through
slog.Group, aLogValuerorWithGroup, was logged in full. secret:"true"on an embedded config struct hides every field inside it. It was ignored, so a malformed value appeared in the startup error.- A shutdown requested while the server is starting is honoured. It returned nil and the server came up anyway.
- A WebSocket handler's context ends with its connection. A hijacked connection's context was cancelled only when the handler returned, so a handler waiting on it outlived its peer and the server's close at shutdown.
- Responses that depend on a request header say so in
Vary. Header and media-type versioning now name the header (orAccept), and a frontend with an SPA fallback sendsVary: Accept, so a cache cannot serve one variant in place of another. - On Windows, a mount refuses a path segment shaped like an 8.3 short name
such as
/ENV~1, which reached dotfiles and nested mounts by another name. - SSE keepalives and WebSocket pong checks use the monotonic clock, so a wall-clock step cannot starve keepalives or close healthy connections.
- A 103 Early Hints no longer swallows the response after it.
Fixed
- A failure after a
Flushthat came before the firstWriteaborts the connection. The implicit200a flush sends was not recorded, so a handler or middleware that flushed its headers early and then failed had the JSON error envelope appended to the stream as though it were the body, andSetStatusafter the flush was silently accepted and logged as the status. The response is now treated as started from the flush, so the failure is logged once, the transfer fails for the client and the access log saysaborted. Runstops the lifecycle components when the server fails to serve. A certificate that would not load, or an accept error that cannot be retried, returned the error with every component that had started before the socket opened still running. The components are now stopped as a shutdown stops them, and the listener is closed.- A second
SIGINTorSIGTERMwhileRunSignalsis draining ends the process.signal.NotifyContextkept swallowing signals untilRunSignalsreturned, so a second Ctrl-C during a drain of up toShutdownTimeout, or without limit when it is negative, did nothing. TryFromandFromaccept a nil interface value from a provider. A provider of an interface type returningnil, nil, the natural encoding of an anonymous caller, made every request a 500 because the storednilfailed the type assertion. It now returns the nil the provider produced.- A CORS policy that cannot be served is logged as a build failure. It was
recorded after the start-up log had reported the routes registered, so
ServeHTTPanswered every request with a bare 500 and the log named no reason. It is now reported with the other build errors. - An
Appthat was shut down can be run again. The WebSocket and event stream registries stayed in the draining state, so the second run answered every WebSocket and event stream with 503. They are reopened when the application starts running. Context.Context's documentation says when the context is cancelled. It claimed the context is cancelled when the server begins shutting down, butShutdownleaves an ordinary request to finish and cancels its context only with its connection at the deadline. The behaviour is unchanged; the documentation now says so and points long-lived responses at event streams and WebSocket routes, whose contexts do end when shutdown begins.- A lookup path that does not begin with
/matches nothing. The router dropped the first byte of one, so behindhttp.StripPrefix("/api", app)a request for/apiXadmin/panelwas served as/admin/panel, and anOPTIONS *request matched a root wildcard. Guards still ran, but a firewall or cache keyed on the URL saw a different path from the one the application routed on. - The IPv6 zone is dropped from a client address, and a trusted proxy written
as an IPv4-in-IPv6 CIDR is honoured. A zoned address (
fe80::1%eth0) was never inside a trusted prefix, so a trusted proxy that reported itself with a zone was taken for the client, andContext.ClientIP()and the per-client connection key at a full-length prefix were a different string for every zone suffix, so one client could mint any number of them. A trusted proxy written as::ffff:10.0.0.0/104parsed and then matched nothing, leaving the proxy it named untrusted without a message; it now means10.0.0.0/8. Unique()over[]time.Timewas quadratic.time.Timeholds a*Location, so it was not hashable and 16000 timestamps (368 KB) cost 12 s of CPU. Times are keyed by instant, andnetipaddresses by themselves. Element types that cannot be keyed ([]*T,[]any, nested slices) are still compared pair by pair and should carry aMaxItemsbound.Matches()andMatchesNot()recompiled their expression on every request. Rules are declared insideValidate, which runs per request, so a slug pattern cost 83 microseconds and 491 allocations, twenty times the rest of the validation. Compiled expressions are kept by pattern text, up to 1024 distinct patterns.- A negative
MaxBodySizerefused every JSON body. It is documented to remove the limit, buthttp.MaxBytesReaderclamps a negative limit to zero, and aCaptureBodyroute withMaxBodySize(math.MaxInt64)overflowedlimit+1and read the body as empty. A limit that is not positive now leaves the body unwrapped, and one too large to add a byte to is unbounded. MultipleOfused an absolute tolerance of 1e-9.19.99failedMultipleOf(0.01),5.0000000001passedMultipleOf(5), and any value passed a factor below 1e-9. The quotient is now compared with its nearest whole number within a few units of float64 precision.- Registering a route on a self-referential collection type hung or
overflowed the stack.
type Tree map[string]Treeas a body field looped inlocatedWithin,type List []Listas a query field overflowedsetterFor, and both looped in the schema builder. Registration now terminates: a body field is accepted and described once, a parameter is refused with an error. - A
time.Durationin a JSON body was a 422 for every value and a 500 in a response. encoding/json/v2 has no representation for it and accepts noformattag. It is now a string such as"1500ms", both ways, as the document says. SSE and WebSocket payloads are not covered. - OpenAPI: an embedded
*Tis described as a top-level, optional set of members instead of a nullable member named for the type, matching what the decoder accepts. - OpenAPI: a generic type has a component key a
$refcan resolve.Page[example.com/app.Item]was used as the key as it stood, so its reference held a slash and brackets; it is nowPage_app.Item. - OpenAPI: the rules of a nested model are documented.
describeConstraintsreturned before descending. The rules of every model thatNestedreaches from a zero value are written onto the schema of its type; a model nested once per element of a collection is not among them. - A websocket connection now holds its slot until its socket is closed.
The slot was returned when the handler did, but closing waits for the peer's
close frame (
CloseGracePeriod) and, when another goroutine is writing to a peer that does not read, for the write half (WriteTimeout), so a peer that withheld its close frame held sockets and goroutines thatMaxConnectionsandMaxConnectionsPerIPsay nobody may: about 300 sockets against a limit of 4. Shutdown now also waits for connections that are closing. - A websocket's room is taken before the handshake is answered. The connection was recorded after the 101 had told the peer it was connected, so a client dialling again as soon as it read the response could be admitted past the connection limits. Room is reserved under the register's lock before the upgrade.
- A refused websocket handshake no longer runs the route's guards and dependencies first. A cross-origin handshake, or one arriving at a full server, answered 403 or 503 only after the session dependency had run with the visitor's cookie, so a refused handshake still cost a store lookup and any side effect it has. A request that asks to upgrade is now judged for its origin and the connection limits first. A plain GET still reaches the guards first, so a client without credentials still gets 401.
- A websocket's close status reaches the peer when two closers race. A
second
Close(shutdown, a keepalive timeout, a returning handler) tore the transport down under the first one's close frame, so the peer saw 1006. It now waits for that frame. A peer's own close is also echoed when another goroutine is writing at that moment; it used to be skipped. - A close reason that is not UTF-8 is repaired before it is sent. A reason an application forwarded from elsewhere went out as it was, obliging the peer to fail the connection instead of reading the status. Invalid bytes become U+FFFD.
- An idle event stream over HTTP/2 was reset one
WriteTimeoutafter its last event. A stream armed a write deadline before every event and never took it off. On HTTP/2 that deadline is a timer that resets the stream when it fires, so with the default 10 secondWriteTimeoutand 15 second keepalive every idle stream died before its first keepalive, and browsers reconnected in a loop. A deadline now exists only while a write is in progress. On HTTP/1 the same change stops the bytes that end a response from meeting a deadline that had long expired, and a graceful shutdown or a handler's return now ends an idle stream cleanly rather than aborting it, since an interrupt is applied only to a write that is in flight. - A stream's deadline state raced with shutdown. A stream is in the
register before its header is written, so a shutdown could end it while it
was still opening.
openwrote the field recording whether the response carries deadlines without the lock thatinterruptread it under, which the race detector reported, and the header was written with no deadline at all, so an interrupt landing in that window was lost. It is now decided under the lock and the header is written as an armed write that a shutdown can stop; a stream that has already ended never writes it. - JSON log output escapes C1 controls, DEL and bidirectional controls. The
JSON handler wrote U+0080 to U+009F, DEL and the bidirectional controls as
themselves, so a decoded request path carrying a CSI reached a terminal
reading the log with
tail. The finished record is now escaped as\uXXXX, which a JSON reader decodes to the same text, covering messages, keys and values logged withslog.Any. The console format already did this. - A handler that sets
Varyno longer erases theVarythe framework had added. CORS (Origin, and the preflight request headers), theLocalemiddleware (Accept-Language,Cookieor the locale header, whichever sources are configured) and header or media-type versioning added their field before the handler ran, into a header the handler owns, so a handler doing ordinary content negotiation withctx.SetHeader("Vary", "Accept")replaced it. The response then carried a reflectedAccess-Control-Allow-Originor aContent-Languagechosen fromAccept-Languagewith nothing telling a shared cache what it depended on, and the cache stored it for every client. The fields are now recorded on the response writer and merged intoVarywhen the response is written (and when a response with no body is committed), as compression already did forAccept-Encoding; a handler's ownVaryadds to them. - A locale whose strftime format names itself no longer kills the process.
time.formats.default: "%c", or a date and a time format that name each other, recursed until the stack was exhausted, which Go reports as a fatal error that norecovercatches, on the first request that localized a time in that locale rather than when the file was loaded. Expansion is now bounded, so the directive is written out as it stands past four levels. - A locale file can no longer amplify its own text without bound. A strftime
width was read as written, so
%500000000Y, eleven bytes, made half a gigabyte; a hundred%<n>1000000dplaceholders, twelve hundred bytes of translation, rendered ninety-five megabytes and left the pooled buffer holding that capacity; and a JSON locale nested to the decoder's own limit of ten thousand levels kept a dotted path of every level above each node, so a 54 KB file was retained as 85 MB. Each is now bounded (see above). A format name with no pattern under it is still used as the pattern byLocalize, which made the width reachable from a value an application took from a client; the bound is what makes that safe, and the documentation now says so. - YAML alias bomb. A 330-byte file of seven levels of aliases, each
aliasing the one above it ten times, allocated 216 MB while loading and each
further level cost ten times as much for 45 more bytes; a chain of 5,000
single aliases parsed to a depth of 5,000 under a
MaxDepthof 100 and copied the whole chain at every step. FailOpenno longer discards a quota that had already said no. A storage error on one quota of a policy returned from the check, so a request over the limit of an earlier quota was served, and a store that fails for some counters only (one shard of a cluster) stopped every other limit of the route from holding. The failing quota now goes unmetered and the others still decide; with none counted, noRateLimitheaders are sent.- Quotas returned by a
QuotaResolverare held to the rules static ones follow. A resolved quota was checked for a name, a window and a limit, and nothing else. A name with a colon collides with the storage key of another quota (plan:xfor clientyisplanfor clientx:yin the key the storage documentation recommends), a NUL does the same in the in-process storage, and a name repeated within one policy, or repeating a static one, counted every request once per copy against one counter, so a limit of two refused the second request. Both paths now share one check, and a resolver that returns an invalid or repeated name fails the request with a 500, as a zero limit already did. Accept-Languagenegotiation no longer costs more the more locales there are. Every range of the header was compared with every available locale by lower-casing and splitting the locale again, so a 700-byte header against 300 locales cost about 19,000 allocations and 750 microseconds per request, ahead of routing and of the rate limit. The lower-cased and split forms are computed once when the middleware is built.- A flush that failed is reported through the compression wrapper. The
wrapper had only
Flush(), sohttp.ResponseControllergot nil whatever became of the bytes underneath and a stream could not tell the client had gone. It now hasFlushError. - An event's
Retryshorter than a millisecond is written as 1, not 0. The field is whole milliseconds and the duration was truncated, so a positive delay under a millisecond told a client to reconnect at once. - A guarded mount was served, unguarded, through a folded spelling of its
name. The router refused a request that fell under a more specific mount
once simple case was ignored (
/ADMIN), but a filesystem that folds more than that, such as APFS, opened the same directory through a ligature (U+FB05 for the "st" of/staff), a sharp s (/aU+00DFetsfor/assets), a combining accent (/cafeU+0301 for/cafU+00E9), decomposed Hangul, or/STRASSEfor a mount at/straU+00DFe. The outer mount served the files with none of the inner mount's guards, providers, rate limit,Cache-Control: privateor, for a directory of uploads, the sandboxContent-Security-Policy, so uploaded HTML ran as the application. The outer mount now compares each directory a path passes through with the directory of every mount beneath it, by file identity, so the answer is exact on every platform and for every folding rule and no table is copied. Hard links and bind mounts are covered because only the directory's identity matters. Where both directories are given asDirthe relationship is worked out when the application is built; a mount served from anos.DirFSis compared against every mount beneath it, since its path cannot be read back, and one served from anembed.FShas no second name to defend against. A test that needs a folding volume checks for one at run time and skips on a case-sensitive one, and a model of such a filesystem runs the same logic everywhere. - A body that declares a length over the route's limit is refused before it is
read. A request that sent
Expect: 100-continuewith aContent-Lengthfar over the limit was told to go ahead by the first read, and the 413 came only after the body had been read up to the limit. It is now refused from the declared length, for JSON, urlencoded and multipart bodies. A body that declares no length is still bounded as it is read. - A stalled socket write over HTTP/2 is bounded by
WriteTimeout. A write deadline on an HTTP/2 stream cannot interrupt a frame already being written, so a client that granted a large flow-control window and then stopped reading the socket kept every stream on its connection open past every timeout, SSE streams included, and a shutdown ran to its full timeout. The same client over HTTP/1 was cut in under a second.http.HTTP2Config.WriteByteTimeoutnow followsServerOptions.WriteTimeout, so a connection whose socket accepts no bytes for that long is closed, and a disabledWriteTimeoutdisables it too. - A query parameter and a body member of the same name shared their rules.
The rules were collected and matched by name alone, so a body member's
Required,MaxLenorPatternlanded on the query parameter (or header, cookie, form value) called the same, and the other way round, in the generated document. They are keyed by where the field is read from as well as by its name, in the collection, in the parameters, in the body schema and in nested models, and the same holds at runtime: a query value that failed to parse no longer hides the failure of a body member of the same name. - A
Required()rule on a parameter, form value or body member that has a default made the document call it required. The default is written before any rule runs, so leaving it out is not a failure. - A cancelled context could end the next websocket read.
context.AfterFuncdoes not wait for a callback that has already started when it is stopped, so a context cancelled at the moment its read finished had its callback run after the next read had cleared the deadline, and moved that read's deadline into the past: a healthy connection closed with 1008 for a message that "did not arrive". Stopping now waits for a callback in progress, and one that had not begun finds itself stopped. The same applies to writes and to a transport without deadlines. - The OpenAPI document lists what a websocket handshake or an event stream
is refused with. A websocket route documented 101, 426, 422 and the default
but not the 400 for a malformed handshake, the 403 for a foreign origin (left
out for a route that skips the check) or the 503 for a draining or full
server; an event stream did not document the 503. A status a route declared
with
WithResponseDockeeps its own description. - A failed start-up, or a run whose socket could not be opened, no longer
waits forever on a lifecycle component's
Stop. Nothing bounded the release on those paths, so aStopstuck on an unreachable broker heldRun(orStartLifecycle) for good. It now usesServerOptions.ShutdownTimeout, never less than one second.StartLifecyclecalled while another is still starting waits for it and reports its outcome instead of returning success early, and aStopLifecyclethat overtakes a start makes it release what it had started rather than leave it running. The documentation now says that components are stopped in parallel, in no order, and must not need each other inStop. Content-Digest,Repr-Digest,Digest,Content-MD5,Accept-Ranges,Trailer,Content-LocationandLocationset by a handler that then failed no longer ride on the error response. They describe the body that was never sent.Locationis kept when the error itself answers with a 3xx.- An env file value can be followed by a comment after its closing quote.
KEY="value" # note(and the single-quoted form) was refused as an unclosed quote; it now readsvalue. A comment needs whitespace before it, and a value that ends in the quote it opened with reads as before. - Rate limit seconds and counts no longer wrap. A window near 292 years made
RateLimit-Resetnegative andRetry-After1; seconds now clamp to what a 32-bit int holds. The in-memory storage's count saturates at the top of anintinstead of wrapping under every limit. - A pooled route
Paramsno longer keeps the captured path.Resetand a rewound branch left the request path's substrings in the backing array until a later request overwrote the slot. - An
Accept-LanguageorAccept-Encodingquality is read only between 0 and 1.q=NaN,q=Infandq=1e9parsed as valid, so an infinity outranked every honest range and NaN made the ordering meaningless. NaN is unreadable (one, as before), anything above one is one, and a negative value is a refusal. - A translation of a framework message that cannot render falls back to English. A translation naming a value the call site never passes came back as the empty string, so the summary, an issue or a status message went out blank.
- An empty
ValidationErrorhas a message.Error()indexed its first detail and panicked for one an application built with none, inside the code that reports the error. - An event stream keepalive arrives every interval. It waited on fixed ticks and skipped a tick whose last write was younger than the interval; its own write lands a hair after its tick, so the tick after it always skipped, and a keepalive asked for every 15 seconds arrived at 15, 45 and 60: twice the silence a proxy idle timeout was meant to be kept off with. Seen behind Cloud Run. The wait is now measured from the last write.
- Nothing is written after the closing comment of a stream that reached its
MaxLifetime. A keepalive already waiting for the write lock could go out behind it. - A handler on a route with no bound input can read the whole request
body. The framework drained up to 4 KiB of the body before the handler ran
whenever the route's input bound nothing from it (
Empty, or only path, query, header and cookie fields), so a handler that readctx.Request().Bodyitself, a proxy, an upload streamed to disk or a signature check over the raw bytes, lost the first 4 KiB without any error. Seen on Cloud Run. The drain now happens after the handler returns, which is still what lets a keep-alive connection be reused when the body was not read, and is still bounded at 4 KiB. A route that binds a body is unchanged. An event stream route is treated the same way, and a WebSocket handshake still drains first: it is refused if it carries a body, and nothing may be left to be taken for frames. - A peer still sending when a WebSocket connection is refused reads the
close status instead of a reset. A message over the read limit is answered
with a close frame
1009, but a client that kept sending, a message still streaming in, often saw the connection reset (1006) and never read it: the server closed the socket with unread data in its receive buffer, which makes the kernel send a reset and drop the close frame in flight. Seen on Cloud Run. It was the same after every close the server started on finding a fault, not only1009:1002,1003,1007and1008too. Once the close frame is out, the server now stops sending (a half-close, or theclose_notifyalert on TLS), keeps reading and dropping what arrives until the peer's close frame, the end of its stream,CloseGracePeriodor 1 MiB, whichever is first, and only then closes the transport. Nothing read is kept, a peer that answers the close frame promptly is not made to wait, and one that never stops is cut off at the grace period as before. A close the handler starts withClosegets the same half-close. In a test that sends a message over the limit and keeps sending, the1009reached the client in 0 of 50 runs before and 50 of 50 after.
Documentation
- The symbolic-link guarantee holds for
Dirand for theFSof anos.Root, not foros.DirFS.os.DirFSfollows a link out of its directory, so a link planted in a directory served through it serves the file it names. TheStatic,Frontendand package documentation now say so and point atDirandos.OpenRoot(...).FS(). - OPTIONS and 405 answers skip a route's guards and rate limit, and the
documentation now says what that discloses. They are answered from the
route table alone, as
net/http.ServeMuxdoes, so an unauthenticated client learns which paths exist and which methods they take, and never a route's data. Middleware installed withApp.Usestill covers them; put a guard or a limit there when the existence of a path is itself a secret. - A decoded path parameter can contain
/,..and NUL. A wildcard value is percent-decoded once, so/files/a%2F..%2Fbbindsa/../b. The route documentation says to validate one before using it as a file name. - An event stream has no maximum lifetime: the write timeout bounds one write,
the keepalive bounds silence, and a client that keeps reading holds its
stream and its slot until the handler returns.
SSEStreamnow says so, and how a handler sets a ceiling of its own. - A symbolic link or hard link inside a served directory is followed and served
by the mount that owns the directory; only a link that leaves the directory is
refused. A deployment step that links files into a served directory (
cp -l,rsync -H, a build cache) therefore publishes them under the new name, dotfiles included, unless the link leads into the directory of a mount beneath. SSEStreamandSSEOptions.WriteTimeoutnow say what bounds a stream's age (MaxLifetime) and why a client that reads nothing is not caught by the write timeout.WSOptions.AllowedOriginssays what the same-origin rule trusts.- The residual "a
Unique()on an element type that cannot be keyed still needs aMaxItemsbound" in the changelog's known limits and indocs/security-review.mdno longer holds and can be dropped. Guard: every guard on a route runs before any provider, whatever order they were declared in, so a guard cannot read aNeedsvalue; a check that needs the caller's identity belongs in the provider.Quota: a window is fixed, so up to twiceLimitcan be spent across a boundary; a short quota in the same policy bounds that.
Known limits
The latest review found these and left them, each with the reason.
- The rules that have no JSON Schema keyword (
Prefix,Suffix,Contains,NotBlank,MinBytes, the date rules and others) are not described in the document, which therefore says less than the server enforces there, not more. A member of a JSON body that is not a pointer still acceptsnull, which the decoder reads as the zero value, and the schema does not listnullfor it.Matches()writes its Go (RE2) pattern into the document as it stands. ReadHeaderTimeoutdoes not apply to HTTP/2:net/httpreads an HTTP/2 header block under the connection's own timeouts, so a client that never finishes one is held untilIdleTimeout(2 minutes by default), not for 5 seconds.ServerOptions.WriteTimeoutsays so.- An event-stream client that never reads is bounded by its keepalive writes
filling the socket, and by nothing else, unless
SSEOptions.MaxLifetimeis set, which it is not by default.SSEStreamsays so. - An HTTP/2 GET to a WebSocket route runs the route's guards before it is answered 426, as an HTTP/1 GET without an upgrade does, so that a client without credentials is told to authenticate first.
Investigated, not reproduced
SSEReaderread-timeout timer racing a returnedNext: the timer is stopped when the event completes and none runs between events; 150 events straddling the timeout never lost a stream. An event that finishes in the same instant its timer fires is delivered and the stream ends on the next call, which is what an event that took the whole timeout is due.- A zoned IPv6 address (
%a,%b,%eth0) as its own client underConnectionIPv6Prefix: 128: the zone is dropped before a key is made. - Keepalive against a handler that reads slowly: pongs wait in the socket for the next read and an unanswered ping is judged only while a read is pending.
- A route's
InsecureSkipOriginCheckcannot be switched off by a narrower scope, and an application'sAllowOriginFuncstays in force for a route that only lists origins: this is the field-by-field layering the options document, pinned by a test.
0.2.7 - 2026-09-03
Fixed
- A rule bound to a value rather than to a field is refused when the route is
compiled. A rule set is matched to its field by that field's address, so
v.String(&in.Name)is matched andv.String(in.Name)-- the pointer the field holds rather than the field -- is not. Both compile, becauseStringFieldandNumberFieldadmit the pointer type so that one entry point can serveName stringandNickname *stringalike.
The wrong one failed in the two ways that look most like working. The request was still refused, and the detail carried an emptyfield, so a client was told that something was wrong and not what. And the rule contributed nothing to the generated document, because the description step skips a rule it cannot name -- so the published schema quietly lost a constraint the code was still enforcing.
Neither shows up in a test that asserts a status: a 422 with a nameless detail is still a 422. In the service this was found in it had gone unnoticed across nineteen call sites and five models -- every optional field on five resources.muzak: PATCH /orgs/{org_id}: a String rule is bound to a value rather than to a field of schemas.UpdateOrgIn, so its failures would name no field and its constraints would be missing from the generated document; pass the field's address (v.Rule(&in.Field), not v.Rule(in.Field)), or name it with As()
The check runs once, against a zero value, when the route is compiled -- declaring a rule set only records it, which is what makes that safe. A rule named withAs()is left alone: binding outside the model is then deliberate and the name it reports under is the one you chose.
A rule declared only under a condition is not seen, since the zero-value pass does not take that branch. That residue is smaller than it looks: the condition is usually a nil check that is itself redundant, because a nil pointer field already skips its rules.
0.2.6 - 2026-09-03
Changed
- A numeric rule whose bounds exclude zero now applies to zero. Previously
every rule on a number was skipped when the value was nought, so
v.Number(&in.ClickWindowHours).Between(1, 720)
accepted0-- a rule set saying in as many words that zero is out of range, quietly letting it through. The request reached the database, died on a CHECK constraint, and the caller got a500where the whole point of the rule was to produce a422.
"Optional means optional" reads an empty value as an absent one. For a string that is fair: a field nobody filled in arrives as"". For a number it is a guess, because zero is a value people mean, and the guess was wrong in the direction that fails open. It also made the generated document lie -- the OpenAPI saidminimum: 18while the server accepted nought, so a client reading the contract and a client testing the server learned different rules.
The skip now applies only where zero would have passed anyway. Nothing changes forMax(10),Between(0, 10),NonNegative(),MultipleOf(5)or a bareMust, and a rule of your own is never evaluated speculatively, so a field guarded only byMustbehaves exactly as before.
Migration. A numeric field that may legitimately be absent and whose bounds exclude zero is a pointer, which is what it always should have been -- the same rule that already applied to a field which may legitimately be zero:// before: optional, and silently unvalidated when omitted Age int `json:"age"` v.Number(&in.Age).Between(18, 120) // after: optional, and validated when supplied Age *int `json:"age,omitzero"` v.Number(&in.Age).Between(18, 120)
A field that was always meant to be mandatory needs nothing: it now fails with its own words (must be between 1 and 720) instead of passing.Required()still means what it did and is still the way to demand presence when the bounds themselves admit zero.
0.2.5 - 2026-09-02
Added
RouteFromContextnames the route a request matched, so a service can be instrumented from outside. Middleware is the seam for tracing and metrics, and it runs below the typed layer and before routing, so it never learned which route matched. All it had wasr.URL.Path-- the concrete path.
For a log that is right; you want the path that was requested. For a span name or a metric label it is fatal.GET /orgs/01a0.../apps/01a0.../keysis one time series per organization per app, which is the classic way to take a tracing backend down, and it makes the only question worth asking -- how slow is this endpoint -- unanswerable, because every request is its own endpoint.func Tracing(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { next.ServeHTTP(w, r) if route, ok := muzak.RouteFromContext(r.Context()); ok { // "/orgs/{org_id}/apps/{app_id}/keys" } }) }
Read it afternext.ServeHTTPreturns; before that, routing has not happened and it reports false. A request that matched nothing also reports false, so a 404 is counted as a 404 rather than as traffic to a route named"".
The framework takes no dependency for this and should not: it has none at all, and the OpenTelemetry API alone is ten modules, its SDK fifteen, and an OTLP exporter eighty-eight. Instrumentation belongs beside the framework, and this is the one fact it could not get from outside.StatusRecorder, implemented by the response writer the access log installs, so middleware can read a response's status without wrapping the writer a second time. The assertion can fail --DisableAccessLogmeans no wrapper -- so middleware that needs the status either way should still fall back to wrapping.
Changed
- The access log records
routebeside the path. The concrete path stays in the message, because that is what somebody reading a log wants; the template is the field a query groups by, and the one that joins a log line to the trace and the metric for the same endpoint.
0.2.4 - 2026-09-02
Fixed
- A location tag is read the way
encoding/jsonreads its own, cut at the first comma.query:"limit,omitzero"registered a parameter literally namedlimit,omitzero, because the tag's whole value was taken as the name.
Options have never meant anything in a location tag --defaultandrequiredare tags of their own -- so this was a footgun with no upside: a tag that looks like a json tag gets written like one eventually.
It failed in the worst way available, which is why it went unnoticed. A parameter nothing can send is not an error; it is a filter that silently does not filter, and the endpoint answers the unfiltered question with200. In the service it was found in, it had disabled twenty-five parameters across eight endpoints -- every environment and platform filter, every granularity, every row limit and every percentile -- each with a plausible default sitting behind it, so every response looked reasonable.
Added
CaptureBodykeeps the request body as it arrived, read back withContext.RawBody. A request whose authentication covers its bytes could not be verified: the binder reads the body to decode the input, a route declared withEmptydrains it, and a handler found nothing left to hash. The only way through was middleware that read the body and put an identical reader back, which every user had to discover for themselves, and the failure while they were looking was a signature that never matched for reasons that look like a bug in the client's signing code.r.Post("/webhooks/stripe", handlers.Stripe, muzak.CaptureBody(), muzak.Needs(core.VerifyStripeSignature)) body, ok := ctx.RawBody()
It is a shared option, so a router of webhook receivers declares it once. Capture happens before the dependencies resolve, so a guard verifying a signature refuses an unsigned request before anything decodes it, and after the rate limit is counted, so a client past its budget never makes the server buffer on its behalf. It works for a route whose input isEmptyand for one that binds a multipart form, neither of which reaches the JSON body path at all.
The body is bounded by the route's existingMaxBodySizerather than a second limit, and one over it is refused with413rather than truncated: a truncated body fails its signature check, and a signature failure reads as an attack rather than as the oversized request it is.RawBodyreturns([]byte, bool). The second value is not decoration -- a helper shared between a route that captures and one that does not would otherwise verify a signature against an empty body and pass. The bytes are released with the pooledContext, so anything outliving the request copies them.
This is not a niche case. Stripe, GitHub, Slack and Apple's SKAdNetwork postbacks all sign the raw body.
0.2.3 - 2026-09-02
Added
RateLimitOptions.Resolversupplies quotas per request. AQuotais fixed when the application is built and one name may not carry two policies, which is right for a policy the application owns and cannot express one its customers do: a plan, a negotiated ceiling, a tier read from a database.muzak.WithRateLimit(muzak.RateLimitOptions{ AfterDependencies: true, Resolver: func(ctx *muzak.Context) ([]muzak.Quota, error) { plan, ok := muzak.TryFrom[Plan](ctx) if !ok { return nil, nil // nothing resolved, nothing enforced } return plan.Quotas, nil }, })
Resolved quotas are counted alongside any static ones, so a route can carry both a floor everyone shares and a ceiling that varies, andRateLimit-Policydescribes the union. Setting a resolver turns limiting on even with no static quota, because what is enforced becomes a run time question. Returning nothing enforces nothing, which is what an unauthenticated request reaching a plan-based route should do.
A quota that arrives at run time cannot be validated when the application is built, so one with no name, no window or no limit fails the request rather than being counted under an empty name. Nothing can check a resolver's names against the statically declared ones either, which is the price of the flexibility.
Changed
- A panic in a lifecycle component's
StartorStopbecomes an error instead of killing the process.Stopruns after the drain, on the framework's goroutine, when every request has already been answered and there is nothing left to report a crash to. A partially constructed component panicking there took the process down at the one moment where doing so achieves nothing, and the stack pointed at the framework rather than at the application that supplied the value.
A handler that panics already becomes a 500 rather than a dead process; this is the same bargain for the same reason. The panic and its stack are returned as the component's error, so a failed stop is reported the way any other failed stop is, and the components either side of it are still released.
No migration. A component that never panicked behaves exactly as before.
Documentation
DefaultShutdownTimeoutnames the grace periods of the platforms it has to sit below. The default of 15s is longer than Cloud Run's ten, so the platform killed a drain that was still running and nothing said why.LogFormatAutosays what it does in a container. A container's stdout is not a terminal, so a service under Compose gets JSON even locally, which is right for production and a surprise in front ofdocker compose logs.
0.2.2 - 2026-08-25
Added
validate.TimezoneDataAvailablereports whether a binary can resolve time zone names at all.Timezone()resolves against the zone database, and a Go binary carries one only when something importstime/tzdata. An image built from scratch has no/usr/share/zoneinfoeither, so the rule refuses every value including the correct ones and nothing says why. Call this where the application is built and refuse to start without it, so the fault is a failed deploy rather than a week of rejected requests.
Changed
- The locales a lookup walks are worked out once rather than per lookup. A
chain was rebuilt on every translated message, costing a slice, a map and a
closure, which on a rejected request is once per field. They are settled while
the application is being built, alongside the plural rules, and only read
afterwards.
A message with nothing to interpolate now allocates nothing: 50ns and no allocations, against 91ns and one before.FallbacksForreturns a copy, since the chains are now shared by every request and handing one out directly would let a caller reorder what the whole process resolves against.
0.2.1 - 2026-08-25
Added
- Internationalization, with the framework's own messages included. A
request's locale is resolved once, before the handler runs, and every string
in the response is written in it: the wording of each validation rule, what
the request binder says about a value it could not read, and the sentence
behind each HTTP status.
//go:embed locales var locales embed.FS app := muzak.New(muzak.AppOptions{ I18n: muzak.I18nOptions{Store: i18n.MustLoad(locales, "locales")}, })
A handler translates through its context, which already knows the locale:func greet(ctx *muzak.Context, in Params) (Out, error) { return Out{ Greeting: ctx.T("greeting.hello", "name", in.Name), Items: ctx.T("greeting.items", "count", in.Items), Today: ctx.L(time.Now(), "as", "date"), }, nil }
A locale is passed rather than set. Go has no per-goroutine storage, and a package-level current locale shared by every goroutine would leak one request's language into another: one request setting it, and another two microseconds later reading it. So it is resolved once per request, carried on the request's context, and named explicitly anywhere there is no request.
Where it comes from is declared rather than written by hand.Accept-Languageis negotiated by default, honouring the quality values the client sent, andLocaleFromPath,LocaleFromQuery,LocaleFromHeader,LocaleFromCookieandLocaleFromCustomcover the rest in whatever orderSourceslists them. Nothing a request carries is used unless it matches a locale the application declared, so what reaches a filesystem path or a response header is always one of the application's own strings.
LeavingAppOptions.I18nunset is the feature turned off: no middleware is installed, every message reads exactly as it did before, and nothing inmuzak.dev/framework/i18nis linked into the binary. The framework reaches a translation engine only through theTranslatorinterface, which is what keeps a service that answers in one language from carrying the machinery for ninety. muzak.dev/framework/i18nis the engine:Store,Backend,Simple,Chain, YAML and JSON locale files,%{name}interpolation, CLDR plural rules for around ninety languages, locale fallbacks, default chains, pluggable exception handlers, andLocalizewith a strftime formatter and the number helpers.
Go's own time formatting cannot produce a localized month name, because the reference layout hard-codesJanuary. Patterns are therefore strftime, which names the field and so leaves the formatter free to fill it from the locale. It is also the notation the published locale corpora are written in, so a file taken from one loads unchanged. A pattern beginninggo:is handed to Go's formatter instead, for the formats meant to be read by a machine.- The locale the framework ships covers every string it produces, and is chained
beneath an application's own translations. A locale file names only what a
service adds and the rules it wants worded differently; everything else falls
through. Failures are looked up under four scopes, narrowest first, so one
rule on one field of one model can be phrased without restating any other:
es: errors: messages: blank: "es obligatorio" models: create_item: attributes: name: blank: "cada articulo necesita un nombre" Context.T,Context.LandContext.Localetranslate, localize and report the locale of the request in hand.LocaleFromContextreads the same value where there is acontext.Contextbut no Muzak one, such as in a repository or a goroutine started from a handler.- Forty three more validation rules. The engine had twenty string rules,
eight numeric, six for collections and five for times. What it was missing was
not exotic: a way to say https rather than any URL, an address of any kind, an
exclusive numeric bound, and a check that a field of spaces is not a value.
URLs and addresses, all narrower than theURLthat was already there:HTTPS() URLWithSchemes(...) Host() IP() IPv4() IPv6() CIDR() MAC()
What a value may hold at all, and the character classes:MatchesNot(pattern) NotBlank() NoControl() Alpha() Alphanumeric() Numeric() ASCII()
Formats that parse rather than merely match:Slug() Hex() HexColour() Base64() JSON() Semver() E164() LanguageTag() Timezone() CountryCode() CurrencyCode()
Comparisons, the last two counting bytes rather than characters:EqualFold(other) MinBytes(n) MaxBytes(n)
Numbers, collections and times:GreaterThan(n) LessThan(n) NonNegative() NonPositive() Whole() Port() OneOf(...) Items(n) NotEmpty() Contains(v) Excludes(v) Past() Future() Within(d)
Each carries the name of the rule behind it, so each is translated the way every rule that came before it is, and each describes itself in the generated document wherever JSON Schema has a way to say it: a format for the addresses, an expression for the character classes, bounds for the numbers and item counts for the collections.
Three of these close holes rather than add conveniences.GreaterThanandLessThanare the exclusive numeric bounds, which had no equivalent at all: only the inclusive pair and the comparisons against zero existed, so a price that must be above nothing could not be stated.NotBlankrejects a field of spaces, which satisfiesRequiredwhile carrying nothing anyone would call a value.NoControlrejects a carriage return in something bound for a header, a log line or a redirect, which is where an injection starts.NotBlankandNotEmptyrun even when a field was not supplied, unlike every other rule, because an absent value is blank and an absent collection is empty. That makes each a stronger presence check rather than something to pairRequiredwith. The skip every other rule follows is now a named predicate, so the exception is stated in one place rather than implied by a comparison.CountryCodeandCurrencyCodelook the value up in the assigned registers rather than merely counting letters, soXQis refused although it is two upper case letters. Both tables carry the date they were taken, and both need regenerating when the standards change: countries are added and withdrawn, and currencies are redenominated.
The generated document describes the shape rather than the table. Two hundred values in every schema that names a country would be noise rather than documentation. UseOneOfwith your own list when a service trades in a known handful, which is narrower and self-documenting.AlphaandAlphanumericjudge letters as Unicode letters rather than as the twenty six of English, so a name in any script passes. Narrow it by composing:Alpha().ASCII(). validate.Kindnames the rule behind a failure, andProblemcarries it alongside the English it has always produced.MessageKeyon every rule set, andValidation.RejectKey, name a translation for an override rather than fixing its wording in one language.HTTPError.WithMessageKeydoes the same for an error an application raises itself.- Responses report
Content-Language, and addVary: Accept-Languagewhen that header took part in choosing the locale, so a cache in front of the service cannot serve one language to a client that asked for another.
Changed
muzak.ErrorDetail,muzak.AppOptions,muzak.HTTPError,muzak.ValidationErrorandvalidate.Problemeach gained fields. The new members ofErrorDetailare taggedjson:"-", so neither the error envelope nor the generated OpenAPI document changes.
Migration: a keyed composite literal is unaffected. An unkeyed one, such asmuzak.ErrorDetail{"limit", "query", "is required"}, no longer compiles; add the field names.- The default middleware chain gains one entry, between panic recovery and the access log, when a translation store is configured. It is not installed otherwise. The access log records the resolved locale when there is one.
validate.Positiveandvalidate.Negativereport their own rules rather than the general numeric bounds, because their wording names zero rather than interpolating it. Their English is unchanged.PositiveandNegativenow describe an exclusive bound. Both reject zero, and both were generatingminimum: 0andmaximum: 0, which tell a client that zero is allowed. They now generateexclusiveMinimumandexclusiveMaximum, andvalidate.Constraintsandmuzak.Schemagained the two fields to carry them.
Migration: none in code. A generated document changes, and it changes to describe what the rules have always enforced.
0.2.0 - 2026-08-24
Changed
- The documentation page is no longer part of the framework. The small
self-contained page that was embedded in the module is gone, and
AppOptions.DocsUInames the UI to serve instead. It is nil by default, so an application publishes its OpenAPI document at/openapi.jsonand serves no page at all unless it asks for one:import "muzak.dev/openapi/ui" app := muzak.New(muzak.AppOptions{DocsUI: ui.Files()})
A UI is a module of its own so that a service which does not want one does not carry it: Go downloads and links a module only when something imports it, so leavingDocsUIunset costs a binary nothing rather than embedding a page it will never serve. Nothing is fetched at run time either way.
Migration: add the import and the option to keep a page at/docs; change nothing to keep only the document.DocsPathis no longer reserved when no UI is configured, so a route of your own may use it.
Added
muzak.dev/openapi/uiserves the OpenAPI dashboard: the reference grouped by tag, every schema as an outline, request snippets in thirteen languages, and a console that sends a request from the page and reports the status, the timing, the headers and the body. It is built from the same document the framework generates, so tags, summaries, descriptions, deprecations, parameters with their validation constraints, request bodies and the response model declared for every status code all reach it.AppOptions.DocsUItakes anyfs.FSmeeting a small contract - anindex.htmlwhose absolute URLs are written under/__muzak_docs__/and which reads its document from/__muzak_spec__- so a service can serve a dashboard of its own instead.
0.1.1 - 2026-08-24
Added
WithResponseModel[T](code, description)documents a status code and the model its body carries, so one operation can describe a different schema per status code rather than the error envelope everywhere: a400and a500carrying the service's own error type, a409carrying a conflict report, a304carrying nothing. The type argument is written exactly as a handler'sOuttype is, and is described once in the components section. LikeWithResponseDocit is a router option as well as a route option, and the last declaration of a status code wins, so a route replaces what it inherited.- An empty description passed to
WithResponseDocorWithResponseModelnow falls back to the status code's standard reason phrase, soWithResponseDoc(404, "")is documented as "Not Found" rather than as a response with no description at all. - A documented response status outside 100-599 is a build error naming the route and the code, rather than a response key in the document that no client could ever receive.
- A request console in the documentation page at
/docs. Every operation can be sent from the page itself, with the parameters, the JSON body and the multipart form filled in beside the schema they come from; the response is shown with its status, timing, size, headers and body, an event stream is read as it arrives, and the same request can be copied as acurlcommand. An Authorize panel adds a bearer token, an API key header or basic credentials to what the console sends. They are held in the tab and never stored. - The rest of the page grew with it: operations grouped by tag with a description per group, schemas as expandable outlines carrying the constraints the application enforces, generated examples, a filter over every operation, deep links to an operation or a group, and a light, dark or system theme.
- A constructor for each HTTP outcome worth a name:
muzak.NotFound(message),muzak.Forbidden(message),muzak.Conflict(message)and seventeen more, covering 400 through 504. Each returns an*HTTPErrorcarrying that status and its classifier, soWrap,WithCodeandWithDetailschain onto every one of them, and an empty message uses the standard sentence for the status.NewHTTPError(status, message)still covers anything without a name of its own. - Machine-readable codes for the statuses that had none:
payment_required,not_acceptable,request_timeout,gone,precondition_failed,not_implemented,bad_gateway,service_unavailableandgateway_timeout. - The address the documentation ended up at is reported when the server starts
listening, as a URL that can be opened from the terminal:
Documentation at http://localhost:8080/docs. A wildcard bind is reported as localhost, since that is where a browser can reach it. AppOptions.DocsPathandAppOptions.OpenAPIPathare now validated while the application is built. A path that is not absolute, one that is the same as the other, or one that an application route already answers is a build error naming the option to change, rather than a page nobody can reach or a route silently shadowed by the documentation.OpenAPIOptions.Tagsdescribes the groups operations are sorted into and decides the order the documentation presents them in. Routes join a group withWithTagsas before; a described tag no route carries is left out, and a tag nothing describes follows the described ones.
Changed
CodeForStatusreturns a specific classifier for the nine statuses listed above instead of the genericclient_errororinternal_error. A client switching on the code sees the more precise value; one switching on the status is unaffected.- The documentation page and the OpenAPI document are compressed once when the application is built and served with an entity tag per representation, so a client that accepts gzip transfers a fraction of the bytes and a reload transfers none.
- The page's content security policy now names the page's own script and
stylesheet by hash instead of by a nonce issued per response. The page is a
constant again, which is what lets it be cached, revalidated and compressed
ahead of time;
Cache-Controlisno-cacherather thanno-store.
0.1.0 - 2026-08-23
First public release, published as muzak.dev/framework on the Go module proxy.
This is a pre-1.0 version. The surface is covered by tests and used by the example application, but it is not frozen: expect it to move before 1.0.0.
Added
- Routing on a segment-wise radix trie, with static and parameter segments and routers that nest under a prefix.
- Handlers of the form
func(ctx *muzak.Context, in In) (Out, error), where the input type is the request and the return type is the response. Neither type argument is written at the call site. - Request binding by struct tag from
path,query,header,cookie,formandfile, or from the JSON body when no tag is present, with the binding plan compiled once per route. - Validation declared against the field address rather than its name, so
v.String(&in.Email)survives a rename andv.Number(&in.Email)does not compile. - Dependencies: guards through
WithDependenciesand typed providers read back withFrom[T](ctx), plusWithSingletonfor values built once. - OpenAPI 3.1 generated from the same declarations the code runs on, served at
/openapi.jsonwith a self-contained UI at/docs. - RFC 6455 WebSockets and typed server-sent events, implemented in the module rather than delegated to a dependency.
- Rate limiting, configuration loading through
MustLoadConfig, and an in-process test client undertestclient. - API versioning.
AppOptions.Versioningturns it on,WithVersiondeclares what a route or router answers, and the version is read from the path, a header, theAcceptheader or a function of your own. See Versioning. - Per-address bounds on long-lived connections:
WSOptions.MaxConnectionsPerIPandSSEOptions.MaxStreamsPerIP, so one client cannot hold every slot the process has. - Conservative defaults throughout: non-zero listener timeouts, a one mebibyte body cap, rejection of unknown JSON members, CORS closed until a policy is written, cross-origin WebSocket handshakes refused, no forwarding header believed until a proxy is named, and a panic reported as a generic 500 with the stack kept in the log. The full list is in Safe Defaults.
- Dual licence, MIT or Apache-2.0 at your option.