diff --git a/pkg/modules/humaecho5/humaecho5.go b/pkg/modules/humaecho5/humaecho5.go
new file mode 100644
index 000000000..1c1f28b8e
--- /dev/null
+++ b/pkg/modules/humaecho5/humaecho5.go
@@ -0,0 +1,185 @@
+// Vikunja is a to-do list application to facilitate your life.
+// Copyright 2018-present Vikunja and contributors. All rights reserved.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+// Package humaecho5 is a Huma adapter for labstack/echo/v5, vendored from
+// the unmerged upstream PR https://github.com/danielgtaylor/huma/pull/959
+// until it lands (Huma's own humaecho is echo/v4 only). Delete and switch
+// back to upstream once that PR merges.
+//
+// Vikunja-specific glue: every request stashes its *echo.Context on
+// context.Context under EchoContextKey so handlers can reach the echo
+// context via auth.GetAuthFromContext without per-handler wiring.
+package humaecho5
+
+import (
+ "context"
+ "crypto/tls"
+ "io"
+ "mime/multipart"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+
+ "github.com/danielgtaylor/huma/v2"
+ "github.com/labstack/echo/v5"
+)
+
+// MultipartMaxMemory caps in-memory buffering for multipart form parsing.
+var MultipartMaxMemory int64 = 8 * 1024
+
+type echoContextKey struct{}
+
+// EchoContextKey retrieves the underlying *echo.Context from a Huma
+// handler's context.Context.
+var EchoContextKey = echoContextKey{}
+
+// Unwrap extracts the underlying Echo context from a Huma context. Panics if
+// called on a context from a different adapter.
+func Unwrap(ctx huma.Context) *echo.Context {
+ for {
+ if c, ok := ctx.(interface{ Unwrap() huma.Context }); ok {
+ ctx = c.Unwrap()
+ continue
+ }
+ break
+ }
+ if c, ok := ctx.(*echoCtx); ok {
+ return c.Unwrap()
+ }
+ panic("not a humaecho5 context")
+}
+
+type echoCtx struct {
+ op *huma.Operation
+ orig *echo.Context
+ status int
+}
+
+var _ huma.Context = &echoCtx{}
+
+func (c *echoCtx) Unwrap() *echo.Context { return c.orig }
+func (c *echoCtx) Operation() *huma.Operation { return c.op }
+
+func (c *echoCtx) Context() context.Context {
+ // Stash echo context so auth.GetAuthFromContext can retrieve it.
+ return context.WithValue((*c.orig).Request().Context(), EchoContextKey, c.orig)
+}
+
+func (c *echoCtx) Method() string { return (*c.orig).Request().Method }
+func (c *echoCtx) Host() string { return (*c.orig).Request().Host }
+func (c *echoCtx) RemoteAddr() string { return (*c.orig).Request().RemoteAddr }
+func (c *echoCtx) URL() url.URL { return *(*c.orig).Request().URL }
+
+func (c *echoCtx) Param(name string) string { return (*c.orig).Param(name) }
+func (c *echoCtx) Query(name string) string { return (*c.orig).QueryParam(name) }
+func (c *echoCtx) Header(name string) string { return (*c.orig).Request().Header.Get(name) }
+
+func (c *echoCtx) EachHeader(cb func(name, value string)) {
+ for name, values := range (*c.orig).Request().Header {
+ for _, value := range values {
+ cb(name, value)
+ }
+ }
+}
+
+func (c *echoCtx) BodyReader() io.Reader { return (*c.orig).Request().Body }
+
+func (c *echoCtx) GetMultipartForm() (*multipart.Form, error) {
+ err := (*c.orig).Request().ParseMultipartForm(MultipartMaxMemory)
+ return (*c.orig).Request().MultipartForm, err
+}
+
+func (c *echoCtx) SetReadDeadline(deadline time.Time) error {
+ return huma.SetReadDeadline((*c.orig).Response(), deadline)
+}
+
+func (c *echoCtx) SetStatus(code int) {
+ c.status = code
+ (*c.orig).Response().WriteHeader(code)
+}
+
+func (c *echoCtx) Status() int { return c.status }
+
+func (c *echoCtx) AppendHeader(name, value string) {
+ (*c.orig).Response().Header().Add(name, value)
+}
+
+func (c *echoCtx) SetHeader(name, value string) {
+ (*c.orig).Response().Header().Set(name, value)
+}
+
+func (c *echoCtx) BodyWriter() io.Writer { return (*c.orig).Response() }
+
+func (c *echoCtx) TLS() *tls.ConnectionState { return (*c.orig).Request().TLS }
+
+func (c *echoCtx) Version() huma.ProtoVersion {
+ r := (*c.orig).Request()
+ return huma.ProtoVersion{
+ Proto: r.Proto,
+ ProtoMajor: r.ProtoMajor,
+ ProtoMinor: r.ProtoMinor,
+ }
+}
+
+type router interface {
+ Add(method, path string, handler echo.HandlerFunc, middlewares ...echo.MiddlewareFunc) echo.RouteInfo
+}
+
+type echoAdapter struct {
+ http.Handler
+ router router
+ // groupPrefix (e.g. "/api/v2") gets prepended to internal Huma
+ // dispatches whose path doesn't already start with it — required so
+ // autopatch's relative path resolution works under a sub-group.
+ groupPrefix string
+}
+
+func (a *echoAdapter) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ if a.groupPrefix != "" && !strings.HasPrefix(r.URL.Path, a.groupPrefix) {
+ r = r.Clone(r.Context())
+ r.URL.Path = a.groupPrefix + r.URL.Path
+ if r.URL.RawPath != "" {
+ r.URL.RawPath = a.groupPrefix + r.URL.RawPath
+ }
+ }
+ a.Handler.ServeHTTP(w, r)
+}
+
+func (a *echoAdapter) Handle(op *huma.Operation, handler func(huma.Context)) {
+ // Convert {param} to :param for Echo's router.
+ path := op.Path
+ path = strings.ReplaceAll(path, "{", ":")
+ path = strings.ReplaceAll(path, "}", "")
+ a.router.Add(op.Method, path, func(c *echo.Context) error {
+ ctx := &echoCtx{op: op, orig: c}
+ handler(ctx)
+ return nil
+ })
+}
+
+// New creates a new Huma API using the provided Echo router.
+func New(r *echo.Echo, config huma.Config) huma.API {
+ return huma.NewAPI(config, &echoAdapter{Handler: r, router: r})
+}
+
+// NewWithGroup mounts a Huma API on a group so handlers inherit its
+// middleware. groupPrefix must equal the prefix g was constructed with;
+// the adapter uses it to rewrite internal Huma dispatches (notably
+// autopatch's GET+PUT round trip) onto absolute URLs.
+func NewWithGroup(r *echo.Echo, g *echo.Group, groupPrefix string, config huma.Config) huma.API {
+ return huma.NewAPI(config, &echoAdapter{Handler: r, router: g, groupPrefix: groupPrefix})
+}
diff --git a/pkg/modules/humaecho5/humaecho5_test.go b/pkg/modules/humaecho5/humaecho5_test.go
new file mode 100644
index 000000000..9594ea106
--- /dev/null
+++ b/pkg/modules/humaecho5/humaecho5_test.go
@@ -0,0 +1,86 @@
+// Vikunja is a to-do list application to facilitate your life.
+// Copyright 2018-present Vikunja and contributors. All rights reserved.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package humaecho5_test
+
+import (
+ "context"
+ "encoding/json"
+ "net/http/httptest"
+ "testing"
+
+ "code.vikunja.io/api/pkg/modules/humaecho5"
+ "github.com/danielgtaylor/huma/v2"
+ "github.com/labstack/echo/v5"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestAdapterRoundtrip proves that a Huma operation registered against the
+// v5 adapter is served by Echo and that the echo.Context is retrievable
+// from the handler's context.Context via EchoContextKey.
+func TestAdapterRoundtrip(t *testing.T) {
+ e := echo.New()
+ api := humaecho5.New(e, huma.DefaultConfig("spike", "0.0.1"))
+
+ type pingInput struct {
+ Name string `path:"name"`
+ }
+ type pingOutput struct {
+ Body struct {
+ Echo string `json:"echo"`
+ HasEchoCtx bool `json:"has_echo_ctx"`
+ }
+ }
+
+ huma.Register(api, huma.Operation{
+ OperationID: "ping",
+ Method: "GET",
+ Path: "/ping/{name}",
+ }, func(ctx context.Context, in *pingInput) (*pingOutput, error) {
+ _, ok := ctx.Value(humaecho5.EchoContextKey).(*echo.Context)
+ out := &pingOutput{}
+ out.Body.Echo = in.Name
+ out.Body.HasEchoCtx = ok
+ return out, nil
+ })
+
+ req := httptest.NewRequest("GET", "/ping/world", nil)
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, req)
+
+ require.Equal(t, 200, rec.Code, "body: %s", rec.Body.String())
+ var got struct {
+ Echo string `json:"echo"`
+ HasEchoCtx bool `json:"has_echo_ctx"`
+ }
+ require.NoError(t, json.NewDecoder(rec.Body).Decode(&got))
+ assert.Equal(t, "world", got.Echo)
+ assert.True(t, got.HasEchoCtx, "echo.Context not stashed on request ctx")
+}
+
+// TestOpenAPISpecServed proves Huma serves the OAS 3.1 spec document
+// on its configured URL.
+func TestOpenAPISpecServed(t *testing.T) {
+ e := echo.New()
+ _ = humaecho5.New(e, huma.DefaultConfig("spike", "0.0.1"))
+ req := httptest.NewRequest("GET", "/openapi.json", nil)
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, req)
+ require.Equal(t, 200, rec.Code)
+ assert.Contains(t, rec.Body.String(), `"openapi":"3.1`,
+ "expected OAS 3.1 header, got %s", rec.Body.String())
+}