diff --git a/CHANGELOG.md b/CHANGELOG.md index 03d270b..fc10816 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ This module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html --- +## [1.3.2] — 2026-08-08 + +Patch. Scaffold/wire middleware order now mirrors `web.New`. + +### Changed + +- Generated wire (and the wire builtin example) apply middleware in `web.New`'s order — + `Recover` outermost, a time-ordered UUID v7 request ID (`newRequestID`), then + `RequestLogger` — with the env-gated allow-all CORS as the only deliberate divergence. + Previously the scaffold put `RequestID` before `Recover` and used a v4 request ID. + ## [1.3.1] — 2026-08-08 Patch. Correct the `web.allowedorigins-removed` rule message and migration docs to name the diff --git a/README.md b/README.md index 63b83d5..7d94914 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # einherjar/mcp -[![version](https://img.shields.io/badge/version-v1.3.1-5C4EE5?style=flat-square)](https://code.nochebuena.dev/einherjar/mcp) +[![version](https://img.shields.io/badge/version-v1.3.2-5C4EE5?style=flat-square)](https://code.nochebuena.dev/einherjar/mcp) [![license](https://img.shields.io/badge/license-AGPL--3.0-22863A?style=flat-square)](LICENSE) [![go](https://img.shields.io/badge/Go-1.26+-00ADD8?style=flat-square&logo=go&logoColor=white)](https://go.dev) diff --git a/cmd/server/main.go b/cmd/server/main.go index c1cf44c..49507fa 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -23,7 +23,7 @@ import ( const ( serverName = "einherjar-mcp" - serverVersion = "v1.3.1" + serverVersion = "v1.3.2" ) func main() { diff --git a/data/index.json b/data/index.json index 67016c0..99dc8f3 100644 --- a/data/index.json +++ b/data/index.json @@ -1,7 +1,7 @@ { "schema": "einherjar.mcp/index/v1", "framework": "einherjar", - "builtAt": "2026-08-08T16:51:53.502628448Z", + "builtAt": "2026-08-08T20:08:14.25644726Z", "modules": [ { "name": "auth", @@ -10737,7 +10737,7 @@ "module": "wire", "subPackage": "", "title": "wire.go — Run()", - "code": "\u003e // v1.x (removed) — compiled but could serve no CORS after v1.2.0:\n\u003e // mw.CORS(cfg.Web.AllowedOrigins)\n\u003e // web.New(logger, web.Config{AllowedOrigins: origins})\n\u003e\n\u003e // v2.0.0 — the single source of truth:\n\u003e mw.CORS(cfg.Server.CORSOrigins) // server.New tier\n\u003e web.New(logger, web.Config{Server: cfg.Server}) // web.New reads it automatically\n\u003e", + "code": "\u003e // v1.x (removed) — compiled but could serve no CORS after v1.2.0:\n\u003e // mw.CORS(cfg.Web.AllowedOrigins)\n\u003e // web.New(logger, web.Config{AllowedOrigins: origins})\n\u003e\n\u003e // v1.3.0 — the single source of truth:\n\u003e mw.CORS(cfg.Server.CORSOrigins) // server.New tier\n\u003e web.New(logger, web.Config{Server: cfg.Server}) // web.New reads it automatically\n\u003e", "language": "go" }, { @@ -10794,7 +10794,7 @@ "interfaceAsserts": [], "tests": [] }, - "readme": "# Wiring Conventions\n\n\u003e Forging a service is mostly wiring. Do it the same way every time.\n\nThis is not an Einherjar *module* — it is the canonical *application* shape that uses\nEinherjar modules. Apps live in their own repository with an `internal/wire/` package that\nmirrors this template. The conventions here are distilled from production services built on\nEinherjar v1 (`iron-dough-api`, `pei-api`) and describe the **one opinionated minimum** a\nscaffolded app should have. You *can* hand-roll something else — but then it is yours to\nmaintain, and it will not match what the rest of the ecosystem reads at a glance.\n\n## The opinionated minimum\n\nEvery scaffolded Einherjar application has, at minimum:\n\n1. **A clean `main.go`** — nothing but `.env` autoload and a call to `wire.Run()`.\n2. **An `internal/wire/` package** — one file per feature plus `wire.go`, which assembles everything.\n3. **An `internal/config/config.go`** — one global `Config` that *composes* the framework's\n component configs, loaded from the environment with `caarlos0/env`.\n4. **A `.env.example`** kept in lock-step with that config (see *Config \u0026 .env.example*).\n\nAnything a developer freely chooses — how migrations run, how the first admin is seeded, an\ninit-by-endpoint/webhook/email flow — is **not** part of this convention and is left to the app.\n\n## Project layout\n\n```\ncmd/\u003capp\u003e/main.go one-line entrypoint: godotenv autoload + wire.Run()\ninternal/wire/wire.go Run() — loads config, builds infra, registers feature hooks\ninternal/wire/\u003cfeature\u003e.go one file per feature, hosts a with\u003cFeature\u003e hook\ninternal/wire/middleware.go authz, skipPublicPaths, skipMethodPath helpers\ninternal/config/config.go global Config composing framework component configs\n.env.example every env var the config reads, documented, in sync\ninternal/\u003cfeature\u003e/dto/ request/response DTOs\ninternal/\u003cfeature\u003e/handler/ HTTP handlers\ninternal/\u003cfeature\u003e/repository/ data access\ninternal/\u003cfeature\u003e/service/ domain logic\n```\n\n## main.go\n\n`cmd/\u003capp\u003e/main.go` contains **nothing** but the `.env` autoload and the call to `wire.Run()`.\nThe blank import `_ \"github.com/joho/godotenv/autoload\"` is the standard, documented way to load\na local `.env` — it never overrides variables already set in the real environment, and a missing\nfile is not an error, so deployments (vars injected by the platform, no `.env`) are unaffected.\n\n```go\npackage main\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\n\t_ \"github.com/joho/godotenv/autoload\"\n\n\t\"myapp/internal/wire\"\n)\n\nfunc main() {\n\tif err := wire.Run(); err != nil {\n\t\tfmt.Fprintln(os.Stderr, \"fatal:\", err)\n\t\tos.Exit(1)\n\t}\n}\n```\n\nNo config parsing, no component construction, no logging setup — all of that lives in\n`internal/wire/`. A `main.go` that builds anything itself is the single most common scaffolding\nmistake.\n\n## Config\n\n`internal/config/config.go` is **one** `Config` struct that *composes* the framework's component\nconfigs as nested fields, alongside the app's own settings. `caarlos0/env` recurses into the\nnested fields, so each Einherjar component's `EINHERJAR_*` env tags load automatically next to\nthe app-owned fields. App-owned fields use the `APP_*` prefix so they never collide with the\nframework's `EINHERJAR_*` namespace. There is exactly one `Load()`.\n\n```go\npackage config\n\nimport (\n\t\"time\"\n\n\t\"github.com/caarlos0/env/v11\"\n\n\t\"code.nochebuena.dev/einherjar/core/logz\"\n\t\"code.nochebuena.dev/einherjar/db-postgres\"\n\t\"code.nochebuena.dev/einherjar/web/server\"\n)\n\n// JWTConfig is app-owned: the secret is handed to the signer in code, not consumed\n// by a framework component, so it carries no EINHERJAR_ prefix.\ntype JWTConfig struct {\n\tSecret string `env:\"APP_JWT_SECRET,required,notEmpty\"`\n\tIssuer string `env:\"APP_JWT_ISSUER\" envDefault:\"myapp\"`\n\tAccessTTL time.Duration `env:\"APP_JWT_ACCESS_TTL\" envDefault:\"1h\"`\n\tRefreshTTL time.Duration `env:\"APP_JWT_REFRESH_TTL\" envDefault:\"168h\"`\n}\n\n// Config is the fully-resolved startup configuration. Einherjar component configs\n// are nested fields; caarlos0/env recurses into them, populating their\n// EINHERJAR_SERVER_* / EINHERJAR_PG_* tags from the environment.\ntype Config struct {\n\tAppEnv string `env:\"APP_ENV\" envDefault:\"local\"`\n\n\tJWT JWTConfig\n\n\t// Framework component configs — composed verbatim. Their own EINHERJAR_* tags\n\t// load through this one env.Parse call.\n\tLog logz.Config // EINHERJAR_LOG_*\n\tServer server.Config // EINHERJAR_SERVER_* (incl. EINHERJAR_SERVER_CORS_ORIGINS)\n\tPG postgres.Config // EINHERJAR_PG_*\n}\n\nfunc Load() (Config, error) {\n\tvar cfg Config\n\tif err := env.Parse(\u0026cfg); err != nil {\n\t\treturn Config{}, err\n\t}\n\treturn cfg, nil\n}\n```\n\nNever read framework env vars (`EINHERJAR_*`) with `os.Getenv` — compose the component's `Config`\ntype and let `caarlos0/env` load it. A raw `os.Getenv(\"EINHERJAR_PG_HOST\")` in application code is\nthe mistake this convention removes.\n\n## Config \u0026 .env.example\n\nEvery environment variable the `config` package reads **must** also appear in `.env.example` at\nthe repo root, documented. The two are kept in **lock-step**: when a feature introduces a new env\nvar, the same change adds its `env:\"...\"` tag to `config` **and** a documented line to\n`.env.example`. This is not optional bookkeeping — it is what stops a long feature from shipping\nand then failing at boot because nobody knew which variables to set.\n\n**Two kinds of var, two ways to keep them honest:**\n\n- **Framework component vars (`EINHERJAR_*`)** — when you compose a new component later (e.g.\n `cachevalkey.Config`, `minio.Config`, `smtp.Config`), the MCP knows its real vars: call\n `get_config_env(\"\u003cmodule\u003e\")` for the exact set (name, required, default) and add each to\n `.env.example`. The `config.unknown-env-var` rule rejects any `EINHERJAR_*` tag the framework\n doesn't declare, so a typo like `EINHERJAR_PG_DATABASE` is caught at `validate_snippet` time.\n- **App-owned vars (`APP_*`)** — like `APP_JWT_SECRET` above: the framework can't know these, so\n keeping them in `.env.example` is your discipline, not something it can name-check.\n\nAfter you compose a component, run **`check_env`** with what the app composes: it flags\n`EINHERJAR_*` names that don't exist, required vars you forgot to document, and vars set for a\nconfig you don't actually compose (dead vars). Prefer the **`composes`** input (exact struct\nselectors like `web/server/Config`) over `modules` — it catches struct-level dead vars (e.g.\n`EINHERJAR_HEALTH_CHECK_TIMEOUT` lives on `web/health.Config`, so it is dead if you compose\n`web/server/Config` but not the health config). `get_scaffold` already emits a `.env.example`\nderived from these same tags, so the starting point is correct by construction.\n\n```bash\n# .env.example — copy to .env for local dev. Every var the app reads lives here.\n\n# ── App ───────────────────────────────────────────────────────────────────\nAPP_ENV=local\n# CORS: local uses mw.CORSAllowAll(); non-local reads EINHERJAR_SERVER_CORS_ORIGINS (below).\nAPP_JWT_SECRET=change-me\nAPP_JWT_ISSUER=myapp\n\n# ── Einherjar: logging (EINHERJAR_LOG_*) ──────────────────────────────────\n# EINHERJAR_LOG_LEVEL=INFO\n# EINHERJAR_LOG_JSON=false\n\n# ── Einherjar: HTTP server (EINHERJAR_SERVER_*) ───────────────────────────\nEINHERJAR_SERVER_HOST=0.0.0.0\nEINHERJAR_SERVER_PORT=8080\n# EINHERJAR_SERVER_CORS_ORIGINS — explicit origins for non-local envs (comma-separated).\n# \"*\" is rejected by mw.CORS; local dev uses mw.CORSAllowAll() and ignores this.\n# EINHERJAR_SERVER_CORS_ORIGINS=\n\n# ── Einherjar: PostgreSQL (EINHERJAR_PG_*) ────────────────────────────────\nEINHERJAR_PG_HOST=localhost\nEINHERJAR_PG_PORT=5432\nEINHERJAR_PG_USER=postgres\nEINHERJAR_PG_PASSWORD=postgres\nEINHERJAR_PG_NAME=myapp\n```\n\nTo discover the full set for any component you compose, use `get_config_env(\"\u003cmodule\u003e\")` rather\nthan reading source by hand; every var it returns belongs in `.env.example`, and `check_env`\nconfirms none are missing, misspelled, or dead.\n\n## wire.go — Run()\n\nThe application entry point. The order below is load-bearing: configuration first, observability\nsecond, infrastructure third, cross-cutting helpers fourth, then the launcher with every component\nappended, then feature hooks, then `lc.Run()`.\n\n**`web.New` vs `server.New` — pick the right tier:**\n\n- **`web.New(logger, web.Config{Server: cfg.Server})`** — batteries-included. It pre-wires the\n recommended middleware stack (Recover → RequestID → RequestLogger) and applies `mw.CORS` from\n `EINHERJAR_SERVER_CORS_ORIGINS` automatically (explicit origins only; empty ⇒ CORS off + a log\n line). Use it for a plain service that just needs the defaults. It does **not** support allow-all\n CORS or a custom middleware order.\n- **`server.New(logger, cfg.Server, server.WithMiddleware(...))`** — full control. You compose the\n middleware list yourself. Use it when you need a **custom middleware order**, extra middleware\n (auth, enrichment), a custom request-ID generator, or **allow-all CORS in local dev**\n (`mw.CORSAllowAll`, gated by `AppEnv` — see below). The starter below uses `server.New` precisely\n because it inserts JWT auth + enrichment into the stack.\n\nWhichever tier you pick, CORS origins always come from the framework var\n`EINHERJAR_SERVER_CORS_ORIGINS` (`cfg.Server.CORSOrigins`) — never invent an app-owned CORS var.\n\n\u003e **CORS has one home: `server.Config.CORSOrigins`.** In framework `v1.x`, `web.Config`\n\u003e carried an `AllowedOrigins` field. It was env-backed through `v1.1.x` and a code-only override\n\u003e in `v1.2.0` — reading it after the env tag moved silently served *no* CORS. **`v2.0.0` removed the\n\u003e field entirely** so the mistake fails at compile time instead of at runtime. If you are migrating\n\u003e code that read `web.Config.AllowedOrigins` or set it in a struct literal, switch to\n\u003e `cfg.Server.CORSOrigins`:\n\u003e\n\u003e ```go\n\u003e // v1.x (removed) — compiled but could serve no CORS after v1.2.0:\n\u003e // mw.CORS(cfg.Web.AllowedOrigins)\n\u003e // web.New(logger, web.Config{AllowedOrigins: origins})\n\u003e\n\u003e // v2.0.0 — the single source of truth:\n\u003e mw.CORS(cfg.Server.CORSOrigins) // server.New tier\n\u003e web.New(logger, web.Config{Server: cfg.Server}) // web.New reads it automatically\n\u003e ```\n\u003e\n\u003e `validate_snippet` flags any lingering `AllowedOrigins` reference (`web.allowedorigins-removed`).\n\n```go\npackage wire\n\nimport (\n\t\"github.com/google/uuid\"\n\n\tauthjwt \"code.nochebuena.dev/einherjar/auth-jwt\"\n\t\"code.nochebuena.dev/einherjar/auth/authmw\"\n\t\"code.nochebuena.dev/einherjar/auth/rbac\"\n\t\"code.nochebuena.dev/einherjar/core/launcher\"\n\t\"code.nochebuena.dev/einherjar/core/logz\"\n\t\"code.nochebuena.dev/einherjar/core/valid\"\n\t\"code.nochebuena.dev/einherjar/db-postgres\"\n\t\"code.nochebuena.dev/einherjar/web/mw\"\n\t\"code.nochebuena.dev/einherjar/web/server\"\n\n\t\"myapp/internal/config\"\n)\n\nfunc Run() error {\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// logz.Config is composed in config, so EINHERJAR_LOG_LEVEL / _JSON load from\n\t// the environment; StaticArgs are set here (they carry no env tag).\n\tlogCfg := cfg.Log\n\tlogCfg.StaticArgs = []any{\"service\", \"myapp\", \"env\", cfg.AppEnv}\n\tlogger := logz.New(logCfg)\n\n\tsigner := authjwt.NewHMACSigner([]byte(cfg.JWT.Secret))\n\n\tpublicPaths := []string{\n\t\t\"/health\",\n\t\t\"/api/v1/auth/login\",\n\t\t\"/api/v1/auth/refresh\",\n\t}\n\n\tdb := postgres.New(logger, cfg.PG)\n\n\t// CORS convention: allow-all in local dev, explicit origins everywhere else.\n\t// Origins come from the framework's own EINHERJAR_SERVER_CORS_ORIGINS\n\t// (cfg.Server.CORSOrigins) — never invent an APP_CORS_ORIGINS var. mw.CORS panics\n\t// on \"*\" (it matches no real origin) — allow-all is mw.CORSAllowAll, never a \"*\".\n\tcorsMW := mw.CORSAllowAll()\n\tif !strings.EqualFold(cfg.AppEnv, \"local\") {\n\t\tcorsMW = mw.CORS(cfg.Server.CORSOrigins)\n\t}\n\n\tsrv := server.New(logger, cfg.Server,\n\t\tserver.WithMiddleware(\n\t\t\tmw.RequestID(uuid.NewString),\n\t\t\tmw.Recover(logger),\n\t\t\tcorsMW,\n\t\t\tmw.RequestLogger(logger),\n\t\t\tauthjwt.AuthMiddleware(logger, signer, publicPaths),\n\t\t\tauthmw.EnrichmentMiddleware(logger, \u0026claimsEnricher{}),\n\t\t),\n\t)\n\n\tv := valid.New(valid.WithMessageProvider(valid.SpanishMessages))\n\tprovider := rbac.NewClaimsPermissionProvider(\"masks\", claimsFromCtx)\n\n\tlc := launcher.New(logger)\n\tlc.Append(db, srv)\n\n\twithHealth(lc, srv, logger, db)\n\twithUsers(lc, srv, db, logger, provider, v)\n\t// … one withFeature(...) call per feature in your domain.\n\n\treturn lc.Run()\n}\n```\n\n## Feature hook\n\nOne file per feature in `internal/wire/`. The function signature is fixed:\n`launcher.Launcher` first, `server.Server` second when registering routes, deps last. The body is\n*one* call to `lc.BeforeStart`. Everything else — repository, service, handler construction, route\nregistration — lives inside the closure.\n\n```go\npackage wire\n\nimport (\n\t\"code.nochebuena.dev/einherjar/contracts/logging\"\n\t\"code.nochebuena.dev/einherjar/contracts/security\"\n\t\"code.nochebuena.dev/einherjar/core/launcher\"\n\t\"code.nochebuena.dev/einherjar/core/valid\"\n\t\"code.nochebuena.dev/einherjar/db-postgres\"\n\t\"code.nochebuena.dev/einherjar/web/server\"\n\n\t\"myapp/internal/domains\"\n\tuserhandler \"myapp/internal/user/handler\"\n\tuserrepo \"myapp/internal/user/repository\"\n\tusersvc \"myapp/internal/user/service\"\n)\n\nfunc withUsers(\n\tlc launcher.Launcher,\n\tsrv server.Server,\n\tdb postgres.Provider,\n\tlogger logging.Logger,\n\tprovider security.PermissionProvider,\n\tv valid.Validator,\n) {\n\tlc.BeforeStart(func() error {\n\t\trepo := userrepo.New(db)\n\t\tuow := postgres.NewUnitOfWork(logger, db)\n\t\tsvc := usersvc.New(repo, uow)\n\t\th := userhandler.New(svc, v)\n\n\t\t// Literal-segment routes register BEFORE parametrised siblings.\n\t\tsrv.Put(\"/api/v1/users/me/password\", h.ChangeOwnPassword)\n\n\t\tsrv.With(authz(provider, domains.ResourceUsers, domains.GrantReadUser)).\n\t\t\tGet(\"/api/v1/users\", h.ListUsers)\n\t\tsrv.With(authz(provider, domains.ResourceUsers, domains.GrantCreateUser)).\n\t\t\tPost(\"/api/v1/users\", h.CreateUser)\n\t\tsrv.With(authz(provider, domains.ResourceUsers, domains.GrantUpdateUser)).\n\t\t\tPut(\"/api/v1/users/{user_id}\", h.UpdateUser)\n\n\t\treturn nil\n\t})\n}\n```\n\n## Route ordering\n\nchi matches paths in registration order. Always register literal-segment routes before\nparametrised-segment routes that share the same prefix.\n\n✅ Correct:\n\n```go\nsrv.Put(\"/api/v1/users/me/password\", h.ChangeOwnPassword)\nsrv.Put(\"/api/v1/users/{user_id}\", h.UpdateUser)\n```\n\n❌ Wrong — chi binds `me` to `{user_id}` and the literal route is unreachable:\n\n```go\nsrv.Put(\"/api/v1/users/{user_id}\", h.UpdateUser)\nsrv.Put(\"/api/v1/users/me/password\", h.ChangeOwnPassword)\n```\n\n## Authorization\n\nEvery protected route registers with `.With(authz(provider, resource, grant))`:\n\n```go\nsrv.With(authz(provider, domains.ResourceUsers, domains.GrantReadUser)).\n Get(\"/api/v1/users\", h.ListUsers)\n```\n\nResource constants and grant bits live in `internal/domains/`. Routes that the caller owns\n(`/me/...`) intentionally skip authz — they are reachable to any authenticated user.\n\n## Middleware helpers\n\nThese belong in `internal/wire/middleware.go` and are used across every feature hook.\n\n```go\n// authz returns a per-route authorization middleware that checks one bit.\nfunc authz(p security.PermissionProvider, resource string, bit int) func(http.Handler) http.Handler {\n\treturn authmw.AuthzMiddleware(nil, p, resource, security.Permission(bit))\n}\n\n// skipPublicPaths wraps mw so it is bypassed for any path that matches publicPaths.\n// Use this for middleware that must not run on unauthenticated endpoints\n// (e.g. EnrichmentMiddleware).\nfunc skipPublicPaths(publicPaths []string, mw func(http.Handler) http.Handler) func(http.Handler) http.Handler {\n\treturn func(next http.Handler) http.Handler {\n\t\tinner := mw(next)\n\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tfor _, p := range publicPaths {\n\t\t\t\tif matched, _ := path.Match(p, r.URL.Path); matched {\n\t\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t\tinner.ServeHTTP(w, r)\n\t\t})\n\t}\n}\n\n// skipMethodPath bypasses mw only when BOTH method and path match. Use this to\n// expose ONE method on an otherwise-authenticated path (e.g. GET /api/v1/config\n// public while PUT is not). Adding such a path to publicPaths would silently\n// strip identity from context on the protected methods, breaking authz().\nfunc skipMethodPath(method, pathPattern string, mw func(http.Handler) http.Handler) func(http.Handler) http.Handler {\n\treturn func(next http.Handler) http.Handler {\n\t\tinner := mw(next)\n\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tif r.Method == method {\n\t\t\t\tif matched, _ := path.Match(pathPattern, r.URL.Path); matched {\n\t\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t\tinner.ServeHTTP(w, r)\n\t\t})\n\t}\n}\n```\n\n## Adapters at the wire boundary\n\nWhen a framework type does not match a service-layer port, write a small typed adapter in\n`internal/wire/`. Always compile-time assert with `var _ TargetIface = (*adapter)(nil)`.\n\nThe framework intentionally exposes only `Signer.Sign(claims) (string, error)` — **the framework\ngives you a signing primitive; the access/refresh strategy, claim layout, and response shape are\napplication concerns.** A \"helper\" that returned a fixed `{access, refresh, type, expiresIn}` struct\nwould silently decide for every app whether refresh tokens exist, what fields to expose, and what\ncasing to use. Those are wire-format choices the app owns.\n\n```go\nimport (\n\t\"time\"\n\n\t\"github.com/golang-jwt/jwt/v5\"\n\t\"github.com/google/uuid\"\n\n\tauthjwt \"code.nochebuena.dev/einherjar/auth-jwt\"\n)\n\ntype tokenSignerAdapter struct {\n\tsigner authjwt.Signer\n\tcfg authjwt.TokenConfig\n}\n\nvar _ authsvc.TokenSigner = (*tokenSignerAdapter)(nil)\n\nfunc (a *tokenSignerAdapter) IssueTokenPair(subject string, custom map[string]any) (authdto.TokenPairResponse, error) {\n\tnow := time.Now()\n\n\taccess := jwt.MapClaims{\n\t\t\"sub\": subject,\n\t\t\"iss\": a.cfg.Issuer,\n\t\t\"iat\": now.Unix(),\n\t\t\"exp\": now.Add(a.cfg.AccessTTL).Unix(),\n\t}\n\tfor k, v := range custom {\n\t\taccess[k] = v\n\t}\n\taccessToken, err := a.signer.Sign(access)\n\tif err != nil {\n\t\treturn authdto.TokenPairResponse{}, err\n\t}\n\n\trefreshToken, err := a.signer.Sign(jwt.MapClaims{\n\t\t\"sub\": subject,\n\t\t\"jti\": uuid.NewString(),\n\t\t\"iat\": now.Unix(),\n\t\t\"exp\": now.Add(a.cfg.RefreshTTL).Unix(),\n\t})\n\tif err != nil {\n\t\treturn authdto.TokenPairResponse{}, err\n\t}\n\n\treturn authdto.TokenPairResponse{\n\t\tAccessToken: accessToken,\n\t\tRefreshToken: refreshToken,\n\t\tTokenType: \"Bearer\",\n\t\tExpiresIn: int(a.cfg.AccessTTL.Seconds()),\n\t}, nil\n}\n```\n" + "readme": "# Wiring Conventions\n\n\u003e Forging a service is mostly wiring. Do it the same way every time.\n\nThis is not an Einherjar *module* — it is the canonical *application* shape that uses\nEinherjar modules. Apps live in their own repository with an `internal/wire/` package that\nmirrors this template. The conventions here are distilled from production services built on\nEinherjar v1 (`iron-dough-api`, `pei-api`) and describe the **one opinionated minimum** a\nscaffolded app should have. You *can* hand-roll something else — but then it is yours to\nmaintain, and it will not match what the rest of the ecosystem reads at a glance.\n\n## The opinionated minimum\n\nEvery scaffolded Einherjar application has, at minimum:\n\n1. **A clean `main.go`** — nothing but `.env` autoload and a call to `wire.Run()`.\n2. **An `internal/wire/` package** — one file per feature plus `wire.go`, which assembles everything.\n3. **An `internal/config/config.go`** — one global `Config` that *composes* the framework's\n component configs, loaded from the environment with `caarlos0/env`.\n4. **A `.env.example`** kept in lock-step with that config (see *Config \u0026 .env.example*).\n\nAnything a developer freely chooses — how migrations run, how the first admin is seeded, an\ninit-by-endpoint/webhook/email flow — is **not** part of this convention and is left to the app.\n\n## Project layout\n\n```\ncmd/\u003capp\u003e/main.go one-line entrypoint: godotenv autoload + wire.Run()\ninternal/wire/wire.go Run() — loads config, builds infra, registers feature hooks\ninternal/wire/\u003cfeature\u003e.go one file per feature, hosts a with\u003cFeature\u003e hook\ninternal/wire/middleware.go authz, skipPublicPaths, skipMethodPath helpers\ninternal/config/config.go global Config composing framework component configs\n.env.example every env var the config reads, documented, in sync\ninternal/\u003cfeature\u003e/dto/ request/response DTOs\ninternal/\u003cfeature\u003e/handler/ HTTP handlers\ninternal/\u003cfeature\u003e/repository/ data access\ninternal/\u003cfeature\u003e/service/ domain logic\n```\n\n## main.go\n\n`cmd/\u003capp\u003e/main.go` contains **nothing** but the `.env` autoload and the call to `wire.Run()`.\nThe blank import `_ \"github.com/joho/godotenv/autoload\"` is the standard, documented way to load\na local `.env` — it never overrides variables already set in the real environment, and a missing\nfile is not an error, so deployments (vars injected by the platform, no `.env`) are unaffected.\n\n```go\npackage main\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\n\t_ \"github.com/joho/godotenv/autoload\"\n\n\t\"myapp/internal/wire\"\n)\n\nfunc main() {\n\tif err := wire.Run(); err != nil {\n\t\tfmt.Fprintln(os.Stderr, \"fatal:\", err)\n\t\tos.Exit(1)\n\t}\n}\n```\n\nNo config parsing, no component construction, no logging setup — all of that lives in\n`internal/wire/`. A `main.go` that builds anything itself is the single most common scaffolding\nmistake.\n\n## Config\n\n`internal/config/config.go` is **one** `Config` struct that *composes* the framework's component\nconfigs as nested fields, alongside the app's own settings. `caarlos0/env` recurses into the\nnested fields, so each Einherjar component's `EINHERJAR_*` env tags load automatically next to\nthe app-owned fields. App-owned fields use the `APP_*` prefix so they never collide with the\nframework's `EINHERJAR_*` namespace. There is exactly one `Load()`.\n\n```go\npackage config\n\nimport (\n\t\"time\"\n\n\t\"github.com/caarlos0/env/v11\"\n\n\t\"code.nochebuena.dev/einherjar/core/logz\"\n\t\"code.nochebuena.dev/einherjar/db-postgres\"\n\t\"code.nochebuena.dev/einherjar/web/server\"\n)\n\n// JWTConfig is app-owned: the secret is handed to the signer in code, not consumed\n// by a framework component, so it carries no EINHERJAR_ prefix.\ntype JWTConfig struct {\n\tSecret string `env:\"APP_JWT_SECRET,required,notEmpty\"`\n\tIssuer string `env:\"APP_JWT_ISSUER\" envDefault:\"myapp\"`\n\tAccessTTL time.Duration `env:\"APP_JWT_ACCESS_TTL\" envDefault:\"1h\"`\n\tRefreshTTL time.Duration `env:\"APP_JWT_REFRESH_TTL\" envDefault:\"168h\"`\n}\n\n// Config is the fully-resolved startup configuration. Einherjar component configs\n// are nested fields; caarlos0/env recurses into them, populating their\n// EINHERJAR_SERVER_* / EINHERJAR_PG_* tags from the environment.\ntype Config struct {\n\tAppEnv string `env:\"APP_ENV\" envDefault:\"local\"`\n\n\tJWT JWTConfig\n\n\t// Framework component configs — composed verbatim. Their own EINHERJAR_* tags\n\t// load through this one env.Parse call.\n\tLog logz.Config // EINHERJAR_LOG_*\n\tServer server.Config // EINHERJAR_SERVER_* (incl. EINHERJAR_SERVER_CORS_ORIGINS)\n\tPG postgres.Config // EINHERJAR_PG_*\n}\n\nfunc Load() (Config, error) {\n\tvar cfg Config\n\tif err := env.Parse(\u0026cfg); err != nil {\n\t\treturn Config{}, err\n\t}\n\treturn cfg, nil\n}\n```\n\nNever read framework env vars (`EINHERJAR_*`) with `os.Getenv` — compose the component's `Config`\ntype and let `caarlos0/env` load it. A raw `os.Getenv(\"EINHERJAR_PG_HOST\")` in application code is\nthe mistake this convention removes.\n\n## Config \u0026 .env.example\n\nEvery environment variable the `config` package reads **must** also appear in `.env.example` at\nthe repo root, documented. The two are kept in **lock-step**: when a feature introduces a new env\nvar, the same change adds its `env:\"...\"` tag to `config` **and** a documented line to\n`.env.example`. This is not optional bookkeeping — it is what stops a long feature from shipping\nand then failing at boot because nobody knew which variables to set.\n\n**Two kinds of var, two ways to keep them honest:**\n\n- **Framework component vars (`EINHERJAR_*`)** — when you compose a new component later (e.g.\n `cachevalkey.Config`, `minio.Config`, `smtp.Config`), the MCP knows its real vars: call\n `get_config_env(\"\u003cmodule\u003e\")` for the exact set (name, required, default) and add each to\n `.env.example`. The `config.unknown-env-var` rule rejects any `EINHERJAR_*` tag the framework\n doesn't declare, so a typo like `EINHERJAR_PG_DATABASE` is caught at `validate_snippet` time.\n- **App-owned vars (`APP_*`)** — like `APP_JWT_SECRET` above: the framework can't know these, so\n keeping them in `.env.example` is your discipline, not something it can name-check.\n\nAfter you compose a component, run **`check_env`** with what the app composes: it flags\n`EINHERJAR_*` names that don't exist, required vars you forgot to document, and vars set for a\nconfig you don't actually compose (dead vars). Prefer the **`composes`** input (exact struct\nselectors like `web/server/Config`) over `modules` — it catches struct-level dead vars (e.g.\n`EINHERJAR_HEALTH_CHECK_TIMEOUT` lives on `web/health.Config`, so it is dead if you compose\n`web/server/Config` but not the health config). `get_scaffold` already emits a `.env.example`\nderived from these same tags, so the starting point is correct by construction.\n\n```bash\n# .env.example — copy to .env for local dev. Every var the app reads lives here.\n\n# ── App ───────────────────────────────────────────────────────────────────\nAPP_ENV=local\n# CORS: local uses mw.CORSAllowAll(); non-local reads EINHERJAR_SERVER_CORS_ORIGINS (below).\nAPP_JWT_SECRET=change-me\nAPP_JWT_ISSUER=myapp\n\n# ── Einherjar: logging (EINHERJAR_LOG_*) ──────────────────────────────────\n# EINHERJAR_LOG_LEVEL=INFO\n# EINHERJAR_LOG_JSON=false\n\n# ── Einherjar: HTTP server (EINHERJAR_SERVER_*) ───────────────────────────\nEINHERJAR_SERVER_HOST=0.0.0.0\nEINHERJAR_SERVER_PORT=8080\n# EINHERJAR_SERVER_CORS_ORIGINS — explicit origins for non-local envs (comma-separated).\n# \"*\" is rejected by mw.CORS; local dev uses mw.CORSAllowAll() and ignores this.\n# EINHERJAR_SERVER_CORS_ORIGINS=\n\n# ── Einherjar: PostgreSQL (EINHERJAR_PG_*) ────────────────────────────────\nEINHERJAR_PG_HOST=localhost\nEINHERJAR_PG_PORT=5432\nEINHERJAR_PG_USER=postgres\nEINHERJAR_PG_PASSWORD=postgres\nEINHERJAR_PG_NAME=myapp\n```\n\nTo discover the full set for any component you compose, use `get_config_env(\"\u003cmodule\u003e\")` rather\nthan reading source by hand; every var it returns belongs in `.env.example`, and `check_env`\nconfirms none are missing, misspelled, or dead.\n\n## wire.go — Run()\n\nThe application entry point. The order below is load-bearing: configuration first, observability\nsecond, infrastructure third, cross-cutting helpers fourth, then the launcher with every component\nappended, then feature hooks, then `lc.Run()`.\n\n**`web.New` vs `server.New` — pick the right tier:**\n\n- **`web.New(logger, web.Config{Server: cfg.Server})`** — batteries-included. It pre-wires the\n recommended middleware stack (Recover → RequestID → RequestLogger) and applies `mw.CORS` from\n `EINHERJAR_SERVER_CORS_ORIGINS` automatically (explicit origins only; empty ⇒ CORS off + a log\n line). Use it for a plain service that just needs the defaults. It does **not** support allow-all\n CORS or a custom middleware order.\n- **`server.New(logger, cfg.Server, server.WithMiddleware(...))`** — full control. You compose the\n middleware list yourself. Use it when you need a **custom middleware order**, extra middleware\n (auth, enrichment), a custom request-ID generator, or **allow-all CORS in local dev**\n (`mw.CORSAllowAll`, gated by `AppEnv` — see below). The starter below uses `server.New` precisely\n because it inserts JWT auth + enrichment into the stack.\n\nWhichever tier you pick, CORS origins always come from the framework var\n`EINHERJAR_SERVER_CORS_ORIGINS` (`cfg.Server.CORSOrigins`) — never invent an app-owned CORS var.\n\n\u003e **CORS has one home: `server.Config.CORSOrigins`.** In framework `v1.x`, `web.Config`\n\u003e carried an `AllowedOrigins` field. It was env-backed through `v1.1.x` and a code-only override\n\u003e in `v1.2.0` — reading it after the env tag moved silently served *no* CORS. **`v1.3.0` removed the\n\u003e field entirely** so the mistake fails at compile time instead of at runtime. If you are migrating\n\u003e code that read `web.Config.AllowedOrigins` or set it in a struct literal, switch to\n\u003e `cfg.Server.CORSOrigins`:\n\u003e\n\u003e ```go\n\u003e // v1.x (removed) — compiled but could serve no CORS after v1.2.0:\n\u003e // mw.CORS(cfg.Web.AllowedOrigins)\n\u003e // web.New(logger, web.Config{AllowedOrigins: origins})\n\u003e\n\u003e // v1.3.0 — the single source of truth:\n\u003e mw.CORS(cfg.Server.CORSOrigins) // server.New tier\n\u003e web.New(logger, web.Config{Server: cfg.Server}) // web.New reads it automatically\n\u003e ```\n\u003e\n\u003e `validate_snippet` flags any lingering `AllowedOrigins` reference (`web.allowedorigins-removed`).\n\n```go\npackage wire\n\nimport (\n\t\"github.com/google/uuid\"\n\n\tauthjwt \"code.nochebuena.dev/einherjar/auth-jwt\"\n\t\"code.nochebuena.dev/einherjar/auth/authmw\"\n\t\"code.nochebuena.dev/einherjar/auth/rbac\"\n\t\"code.nochebuena.dev/einherjar/core/launcher\"\n\t\"code.nochebuena.dev/einherjar/core/logz\"\n\t\"code.nochebuena.dev/einherjar/core/valid\"\n\t\"code.nochebuena.dev/einherjar/db-postgres\"\n\t\"code.nochebuena.dev/einherjar/web/mw\"\n\t\"code.nochebuena.dev/einherjar/web/server\"\n\n\t\"myapp/internal/config\"\n)\n\nfunc Run() error {\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// logz.Config is composed in config, so EINHERJAR_LOG_LEVEL / _JSON load from\n\t// the environment; StaticArgs are set here (they carry no env tag).\n\tlogCfg := cfg.Log\n\tlogCfg.StaticArgs = []any{\"service\", \"myapp\", \"env\", cfg.AppEnv}\n\tlogger := logz.New(logCfg)\n\n\tsigner := authjwt.NewHMACSigner([]byte(cfg.JWT.Secret))\n\n\tpublicPaths := []string{\n\t\t\"/health\",\n\t\t\"/api/v1/auth/login\",\n\t\t\"/api/v1/auth/refresh\",\n\t}\n\n\tdb := postgres.New(logger, cfg.PG)\n\n\t// CORS convention: allow-all in local dev, explicit origins everywhere else.\n\t// Origins come from the framework's own EINHERJAR_SERVER_CORS_ORIGINS\n\t// (cfg.Server.CORSOrigins) — never invent an APP_CORS_ORIGINS var. mw.CORS panics\n\t// on \"*\" (it matches no real origin) — allow-all is mw.CORSAllowAll, never a \"*\".\n\tcorsMW := mw.CORSAllowAll()\n\tif !strings.EqualFold(cfg.AppEnv, \"local\") {\n\t\tcorsMW = mw.CORS(cfg.Server.CORSOrigins)\n\t}\n\n\tsrv := server.New(logger, cfg.Server,\n\t\tserver.WithMiddleware(\n\t\t\tmw.RequestID(uuid.NewString),\n\t\t\tmw.Recover(logger),\n\t\t\tcorsMW,\n\t\t\tmw.RequestLogger(logger),\n\t\t\tauthjwt.AuthMiddleware(logger, signer, publicPaths),\n\t\t\tauthmw.EnrichmentMiddleware(logger, \u0026claimsEnricher{}),\n\t\t),\n\t)\n\n\tv := valid.New(valid.WithMessageProvider(valid.SpanishMessages))\n\tprovider := rbac.NewClaimsPermissionProvider(\"masks\", claimsFromCtx)\n\n\tlc := launcher.New(logger)\n\tlc.Append(db, srv)\n\n\twithHealth(lc, srv, logger, db)\n\twithUsers(lc, srv, db, logger, provider, v)\n\t// … one withFeature(...) call per feature in your domain.\n\n\treturn lc.Run()\n}\n```\n\n## Feature hook\n\nOne file per feature in `internal/wire/`. The function signature is fixed:\n`launcher.Launcher` first, `server.Server` second when registering routes, deps last. The body is\n*one* call to `lc.BeforeStart`. Everything else — repository, service, handler construction, route\nregistration — lives inside the closure.\n\n```go\npackage wire\n\nimport (\n\t\"code.nochebuena.dev/einherjar/contracts/logging\"\n\t\"code.nochebuena.dev/einherjar/contracts/security\"\n\t\"code.nochebuena.dev/einherjar/core/launcher\"\n\t\"code.nochebuena.dev/einherjar/core/valid\"\n\t\"code.nochebuena.dev/einherjar/db-postgres\"\n\t\"code.nochebuena.dev/einherjar/web/server\"\n\n\t\"myapp/internal/domains\"\n\tuserhandler \"myapp/internal/user/handler\"\n\tuserrepo \"myapp/internal/user/repository\"\n\tusersvc \"myapp/internal/user/service\"\n)\n\nfunc withUsers(\n\tlc launcher.Launcher,\n\tsrv server.Server,\n\tdb postgres.Provider,\n\tlogger logging.Logger,\n\tprovider security.PermissionProvider,\n\tv valid.Validator,\n) {\n\tlc.BeforeStart(func() error {\n\t\trepo := userrepo.New(db)\n\t\tuow := postgres.NewUnitOfWork(logger, db)\n\t\tsvc := usersvc.New(repo, uow)\n\t\th := userhandler.New(svc, v)\n\n\t\t// Literal-segment routes register BEFORE parametrised siblings.\n\t\tsrv.Put(\"/api/v1/users/me/password\", h.ChangeOwnPassword)\n\n\t\tsrv.With(authz(provider, domains.ResourceUsers, domains.GrantReadUser)).\n\t\t\tGet(\"/api/v1/users\", h.ListUsers)\n\t\tsrv.With(authz(provider, domains.ResourceUsers, domains.GrantCreateUser)).\n\t\t\tPost(\"/api/v1/users\", h.CreateUser)\n\t\tsrv.With(authz(provider, domains.ResourceUsers, domains.GrantUpdateUser)).\n\t\t\tPut(\"/api/v1/users/{user_id}\", h.UpdateUser)\n\n\t\treturn nil\n\t})\n}\n```\n\n## Route ordering\n\nchi matches paths in registration order. Always register literal-segment routes before\nparametrised-segment routes that share the same prefix.\n\n✅ Correct:\n\n```go\nsrv.Put(\"/api/v1/users/me/password\", h.ChangeOwnPassword)\nsrv.Put(\"/api/v1/users/{user_id}\", h.UpdateUser)\n```\n\n❌ Wrong — chi binds `me` to `{user_id}` and the literal route is unreachable:\n\n```go\nsrv.Put(\"/api/v1/users/{user_id}\", h.UpdateUser)\nsrv.Put(\"/api/v1/users/me/password\", h.ChangeOwnPassword)\n```\n\n## Authorization\n\nEvery protected route registers with `.With(authz(provider, resource, grant))`:\n\n```go\nsrv.With(authz(provider, domains.ResourceUsers, domains.GrantReadUser)).\n Get(\"/api/v1/users\", h.ListUsers)\n```\n\nResource constants and grant bits live in `internal/domains/`. Routes that the caller owns\n(`/me/...`) intentionally skip authz — they are reachable to any authenticated user.\n\n## Middleware helpers\n\nThese belong in `internal/wire/middleware.go` and are used across every feature hook.\n\n```go\n// authz returns a per-route authorization middleware that checks one bit.\nfunc authz(p security.PermissionProvider, resource string, bit int) func(http.Handler) http.Handler {\n\treturn authmw.AuthzMiddleware(nil, p, resource, security.Permission(bit))\n}\n\n// skipPublicPaths wraps mw so it is bypassed for any path that matches publicPaths.\n// Use this for middleware that must not run on unauthenticated endpoints\n// (e.g. EnrichmentMiddleware).\nfunc skipPublicPaths(publicPaths []string, mw func(http.Handler) http.Handler) func(http.Handler) http.Handler {\n\treturn func(next http.Handler) http.Handler {\n\t\tinner := mw(next)\n\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tfor _, p := range publicPaths {\n\t\t\t\tif matched, _ := path.Match(p, r.URL.Path); matched {\n\t\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t\tinner.ServeHTTP(w, r)\n\t\t})\n\t}\n}\n\n// skipMethodPath bypasses mw only when BOTH method and path match. Use this to\n// expose ONE method on an otherwise-authenticated path (e.g. GET /api/v1/config\n// public while PUT is not). Adding such a path to publicPaths would silently\n// strip identity from context on the protected methods, breaking authz().\nfunc skipMethodPath(method, pathPattern string, mw func(http.Handler) http.Handler) func(http.Handler) http.Handler {\n\treturn func(next http.Handler) http.Handler {\n\t\tinner := mw(next)\n\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tif r.Method == method {\n\t\t\t\tif matched, _ := path.Match(pathPattern, r.URL.Path); matched {\n\t\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t\tinner.ServeHTTP(w, r)\n\t\t})\n\t}\n}\n```\n\n## Adapters at the wire boundary\n\nWhen a framework type does not match a service-layer port, write a small typed adapter in\n`internal/wire/`. Always compile-time assert with `var _ TargetIface = (*adapter)(nil)`.\n\nThe framework intentionally exposes only `Signer.Sign(claims) (string, error)` — **the framework\ngives you a signing primitive; the access/refresh strategy, claim layout, and response shape are\napplication concerns.** A \"helper\" that returned a fixed `{access, refresh, type, expiresIn}` struct\nwould silently decide for every app whether refresh tokens exist, what fields to expose, and what\ncasing to use. Those are wire-format choices the app owns.\n\n```go\nimport (\n\t\"time\"\n\n\t\"github.com/golang-jwt/jwt/v5\"\n\t\"github.com/google/uuid\"\n\n\tauthjwt \"code.nochebuena.dev/einherjar/auth-jwt\"\n)\n\ntype tokenSignerAdapter struct {\n\tsigner authjwt.Signer\n\tcfg authjwt.TokenConfig\n}\n\nvar _ authsvc.TokenSigner = (*tokenSignerAdapter)(nil)\n\nfunc (a *tokenSignerAdapter) IssueTokenPair(subject string, custom map[string]any) (authdto.TokenPairResponse, error) {\n\tnow := time.Now()\n\n\taccess := jwt.MapClaims{\n\t\t\"sub\": subject,\n\t\t\"iss\": a.cfg.Issuer,\n\t\t\"iat\": now.Unix(),\n\t\t\"exp\": now.Add(a.cfg.AccessTTL).Unix(),\n\t}\n\tfor k, v := range custom {\n\t\taccess[k] = v\n\t}\n\taccessToken, err := a.signer.Sign(access)\n\tif err != nil {\n\t\treturn authdto.TokenPairResponse{}, err\n\t}\n\n\trefreshToken, err := a.signer.Sign(jwt.MapClaims{\n\t\t\"sub\": subject,\n\t\t\"jti\": uuid.NewString(),\n\t\t\"iat\": now.Unix(),\n\t\t\"exp\": now.Add(a.cfg.RefreshTTL).Unix(),\n\t})\n\tif err != nil {\n\t\treturn authdto.TokenPairResponse{}, err\n\t}\n\n\treturn authdto.TokenPairResponse{\n\t\tAccessToken: accessToken,\n\t\tRefreshToken: refreshToken,\n\t\tTokenType: \"Bearer\",\n\t\tExpiresIn: int(a.cfg.AccessTTL.Seconds()),\n\t}, nil\n}\n```\n" } ] } diff --git a/internal/index/builtins/README.md b/internal/index/builtins/README.md index a42eb0a..a8b3323 100644 --- a/internal/index/builtins/README.md +++ b/internal/index/builtins/README.md @@ -277,10 +277,13 @@ func Run() error { srv := server.New(logger, cfg.Server, server.WithMiddleware( - mw.RequestID(uuid.NewString), + // Recover outermost, time-ordered request ID, then logging — same order + // as web.New. corsMW (env-gated allow-all) sits before auth so preflight + // OPTIONS short-circuit without hitting the auth middleware. mw.Recover(logger), - corsMW, + mw.RequestID(newRequestID), mw.RequestLogger(logger), + corsMW, authjwt.AuthMiddleware(logger, signer, publicPaths), authmw.EnrichmentMiddleware(logger, &claimsEnricher{}), ), @@ -298,6 +301,15 @@ func Run() error { return lc.Run() } + +// newRequestID returns a time-ordered UUID v7 (falling back to v4), matching web.New. +func newRequestID() string { + id, err := uuid.NewV7() + if err != nil { + return uuid.NewString() + } + return id.String() +} ``` ## Feature hook diff --git a/internal/tools/scaffold.go b/internal/tools/scaffold.go index 84e9758..8a14ad2 100644 --- a/internal/tools/scaffold.go +++ b/internal/tools/scaffold.go @@ -143,10 +143,13 @@ func Run() error { srv := server.New(logger, cfg.Server, server.WithMiddleware( - mw.RequestID(uuid.NewString), + // Same stack and order as web.New — Recover outermost, time-ordered + // request ID, then logging. The only deliberate difference is the + // env-gated allow-all CORS (corsMW) above, which web.New does not offer. mw.Recover(logger), - corsMW, + mw.RequestID(newRequestID), mw.RequestLogger(logger), + corsMW, ), ) @@ -158,6 +161,16 @@ func Run() error { return lc.Run() } + +// newRequestID returns a time-ordered UUID v7 (falling back to v4 on error), +// matching web.New's request-ID generator. +func newRequestID() string { + id, err := uuid.NewV7() + if err != nil { + return uuid.NewString() + } + return id.String() +} ` const tplHealth = `package wire