feat(mcp): scaffold uses real EINHERJAR_SERVER_CORS_ORIGINS; document web.New vs server.New (v1.2.0)
This commit is contained in:
@@ -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
|
## [1.1.2] — 2026-08-08
|
||||||
|
|
||||||
Patch. Fixes surfaced by an adversarial review plus the CORS wildcard trap.
|
Patch. Fixes surfaced by an adversarial review plus the CORS wildcard trap.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# einherjar/mcp
|
# einherjar/mcp
|
||||||
|
|
||||||
[](https://code.nochebuena.dev/einherjar/mcp)
|
[](https://code.nochebuena.dev/einherjar/mcp)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://go.dev)
|
[](https://go.dev)
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -23,7 +23,7 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
serverName = "einherjar-mcp"
|
serverName = "einherjar-mcp"
|
||||||
serverVersion = "v1.1.2"
|
serverVersion = "v1.2.0"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|||||||
@@ -102,15 +102,14 @@ type JWTConfig struct {
|
|||||||
// are nested fields; caarlos0/env recurses into them, populating their
|
// are nested fields; caarlos0/env recurses into them, populating their
|
||||||
// EINHERJAR_SERVER_* / EINHERJAR_PG_* tags from the environment.
|
// EINHERJAR_SERVER_* / EINHERJAR_PG_* tags from the environment.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
AppEnv string `env:"APP_ENV" envDefault:"local"`
|
AppEnv string `env:"APP_ENV" envDefault:"local"`
|
||||||
CORSOrigins []string `env:"APP_CORS_ORIGINS" envSeparator:","`
|
|
||||||
|
|
||||||
JWT JWTConfig
|
JWT JWTConfig
|
||||||
|
|
||||||
// Framework component configs — composed verbatim. Their own EINHERJAR_* tags
|
// Framework component configs — composed verbatim. Their own EINHERJAR_* tags
|
||||||
// load through this one env.Parse call.
|
// load through this one env.Parse call.
|
||||||
Log logz.Config // EINHERJAR_LOG_*
|
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_*
|
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
|
`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
|
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.
|
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
|
`EINHERJAR_HEALTH_CHECK_TIMEOUT` lives on `web/health.Config`, so it is dead if you compose
|
||||||
emits a `.env.example` derived from these same tags, so the starting point is correct by construction.
|
`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
|
```bash
|
||||||
# .env.example — copy to .env for local dev. Every var the app reads lives here.
|
# .env.example — copy to .env for local dev. Every var the app reads lives here.
|
||||||
|
|
||||||
# ── App ───────────────────────────────────────────────────────────────────
|
# ── App ───────────────────────────────────────────────────────────────────
|
||||||
APP_ENV=local
|
APP_ENV=local
|
||||||
# APP_CORS_ORIGINS — explicit origins for non-local envs (comma-separated).
|
# CORS: local uses mw.CORSAllowAll(); non-local reads EINHERJAR_SERVER_CORS_ORIGINS (below).
|
||||||
# Local uses mw.CORSAllowAll() and ignores this; "*" is rejected by mw.CORS — never use it.
|
|
||||||
APP_CORS_ORIGINS=
|
|
||||||
APP_JWT_SECRET=change-me
|
APP_JWT_SECRET=change-me
|
||||||
APP_JWT_ISSUER=myapp
|
APP_JWT_ISSUER=myapp
|
||||||
|
|
||||||
@@ -170,6 +168,9 @@ APP_JWT_ISSUER=myapp
|
|||||||
# ── Einherjar: HTTP server (EINHERJAR_SERVER_*) ───────────────────────────
|
# ── Einherjar: HTTP server (EINHERJAR_SERVER_*) ───────────────────────────
|
||||||
EINHERJAR_SERVER_HOST=0.0.0.0
|
EINHERJAR_SERVER_HOST=0.0.0.0
|
||||||
EINHERJAR_SERVER_PORT=8080
|
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: PostgreSQL (EINHERJAR_PG_*) ────────────────────────────────
|
||||||
EINHERJAR_PG_HOST=localhost
|
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
|
second, infrastructure third, cross-cutting helpers fourth, then the launcher with every component
|
||||||
appended, then feature hooks, then `lc.Run()`.
|
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
|
```go
|
||||||
package wire
|
package wire
|
||||||
|
|
||||||
@@ -231,11 +248,12 @@ func Run() error {
|
|||||||
db := postgres.New(logger, cfg.PG)
|
db := postgres.New(logger, cfg.PG)
|
||||||
|
|
||||||
// CORS convention: allow-all in local dev, explicit origins everywhere else.
|
// 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,
|
// Origins come from the framework's own EINHERJAR_SERVER_CORS_ORIGINS
|
||||||
// never a "*" in APP_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()
|
corsMW := mw.CORSAllowAll()
|
||||||
if !strings.EqualFold(cfg.AppEnv, "local") {
|
if !strings.EqualFold(cfg.AppEnv, "local") {
|
||||||
corsMW = mw.CORS(cfg.CORSOrigins)
|
corsMW = mw.CORS(cfg.Server.CORSOrigins)
|
||||||
}
|
}
|
||||||
|
|
||||||
srv := server.New(logger, cfg.Server,
|
srv := server.New(logger, cfg.Server,
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ func TestRenderEnvExample(t *testing.T) {
|
|||||||
// Required, no default → uncommented with a runnable dev value.
|
// Required, no default → uncommented with a runnable dev value.
|
||||||
mustContain := []string{
|
mustContain := []string{
|
||||||
"APP_ENV=local",
|
"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_HOST=localhost",
|
||||||
"EINHERJAR_PG_USER=postgres",
|
"EINHERJAR_PG_USER=postgres",
|
||||||
"EINHERJAR_PG_PASSWORD=postgres",
|
"EINHERJAR_PG_PASSWORD=postgres",
|
||||||
@@ -57,10 +58,10 @@ func TestRenderEnvExample(t *testing.T) {
|
|||||||
mustNotContain := []string{
|
mustNotContain := []string{
|
||||||
"EINHERJAR_PG_DATABASE",
|
"EINHERJAR_PG_DATABASE",
|
||||||
"EINHERJAR_SERVER_ADDR",
|
"EINHERJAR_SERVER_ADDR",
|
||||||
"APP_CORS_ORIGINS=*", // the wildcard trap must never be emitted
|
// APP_CORS_ORIGINS was an invented var — the scaffold now uses the real
|
||||||
// CORS lives on web.Config, which the scaffold does not compose — so it
|
// framework var (EINHERJAR_SERVER_CORS_ORIGINS on server.Config), so no
|
||||||
// must not be emitted (it would be a dead var).
|
// form of APP_CORS_ORIGINS may ever be emitted.
|
||||||
"EINHERJAR_SERVER_CORS_ORIGINS",
|
"APP_CORS_ORIGINS",
|
||||||
}
|
}
|
||||||
for _, s := range mustNotContain {
|
for _, s := range mustNotContain {
|
||||||
if strings.Contains(got, s) {
|
if strings.Contains(got, s) {
|
||||||
@@ -164,36 +165,36 @@ func TestCheckEnvUnknownModule(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestCheckEnvStructLevel proves the struct-granularity path catches a dead var
|
// TestCheckEnvStructLevel proves the struct-granularity path catches a dead var
|
||||||
// that module granularity cannot: EINHERJAR_SERVER_CORS_ORIGINS lives on
|
// that module granularity cannot: EINHERJAR_HEALTH_CHECK_TIMEOUT lives on
|
||||||
// web.Config, so an app composing web/server/Config (not web.Config) never reads
|
// web/health.Config, so an app composing web/server/Config (not web/health/Config)
|
||||||
// it — module "web" would hide that, struct selectors surface it.
|
// never reads it — module "web" would hide that, struct selectors surface it.
|
||||||
func TestCheckEnvStructLevel(t *testing.T) {
|
func TestCheckEnvStructLevel(t *testing.T) {
|
||||||
idx := loadRealIndex(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"})
|
structF, err := checkEnvVars(idx, env, nil, []string{"web/server/Config", "db-postgres/Config"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
dead := false
|
dead := false
|
||||||
for _, f := range structF {
|
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
|
dead = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !dead {
|
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)
|
modF, err := checkEnvVars(idx, env, []string{"web", "db-postgres"}, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
for _, f := range modF {
|
for _, f := range modF {
|
||||||
if f.Var == "EINHERJAR_SERVER_CORS_ORIGINS" {
|
if f.Var == "EINHERJAR_HEALTH_CHECK_TIMEOUT" {
|
||||||
t.Errorf("module-level should not flag CORS (it lives in module web), got %+v", f)
|
t.Errorf("module-level should not flag the health timeout (it lives in module web), got %+v", f)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,13 +131,14 @@ func Run() error {
|
|||||||
|
|
||||||
db := postgres.New(logger, cfg.PG)
|
db := postgres.New(logger, cfg.PG)
|
||||||
|
|
||||||
// CORS: allow-all in local dev; explicit origins elsewhere. mw.CORS panics on
|
// CORS: allow-all in local dev; explicit origins elsewhere. Origins come from the
|
||||||
// "*", so never pass a wildcard through APP_CORS_ORIGINS outside local.
|
// 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
|
var corsMW func(http.Handler) http.Handler
|
||||||
if strings.EqualFold(cfg.AppEnv, "local") {
|
if strings.EqualFold(cfg.AppEnv, "local") {
|
||||||
corsMW = mw.CORSAllowAll()
|
corsMW = mw.CORSAllowAll()
|
||||||
} else {
|
} else {
|
||||||
corsMW = mw.CORS(cfg.CORSOrigins)
|
corsMW = mw.CORS(cfg.Server.CORSOrigins)
|
||||||
}
|
}
|
||||||
|
|
||||||
srv := server.New(logger, cfg.Server,
|
srv := server.New(logger, cfg.Server,
|
||||||
@@ -202,14 +203,13 @@ import (
|
|||||||
// the nested framework configs, populating their EINHERJAR_* tags from the
|
// the nested framework configs, populating their EINHERJAR_* tags from the
|
||||||
// environment next to the app-owned APP_* fields.
|
// environment next to the app-owned APP_* fields.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
AppEnv string ` + "`" + `env:"APP_ENV" envDefault:"local"` + "`" + `
|
AppEnv string ` + "`" + `env:"APP_ENV" envDefault:"local"` + "`" + `
|
||||||
CORSOrigins []string ` + "`" + `env:"APP_CORS_ORIGINS" envSeparator:","` + "`" + `
|
|
||||||
|
|
||||||
// Framework component configs — composed verbatim; their EINHERJAR_* tags
|
// Framework component configs — composed verbatim; their EINHERJAR_* tags
|
||||||
// load through this same env.Parse call.
|
// load through this same env.Parse call.
|
||||||
Launcher launcher.Config // EINHERJAR_COMPONENT_STOP_TIMEOUT
|
Launcher launcher.Config // EINHERJAR_COMPONENT_STOP_TIMEOUT
|
||||||
Log logz.Config // EINHERJAR_LOG_*
|
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
|
Health health.Config // EINHERJAR_HEALTH_CHECK_TIMEOUT
|
||||||
PG postgres.Config // EINHERJAR_PG_*
|
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 (APP_*) ────────────────────────────────────────────────────────────\n")
|
||||||
b.WriteString("APP_ENV=local\n")
|
b.WriteString("APP_ENV=local\n")
|
||||||
b.WriteString("# APP_CORS_ORIGINS — explicit origins for non-local envs (comma-separated).\n")
|
b.WriteString("# CORS: local uses mw.CORSAllowAll(); non-local reads EINHERJAR_SERVER_CORS_ORIGINS below.\n\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")
|
|
||||||
|
|
||||||
writeEnvSection(&b, "Einherjar: launcher", envspec.FindStruct(idx, "core", "launcher", "Config"), app)
|
writeEnvSection(&b, "Einherjar: launcher", envspec.FindStruct(idx, "core", "launcher", "Config"), app)
|
||||||
writeEnvSection(&b, "Einherjar: logging", envspec.FindStruct(idx, "core", "logz", "Config"), app)
|
writeEnvSection(&b, "Einherjar: logging", envspec.FindStruct(idx, "core", "logz", "Config"), app)
|
||||||
|
|||||||
Reference in New Issue
Block a user