Files
mcp/internal/index/builtins/README.md
T

19 KiB

Wiring Conventions

Forging a service is mostly wiring. Do it the same way every time.

This is not an Einherjar module — it is the canonical application shape that uses Einherjar modules. Apps live in their own repository with an internal/wire/ package that mirrors this template. The conventions here are distilled from production services built on Einherjar v1 (iron-dough-api, pei-api) and describe the one opinionated minimum a scaffolded app should have. You can hand-roll something else — but then it is yours to maintain, and it will not match what the rest of the ecosystem reads at a glance.

The opinionated minimum

Every scaffolded Einherjar application has, at minimum:

  1. A clean main.go — nothing but .env autoload and a call to wire.Run().
  2. An internal/wire/ package — one file per feature plus wire.go, which assembles everything.
  3. An internal/config/config.go — one global Config that composes the framework's component configs, loaded from the environment with caarlos0/env.
  4. A .env.example kept in lock-step with that config (see Config & .env.example).

Anything a developer freely chooses — how migrations run, how the first admin is seeded, an init-by-endpoint/webhook/email flow — is not part of this convention and is left to the app.

Project layout

cmd/<app>/main.go              one-line entrypoint: godotenv autoload + wire.Run()
internal/wire/wire.go          Run() — loads config, builds infra, registers feature hooks
internal/wire/<feature>.go     one file per feature, hosts a with<Feature> hook
internal/wire/middleware.go    authz, skipPublicPaths, skipMethodPath helpers
internal/config/config.go      global Config composing framework component configs
.env.example                   every env var the config reads, documented, in sync
internal/<feature>/dto/        request/response DTOs
internal/<feature>/handler/    HTTP handlers
internal/<feature>/repository/ data access
internal/<feature>/service/    domain logic

main.go

cmd/<app>/main.go contains nothing but the .env autoload and the call to wire.Run(). The blank import _ "github.com/joho/godotenv/autoload" is the standard, documented way to load a local .env — it never overrides variables already set in the real environment, and a missing file is not an error, so deployments (vars injected by the platform, no .env) are unaffected.

package main

import (
	"fmt"
	"os"

	_ "github.com/joho/godotenv/autoload"

	"myapp/internal/wire"
)

func main() {
	if err := wire.Run(); err != nil {
		fmt.Fprintln(os.Stderr, "fatal:", err)
		os.Exit(1)
	}
}

No config parsing, no component construction, no logging setup — all of that lives in internal/wire/. A main.go that builds anything itself is the single most common scaffolding mistake.

Config

internal/config/config.go is one Config struct that composes the framework's component configs as nested fields, alongside the app's own settings. caarlos0/env recurses into the nested fields, so each Einherjar component's EINHERJAR_* env tags load automatically next to the app-owned fields. App-owned fields use the APP_* prefix so they never collide with the framework's EINHERJAR_* namespace. There is exactly one Load().

package config

import (
	"time"

	"github.com/caarlos0/env/v11"

	"code.nochebuena.dev/einherjar/core/logz"
	"code.nochebuena.dev/einherjar/db-postgres"
	"code.nochebuena.dev/einherjar/web/server"
)

// JWTConfig is app-owned: the secret is handed to the signer in code, not consumed
// by a framework component, so it carries no EINHERJAR_ prefix.
type JWTConfig struct {
	Secret     string        `env:"APP_JWT_SECRET,required,notEmpty"`
	Issuer     string        `env:"APP_JWT_ISSUER"      envDefault:"myapp"`
	AccessTTL  time.Duration `env:"APP_JWT_ACCESS_TTL"  envDefault:"1h"`
	RefreshTTL time.Duration `env:"APP_JWT_REFRESH_TTL" envDefault:"168h"`
}

// Config is the fully-resolved startup configuration. Einherjar component configs
// 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"`

	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_* (incl. EINHERJAR_SERVER_CORS_ORIGINS)
	PG     postgres.Config // EINHERJAR_PG_*
}

func Load() (Config, error) {
	var cfg Config
	if err := env.Parse(&cfg); err != nil {
		return Config{}, err
	}
	return cfg, nil
}

Never read framework env vars (EINHERJAR_*) with os.Getenv — compose the component's Config type and let caarlos0/env load it. A raw os.Getenv("EINHERJAR_PG_HOST") in application code is the mistake this convention removes.

Config & .env.example

Every environment variable the config package reads must also appear in .env.example at the repo root, documented. The two are kept in lock-step: when a feature introduces a new env var, the same change adds its env:"..." tag to config and a documented line to .env.example. This is not optional bookkeeping — it is what stops a long feature from shipping and then failing at boot because nobody knew which variables to set.

Two kinds of var, two ways to keep them honest:

  • Framework component vars (EINHERJAR_*) — when you compose a new component later (e.g. cachevalkey.Config, minio.Config, smtp.Config), the MCP knows its real vars: call get_config_env("<module>") for the exact set (name, required, default) and add each to .env.example. The config.unknown-env-var rule rejects any EINHERJAR_* tag the framework doesn't declare, so a typo like EINHERJAR_PG_DATABASE is caught at validate_snippet time.
  • App-owned vars (APP_*) — like APP_JWT_SECRET above: the framework can't know these, so keeping them in .env.example is your discipline, not something it can name-check.

After you compose a component, run check_env with what the app composes: it flags 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_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.

# .env.example — copy to .env for local dev. Every var the app reads lives here.

# ── App ───────────────────────────────────────────────────────────────────
APP_ENV=local
# CORS: local uses mw.CORSAllowAll(); non-local reads EINHERJAR_SERVER_CORS_ORIGINS (below).
APP_JWT_SECRET=change-me
APP_JWT_ISSUER=myapp

# ── Einherjar: logging (EINHERJAR_LOG_*) ──────────────────────────────────
# EINHERJAR_LOG_LEVEL=INFO
# EINHERJAR_LOG_JSON=false

# ── 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
EINHERJAR_PG_PORT=5432
EINHERJAR_PG_USER=postgres
EINHERJAR_PG_PASSWORD=postgres
EINHERJAR_PG_NAME=myapp

To discover the full set for any component you compose, use get_config_env("<module>") rather than reading source by hand; every var it returns belongs in .env.example, and check_env confirms none are missing, misspelled, or dead.

wire.go — Run()

The application entry point. The order below is load-bearing: configuration first, observability 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.

CORS has one home: server.Config.CORSOrigins. In framework v1.x, web.Config carried an AllowedOrigins field. It was env-backed through v1.1.x and a code-only override in v1.2.0 — reading it after the env tag moved silently served no CORS. v1.3.0 removed the field entirely so the mistake fails at compile time instead of at runtime. If you are migrating code that read web.Config.AllowedOrigins or set it in a struct literal, switch to cfg.Server.CORSOrigins:

// v1.x (removed) — compiled but could serve no CORS after v1.2.0:
//   mw.CORS(cfg.Web.AllowedOrigins)
//   web.New(logger, web.Config{AllowedOrigins: origins})

// v1.3.0 — the single source of truth:
mw.CORS(cfg.Server.CORSOrigins)                               // server.New tier
web.New(logger, web.Config{Server: cfg.Server})              // web.New reads it automatically

validate_snippet flags any lingering AllowedOrigins reference (web.allowedorigins-removed).

package wire

import (
	"github.com/google/uuid"

	authjwt "code.nochebuena.dev/einherjar/auth-jwt"
	"code.nochebuena.dev/einherjar/auth/authmw"
	"code.nochebuena.dev/einherjar/auth/rbac"
	"code.nochebuena.dev/einherjar/core/launcher"
	"code.nochebuena.dev/einherjar/core/logz"
	"code.nochebuena.dev/einherjar/core/valid"
	"code.nochebuena.dev/einherjar/db-postgres"
	"code.nochebuena.dev/einherjar/web/mw"
	"code.nochebuena.dev/einherjar/web/server"

	"myapp/internal/config"
)

func Run() error {
	cfg, err := config.Load()
	if err != nil {
		return err
	}

	// logz.Config is composed in config, so EINHERJAR_LOG_LEVEL / _JSON load from
	// the environment; StaticArgs are set here (they carry no env tag).
	logCfg := cfg.Log
	logCfg.StaticArgs = []any{"service", "myapp", "env", cfg.AppEnv}
	logger := logz.New(logCfg)

	signer := authjwt.NewHMACSigner([]byte(cfg.JWT.Secret))

	publicPaths := []string{
		"/health",
		"/api/v1/auth/login",
		"/api/v1/auth/refresh",
	}

	db  := postgres.New(logger, cfg.PG)

	// CORS convention: allow-all in local dev, explicit origins everywhere else.
	// 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.Server.CORSOrigins)
	}

	srv := server.New(logger, cfg.Server,
		server.WithMiddleware(
			// 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),
			mw.RequestID(newRequestID),
			mw.RequestLogger(logger),
			corsMW,
			authjwt.AuthMiddleware(logger, signer, publicPaths),
			authmw.EnrichmentMiddleware(logger, &claimsEnricher{}),
		),
	)

	v        := valid.New(valid.WithMessageProvider(valid.SpanishMessages))
	provider := rbac.NewClaimsPermissionProvider("masks", claimsFromCtx)

	lc := launcher.New(logger)
	lc.Append(db, srv)

	withHealth(lc, srv, logger, db)
	withUsers(lc, srv, db, logger, provider, v)
	// … one withFeature(...) call per feature in your domain.

	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

One file per feature in internal/wire/. The function signature is fixed: launcher.Launcher first, server.Server second when registering routes, deps last. The body is one call to lc.BeforeStart. Everything else — repository, service, handler construction, route registration — lives inside the closure.

package wire

import (
	"code.nochebuena.dev/einherjar/contracts/logging"
	"code.nochebuena.dev/einherjar/contracts/security"
	"code.nochebuena.dev/einherjar/core/launcher"
	"code.nochebuena.dev/einherjar/core/valid"
	"code.nochebuena.dev/einherjar/db-postgres"
	"code.nochebuena.dev/einherjar/web/server"

	"myapp/internal/domains"
	userhandler "myapp/internal/user/handler"
	userrepo    "myapp/internal/user/repository"
	usersvc     "myapp/internal/user/service"
)

func withUsers(
	lc       launcher.Launcher,
	srv      server.Server,
	db       postgres.Provider,
	logger   logging.Logger,
	provider security.PermissionProvider,
	v        valid.Validator,
) {
	lc.BeforeStart(func() error {
		repo := userrepo.New(db)
		uow  := postgres.NewUnitOfWork(logger, db)
		svc  := usersvc.New(repo, uow)
		h    := userhandler.New(svc, v)

		// Literal-segment routes register BEFORE parametrised siblings.
		srv.Put("/api/v1/users/me/password", h.ChangeOwnPassword)

		srv.With(authz(provider, domains.ResourceUsers, domains.GrantReadUser)).
			Get("/api/v1/users", h.ListUsers)
		srv.With(authz(provider, domains.ResourceUsers, domains.GrantCreateUser)).
			Post("/api/v1/users", h.CreateUser)
		srv.With(authz(provider, domains.ResourceUsers, domains.GrantUpdateUser)).
			Put("/api/v1/users/{user_id}", h.UpdateUser)

		return nil
	})
}

Route ordering

chi matches paths in registration order. Always register literal-segment routes before parametrised-segment routes that share the same prefix.

Correct:

srv.Put("/api/v1/users/me/password", h.ChangeOwnPassword)
srv.Put("/api/v1/users/{user_id}",   h.UpdateUser)

Wrong — chi binds me to {user_id} and the literal route is unreachable:

srv.Put("/api/v1/users/{user_id}",   h.UpdateUser)
srv.Put("/api/v1/users/me/password", h.ChangeOwnPassword)

Authorization

Every protected route registers with .With(authz(provider, resource, grant)):

srv.With(authz(provider, domains.ResourceUsers, domains.GrantReadUser)).
    Get("/api/v1/users", h.ListUsers)

Resource constants and grant bits live in internal/domains/. Routes that the caller owns (/me/...) intentionally skip authz — they are reachable to any authenticated user.

Middleware helpers

These belong in internal/wire/middleware.go and are used across every feature hook.

// authz returns a per-route authorization middleware that checks one bit.
func authz(p security.PermissionProvider, resource string, bit int) func(http.Handler) http.Handler {
	return authmw.AuthzMiddleware(nil, p, resource, security.Permission(bit))
}

// skipPublicPaths wraps mw so it is bypassed for any path that matches publicPaths.
// Use this for middleware that must not run on unauthenticated endpoints
// (e.g. EnrichmentMiddleware).
func skipPublicPaths(publicPaths []string, mw func(http.Handler) http.Handler) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		inner := mw(next)
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			for _, p := range publicPaths {
				if matched, _ := path.Match(p, r.URL.Path); matched {
					next.ServeHTTP(w, r)
					return
				}
			}
			inner.ServeHTTP(w, r)
		})
	}
}

// skipMethodPath bypasses mw only when BOTH method and path match. Use this to
// expose ONE method on an otherwise-authenticated path (e.g. GET /api/v1/config
// public while PUT is not). Adding such a path to publicPaths would silently
// strip identity from context on the protected methods, breaking authz().
func skipMethodPath(method, pathPattern string, mw func(http.Handler) http.Handler) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		inner := mw(next)
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			if r.Method == method {
				if matched, _ := path.Match(pathPattern, r.URL.Path); matched {
					next.ServeHTTP(w, r)
					return
				}
			}
			inner.ServeHTTP(w, r)
		})
	}
}

Adapters at the wire boundary

When a framework type does not match a service-layer port, write a small typed adapter in internal/wire/. Always compile-time assert with var _ TargetIface = (*adapter)(nil).

The framework intentionally exposes only Signer.Sign(claims) (string, error)the framework gives you a signing primitive; the access/refresh strategy, claim layout, and response shape are application concerns. A "helper" that returned a fixed {access, refresh, type, expiresIn} struct would silently decide for every app whether refresh tokens exist, what fields to expose, and what casing to use. Those are wire-format choices the app owns.

import (
	"time"

	"github.com/golang-jwt/jwt/v5"
	"github.com/google/uuid"

	authjwt "code.nochebuena.dev/einherjar/auth-jwt"
)

type tokenSignerAdapter struct {
	signer authjwt.Signer
	cfg    authjwt.TokenConfig
}

var _ authsvc.TokenSigner = (*tokenSignerAdapter)(nil)

func (a *tokenSignerAdapter) IssueTokenPair(subject string, custom map[string]any) (authdto.TokenPairResponse, error) {
	now := time.Now()

	access := jwt.MapClaims{
		"sub": subject,
		"iss": a.cfg.Issuer,
		"iat": now.Unix(),
		"exp": now.Add(a.cfg.AccessTTL).Unix(),
	}
	for k, v := range custom {
		access[k] = v
	}
	accessToken, err := a.signer.Sign(access)
	if err != nil {
		return authdto.TokenPairResponse{}, err
	}

	refreshToken, err := a.signer.Sign(jwt.MapClaims{
		"sub": subject,
		"jti": uuid.NewString(),
		"iat": now.Unix(),
		"exp": now.Add(a.cfg.RefreshTTL).Unix(),
	})
	if err != nil {
		return authdto.TokenPairResponse{}, err
	}

	return authdto.TokenPairResponse{
		AccessToken:  accessToken,
		RefreshToken: refreshToken,
		TokenType:    "Bearer",
		ExpiresIn:    int(a.cfg.AccessTTL.Seconds()),
	}, nil
}