diff --git a/CHANGELOG.md b/CHANGELOG.md index 742e06a..31f27a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,22 @@ This module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html --- +## [1.2.0] — 2026-08-08 + +Minor. Scaffold and wire docs track the framework’s v1.2.0 CORS change. + +### Changed + +- Scaffold no longer invents an `APP_CORS_ORIGINS` var — it reads the framework’s real + `EINHERJAR_SERVER_CORS_ORIGINS` (now on `server.Config`) via `cfg.Server.CORSOrigins`. +- `.env.example` documents `EINHERJAR_SERVER_CORS_ORIGINS` in the server section instead of a + bespoke app-owned var. + +### Added + +- Wire builtin docs explain when to use `web.New` (batteries-included) vs `server.New` + (full control, env-gated allow-all CORS), so generated services pick the right tier. + ## [1.1.2] — 2026-08-08 Patch. Fixes surfaced by an adversarial review plus the CORS wildcard trap. diff --git a/README.md b/README.md index 47c04ec..7e3b605 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # einherjar/mcp -[![version](https://img.shields.io/badge/version-v1.1.2-5C4EE5?style=flat-square)](https://code.nochebuena.dev/einherjar/mcp) +[![version](https://img.shields.io/badge/version-v1.2.0-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 d296a03..7ddd6b3 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -23,7 +23,7 @@ import ( const ( serverName = "einherjar-mcp" - serverVersion = "v1.1.2" + serverVersion = "v1.2.0" ) func main() { diff --git a/internal/index/builtins/README.md b/internal/index/builtins/README.md index 000af4a..a5b1c54 100644 --- a/internal/index/builtins/README.md +++ b/internal/index/builtins/README.md @@ -102,15 +102,14 @@ type JWTConfig struct { // are nested fields; caarlos0/env recurses into them, populating their // EINHERJAR_SERVER_* / EINHERJAR_PG_* tags from the environment. type Config struct { - AppEnv string `env:"APP_ENV" envDefault:"local"` - CORSOrigins []string `env:"APP_CORS_ORIGINS" envSeparator:","` + AppEnv string `env:"APP_ENV" envDefault:"local"` JWT JWTConfig // Framework component configs — composed verbatim. Their own EINHERJAR_* tags // load through this one env.Parse call. Log logz.Config // EINHERJAR_LOG_* - Server server.Config // EINHERJAR_SERVER_* + Server server.Config // EINHERJAR_SERVER_* (incl. EINHERJAR_SERVER_CORS_ORIGINS) PG postgres.Config // EINHERJAR_PG_* } @@ -149,17 +148,16 @@ After you compose a component, run **`check_env`** with what the app composes: i `EINHERJAR_*` names that don't exist, required vars you forgot to document, and vars set for a config you don't actually compose (dead vars). Prefer the **`composes`** input (exact struct selectors like `web/server/Config`) over `modules` — it catches struct-level dead vars (e.g. -`EINHERJAR_SERVER_CORS_ORIGINS` lives on `web.Config`, not `server.Config`). `get_scaffold` already -emits a `.env.example` derived from these same tags, so the starting point is correct by construction. +`EINHERJAR_HEALTH_CHECK_TIMEOUT` lives on `web/health.Config`, so it is dead if you compose +`web/server/Config` but not the health config). `get_scaffold` already emits a `.env.example` +derived from these same tags, so the starting point is correct by construction. ```bash # .env.example — copy to .env for local dev. Every var the app reads lives here. # ── App ─────────────────────────────────────────────────────────────────── APP_ENV=local -# APP_CORS_ORIGINS — explicit origins for non-local envs (comma-separated). -# Local uses mw.CORSAllowAll() and ignores this; "*" is rejected by mw.CORS — never use it. -APP_CORS_ORIGINS= +# CORS: local uses mw.CORSAllowAll(); non-local reads EINHERJAR_SERVER_CORS_ORIGINS (below). APP_JWT_SECRET=change-me APP_JWT_ISSUER=myapp @@ -170,6 +168,9 @@ APP_JWT_ISSUER=myapp # ── Einherjar: HTTP server (EINHERJAR_SERVER_*) ─────────────────────────── EINHERJAR_SERVER_HOST=0.0.0.0 EINHERJAR_SERVER_PORT=8080 +# EINHERJAR_SERVER_CORS_ORIGINS — explicit origins for non-local envs (comma-separated). +# "*" is rejected by mw.CORS; local dev uses mw.CORSAllowAll() and ignores this. +# EINHERJAR_SERVER_CORS_ORIGINS= # ── Einherjar: PostgreSQL (EINHERJAR_PG_*) ──────────────────────────────── EINHERJAR_PG_HOST=localhost @@ -189,6 +190,22 @@ The application entry point. The order below is load-bearing: configuration firs second, infrastructure third, cross-cutting helpers fourth, then the launcher with every component appended, then feature hooks, then `lc.Run()`. +**`web.New` vs `server.New` — pick the right tier:** + +- **`web.New(logger, web.Config{Server: cfg.Server})`** — batteries-included. It pre-wires the + recommended middleware stack (Recover → RequestID → RequestLogger) and applies `mw.CORS` from + `EINHERJAR_SERVER_CORS_ORIGINS` automatically (explicit origins only; empty ⇒ CORS off + a log + line). Use it for a plain service that just needs the defaults. It does **not** support allow-all + CORS or a custom middleware order. +- **`server.New(logger, cfg.Server, server.WithMiddleware(...))`** — full control. You compose the + middleware list yourself. Use it when you need a **custom middleware order**, extra middleware + (auth, enrichment), a custom request-ID generator, or **allow-all CORS in local dev** + (`mw.CORSAllowAll`, gated by `AppEnv` — see below). The starter below uses `server.New` precisely + because it inserts JWT auth + enrichment into the stack. + +Whichever tier you pick, CORS origins always come from the framework var +`EINHERJAR_SERVER_CORS_ORIGINS` (`cfg.Server.CORSOrigins`) — never invent an app-owned CORS var. + ```go package wire @@ -231,11 +248,12 @@ func Run() error { db := postgres.New(logger, cfg.PG) // CORS convention: allow-all in local dev, explicit origins everywhere else. - // mw.CORS panics on "*" (it matches no real origin) — allow-all is mw.CORSAllowAll, - // never a "*" in APP_CORS_ORIGINS. + // Origins come from the framework's own EINHERJAR_SERVER_CORS_ORIGINS + // (cfg.Server.CORSOrigins) — never invent an APP_CORS_ORIGINS var. mw.CORS panics + // on "*" (it matches no real origin) — allow-all is mw.CORSAllowAll, never a "*". corsMW := mw.CORSAllowAll() if !strings.EqualFold(cfg.AppEnv, "local") { - corsMW = mw.CORS(cfg.CORSOrigins) + corsMW = mw.CORS(cfg.Server.CORSOrigins) } srv := server.New(logger, cfg.Server, diff --git a/internal/tools/env_tools_test.go b/internal/tools/env_tools_test.go index 32d4b2a..a168d99 100644 --- a/internal/tools/env_tools_test.go +++ b/internal/tools/env_tools_test.go @@ -30,7 +30,8 @@ func TestRenderEnvExample(t *testing.T) { // Required, no default → uncommented with a runnable dev value. mustContain := []string{ "APP_ENV=local", - "APP_CORS_ORIGINS=", + // CORS now lives on server.Config (composed) → documented, commented, no default. + "# EINHERJAR_SERVER_CORS_ORIGINS=", "EINHERJAR_PG_HOST=localhost", "EINHERJAR_PG_USER=postgres", "EINHERJAR_PG_PASSWORD=postgres", @@ -57,10 +58,10 @@ func TestRenderEnvExample(t *testing.T) { mustNotContain := []string{ "EINHERJAR_PG_DATABASE", "EINHERJAR_SERVER_ADDR", - "APP_CORS_ORIGINS=*", // the wildcard trap must never be emitted - // CORS lives on web.Config, which the scaffold does not compose — so it - // must not be emitted (it would be a dead var). - "EINHERJAR_SERVER_CORS_ORIGINS", + // APP_CORS_ORIGINS was an invented var — the scaffold now uses the real + // framework var (EINHERJAR_SERVER_CORS_ORIGINS on server.Config), so no + // form of APP_CORS_ORIGINS may ever be emitted. + "APP_CORS_ORIGINS", } for _, s := range mustNotContain { if strings.Contains(got, s) { @@ -164,36 +165,36 @@ func TestCheckEnvUnknownModule(t *testing.T) { } // TestCheckEnvStructLevel proves the struct-granularity path catches a dead var -// that module granularity cannot: EINHERJAR_SERVER_CORS_ORIGINS lives on -// web.Config, so an app composing web/server/Config (not web.Config) never reads -// it — module "web" would hide that, struct selectors surface it. +// that module granularity cannot: EINHERJAR_HEALTH_CHECK_TIMEOUT lives on +// web/health.Config, so an app composing web/server/Config (not web/health/Config) +// never reads it — module "web" would hide that, struct selectors surface it. func TestCheckEnvStructLevel(t *testing.T) { idx := loadRealIndex(t) - env := "EINHERJAR_SERVER_CORS_ORIGINS=*\nEINHERJAR_PG_HOST=h\nEINHERJAR_PG_USER=u\nEINHERJAR_PG_PASSWORD=p\nEINHERJAR_PG_NAME=n\n" + env := "EINHERJAR_HEALTH_CHECK_TIMEOUT=5s\nEINHERJAR_PG_HOST=h\nEINHERJAR_PG_USER=u\nEINHERJAR_PG_PASSWORD=p\nEINHERJAR_PG_NAME=n\n" - // struct-level: server/Config has no CORS → CORS flagged as dead. + // struct-level: server/Config has no health timeout → the var is flagged as dead. structF, err := checkEnvVars(idx, env, nil, []string{"web/server/Config", "db-postgres/Config"}) if err != nil { t.Fatal(err) } dead := false for _, f := range structF { - if f.Kind == "not-composed" && f.Var == "EINHERJAR_SERVER_CORS_ORIGINS" { + if f.Kind == "not-composed" && f.Var == "EINHERJAR_HEALTH_CHECK_TIMEOUT" { dead = true } } if !dead { - t.Errorf("struct-level: expected EINHERJAR_SERVER_CORS_ORIGINS flagged not-composed; got %+v", structF) + t.Errorf("struct-level: expected EINHERJAR_HEALTH_CHECK_TIMEOUT flagged not-composed; got %+v", structF) } - // module-level ["web"] keeps CORS silent (documents the coarse behavior). + // module-level ["web"] keeps it silent (health lives in module web). modF, err := checkEnvVars(idx, env, []string{"web", "db-postgres"}, nil) if err != nil { t.Fatal(err) } for _, f := range modF { - if f.Var == "EINHERJAR_SERVER_CORS_ORIGINS" { - t.Errorf("module-level should not flag CORS (it lives in module web), got %+v", f) + if f.Var == "EINHERJAR_HEALTH_CHECK_TIMEOUT" { + t.Errorf("module-level should not flag the health timeout (it lives in module web), got %+v", f) } } } diff --git a/internal/tools/scaffold.go b/internal/tools/scaffold.go index 06cbafe..84e9758 100644 --- a/internal/tools/scaffold.go +++ b/internal/tools/scaffold.go @@ -131,13 +131,14 @@ func Run() error { db := postgres.New(logger, cfg.PG) - // CORS: allow-all in local dev; explicit origins elsewhere. mw.CORS panics on - // "*", so never pass a wildcard through APP_CORS_ORIGINS outside local. + // CORS: allow-all in local dev; explicit origins elsewhere. Origins come from the + // framework's own EINHERJAR_SERVER_CORS_ORIGINS (cfg.Server.CORSOrigins). mw.CORS + // panics on "*", so never set that var to a wildcard outside local. var corsMW func(http.Handler) http.Handler if strings.EqualFold(cfg.AppEnv, "local") { corsMW = mw.CORSAllowAll() } else { - corsMW = mw.CORS(cfg.CORSOrigins) + corsMW = mw.CORS(cfg.Server.CORSOrigins) } srv := server.New(logger, cfg.Server, @@ -202,14 +203,13 @@ import ( // the nested framework configs, populating their EINHERJAR_* tags from the // environment next to the app-owned APP_* fields. type Config struct { - AppEnv string ` + "`" + `env:"APP_ENV" envDefault:"local"` + "`" + ` - CORSOrigins []string ` + "`" + `env:"APP_CORS_ORIGINS" envSeparator:","` + "`" + ` + AppEnv string ` + "`" + `env:"APP_ENV" envDefault:"local"` + "`" + ` // Framework component configs — composed verbatim; their EINHERJAR_* tags // load through this same env.Parse call. Launcher launcher.Config // EINHERJAR_COMPONENT_STOP_TIMEOUT Log logz.Config // EINHERJAR_LOG_* - Server server.Config // EINHERJAR_SERVER_* + Server server.Config // EINHERJAR_SERVER_* (incl. EINHERJAR_SERVER_CORS_ORIGINS) Health health.Config // EINHERJAR_HEALTH_CHECK_TIMEOUT PG postgres.Config // EINHERJAR_PG_* } @@ -237,9 +237,7 @@ func renderEnvExample(idx *index.Index, app string) string { b.WriteString("# ── App (APP_*) ────────────────────────────────────────────────────────────\n") b.WriteString("APP_ENV=local\n") - b.WriteString("# APP_CORS_ORIGINS — explicit origins for non-local envs (comma-separated).\n") - b.WriteString("# Local uses mw.CORSAllowAll() and ignores this; \"*\" is rejected by mw.CORS — never use it.\n") - b.WriteString("APP_CORS_ORIGINS=\n\n") + b.WriteString("# CORS: local uses mw.CORSAllowAll(); non-local reads EINHERJAR_SERVER_CORS_ORIGINS below.\n\n") writeEnvSection(&b, "Einherjar: launcher", envspec.FindStruct(idx, "core", "launcher", "Config"), app) writeEnvSection(&b, "Einherjar: logging", envspec.FindStruct(idx, "core", "logz", "Config"), app)