From 766adb29892c6301fa600b273aee3200446bdca5 Mon Sep 17 00:00:00 2001 From: Rene Nochebuena Guerrero Date: Wed, 12 Aug 2026 17:59:16 -0600 Subject: [PATCH] docs(mcp): document httputil handlers + WithStatus in wire builtin (v1.3.3) --- CHANGELOG.md | 15 ++++++ README.md | 2 +- cmd/server/main.go | 2 +- data/index.json | 78 +++++++++++++++++-------------- internal/index/builtins/README.md | 49 ++++++++++++++++++- 5 files changed, 109 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc10816..325eded 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,21 @@ This module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html --- +## [1.3.3] — 2026-08-09 + +Patch. Documents the httputil handler adapters and configurable success status. + +### Added + +- Wire builtin gains an "HTTP handlers (httputil)" section — `Handle`/`HandleNoBody`/ + `HandleEmpty`, their default success statuses (200/200/204), and `httputil.WithStatus` + (web v1.5.0) for 201 Created / 202 Accepted, with the 2xx-only panic-at-wiring rule and + automatic error mapping. + +### Fixed + +- Wire builtin feature-hook example passes `logger` to the handler constructor (httputil needs it). + ## [1.3.2] — 2026-08-08 Patch. Scaffold/wire middleware order now mirrors `web.New`. diff --git a/README.md b/README.md index 7d94914..a138ce7 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # einherjar/mcp -[![version](https://img.shields.io/badge/version-v1.3.2-5C4EE5?style=flat-square)](https://code.nochebuena.dev/einherjar/mcp) +[![version](https://img.shields.io/badge/version-v1.3.3-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 49507fa..f352d2c 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -23,7 +23,7 @@ import ( const ( serverName = "einherjar-mcp" - serverVersion = "v1.3.2" + serverVersion = "v1.3.3" ) func main() { diff --git a/data/index.json b/data/index.json index 99dc8f3..55fca06 100644 --- a/data/index.json +++ b/data/index.json @@ -1,7 +1,7 @@ { "schema": "einherjar.mcp/index/v1", "framework": "einherjar", - "builtAt": "2026-08-08T20:08:14.25644726Z", + "builtAt": "2026-08-12T21:33:11.607240972Z", "modules": [ { "name": "auth", @@ -733,7 +733,7 @@ } ] }, - "readme": "# einherjar/auth\n\n[![version](https://img.shields.io/badge/version-v1.3.0-5C4EE5?style=flat-square)](https://code.nochebuena.dev/einherjar/auth)\n[![license](https://img.shields.io/badge/license-AGPL--3.0-22863A?style=flat-square)](LICENSE)\n[![go](https://img.shields.io/badge/Go-1.26+-00ADD8?style=flat-square\u0026logo=go\u0026logoColor=white)](https://go.dev)\n\n\u003e Not every warrior who knocks at the gate deserves to pass. The Valkyries choose.\n\nProvider-agnostic HTTP authentication and authorization middleware for the Einherjar framework.\n\n## Sub-packages\n\n| Package | Description |\n|---|---|\n| [`authmw`](authmw/) | HTTP middleware: `EnrichmentMiddleware`, `AuthzMiddleware`, `SetTokenData`, `BagEnricher` |\n| [`rbac`](rbac/) | Permission providers: `ClaimsPermissionProvider`, `CachedPermissionProvider`, `ChainPermissionProvider` |\n\n## Dependency graph\n\n```\ncontracts/security ──► auth/authmw ──► auth/rbac\ncontracts/security ──► auth/rbac\ncore/xerrors ──► auth/authmw\nweb/httputil ──► auth/authmw\n```\n\nNo external dependencies.\n\n## Wiring example\n\n```go\nimport (\n \"code.nochebuena.dev/einherjar/auth/authmw\"\n \"code.nochebuena.dev/einherjar/auth/rbac\"\n \"code.nochebuena.dev/einherjar/contracts/security\"\n)\n\n// Application implements IdentityEnricher to load user data.\nenricher := userservice.NewIdentityEnricher(userRepo)\n\n// Build permission resolution chain.\npermissions := rbac.NewChainPermissionProvider(\n rbac.NewClaimsPermissionProvider(\"perms\", authmw.GetClaims), // JWT fast-path\n rbac.NewCachedPermissionProvider(dbProvider, valkeyCache, 5*time.Minute), // DB fallback\n)\n\n// Provider AuthMiddleware (from auth-jwt or auth-firebase) goes first.\n// Then enrichment globally:\nsrv.Use(authmw.EnrichmentMiddleware(logger, enricher,\n authmw.WithTenantHeader(\"X-Tenant-ID\"),\n))\n\n// Per-route authorization:\nconst ReadOrders = security.Permission(0)\nsrv.With(authmw.AuthzMiddleware(logger, permissions, \"orders\", ReadOrders)).\n Get(\"/orders\", ordersHandler)\n```\n\n## Custom enrichment\n\n`BagEnricher` lets you attach any request attribute to the `SecurityBag` in context.\nPermission providers read it via `bag.Get(key)` — no scattered context keys.\n\n```go\nconst KeyHardwareID = \"hardware_id\" // owned by your package; document the value type\n\nhwEnricher := authmw.BagEnricher(func(bag security.SecurityBag, r *http.Request) security.SecurityBag {\n return bag.With(KeyHardwareID, r.Header.Get(\"X-Hardware-ID\"))\n})\n\nsrv.Use(authmw.EnrichmentMiddleware(logger, enricher,\n authmw.WithTenantHeader(\"X-Tenant-ID\"),\n authmw.WithBagEnricher(hwEnricher),\n))\n```\n\nWith a hardware-ID-bound permission model, override the cache key so the hardware ID\nis included — otherwise two hardware IDs for the same user share a cache entry:\n\n```go\ncached := rbac.NewCachedPermissionProvider(dbProvider, cache, 5*time.Minute,\n rbac.WithCacheKey(func(bag security.SecurityBag, uid, resource string) string {\n hwID, _ := bag.Get(KeyHardwareID)\n return fmt.Sprintf(\"rbac:%s:%s:%v:%s\", bag.Identity().TenantID, uid, hwID, resource)\n }),\n)\n```\n\n## Multi-tenant\n\n```go\n// Read TenantID from header; CachedPermissionProvider scopes keys automatically.\nsrv.Use(authmw.EnrichmentMiddleware(logger, enricher, authmw.WithTenantHeader(\"X-Tenant-ID\")))\n```\n\n- JWT carries \"who are you\" only — no per-tenant permission claims required\n- `WithTenantHeader` populates `security.Identity.TenantID` from the request\n- `CachedPermissionProvider` uses `\"rbac:{tenantID}:{uid}:{resource}\"` when TenantID is non-empty\n\n## Permission model\n\nPermissions are a 63-bit set (`security.Permission(0)` through `security.MaxPermission`).\nDefine application permissions as constants:\n\n```go\nconst (\n Read = security.Permission(0)\n Write = security.Permission(1)\n Delete = security.Permission(2)\n Admin = security.Permission(3)\n)\n```\n\nIssue tokens with embedded masks (via auth-jwt):\n\n```go\ncustomClaims := map[string]any{\n \"perms\": map[string]any{\n \"orders\": int64(security.PermissionMask(0).Grant(Read).Grant(Write)),\n },\n}\n```\n\n## Environment variables\n\nNone. Auth middleware is wired in code, not configured via environment.\n\n## Install\n\n```bash\ngo get code.nochebuena.dev/einherjar/auth@v1.1.2\n```\n", + "readme": "# einherjar/auth\n\n[![version](https://img.shields.io/badge/version-v1.4.0-5C4EE5?style=flat-square)](https://code.nochebuena.dev/einherjar/auth)\n[![license](https://img.shields.io/badge/license-AGPL--3.0-22863A?style=flat-square)](LICENSE)\n[![go](https://img.shields.io/badge/Go-1.26+-00ADD8?style=flat-square\u0026logo=go\u0026logoColor=white)](https://go.dev)\n\n\u003e Not every warrior who knocks at the gate deserves to pass. The Valkyries choose.\n\nProvider-agnostic HTTP authentication and authorization middleware for the Einherjar framework.\n\n## Sub-packages\n\n| Package | Description |\n|---|---|\n| [`authmw`](authmw/) | HTTP middleware: `EnrichmentMiddleware`, `AuthzMiddleware`, `SetTokenData`, `BagEnricher` |\n| [`rbac`](rbac/) | Permission providers: `ClaimsPermissionProvider`, `CachedPermissionProvider`, `ChainPermissionProvider` |\n\n## Dependency graph\n\n```\ncontracts/security ──► auth/authmw ──► auth/rbac\ncontracts/security ──► auth/rbac\ncore/xerrors ──► auth/authmw\nweb/httputil ──► auth/authmw\n```\n\nNo external dependencies.\n\n## Wiring example\n\n```go\nimport (\n \"code.nochebuena.dev/einherjar/auth/authmw\"\n \"code.nochebuena.dev/einherjar/auth/rbac\"\n \"code.nochebuena.dev/einherjar/contracts/security\"\n)\n\n// Application implements IdentityEnricher to load user data.\nenricher := userservice.NewIdentityEnricher(userRepo)\n\n// Build permission resolution chain.\npermissions := rbac.NewChainPermissionProvider(\n rbac.NewClaimsPermissionProvider(\"perms\", authmw.GetClaims), // JWT fast-path\n rbac.NewCachedPermissionProvider(dbProvider, valkeyCache, 5*time.Minute), // DB fallback\n)\n\n// Provider AuthMiddleware (from auth-jwt or auth-firebase) goes first.\n// Then enrichment globally:\nsrv.Use(authmw.EnrichmentMiddleware(logger, enricher,\n authmw.WithTenantHeader(\"X-Tenant-ID\"),\n))\n\n// Per-route authorization:\nconst ReadOrders = security.Permission(0)\nsrv.With(authmw.AuthzMiddleware(logger, permissions, \"orders\", ReadOrders)).\n Get(\"/orders\", ordersHandler)\n```\n\n## Custom enrichment\n\n`BagEnricher` lets you attach any request attribute to the `SecurityBag` in context.\nPermission providers read it via `bag.Get(key)` — no scattered context keys.\n\n```go\nconst KeyHardwareID = \"hardware_id\" // owned by your package; document the value type\n\nhwEnricher := authmw.BagEnricher(func(bag security.SecurityBag, r *http.Request) security.SecurityBag {\n return bag.With(KeyHardwareID, r.Header.Get(\"X-Hardware-ID\"))\n})\n\nsrv.Use(authmw.EnrichmentMiddleware(logger, enricher,\n authmw.WithTenantHeader(\"X-Tenant-ID\"),\n authmw.WithBagEnricher(hwEnricher),\n))\n```\n\nWith a hardware-ID-bound permission model, override the cache key so the hardware ID\nis included — otherwise two hardware IDs for the same user share a cache entry:\n\n```go\ncached := rbac.NewCachedPermissionProvider(dbProvider, cache, 5*time.Minute,\n rbac.WithCacheKey(func(bag security.SecurityBag, uid, resource string) string {\n hwID, _ := bag.Get(KeyHardwareID)\n return fmt.Sprintf(\"rbac:%s:%s:%v:%s\", bag.Identity().TenantID, uid, hwID, resource)\n }),\n)\n```\n\n## Multi-tenant\n\n```go\n// Read TenantID from header; CachedPermissionProvider scopes keys automatically.\nsrv.Use(authmw.EnrichmentMiddleware(logger, enricher, authmw.WithTenantHeader(\"X-Tenant-ID\")))\n```\n\n- JWT carries \"who are you\" only — no per-tenant permission claims required\n- `WithTenantHeader` populates `security.Identity.TenantID` from the request\n- `CachedPermissionProvider` uses `\"rbac:{tenantID}:{uid}:{resource}\"` when TenantID is non-empty\n\n## Permission model\n\nPermissions are a 63-bit set (`security.Permission(0)` through `security.MaxPermission`).\nDefine application permissions as constants:\n\n```go\nconst (\n Read = security.Permission(0)\n Write = security.Permission(1)\n Delete = security.Permission(2)\n Admin = security.Permission(3)\n)\n```\n\nIssue tokens with embedded masks (via auth-jwt):\n\n```go\ncustomClaims := map[string]any{\n \"perms\": map[string]any{\n \"orders\": int64(security.PermissionMask(0).Grant(Read).Grant(Write)),\n },\n}\n```\n\n## Environment variables\n\nNone. Auth middleware is wired in code, not configured via environment.\n\n## Install\n\n```bash\ngo get code.nochebuena.dev/einherjar/auth@v1.1.2\n```\n", "changelog": "# Changelog\n\n## v1.1.3 — 2026-08-08\n\nPatch — coordinated framework version alignment. Bumped einherjar dependencies (\\`contracts\\`, \\`core\\`, \\`web\\`) to v1.1.3.\nNo code or API changes.## v1.1.2 — 2026-08-08\n\nPatch — documentation fix plus coordinated framework version alignment.\n\n### Fixed\n\n- README wiring example: `rbac.NewClaimsPermissionProvider(\"perms\", authmw.GetClaims)` (was\n missing the `getClaims` argument); install line updated to v1.1.2.\n\n### Changed\n\n- Bumped `contracts`, `core`, `web` to v1.1.2.\n\n## v1.1.1 — 2026-08-07\n\nPatch — coordinated framework version alignment. Bumped `contracts`, `core`, `web` to v1.1.1.\nNo code or API changes.\n\n## v1.1.0 — 2026-08-07\n\nCoordinated framework version alignment — released in lockstep at v1.1.0.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`, `core`, `web`) to v1.1.0 via `go get` + `go mod tidy`.\n No code or API changes.\n\n## v1.0.0\n\nInitial release.\n\n### `authmw`\n\n- `BagEnricher` type — `func(bag security.SecurityBag, r *http.Request) security.SecurityBag`;\n enriches the request-scoped SecurityBag after the base Identity is built. Register via\n `WithBagEnricher`. Multiple enrichers run in registration order, each receiving the bag\n returned by the previous one.\n- `SetTokenData` — integration contract for provider packages (auth-jwt, auth-firebase).\n Stores uid and raw claims in context via typed keys; consumed by `EnrichmentMiddleware`.\n- `GetClaims` — exported accessor for raw token claims stored by `SetTokenData`. Available\n to custom `IdentityEnricher` implementations and `ClaimsPermissionProvider`.\n- `EnrichmentMiddleware` — builds a `security.SecurityBag` from uid+claims. Calls the\n application `IdentityEnricher`, wraps the Identity in a SecurityBag, runs all registered\n `BagEnricher` functions in order, then stores the bag via `security.SetBagInContext`.\n Accepts `logging.Logger`; routes errors through `httputil.Error` (401 on missing token,\n 500 on enricher failure).\n- `AuthzMiddleware` — per-route permission gate. Returns 401 on missing identity, 403 on\n provider error or insufficient permissions (fail-closed).\n- `IdentityEnricher` interface — implemented by the application to load user data from uid+claims.\n- `EnrichOpt` type — `func(*enrichConfig)`.\n- `WithTenantHeader(header string) EnrichOpt` — reads Identity.TenantID from a named request\n header. Implemented as a `BagEnricher` internally.\n- `WithBagEnricher(fn BagEnricher) EnrichOpt` — registers a custom enricher. Use for any\n attribute beyond TenantID: hardware IDs, grant codes, etc.\n\n### `rbac`\n\n- `NewClaimsPermissionProvider` — reads pre-computed bitmasks from JWT claims in context.\n Flat format: `claims[claimsKey][resource] = mask`. Wildcard `\"*\"` fallback.\n Handles int64, float64, json.Number.\n- `NewCachedPermissionProvider` — wraps any `security.PermissionProvider` with TTL caching.\n Default cache key: `\"rbac:{uid}:{resource}\"` (single-tenant) or\n `\"rbac:{tenantID}:{uid}:{resource}\"` (multi-tenant). TenantID sourced from the SecurityBag\n in context automatically. Accepts `...CachedOpt` for customization.\n- `CachedOpt` type — `func(*cachedConfig)`.\n- `WithCacheKey(fn func(security.SecurityBag, string, string) string) CachedOpt` — overrides\n the default cache key function. Use when additional bag attributes (hardware IDs, grant codes)\n must be part of the key.\n- `NewChainPermissionProvider` — tries providers in order; returns first non-zero mask. Errors\n short-circuit.\n- `Cache` interface — pluggable cache backend. Satisfied by `einherjar/cache-valkey` via duck\n typing.\n" }, { @@ -1521,7 +1521,7 @@ } ] }, - "readme": "# einherjar/auth-jwt\n\n[![version](https://img.shields.io/badge/version-v1.3.0-5C4EE5?style=flat-square)](https://code.nochebuena.dev/einherjar/auth-jwt)\n[![license](https://img.shields.io/badge/license-AGPL--3.0-22863A?style=flat-square)](LICENSE)\n[![go](https://img.shields.io/badge/Go-1.26+-00ADD8?style=flat-square\u0026logo=go\u0026logoColor=white)](https://go.dev)\n\n\u003e A warrior's seal is recognized anywhere — but only if it cannot be forged.\n\nJWT authentication middleware and token lifecycle management for the Einherjar framework.\nSupports HMAC-SHA256 (HS256), RSA-SHA256 (RS256), and ECDSA (ES256/ES384/ES512).\n\n## API\n\n| Symbol | Kind | Description |\n|---|---|---|\n| `Verifier` | interface | Validates JWT strings |\n| `Signer` | interface | Extends `Verifier`; also signs tokens |\n| `NewHMACSigner(secret)` | func | HS256 signer |\n| `NewRSASigner(key)` | func | RS256 signer |\n| `NewRSASignerFromPEM(pem)` | func | RS256 signer from PKCS#8/PKCS#1 PEM |\n| `NewRSAPublicKeyVerifier(key)` | func | RS256 verifier (verify-only) |\n| `NewRSAPublicKeyVerifierFromPEM(pem)` | func | RS256 verifier from PKIX/PKCS#1 PEM |\n| `NewECSigner(key)` | func | ES256/384/512 signer (curve auto-detected) |\n| `NewECSignerFromPEM(pem)` | func | EC signer from PKCS#8 PEM |\n| `NewECPublicKeyVerifier(key)` | func | EC verifier (verify-only) |\n| `NewECPublicKeyVerifierFromPEM(pem)` | func | EC verifier from PKIX PEM |\n| `TokenConfig` | struct | AccessTTL, RefreshTTL, Issuer |\n| `TokenPair` | struct | AccessToken, RefreshToken, ExpiresIn |\n| `IssueTokenPair(signer, uid, claims, cfg)` | func | Sign access + refresh pair |\n| `Blacklist` | interface | JTI revocation store (duck-typed by cache-valkey) |\n| `ErrTokenRevoked` | var | Sentinel for replay-attack detection |\n| `RefreshTokenPair(ctx, signer, token, bl, cfg, claims)` | func | Rotate tokens with blacklist check |\n| `AuthMiddleware(logger, verifier, publicPaths)` | func | HTTP middleware — verifies Bearer token, calls `authmw.SetTokenData` |\n\n## Dependency graph\n\n```\ncontracts/logging ──► auth-jwt\ncontracts/security ──► auth-jwt (via auth/authmw)\ncore/xerrors ──► auth-jwt\nweb/httputil ──► auth-jwt\nauth/authmw ──► auth-jwt\njwt/v5 ──► auth-jwt (only external dependency)\n```\n\n## Wiring example — HMAC, full stack\n\n```go\nimport (\n \"code.nochebuena.dev/einherjar/auth-jwt\"\n \"code.nochebuena.dev/einherjar/auth/authmw\"\n \"code.nochebuena.dev/einherjar/auth/rbac\"\n)\n\nsigner := authjwt.NewHMACSigner([]byte(os.Getenv(\"JWT_SECRET\")))\ncfg := authjwt.TokenConfig{\n AccessTTL: 15 * time.Minute,\n RefreshTTL: 7 * 24 * time.Hour,\n Issuer: \"myapp\",\n}\n\n// JWT verification runs first (global).\nsrv.Use(authjwt.AuthMiddleware(logger, signer, []string{\"/health\", \"/auth/*\"}))\n\n// Enrichment and authz follow.\nsrv.Use(authmw.EnrichmentMiddleware(logger, userEnricher))\n\nconst ReadOrders = security.Permission(0)\nsrv.With(authmw.AuthzMiddleware(logger, permissions, \"orders\", ReadOrders)).\n Get(\"/orders\", ordersHandler)\n\n// Login handler issues tokens:\npair, err := authjwt.IssueTokenPair(signer, uid, customClaims, cfg)\n\n// Refresh handler rotates tokens:\nnewPair, err := authjwt.RefreshTokenPair(ctx, signer, body.RefreshToken, blacklist, cfg, freshClaims)\nif errors.Is(err, authjwt.ErrTokenRevoked) {\n // replay attack — return 401 and require re-login\n}\n```\n\n## Verifier-only microservice (RSA)\n\n```go\n// Service that verifies tokens but never issues them.\nverifier, err := authjwt.NewRSAPublicKeyVerifierFromPEM([]byte(os.Getenv(\"RSA_PUBLIC_KEY_PEM\")))\nsrv.Use(authjwt.AuthMiddleware(logger, verifier, publicPaths))\n```\n\n## Environment variables\n\nNone. All configuration is passed in code.\n\n## Install\n\n```bash\ngo get code.nochebuena.dev/einherjar/auth-jwt@v1.1.2\n```\n", + "readme": "# einherjar/auth-jwt\n\n[![version](https://img.shields.io/badge/version-v1.4.0-5C4EE5?style=flat-square)](https://code.nochebuena.dev/einherjar/auth-jwt)\n[![license](https://img.shields.io/badge/license-AGPL--3.0-22863A?style=flat-square)](LICENSE)\n[![go](https://img.shields.io/badge/Go-1.26+-00ADD8?style=flat-square\u0026logo=go\u0026logoColor=white)](https://go.dev)\n\n\u003e A warrior's seal is recognized anywhere — but only if it cannot be forged.\n\nJWT authentication middleware and token lifecycle management for the Einherjar framework.\nSupports HMAC-SHA256 (HS256), RSA-SHA256 (RS256), and ECDSA (ES256/ES384/ES512).\n\n## API\n\n| Symbol | Kind | Description |\n|---|---|---|\n| `Verifier` | interface | Validates JWT strings |\n| `Signer` | interface | Extends `Verifier`; also signs tokens |\n| `NewHMACSigner(secret)` | func | HS256 signer |\n| `NewRSASigner(key)` | func | RS256 signer |\n| `NewRSASignerFromPEM(pem)` | func | RS256 signer from PKCS#8/PKCS#1 PEM |\n| `NewRSAPublicKeyVerifier(key)` | func | RS256 verifier (verify-only) |\n| `NewRSAPublicKeyVerifierFromPEM(pem)` | func | RS256 verifier from PKIX/PKCS#1 PEM |\n| `NewECSigner(key)` | func | ES256/384/512 signer (curve auto-detected) |\n| `NewECSignerFromPEM(pem)` | func | EC signer from PKCS#8 PEM |\n| `NewECPublicKeyVerifier(key)` | func | EC verifier (verify-only) |\n| `NewECPublicKeyVerifierFromPEM(pem)` | func | EC verifier from PKIX PEM |\n| `TokenConfig` | struct | AccessTTL, RefreshTTL, Issuer |\n| `TokenPair` | struct | AccessToken, RefreshToken, ExpiresIn |\n| `IssueTokenPair(signer, uid, claims, cfg)` | func | Sign access + refresh pair |\n| `Blacklist` | interface | JTI revocation store (duck-typed by cache-valkey) |\n| `ErrTokenRevoked` | var | Sentinel for replay-attack detection |\n| `RefreshTokenPair(ctx, signer, token, bl, cfg, claims)` | func | Rotate tokens with blacklist check |\n| `AuthMiddleware(logger, verifier, publicPaths)` | func | HTTP middleware — verifies Bearer token, calls `authmw.SetTokenData` |\n\n## Dependency graph\n\n```\ncontracts/logging ──► auth-jwt\ncontracts/security ──► auth-jwt (via auth/authmw)\ncore/xerrors ──► auth-jwt\nweb/httputil ──► auth-jwt\nauth/authmw ──► auth-jwt\njwt/v5 ──► auth-jwt (only external dependency)\n```\n\n## Wiring example — HMAC, full stack\n\n```go\nimport (\n \"code.nochebuena.dev/einherjar/auth-jwt\"\n \"code.nochebuena.dev/einherjar/auth/authmw\"\n \"code.nochebuena.dev/einherjar/auth/rbac\"\n)\n\nsigner := authjwt.NewHMACSigner([]byte(os.Getenv(\"JWT_SECRET\")))\ncfg := authjwt.TokenConfig{\n AccessTTL: 15 * time.Minute,\n RefreshTTL: 7 * 24 * time.Hour,\n Issuer: \"myapp\",\n}\n\n// JWT verification runs first (global).\nsrv.Use(authjwt.AuthMiddleware(logger, signer, []string{\"/health\", \"/auth/*\"}))\n\n// Enrichment and authz follow.\nsrv.Use(authmw.EnrichmentMiddleware(logger, userEnricher))\n\nconst ReadOrders = security.Permission(0)\nsrv.With(authmw.AuthzMiddleware(logger, permissions, \"orders\", ReadOrders)).\n Get(\"/orders\", ordersHandler)\n\n// Login handler issues tokens:\npair, err := authjwt.IssueTokenPair(signer, uid, customClaims, cfg)\n\n// Refresh handler rotates tokens:\nnewPair, err := authjwt.RefreshTokenPair(ctx, signer, body.RefreshToken, blacklist, cfg, freshClaims)\nif errors.Is(err, authjwt.ErrTokenRevoked) {\n // replay attack — return 401 and require re-login\n}\n```\n\n## Verifier-only microservice (RSA)\n\n```go\n// Service that verifies tokens but never issues them.\nverifier, err := authjwt.NewRSAPublicKeyVerifierFromPEM([]byte(os.Getenv(\"RSA_PUBLIC_KEY_PEM\")))\nsrv.Use(authjwt.AuthMiddleware(logger, verifier, publicPaths))\n```\n\n## Environment variables\n\nNone. All configuration is passed in code.\n\n## Install\n\n```bash\ngo get code.nochebuena.dev/einherjar/auth-jwt@v1.1.2\n```\n", "changelog": "# Changelog\n\n## v1.1.3 — 2026-08-08\n\nPatch — coordinated framework version alignment. Bumped einherjar dependencies (\\`auth\\`, \\`contracts\\`, \\`core\\`, \\`web\\`) to v1.1.3.\nNo code or API changes.## v1.1.2 — 2026-08-08\n\nPatch — coordinated framework version alignment. Bumped `auth`, `contracts`, `core`, `web` to\nv1.1.2; README install line updated to v1.1.2.\n\n## v1.1.1 — 2026-08-07\n\nPatch — coordinated framework version alignment. Bumped `auth`, `contracts`, `core`, `web` to v1.1.1.\nNo code or API changes.\n\n## v1.1.0 — 2026-08-07\n\nCoordinated framework version alignment — released in lockstep at v1.1.0.\n\n### Changed\n\n- Bumped einherjar dependencies (`auth`, `contracts`, `core`, `web`) to v1.1.0 via `go get` + `go mod tidy`.\n No code or API changes.\n\n## v1.0.0\n\nInitial release.\n\n### Signers and Verifiers\n\n- `Verifier` interface — `Verify(tokenString string) (*jwt.Token, error)`\n- `Signer` interface — extends `Verifier`; adds `Sign(claims jwt.Claims) (string, error)`\n- `NewHMACSigner(secret []byte) Signer` — HMAC-SHA256 (HS256)\n- `NewRSASigner(privateKey *rsa.PrivateKey) Signer` — RSA-SHA256 (RS256); public key derived from private\n- `NewRSASignerFromPEM(pemKey []byte) (Signer, error)` — parses PKCS#8 or PKCS#1 PEM\n- `NewRSAPublicKeyVerifier(publicKey *rsa.PublicKey) Verifier` — verify-only; use when the service never issues tokens\n- `NewRSAPublicKeyVerifierFromPEM(pemKey []byte) (Verifier, error)` — parses PKIX or PKCS#1 PEM\n- `NewECSigner(privateKey *ecdsa.PrivateKey) Signer` — ECDSA; algorithm auto-detected from curve (P-256→ES256, P-384→ES384, P-521→ES512)\n- `NewECSignerFromPEM(pemKey []byte) (Signer, error)` — parses PKCS#8 PEM\n- `NewECPublicKeyVerifier(publicKey *ecdsa.PublicKey) Verifier` — EC verify-only\n- `NewECPublicKeyVerifierFromPEM(pemKey []byte) (Verifier, error)` — parses PKIX PEM\n- All `Verify` implementations use `jwt.WithJSONNumber()` — preserves large int64 bitmasks through JSON round-trip\n\n### Token issuance\n\n- `TokenConfig` struct — `AccessTTL time.Duration`, `RefreshTTL time.Duration`, `Issuer string`\n- `TokenPair` struct — `AccessToken string`, `RefreshToken string`, `ExpiresIn int64`\n- `IssueTokenPair(signer, uid, customClaims, cfg) (TokenPair, error)` — signs access + refresh pair; `customClaims` merged at top level of access token; refresh token carries only `sub/iss/iat/exp/jti/fam`\n\n### Token refresh\n\n- `Blacklist` interface — `IsRevoked(ctx, jti) (bool, error)` + `Revoke(ctx, jti, ttl) error`; satisfied by `cache-valkey` via duck typing\n- `ErrTokenRevoked` — sentinel error; use `errors.Is(err, authjwt.ErrTokenRevoked)` to detect replay attacks\n- `RefreshTokenPair(ctx, signer, refreshToken, bl, cfg, customClaims) (TokenPair, error)` — validates token, checks blacklist, revokes old JTI, issues new pair; `customClaims` re-embedded in new access token\n\n### HTTP middleware\n\n- `AuthMiddleware(logger, verifier, publicPaths) func(http.Handler) http.Handler` — verifies Bearer tokens; calls `authmw.SetTokenData` on success; routes 401 through `httputil.Error`; `publicPaths` support `path.Match` wildcards\n" }, { @@ -2223,8 +2223,8 @@ } ] }, - "readme": "# einherjar/cache-valkey\n\n[![version](https://img.shields.io/badge/version-v1.3.0-5C4EE5?style=flat-square)](https://code.nochebuena.dev/einherjar/cache-valkey)\n[![license](https://img.shields.io/badge/license-AGPL--3.0-22863A?style=flat-square)](LICENSE)\n[![go](https://img.shields.io/badge/Go-1.26+-00ADD8?style=flat-square\u0026logo=go\u0026logoColor=white)](https://go.dev)\n[![health](https://img.shields.io/badge/health-degraded-E36209?style=flat-square)]()\n\n\u003e Speed is not a virtue. It is the difference between arriving and never arriving.\n\n`code.nochebuena.dev/einherjar/cache-valkey` is the Valkey cache component of the Einherjar framework. It wraps `valkey-go` behind a lifecycle-aware `Component` and ships three adapters — permission cache, rate limiter, and JWT blacklist — that satisfy interfaces in `auth`, `web`, and `auth-jwt` via Go's structural typing, with no cross-module import required.\n\n## Quick start\n\n```go\nimport cachevalkey \"code.nochebuena.dev/einherjar/cache-valkey\"\n\nvk := cachevalkey.New(logger, cachevalkey.Config{\n Addrs: []string{\"localhost:6379\"},\n})\nlc.Append(vk) // OnInit → OnStart → OnStop\n// vk is observability.Checkable (PING-based, LevelDegraded):\nsrv.Get(\"/health\", health.NewHandler(logger, vk).ServeHTTP)\n```\n\n## Connecting to other modules\n\nGo's structural typing handles interface assignment across module boundaries —\nno cast, no glue interface required. Create adapters from the component and pass\nthem directly to the consuming modules:\n\n```go\n// --- 1. Create the component (lifecycle + health + Provider) ---\nvk := cachevalkey.New(logger, cfg)\nlc.Append(vk)\nsrv.Get(\"/health\", health.NewHandler(logger, vk).ServeHTTP)\n\n// --- 2. Create adapters (one per consumer interface) ---\n// Each adapter wraps vk (a Provider) and satisfies one interface in another module.\n// No import of auth, web, or auth-jwt is needed here.\npermCache := cachevalkey.NewPermissionCache(vk)\nrateLimiter := cachevalkey.NewRateLimiterStore(vk, time.Second, 100)\nblacklist := cachevalkey.NewBlacklist(vk)\n\n// --- 3. Wire adapters into consuming modules ---\n// Go's structural typing handles the interface assignment — no cast required.\n\n// auth: permission cache (satisfies rbac.Cache)\npermProvider := rbac.NewCachedPermissionProvider(permCache, baseProvider, rbac.CacheConfig{TTL: 5 * time.Minute})\n\n// web: rate limiter (satisfies mw.RateLimiterStore)\nrouter.Use(mw.IPRateLimit(rateLimiter))\n\n// auth-jwt: refresh token blacklist (satisfies authjwt.Blacklist)\npair, err := authjwt.RefreshTokenPair(ctx, signer, oldRefreshToken, blacklist, tokenCfg, newClaims)\n```\n\nThe compile-time duck-type checks in `compliance_test.go` verify that each adapter\nsatisfies its target interface. If those checks compile, the wiring above is guaranteed\nto work.\n\n## Configuration\n\n| Environment variable | Required | Default | Description |\n|---|---|---|---|\n| `EINHERJAR_VALKEY_ADDRS` | Yes | — | Comma-separated `host:port` addresses |\n| `EINHERJAR_VALKEY_PASSWORD` | No | `\"\"` | Auth password |\n| `EINHERJAR_VALKEY_DB` | No | `0` | Database index |\n| `EINHERJAR_VALKEY_CLIENT_CACHE_MB` | No | `0` | Client-side cache per connection in MB (0 = disabled) |\n\n## API\n\n### Provider interface\n\n`Component` satisfies `Provider`. Pass the result of `New()` wherever a `Provider`\nis expected.\n\n| Method | Description |\n|---|---|\n| `Get(ctx, key) (string, bool, error)` | GET key; returns `(\"\", false, nil)` on miss |\n| `Set(ctx, key, value, ttl) error` | SET key value [EX ttl]; `ttl=0` = no expiry |\n| `Del(ctx, keys...) error` | DEL one or more keys |\n| `Exists(ctx, key) (bool, error)` | EXISTS key |\n| `Expire(ctx, key, ttl) error` | EXPIRE key ttl |\n| `IncrWithTTL(ctx, key, ttl) (int64, error)` | Atomic INCR + EXPIRE on first increment (Lua) |\n| `Native() vk.Client` | Raw valkey-go client for operations not in Provider |\n\n### Adapters\n\n| Constructor | Satisfies | Description |\n|---|---|---|\n| `NewPermissionCache(Provider)` | `auth/rbac.Cache` | Stores int64 permission bitmasks as decimal strings |\n| `NewRateLimiterStore(Provider, window, limit)` | `web/mw.RateLimiterStore` | Fixed-window counter; Lua atomic increment |\n| `NewBlacklist(Provider)` | `auth-jwt.Blacklist` | JWT JTI revocation via SET/EXISTS |\n\n## Dependency graph\n\n```\ncache-valkey\n├── contracts v1.0.0 (lifecycle.Component, observability.Checkable, logging.Logger)\n├── core v1.0.0 (xerrors)\n└── valkey-go v1.0.54 (native client)\n```\n\nNo dependency on `web`, `auth`, or `auth-jwt`.\nThe three adapters satisfy interfaces in those modules via duck typing.\n", - "changelog": "# Changelog — cache-valkey\n\nAll notable changes to this module are documented here.\nFormat follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).\nThis module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n## [1.3.0] — 2026-08-08\n\nMinor — coordinated framework version alignment. No code or API changes in this module.\n\n### Changed\n\n- Bumped `contracts`, `core` to v1.3.0.\n\n## [1.2.0] — 2026-08-08\n\nMinor — coordinated framework version alignment. No code or API changes in this module.\n\n### Changed\n\n- Bumped `contracts`, `core` to v1.2.0.\n\n## [1.1.3] — 2026-08-08\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (\\`contracts\\`, \\`core\\`) to v1.1.3. No code or API changes.## [1.1.2] — 2026-08-08\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped `contracts`, `core` to v1.1.2. No code or API changes.\n\n## [1.1.1] — 2026-08-07\n\nPatch — corrected doc examples plus framework version alignment.\n\n### Fixed\n\n- Package doc examples used a fictional `launcher.Register` / `health.Register` API; corrected to\n `lc.Append` + `web/health.NewHandler(...).ServeHTTP`. Verified by compiling against the real API.\n\n### Changed\n\n- Bumped `contracts` and `core` to v1.1.1.\n\n## [1.1.0] — 2026-08-07\n\nCoordinated framework version alignment — released in lockstep at v1.1.0.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`, `core`) to v1.1.0 via `go get` + `go mod tidy`.\n No code or API changes.\n\n## [1.0.0] — 2026-05-28\n\n### Added\n\n- `Provider` interface: six common Valkey operations (`Get`, `Set`, `Del`, `Exists`,\n `Expire`, `IncrWithTTL`) using only standard Go types, plus `Native() vk.Client`\n escape hatch.\n- `Component` interface: `Provider` + `lifecycle.Component` + `observability.Checkable`.\n- `Config` struct with `EINHERJAR_VALKEY_*` env tags (caarlos0/env compatible).\n- `New(logger, cfg) Component` factory — lifecycle-aware Valkey client.\n- `NewPermissionCache(Provider) *PermissionCache` — satisfies `auth/rbac.Cache`\n via duck typing.\n- `NewRateLimiterStore(Provider, window, limit) *RateLimiterStore` — satisfies\n `web/mw.RateLimiterStore` via duck typing; fixed-window Lua counter.\n- `NewBlacklist(Provider) *Blacklist` — satisfies `auth-jwt.Blacklist` via duck typing.\n" + "readme": "# einherjar/cache-valkey\n\n[![version](https://img.shields.io/badge/version-v1.4.0-5C4EE5?style=flat-square)](https://code.nochebuena.dev/einherjar/cache-valkey)\n[![license](https://img.shields.io/badge/license-AGPL--3.0-22863A?style=flat-square)](LICENSE)\n[![go](https://img.shields.io/badge/Go-1.26+-00ADD8?style=flat-square\u0026logo=go\u0026logoColor=white)](https://go.dev)\n[![health](https://img.shields.io/badge/health-degraded-E36209?style=flat-square)]()\n\n\u003e Speed is not a virtue. It is the difference between arriving and never arriving.\n\n`code.nochebuena.dev/einherjar/cache-valkey` is the Valkey cache component of the Einherjar framework. It wraps `valkey-go` behind a lifecycle-aware `Component` and ships three adapters — permission cache, rate limiter, and JWT blacklist — that satisfy interfaces in `auth`, `web`, and `auth-jwt` via Go's structural typing, with no cross-module import required.\n\n## Quick start\n\n```go\nimport cachevalkey \"code.nochebuena.dev/einherjar/cache-valkey\"\n\nvk := cachevalkey.New(logger, cachevalkey.Config{\n Addrs: []string{\"localhost:6379\"},\n})\nlc.Append(vk) // OnInit → OnStart → OnStop\n// vk is observability.Checkable (PING-based, LevelDegraded):\nsrv.Get(\"/health\", health.NewHandler(logger, vk).ServeHTTP)\n```\n\n## Connecting to other modules\n\nGo's structural typing handles interface assignment across module boundaries —\nno cast, no glue interface required. Create adapters from the component and pass\nthem directly to the consuming modules:\n\n```go\n// --- 1. Create the component (lifecycle + health + Provider) ---\nvk := cachevalkey.New(logger, cfg)\nlc.Append(vk)\nsrv.Get(\"/health\", health.NewHandler(logger, vk).ServeHTTP)\n\n// --- 2. Create adapters (one per consumer interface) ---\n// Each adapter wraps vk (a Provider) and satisfies one interface in another module.\n// No import of auth, web, or auth-jwt is needed here.\npermCache := cachevalkey.NewPermissionCache(vk)\nrateLimiter := cachevalkey.NewRateLimiterStore(vk, time.Second, 100)\nblacklist := cachevalkey.NewBlacklist(vk)\n\n// --- 3. Wire adapters into consuming modules ---\n// Go's structural typing handles the interface assignment — no cast required.\n\n// auth: permission cache (satisfies rbac.Cache)\npermProvider := rbac.NewCachedPermissionProvider(permCache, baseProvider, rbac.CacheConfig{TTL: 5 * time.Minute})\n\n// web: rate limiter (satisfies mw.RateLimiterStore)\nrouter.Use(mw.IPRateLimit(rateLimiter))\n\n// auth-jwt: refresh token blacklist (satisfies authjwt.Blacklist)\npair, err := authjwt.RefreshTokenPair(ctx, signer, oldRefreshToken, blacklist, tokenCfg, newClaims)\n```\n\nThe compile-time duck-type checks in `compliance_test.go` verify that each adapter\nsatisfies its target interface. If those checks compile, the wiring above is guaranteed\nto work.\n\n## Configuration\n\n| Environment variable | Required | Default | Description |\n|---|---|---|---|\n| `EINHERJAR_VALKEY_ADDRS` | Yes | — | Comma-separated `host:port` addresses |\n| `EINHERJAR_VALKEY_PASSWORD` | No | `\"\"` | Auth password |\n| `EINHERJAR_VALKEY_DB` | No | `0` | Database index |\n| `EINHERJAR_VALKEY_CLIENT_CACHE_MB` | No | `0` | Client-side cache per connection in MB (0 = disabled) |\n\n## API\n\n### Provider interface\n\n`Component` satisfies `Provider`. Pass the result of `New()` wherever a `Provider`\nis expected.\n\n| Method | Description |\n|---|---|\n| `Get(ctx, key) (string, bool, error)` | GET key; returns `(\"\", false, nil)` on miss |\n| `Set(ctx, key, value, ttl) error` | SET key value [EX ttl]; `ttl=0` = no expiry |\n| `Del(ctx, keys...) error` | DEL one or more keys |\n| `Exists(ctx, key) (bool, error)` | EXISTS key |\n| `Expire(ctx, key, ttl) error` | EXPIRE key ttl |\n| `IncrWithTTL(ctx, key, ttl) (int64, error)` | Atomic INCR + EXPIRE on first increment (Lua) |\n| `Native() vk.Client` | Raw valkey-go client for operations not in Provider |\n\n### Adapters\n\n| Constructor | Satisfies | Description |\n|---|---|---|\n| `NewPermissionCache(Provider)` | `auth/rbac.Cache` | Stores int64 permission bitmasks as decimal strings |\n| `NewRateLimiterStore(Provider, window, limit)` | `web/mw.RateLimiterStore` | Fixed-window counter; Lua atomic increment |\n| `NewBlacklist(Provider)` | `auth-jwt.Blacklist` | JWT JTI revocation via SET/EXISTS |\n\n## Dependency graph\n\n```\ncache-valkey\n├── contracts v1.0.0 (lifecycle.Component, observability.Checkable, logging.Logger)\n├── core v1.0.0 (xerrors)\n└── valkey-go v1.0.54 (native client)\n```\n\nNo dependency on `web`, `auth`, or `auth-jwt`.\nThe three adapters satisfy interfaces in those modules via duck typing.\n", + "changelog": "# Changelog — cache-valkey\n\nAll notable changes to this module are documented here.\nFormat follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).\nThis module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n## [1.4.0] — 2026-08-09\n\nMinor — coordinated framework release: dependency refresh + lockstep version alignment.\n\n### Changed\n\n- Refreshed dependencies to their latest minor/patch where available.\n- Bumped `contracts`, `core` to v1.4.0.\n\n## [1.3.0] — 2026-08-08\n\nMinor — coordinated framework version alignment. No code or API changes in this module.\n\n### Changed\n\n- Bumped `contracts`, `core` to v1.3.0.\n\n## [1.2.0] — 2026-08-08\n\nMinor — coordinated framework version alignment. No code or API changes in this module.\n\n### Changed\n\n- Bumped `contracts`, `core` to v1.2.0.\n\n## [1.1.3] — 2026-08-08\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (\\`contracts\\`, \\`core\\`) to v1.1.3. No code or API changes.## [1.1.2] — 2026-08-08\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped `contracts`, `core` to v1.1.2. No code or API changes.\n\n## [1.1.1] — 2026-08-07\n\nPatch — corrected doc examples plus framework version alignment.\n\n### Fixed\n\n- Package doc examples used a fictional `launcher.Register` / `health.Register` API; corrected to\n `lc.Append` + `web/health.NewHandler(...).ServeHTTP`. Verified by compiling against the real API.\n\n### Changed\n\n- Bumped `contracts` and `core` to v1.1.1.\n\n## [1.1.0] — 2026-08-07\n\nCoordinated framework version alignment — released in lockstep at v1.1.0.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`, `core`) to v1.1.0 via `go get` + `go mod tidy`.\n No code or API changes.\n\n## [1.0.0] — 2026-05-28\n\n### Added\n\n- `Provider` interface: six common Valkey operations (`Get`, `Set`, `Del`, `Exists`,\n `Expire`, `IncrWithTTL`) using only standard Go types, plus `Native() vk.Client`\n escape hatch.\n- `Component` interface: `Provider` + `lifecycle.Component` + `observability.Checkable`.\n- `Config` struct with `EINHERJAR_VALKEY_*` env tags (caarlos0/env compatible).\n- `New(logger, cfg) Component` factory — lifecycle-aware Valkey client.\n- `NewPermissionCache(Provider) *PermissionCache` — satisfies `auth/rbac.Cache`\n via duck typing.\n- `NewRateLimiterStore(Provider, window, limit) *RateLimiterStore` — satisfies\n `web/mw.RateLimiterStore` via duck typing; fixed-window Lua counter.\n- `NewBlacklist(Provider) *Blacklist` — satisfies `auth-jwt.Blacklist` via duck typing.\n" }, { "name": "contracts", @@ -2839,8 +2839,8 @@ } ] }, - "readme": "# einherjar/contracts\n\n[![version](https://img.shields.io/badge/version-v1.3.0-5C4EE5?style=flat-square)](https://code.nochebuena.dev/einherjar/contracts)\n[![license](https://img.shields.io/badge/license-AGPL--3.0-22863A?style=flat-square)](LICENSE)\n[![go](https://img.shields.io/badge/Go-1.26+-00ADD8?style=flat-square\u0026logo=go\u0026logoColor=white)](https://go.dev)\n\n\u003e *For those who come after.*\n\nThe foundation of the Einherjar framework. Pure interfaces. Zero dependencies. A constitution, not a changelog.\n\n`contracts` defines the interfaces and minimal types that every Einherjar starter depends on. Nothing is above it in the dependency graph. Its interfaces, once published, are permanent — adding a method is a breaking change in Go, and breaking changes require a major version bump and coordinated updates across the entire framework.\n\n---\n\n## What Is Einherjar\n\nIn Norse mythology, the Einherjar are the chosen — warriors who fell in battle and were carried to Valhalla by the Valkyries. There they train, day after day, preparing for Ragnarök. They are not preserved for themselves. They are prepared for what comes after.\n\nThis framework carries that name deliberately. Every interface defined here, every ADR written, every pattern documented exists for the developer who will build on this code without ever being in the room where it was designed. No tribal knowledge. No implicit conventions. The documentation is the system.\n\n---\n\n## Module\n\n```\ncode.nochebuena.dev/einherjar/contracts\n```\n\n**Dependencies:** none. This module imports only the Go standard library.\n**Stability guarantee:** existing interface signatures are permanent. New interfaces may be added. Existing ones are not modified.\n\n---\n\n## Sub-packages\n\n| Package | Purpose |\n|---|---|\n| [`lifecycle`](lifecycle/) | `Component` — three-phase managed lifecycle (OnInit → OnStart → OnStop) |\n| [`observability`](observability/) | `Checkable` + `Level` — health reporting for infrastructure components |\n| [`logging`](logging/) | `Logger` — structured, leveled logging |\n| [`errs`](errs/) | `CodedError` + `ContextualError` — structured error enrichment consumed by the logger |\n| [`security`](security/) | `Identity`, `Permission`, `PermissionMask`, `PermissionProvider` — authentication and authorization primitives |\n\n---\n\n## Usage\n\nImport only the sub-packages you need. There is no root `contracts` package to import.\n\n```go\nimport (\n \"code.nochebuena.dev/einherjar/contracts/lifecycle\"\n \"code.nochebuena.dev/einherjar/contracts/logging\"\n \"code.nochebuena.dev/einherjar/contracts/observability\"\n \"code.nochebuena.dev/einherjar/contracts/errs\"\n \"code.nochebuena.dev/einherjar/contracts/security\"\n)\n```\n\n### Implementing a managed component\n\n```go\n// MyClient satisfies lifecycle.Component and observability.Checkable.\nvar _ lifecycle.Component = (*MyClient)(nil)\nvar _ observability.Checkable = (*MyClient)(nil)\n\nfunc (c *MyClient) OnInit() error { /* open connection */ }\nfunc (c *MyClient) OnStart() error { return nil }\nfunc (c *MyClient) OnStop() error { /* close connection */ }\nfunc (c *MyClient) HealthCheck(ctx context.Context) error { /* ping */ }\nfunc (c *MyClient) Name() string { return \"my-client\" }\nfunc (c *MyClient) Priority() observability.Level { return observability.LevelCritical }\n```\n\n### Implementing structured errors\n\n```go\n// MyErr satisfies errs.CodedError and errs.ContextualError.\nvar _ errs.CodedError = (*MyErr)(nil)\nvar _ errs.ContextualError = (*MyErr)(nil)\n\nfunc (e *MyErr) ErrorCode() string { return e.code }\nfunc (e *MyErr) ErrorContext() map[string]any { return e.ctx }\n```\n\nThe logger in `core` consumes these interfaces to append `error_code` and context\nfields to every log record automatically — without either package importing the other.\n\n### Working with identity and permissions\n\n```go\n// Store identity after authentication.\nctx = security.SetInContext(ctx, security.NewIdentity(uid, name, email))\n\n// Retrieve identity downstream.\nid, ok := security.FromContext(ctx)\n\n// Resolve and check permissions.\nmask, err := provider.ResolveMask(ctx, id.UID, \"orders\")\nif !mask.Has(PermWrite) { /* deny */ }\n```\n\n---\n\n## Dependency Rules\n\n`contracts` sits at the root of the Einherjar dependency graph:\n\n```\ncontracts\n └── core (absorbs launcher, logz, xerrors, valid)\n ├── web (absorbs httpserver, httpmw, httputil, health)\n │ └── auth\n │ ├── auth-jwt\n │ └── auth-firebase\n └── db-*, cache-*, storage-*, telemetry, worker, httpclient\n```\n\n**Changes flow outward from `contracts`, never inward.** A starter never drives a\ncontracts change. Before any modification, calculate the blast radius: which interfaces\nare affected → which starters implement them → which starters consume those starters.\nRelease sequence is always contracts first, then implementors, then consumers.\n\n---\n\n## Compliance\n\n```bash\ngo build ./... # zero external dependencies\ngo vet ./...\ngo test ./... # structural (one type per file) + behavioural tests\ngofmt -l . # no output\n```\n\n---\n\n*The Einherjar did not train for themselves. They trained for Ragnarök — for the battle they knew was coming, for those who would need them to be ready. Write code the same way.*\n", - "changelog": "# Changelog\n\nAll notable changes to this module will be documented in this file.\n\nThe format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),\nand this module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n---\n\n## [1.3.0] — 2026-08-08\n\nMinor — coordinated framework version alignment. No code, API, or dependency changes.\n\n## [1.2.0] — 2026-08-08\n\nMinor — coordinated framework version alignment. No code, API, or dependency changes.\n\n## [1.1.3] — 2026-08-08\n\nPatch — coordinated framework version alignment. No code, API, or dependency changes.\n\n## [1.1.2] — 2026-08-08\n\nPatch — coordinated framework version alignment. No code, API, or dependency changes.\n\n## [1.1.1] — 2026-08-07\n\nPatch — coordinated framework version alignment. Released in lockstep at v1.1.1\nalongside a documentation patch in the data/infra starters. No code, API, or dependency changes.\n\n## [1.1.0] — 2026-08-07\n\n### Added\n\n**`security`**\n\n- `SecurityBag` struct — request-scoped security context that carries both the\n authenticated `Identity` and arbitrary string-keyed attributes injected during\n the enrichment phase (e.g. hardware IDs, grant codes). Value type — all mutation\n methods return a new value without modifying the receiver. The attribute map is\n copied on every `With` call to preserve immutability guarantees.\n- `NewSecurityBag(id Identity) SecurityBag` — constructs a `SecurityBag` wrapping\n the given Identity with an empty attribute map\n- `SecurityBag.Identity() Identity` — returns the authenticated Identity stored in\n the bag\n- `SecurityBag.WithIdentity(id Identity) SecurityBag` — returns a copy of the bag\n with the Identity replaced; used by bag enrichers that modify the Identity (e.g.\n applying a tenant ID from a request header)\n- `SecurityBag.Get(key string) (any, bool)` — returns the attribute stored under\n key, or `(nil, false)` if absent\n- `SecurityBag.With(key string, value any) SecurityBag` — returns a copy of the\n bag with the given key set to value; the receiver is not modified\n- `SetBagInContext(ctx context.Context, bag SecurityBag) context.Context` — stores\n the full `SecurityBag` in ctx; use instead of `SetInContext` when extra attributes\n need to travel alongside the Identity\n- `BagFromContext(ctx context.Context) (SecurityBag, bool)` — retrieves the\n `SecurityBag` stored by `SetBagInContext` or `SetInContext`; permission providers\n use this to access both the Identity and any enrichment attributes\n\n### Changed\n\n**`security`**\n\n- `SetInContext` — now stores a `SecurityBag` wrapping the Identity internally,\n replacing direct `Identity` storage. The function signature is **unchanged**;\n all existing callers continue to work without modification.\n- `FromContext` — now extracts the Identity from the stored `SecurityBag`\n internally. The function signature is **unchanged**; all existing callers\n continue to work without modification. `SetBagInContext` + `FromContext` is\n valid (returns the bag's Identity). `SetInContext` + `BagFromContext` is valid\n (returns a bag wrapping the identity with an empty attribute map).\n\n### Design Notes\n\n- `SecurityBag` is additive — no existing interface or function signature is\n modified. Starters compiled against v1.0.0 continue to work against v1.1.0\n without any code changes.\n- The framework defines no string key constants for bag attributes. All\n framework-known fields are typed fields on `Identity`. Callers define their\n own constants to avoid collisions and maintain compile-time safety at the\n type-assertion call site.\n\n---\n\n## [1.0.0] — 2026-05-27\n\n### Added\n\n**`lifecycle`**\n\n- `Component` interface — `OnInit() error` (open connections, allocate resources),\n `OnStart() error` (start background goroutines and listeners), `OnStop() error`\n (graceful shutdown and resource release); the framework calls these in strict\n phase order and shuts down components in reverse registration order\n\n**`observability`**\n\n- `Level` type — `int`-based criticality classifier for health reporting; zero value\n is `LevelCritical`, making it a safe default for any component that forgets to set it\n- `LevelCritical` constant — marks a component essential to application function;\n a failing critical component sets overall health to DOWN (HTTP 503)\n- `LevelDegraded` constant — marks a component important but not essential; a failing\n degraded component sets overall health to DEGRADED (HTTP 200), not DOWN\n- `Checkable` interface — `HealthCheck(ctx context.Context) error` (connectivity or\n liveness probe), `Name() string` (display name in health responses), `Priority() Level`\n (criticality of this component); implemented by all infrastructure components;\n consumed by the `web` starter's health handler without the data layer ever importing HTTP\n\n**`logging`**\n\n- `Logger` interface — `Debug`, `Info`, `Warn(msg string, args ...any)`, `Error(msg string, err error, args ...any)`,\n `With(args ...any) Logger`, `WithContext(ctx context.Context) Logger`; all Einherjar\n starters accept `Logger` at their constructors and pass it to sub-components — never\n the concrete implementation; `Error` treats `err` as a first-class parameter to enable\n automatic enrichment from `errs.CodedError` and `errs.ContextualError`\n\n**`errs`**\n\n- `CodedError` interface — `ErrorCode() string`; implemented by structured errors that\n carry a machine-readable SCREAMING_SNAKE_CASE code meaningful to frontend consumers;\n consumed by the `core` logger to append `error_code` automatically to every log record\n where the error satisfies this interface; internal or infrastructure errors must not\n carry a code — those get a generic fallback on the frontend\n- `ContextualError` interface — `ErrorContext() map[string]any`; implemented by\n structured errors that carry key-value context fields; consumed by the `core` logger\n to append all context fields to the log record automatically; the returned map must\n not be modified by the caller\n\n**`security`**\n\n- `Identity` struct — `UID`, `TenantID`, `DisplayName`, `Email string`; value type,\n always copied never a pointer, preventing nil-check burden and accidental mutation\n across concurrent middleware\n- `NewIdentity(uid, displayName, email string) Identity` — constructs an Identity from\n token authentication data; `TenantID` is intentionally left empty for enrichment\n in a later middleware step\n- `Identity.WithTenant(id string) Identity` — returns a copy of the Identity with\n `TenantID` set; the receiver is not mutated, safe to call from concurrent middleware\n- `SetInContext(ctx context.Context, id Identity) context.Context` — stores an Identity\n in the context using a package-private key type, preventing collisions with keys from\n other packages\n- `FromContext(ctx context.Context) (Identity, bool)` — retrieves the Identity stored by\n `SetInContext`; returns the zero-value Identity and false if no identity is present\n- `Permission` type — named `int64` bit position (0–62) representing a single\n capability; applications define their own domain constants using this type\n- `MaxPermission` constant — highest valid bit position (62); bit 63 is reserved for\n the sign bit of the underlying `int64`\n- `PermissionMask` type — resolved `int64` bit-mask for a user on a specific resource;\n zero value means no permissions granted\n- `PermissionMask.Has(p Permission) bool` — reports whether the given permission bit\n is set; returns false for out-of-range values (p \u003c 0 or p \u003e MaxPermission)\n- `PermissionMask.Grant(p Permission) PermissionMask` — returns a new mask with the\n bit for p set; the receiver is not modified, safe to use in builder chains\n- `PermissionProvider` interface — `ResolveMask(ctx context.Context, uid, resource string) (PermissionMask, error)`;\n implementations may call `FromContext` to retrieve the Identity and its TenantID\n when multi-tenancy is required\n\n### Design Notes\n\n- `contracts` has zero external dependencies and zero Einherjar dependencies. Its\n `go.mod` requires nothing beyond the Go standard library. If adding something to\n `contracts` requires a new `require` entry, it does not belong in `contracts`.\n\n- Changes flow outward from `contracts`, never inward. A starter never drives a\n contracts change. Before any modification, the blast radius is calculated: which\n interfaces are affected → which starters implement them → which starters consume\n those starters. Release sequence: `contracts` first, then implementors, then\n consumers.\n\n- The `errs` sub-package formalises a contract that previously existed only as\n private duck-typed interfaces inside micro-lib's `logz`. The two interfaces are\n intentionally separate (`CodedError` and `ContextualError` rather than one combined\n `RichError`) to honour ISP: an error may carry a code but no context fields, or\n context fields but no code. Implementors are never forced to provide both.\n\n- `Checkable` lives in `contracts/observability`, not in the `web` starter. Data\n starters (`db-postgres`, `db-sqlite`, etc.) implement `Checkable` without importing\n the HTTP layer. The health handler in `web` consumes `Checkable` — the dependency\n arrow points into the data layer, never out.\n\n- Every file in `contracts` exports exactly one type or interface. This is enforced\n structurally and mechanically by the compliance test using `go/ast` parsing. A\n reviewer reading `permission_mask.go` knows exactly what they are reading without\n opening any other file.\n\n[1.0.0]: https://code.nochebuena.dev/einherjar/contracts/releases/tag/v1.0.0\n" + "readme": "# einherjar/contracts\n\n[![version](https://img.shields.io/badge/version-v1.4.0-5C4EE5?style=flat-square)](https://code.nochebuena.dev/einherjar/contracts)\n[![license](https://img.shields.io/badge/license-AGPL--3.0-22863A?style=flat-square)](LICENSE)\n[![go](https://img.shields.io/badge/Go-1.26+-00ADD8?style=flat-square\u0026logo=go\u0026logoColor=white)](https://go.dev)\n\n\u003e *For those who come after.*\n\nThe foundation of the Einherjar framework. Pure interfaces. Zero dependencies. A constitution, not a changelog.\n\n`contracts` defines the interfaces and minimal types that every Einherjar starter depends on. Nothing is above it in the dependency graph. Its interfaces, once published, are permanent — adding a method is a breaking change in Go, and breaking changes require a major version bump and coordinated updates across the entire framework.\n\n---\n\n## What Is Einherjar\n\nIn Norse mythology, the Einherjar are the chosen — warriors who fell in battle and were carried to Valhalla by the Valkyries. There they train, day after day, preparing for Ragnarök. They are not preserved for themselves. They are prepared for what comes after.\n\nThis framework carries that name deliberately. Every interface defined here, every ADR written, every pattern documented exists for the developer who will build on this code without ever being in the room where it was designed. No tribal knowledge. No implicit conventions. The documentation is the system.\n\n---\n\n## Module\n\n```\ncode.nochebuena.dev/einherjar/contracts\n```\n\n**Dependencies:** none. This module imports only the Go standard library.\n**Stability guarantee:** existing interface signatures are permanent. New interfaces may be added. Existing ones are not modified.\n\n---\n\n## Sub-packages\n\n| Package | Purpose |\n|---|---|\n| [`lifecycle`](lifecycle/) | `Component` — three-phase managed lifecycle (OnInit → OnStart → OnStop) |\n| [`observability`](observability/) | `Checkable` + `Level` — health reporting for infrastructure components |\n| [`logging`](logging/) | `Logger` — structured, leveled logging |\n| [`errs`](errs/) | `CodedError` + `ContextualError` — structured error enrichment consumed by the logger |\n| [`security`](security/) | `Identity`, `Permission`, `PermissionMask`, `PermissionProvider` — authentication and authorization primitives |\n\n---\n\n## Usage\n\nImport only the sub-packages you need. There is no root `contracts` package to import.\n\n```go\nimport (\n \"code.nochebuena.dev/einherjar/contracts/lifecycle\"\n \"code.nochebuena.dev/einherjar/contracts/logging\"\n \"code.nochebuena.dev/einherjar/contracts/observability\"\n \"code.nochebuena.dev/einherjar/contracts/errs\"\n \"code.nochebuena.dev/einherjar/contracts/security\"\n)\n```\n\n### Implementing a managed component\n\n```go\n// MyClient satisfies lifecycle.Component and observability.Checkable.\nvar _ lifecycle.Component = (*MyClient)(nil)\nvar _ observability.Checkable = (*MyClient)(nil)\n\nfunc (c *MyClient) OnInit() error { /* open connection */ }\nfunc (c *MyClient) OnStart() error { return nil }\nfunc (c *MyClient) OnStop() error { /* close connection */ }\nfunc (c *MyClient) HealthCheck(ctx context.Context) error { /* ping */ }\nfunc (c *MyClient) Name() string { return \"my-client\" }\nfunc (c *MyClient) Priority() observability.Level { return observability.LevelCritical }\n```\n\n### Implementing structured errors\n\n```go\n// MyErr satisfies errs.CodedError and errs.ContextualError.\nvar _ errs.CodedError = (*MyErr)(nil)\nvar _ errs.ContextualError = (*MyErr)(nil)\n\nfunc (e *MyErr) ErrorCode() string { return e.code }\nfunc (e *MyErr) ErrorContext() map[string]any { return e.ctx }\n```\n\nThe logger in `core` consumes these interfaces to append `error_code` and context\nfields to every log record automatically — without either package importing the other.\n\n### Working with identity and permissions\n\n```go\n// Store identity after authentication.\nctx = security.SetInContext(ctx, security.NewIdentity(uid, name, email))\n\n// Retrieve identity downstream.\nid, ok := security.FromContext(ctx)\n\n// Resolve and check permissions.\nmask, err := provider.ResolveMask(ctx, id.UID, \"orders\")\nif !mask.Has(PermWrite) { /* deny */ }\n```\n\n---\n\n## Dependency Rules\n\n`contracts` sits at the root of the Einherjar dependency graph:\n\n```\ncontracts\n └── core (absorbs launcher, logz, xerrors, valid)\n ├── web (absorbs httpserver, httpmw, httputil, health)\n │ └── auth\n │ ├── auth-jwt\n │ └── auth-firebase\n └── db-*, cache-*, storage-*, telemetry, worker, httpclient\n```\n\n**Changes flow outward from `contracts`, never inward.** A starter never drives a\ncontracts change. Before any modification, calculate the blast radius: which interfaces\nare affected → which starters implement them → which starters consume those starters.\nRelease sequence is always contracts first, then implementors, then consumers.\n\n---\n\n## Compliance\n\n```bash\ngo build ./... # zero external dependencies\ngo vet ./...\ngo test ./... # structural (one type per file) + behavioural tests\ngofmt -l . # no output\n```\n\n---\n\n*The Einherjar did not train for themselves. They trained for Ragnarök — for the battle they knew was coming, for those who would need them to be ready. Write code the same way.*\n", + "changelog": "# Changelog\n\nAll notable changes to this module will be documented in this file.\n\nThe format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),\nand this module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n---\n\n## [1.4.0] — 2026-08-09\n\nMinor — coordinated framework release: dependency refresh + lockstep version alignment.\n\n### Changed\n\n- Refreshed dependencies to their latest minor/patch where available.\n\n## [1.3.0] — 2026-08-08\n\nMinor — coordinated framework version alignment. No code, API, or dependency changes.\n\n## [1.2.0] — 2026-08-08\n\nMinor — coordinated framework version alignment. No code, API, or dependency changes.\n\n## [1.1.3] — 2026-08-08\n\nPatch — coordinated framework version alignment. No code, API, or dependency changes.\n\n## [1.1.2] — 2026-08-08\n\nPatch — coordinated framework version alignment. No code, API, or dependency changes.\n\n## [1.1.1] — 2026-08-07\n\nPatch — coordinated framework version alignment. Released in lockstep at v1.1.1\nalongside a documentation patch in the data/infra starters. No code, API, or dependency changes.\n\n## [1.1.0] — 2026-08-07\n\n### Added\n\n**`security`**\n\n- `SecurityBag` struct — request-scoped security context that carries both the\n authenticated `Identity` and arbitrary string-keyed attributes injected during\n the enrichment phase (e.g. hardware IDs, grant codes). Value type — all mutation\n methods return a new value without modifying the receiver. The attribute map is\n copied on every `With` call to preserve immutability guarantees.\n- `NewSecurityBag(id Identity) SecurityBag` — constructs a `SecurityBag` wrapping\n the given Identity with an empty attribute map\n- `SecurityBag.Identity() Identity` — returns the authenticated Identity stored in\n the bag\n- `SecurityBag.WithIdentity(id Identity) SecurityBag` — returns a copy of the bag\n with the Identity replaced; used by bag enrichers that modify the Identity (e.g.\n applying a tenant ID from a request header)\n- `SecurityBag.Get(key string) (any, bool)` — returns the attribute stored under\n key, or `(nil, false)` if absent\n- `SecurityBag.With(key string, value any) SecurityBag` — returns a copy of the\n bag with the given key set to value; the receiver is not modified\n- `SetBagInContext(ctx context.Context, bag SecurityBag) context.Context` — stores\n the full `SecurityBag` in ctx; use instead of `SetInContext` when extra attributes\n need to travel alongside the Identity\n- `BagFromContext(ctx context.Context) (SecurityBag, bool)` — retrieves the\n `SecurityBag` stored by `SetBagInContext` or `SetInContext`; permission providers\n use this to access both the Identity and any enrichment attributes\n\n### Changed\n\n**`security`**\n\n- `SetInContext` — now stores a `SecurityBag` wrapping the Identity internally,\n replacing direct `Identity` storage. The function signature is **unchanged**;\n all existing callers continue to work without modification.\n- `FromContext` — now extracts the Identity from the stored `SecurityBag`\n internally. The function signature is **unchanged**; all existing callers\n continue to work without modification. `SetBagInContext` + `FromContext` is\n valid (returns the bag's Identity). `SetInContext` + `BagFromContext` is valid\n (returns a bag wrapping the identity with an empty attribute map).\n\n### Design Notes\n\n- `SecurityBag` is additive — no existing interface or function signature is\n modified. Starters compiled against v1.0.0 continue to work against v1.1.0\n without any code changes.\n- The framework defines no string key constants for bag attributes. All\n framework-known fields are typed fields on `Identity`. Callers define their\n own constants to avoid collisions and maintain compile-time safety at the\n type-assertion call site.\n\n---\n\n## [1.0.0] — 2026-05-27\n\n### Added\n\n**`lifecycle`**\n\n- `Component` interface — `OnInit() error` (open connections, allocate resources),\n `OnStart() error` (start background goroutines and listeners), `OnStop() error`\n (graceful shutdown and resource release); the framework calls these in strict\n phase order and shuts down components in reverse registration order\n\n**`observability`**\n\n- `Level` type — `int`-based criticality classifier for health reporting; zero value\n is `LevelCritical`, making it a safe default for any component that forgets to set it\n- `LevelCritical` constant — marks a component essential to application function;\n a failing critical component sets overall health to DOWN (HTTP 503)\n- `LevelDegraded` constant — marks a component important but not essential; a failing\n degraded component sets overall health to DEGRADED (HTTP 200), not DOWN\n- `Checkable` interface — `HealthCheck(ctx context.Context) error` (connectivity or\n liveness probe), `Name() string` (display name in health responses), `Priority() Level`\n (criticality of this component); implemented by all infrastructure components;\n consumed by the `web` starter's health handler without the data layer ever importing HTTP\n\n**`logging`**\n\n- `Logger` interface — `Debug`, `Info`, `Warn(msg string, args ...any)`, `Error(msg string, err error, args ...any)`,\n `With(args ...any) Logger`, `WithContext(ctx context.Context) Logger`; all Einherjar\n starters accept `Logger` at their constructors and pass it to sub-components — never\n the concrete implementation; `Error` treats `err` as a first-class parameter to enable\n automatic enrichment from `errs.CodedError` and `errs.ContextualError`\n\n**`errs`**\n\n- `CodedError` interface — `ErrorCode() string`; implemented by structured errors that\n carry a machine-readable SCREAMING_SNAKE_CASE code meaningful to frontend consumers;\n consumed by the `core` logger to append `error_code` automatically to every log record\n where the error satisfies this interface; internal or infrastructure errors must not\n carry a code — those get a generic fallback on the frontend\n- `ContextualError` interface — `ErrorContext() map[string]any`; implemented by\n structured errors that carry key-value context fields; consumed by the `core` logger\n to append all context fields to the log record automatically; the returned map must\n not be modified by the caller\n\n**`security`**\n\n- `Identity` struct — `UID`, `TenantID`, `DisplayName`, `Email string`; value type,\n always copied never a pointer, preventing nil-check burden and accidental mutation\n across concurrent middleware\n- `NewIdentity(uid, displayName, email string) Identity` — constructs an Identity from\n token authentication data; `TenantID` is intentionally left empty for enrichment\n in a later middleware step\n- `Identity.WithTenant(id string) Identity` — returns a copy of the Identity with\n `TenantID` set; the receiver is not mutated, safe to call from concurrent middleware\n- `SetInContext(ctx context.Context, id Identity) context.Context` — stores an Identity\n in the context using a package-private key type, preventing collisions with keys from\n other packages\n- `FromContext(ctx context.Context) (Identity, bool)` — retrieves the Identity stored by\n `SetInContext`; returns the zero-value Identity and false if no identity is present\n- `Permission` type — named `int64` bit position (0–62) representing a single\n capability; applications define their own domain constants using this type\n- `MaxPermission` constant — highest valid bit position (62); bit 63 is reserved for\n the sign bit of the underlying `int64`\n- `PermissionMask` type — resolved `int64` bit-mask for a user on a specific resource;\n zero value means no permissions granted\n- `PermissionMask.Has(p Permission) bool` — reports whether the given permission bit\n is set; returns false for out-of-range values (p \u003c 0 or p \u003e MaxPermission)\n- `PermissionMask.Grant(p Permission) PermissionMask` — returns a new mask with the\n bit for p set; the receiver is not modified, safe to use in builder chains\n- `PermissionProvider` interface — `ResolveMask(ctx context.Context, uid, resource string) (PermissionMask, error)`;\n implementations may call `FromContext` to retrieve the Identity and its TenantID\n when multi-tenancy is required\n\n### Design Notes\n\n- `contracts` has zero external dependencies and zero Einherjar dependencies. Its\n `go.mod` requires nothing beyond the Go standard library. If adding something to\n `contracts` requires a new `require` entry, it does not belong in `contracts`.\n\n- Changes flow outward from `contracts`, never inward. A starter never drives a\n contracts change. Before any modification, the blast radius is calculated: which\n interfaces are affected → which starters implement them → which starters consume\n those starters. Release sequence: `contracts` first, then implementors, then\n consumers.\n\n- The `errs` sub-package formalises a contract that previously existed only as\n private duck-typed interfaces inside micro-lib's `logz`. The two interfaces are\n intentionally separate (`CodedError` and `ContextualError` rather than one combined\n `RichError`) to honour ISP: an error may carry a code but no context fields, or\n context fields but no code. Implementors are never forced to provide both.\n\n- `Checkable` lives in `contracts/observability`, not in the `web` starter. Data\n starters (`db-postgres`, `db-sqlite`, etc.) implement `Checkable` without importing\n the HTTP layer. The health handler in `web` consumes `Checkable` — the dependency\n arrow points into the data layer, never out.\n\n- Every file in `contracts` exports exactly one type or interface. This is enforced\n structurally and mechanically by the compliance test using `go/ast` parsing. A\n reviewer reading `permission_mask.go` knows exactly what they are reading without\n opening any other file.\n\n[1.0.0]: https://code.nochebuena.dev/einherjar/contracts/releases/tag/v1.0.0\n" }, { "name": "core", @@ -4189,8 +4189,8 @@ } ] }, - "readme": "# einherjar/core\n\n[![version](https://img.shields.io/badge/version-v1.3.0-5C4EE5?style=flat-square)](https://code.nochebuena.dev/einherjar/core)\n[![license](https://img.shields.io/badge/license-AGPL--3.0-22863A?style=flat-square)](LICENSE)\n[![go](https://img.shields.io/badge/Go-1.26+-00ADD8?style=flat-square\u0026logo=go\u0026logoColor=white)](https://go.dev)\n\n\u003e The chosen warriors do not choose their weapons. They forge them.\n\n`code.nochebuena.dev/einherjar/core` is the foundational implementation module of the\nEinherjar framework. It sits directly above `contracts` in the dependency graph and\nprovides the concrete tools every service needs before anything else can start:\na lifecycle runner, a structured logger, typed errors, and struct validation.\n\n---\n\n## What Is Einherjar?\n\nIn Norse mythology, the Einherjar are the chosen warriors of Valhalla — selected not\nfor glory, but to be ready for what comes after. They train. They prepare. They build\nthe capability that others will rely on.\n\nThis framework is named for that purpose. Every module is a piece of that preparation:\nbuilt carefully, documented for those who were never in the room, and designed to hold\nunder pressure.\n\n---\n\n## Sub-packages\n\n| Package | Import path | Purpose |\n|---|---|---|\n| `launcher` | `.../core/launcher` | Application lifecycle — init, start, shutdown |\n| `logz` | `.../core/logz` | Structured, leveled logging via `log/slog` |\n| `xerrors` | `.../core/xerrors` | Typed error codes with context enrichment |\n| `valid` | `.../core/valid` | Struct validation with pluggable i18n messages |\n\nAll four are in one module because they ship together in every Einherjar service.\nSee [ADR-001](docs/adr/ADR-001-core-module-composition.md).\n\n---\n\n## Usage\n\n### Launcher\n\n```go\nimport (\n \"code.nochebuena.dev/einherjar/core/launcher\"\n \"code.nochebuena.dev/einherjar/core/logz\"\n)\n\nlogger := logz.New(logz.Config{JSON: true, StaticArgs: []any{\"service\", \"api\"}})\n\nlc := launcher.New(logger)\nlc.Append(db, cache, server)\nlc.BeforeStart(func() error {\n return server.RegisterRoutes(db, cache)\n})\n\nif err := lc.Run(); err != nil {\n logger.Error(\"launcher failed\", err)\n os.Exit(1)\n}\n```\n\nEnvironment variables (uses `caarlos0/env` tag syntax — application supplies the loader):\n\n| Variable | Default | Effect |\n|---|---|---|\n| `EINHERJAR_BANNER` | _(on)_ | Set to `off` or `false` to suppress the startup banner |\n| `EINHERJAR_COMPONENT_STOP_TIMEOUT` | `15s` | Maximum time per component `OnStop` |\n\n### Logger\n\n```go\nimport \"code.nochebuena.dev/einherjar/core/logz\"\n\nlogger := logz.New(logz.Config{Level: slog.LevelDebug, JSON: true})\n\n// Attach request context in middleware\nctx = logz.WithRequestID(ctx, requestID)\nctx = logz.WithField(ctx, \"user_id\", userID)\n\n// Enrich logger from context in handlers\nreqLogger := logger.WithContext(ctx)\nreqLogger.Info(\"handling request\", \"path\", r.URL.Path)\n\n// Error enrichment is automatic — no extra code needed\nreqLogger.Error(\"query failed\", err) // appends error_code and context fields\n```\n\nEnvironment variables:\n\n| Variable | Default | Effect |\n|---|---|---|\n| `EINHERJAR_LOG_LEVEL` | `INFO` | Minimum log level (`DEBUG`, `INFO`, `WARN`, `ERROR`) |\n| `EINHERJAR_LOG_JSON` | `false` | JSON output when `true` |\n\n### Errors\n\n```go\nimport \"code.nochebuena.dev/einherjar/core/xerrors\"\n\n// Named constructors for common cases\nerr := xerrors.NotFound(\"user %s not found\", userID)\nerr := xerrors.InvalidInput(\"email is required\")\nerr := xerrors.Aborted(\"order modified by another session\")\n\n// Builder pattern for structured context\nerr := xerrors.New(xerrors.ErrInvalidInput, \"validation failed\").\n WithContext(\"field\", \"email\").\n WithContext(\"rule\", \"required\").\n WithError(cause)\n\n// Inspecting errors\nvar e *xerrors.Err\nif errors.As(err, \u0026e) {\n switch e.Code() {\n case xerrors.ErrNotFound: // HTTP 404\n case xerrors.ErrUnauthorized: // HTTP 401\n case xerrors.ErrInvalidInput: // HTTP 400\n }\n}\n```\n\n### Validation\n\n```go\nimport \"code.nochebuena.dev/einherjar/core/valid\"\n\ntype CreateUserReq struct {\n Email string `json:\"email\" validate:\"required,email\"`\n Age int `json:\"age\" validate:\"gte=18\"`\n}\n\nv := valid.New() // English messages\n// v := valid.New(valid.WithMessageProvider(valid.SpanishMessages))\n\nif err := v.Struct(req); err != nil {\n var xe *xerrors.Err\n errors.As(err, \u0026xe)\n // xe.Code() == xerrors.ErrInvalidInput\n // xe.Fields() == {\"field\": \"email\", \"tag\": \"required\"}\n // xe.Message() == \"field 'email' is required\"\n}\n```\n\nAll go-playground/validator built-in tags have specific messages in both `DefaultMessages`\nand `SpanishMessages`. The generic fallback only fires for unknown tags.\n\n#### Custom validators\n\n```go\nimport \"strings\"\n\nv := valid.New(\n valid.WithCustomValidator(\"nohttp\", func(fl valid.FieldLevel) bool {\n return !strings.HasPrefix(fl.Field().String(), \"http://\")\n }),\n valid.WithMessageProvider(valid.OverrideProvider(\n map[string]func(field, param string) string{\n \"nohttp\": func(field, _ string) string {\n return fmt.Sprintf(\"field '%s' must not use http://\", field)\n },\n },\n valid.DefaultMessages,\n )),\n)\n```\n\n`OverrideProvider` chains a tag→message map with a fallback provider, so custom tag\nmessages are handled without re-implementing all built-ins.\n\n---\n\n## Error Codes\n\n`xerrors` provides the full gRPC canonical error code set plus HTTP 410 (`ErrGone`):\n\n| Constant | Wire value | HTTP | When to use |\n|---|---|---|---|\n| `ErrInvalidInput` | `INVALID_ARGUMENT` | 400 | Malformed or invalid request data |\n| `ErrOutOfRange` | `OUT_OF_RANGE` | 400 | Valid value but outside accepted bounds |\n| `ErrUnauthorized` | `UNAUTHENTICATED` | 401 | Missing or invalid credentials |\n| `ErrPermissionDenied` | `PERMISSION_DENIED` | 403 | Authenticated but not authorised |\n| `ErrNotFound` | `NOT_FOUND` | 404 | Resource does not exist |\n| `ErrAlreadyExists` | `ALREADY_EXISTS` | 409 | Creation conflict (duplicate) |\n| `ErrAborted` | `ABORTED` | 409 | Concurrent modification; retry may succeed |\n| `ErrGone` | `GONE` | 410 | Resource permanently deleted |\n| `ErrPreconditionFailed` | `FAILED_PRECONDITION` | 412 | Business rule blocks the operation |\n| `ErrRateLimited` | `RESOURCE_EXHAUSTED` | 429 | Rate limit or quota exceeded |\n| `ErrCancelled` | `CANCELLED` | 499 | Request cancelled by the caller |\n| `ErrInternal` | `INTERNAL` | 500 | Unexpected server-side failure |\n| `ErrDataLoss` | `DATA_LOSS` | 500 | Unrecoverable data corruption |\n| `ErrNotImplemented` | `UNIMPLEMENTED` | 501 | Operation not implemented |\n| `ErrUnavailable` | `UNAVAILABLE` | 503 | Service temporarily unavailable |\n| `ErrDeadlineExceeded` | `DEADLINE_EXCEEDED` | 504 | Operation timed out |\n\nWire values are stable across versions and safe to persist, send over the network,\nor switch on in client code.\n\n---\n\n## Dependency Rules\n\n```\ncontracts (zero dependencies)\n ↑\n core (depends on contracts only)\n ↑\n starters (depend on core + contracts)\n ↑\n your app\n```\n\n`core` imports `contracts`. Nothing above `core` in this chain may import `core`\ndirectly — they depend on starters, which compose core's sub-packages behind\nframework-specific APIs.\n\n---\n\n## Verification\n\n```bash\ncd core/\ngo build ./... # must compile clean\ngo vet ./... # no warnings\ngo test ./... # structural + behavioural compliance passes\ngofmt -l . # no output\n```\n\n---\n\n## Architecture Decisions\n\n| ADR | Title |\n|---|---|\n| [ADR-001](docs/adr/ADR-001-core-module-composition.md) | Four sub-packages in one Go module |\n| [ADR-002](docs/adr/ADR-002-logz-contracts-errs.md) | logz adopts contracts/errs instead of private duck typing |\n| [ADR-003](docs/adr/ADR-003-config-env-tags.md) | Config naming convention and caarlos0/env tag standard |\n\n---\n\n\u003e *They were not chosen because they were the strongest.*\n\u003e *They were chosen because they understood what they were building toward.*\n", - "changelog": "# Changelog — einherjar/core\n\nAll notable changes to this module are documented here.\nFormat follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).\nThis module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n---\n\n## [1.3.0] — 2026-08-08\n\nMinor — coordinated framework version alignment. No code or API changes in this module.\n\n### Changed\n\n- Bumped `contracts` to v1.3.0.\n\n## [1.2.0] — 2026-08-08\n\nMinor — coordinated framework version alignment. No code or API changes in this module.\n\n### Changed\n\n- Bumped `contracts` to v1.2.0.\n\n## [1.1.3] — 2026-08-08\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (\\`contracts\\`) to v1.1.3. No code or API changes.## [1.1.2] — 2026-08-08\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped `contracts` to v1.1.2. No code or API changes.\n\n## [1.1.1] — 2026-08-07\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped `contracts` to v1.1.1 (framework version alignment). No code or API changes.\n\n## [1.1.0] — 2026-08-07\n\nCoordinated framework release. Documentation fix plus the framework version bump.\n\n### Fixed\n\n- **Package doc examples referenced the retired `logz.Options`.** `logz.New` takes\n `logz.Config`; the `launcher` and `logz` package docs showed `logz.New(logz.Options{…})`,\n which does not compile. Both corrected to `logz.Config`, verified by compiling the example\n patterns against the real API.\n\n### Changed\n\n- Bumped `contracts` to v1.1.0 (framework version alignment).\n\n## [1.0.0] — 2026-05-28\n\n### Added\n\n#### `launcher`\n\n- `Launcher` interface — `Append`, `BeforeStart`, `Run`, `Shutdown`\n- `New(logger logging.Logger, opts ...Options) Launcher` — constructs the lifecycle\n orchestrator; takes `contracts/logging.Logger` (not the concrete `logz` type)\n- `Hook` type (`func() error`) — assembly-phase callback registered via `BeforeStart`\n- `Config` struct — `ComponentStopTimeout time.Duration` with `env:\"EINHERJAR_COMPONENT_STOP_TIMEOUT\" envDefault:\"15s\"` (`caarlos0/env` syntax)\n- Startup banner — printed to stdout before any slog output; disabled via\n `EINHERJAR_BANNER=off` or `EINHERJAR_BANNER=false`\n- Components accepted as `lifecycle.Component` from `contracts` — not a locally\n defined interface; any type that satisfies `contracts/lifecycle.Component` is\n directly compatible\n\n#### `logz`\n\n- `Config` struct — `Level slog.Level`, `JSON bool`, `StaticArgs []any`, `Writer io.Writer`\n - `Level` and `JSON` carry `env` and `envDefault` tags (`caarlos0/env` syntax); `StaticArgs` and `Writer` are programmatic-only fields without tags\n - Env vars: `EINHERJAR_LOG_LEVEL` (default `INFO`), `EINHERJAR_LOG_JSON` (default `false`)\n- `New(cfg Config) logging.Logger` — returns `contracts/logging.Logger`; the\n concrete `slogLogger` struct is unexported\n- Error enrichment — when the error passed to `Logger.Error` satisfies\n `errs.CodedError` or `errs.ContextualError` (from `contracts/errs`), the\n corresponding `error_code` field and context key-value pairs are automatically\n appended to the log record; this replaces the private duck-typed bridge used in\n micro-lib's `logz`\n- Context helpers — `WithRequestID`, `GetRequestID`, `WithField`, `WithFields`\n- `Logger.WithContext(ctx)` — extracts `request_id` and extra fields from context\n and attaches them to every subsequent log record\n\n#### `xerrors`\n\n- `Code` type (stable string wire values, gRPC-aligned)\n- **16 error code constants** — complete gRPC canonical set plus `ErrGone` (HTTP 410)\n - New over micro-lib: `ErrOutOfRange` (gRPC OUT_OF_RANGE, HTTP 400),\n `ErrAborted` (gRPC ABORTED, HTTP 409), `ErrDataLoss` (gRPC DATA_LOSS, HTTP 500)\n- `Code.Description()` — human-readable description for each code\n- `Err` struct — `code`, `message`, `err` (cause), `fields` (context), `platformCode`\n- Base constructors: `New(code, message)`, `Wrap(code, message, err)`\n- **16 convenience constructors** (one per code): `InvalidInput`, `OutOfRange`,\n `Unauthorized`, `PermissionDenied`, `NotFound`, `AlreadyExists`, `Aborted`,\n `Gone`, `PreconditionFailed`, `RateLimited`, `Cancelled`, `Internal`, `DataLoss`,\n `NotImplemented`, `Unavailable`, `DeadlineExceeded`\n- Builder methods: `WithContext`, `WithError`, `WithPlatformCode`\n- Accessors: `Code()`, `Message()`, `Fields()`, `PlatformCode()`, `Detailed()`\n- Standard interfaces: `error`, `Unwrap`, `json.Marshaler`\n- Compile-time assertions: `var _ errs.CodedError = (*Err)(nil)` and\n `var _ errs.ContextualError = (*Err)(nil)` — formalises the duck-type bridge\n from micro-lib into an explicit, verifiable contract\n\n#### `valid`\n\n- `Validator` interface — `Struct(v any) error`\n- `New(opts ...Option) Validator` — constructs a validator backed by\n `go-playground/validator/v10` (backend is hidden; never exposed in the public API)\n- `MessageProvider` interface — `Message(field, tag, param string) string`\n- `DefaultMessages` — built-in English message provider\n- `SpanishMessages` — opt-in Spanish message provider\n- `Option` type and `WithMessageProvider(mp MessageProvider) Option`\n- `FieldLevel` interface — passed to custom validator functions; exposes `Field() reflect.Value`, `Param() string`, `FieldName() string`; go-playground backend never visible\n- `WithCustomValidator(tag string, fn func(FieldLevel) bool) Option` — registers a custom validation tag at construction time; panics on empty or conflicting tag\n- `OverrideProvider(handlers map[string]func(field, param string) string, base MessageProvider) MessageProvider` — composes a tag→message handler map with a fallback; use for custom tag messages without re-implementing built-ins\n- **Full built-in tag coverage** in `DefaultMessages` and `SpanishMessages` — all go-playground/validator tags have specific messages (fields, network, strings, format, comparisons, other; ~150 tags total)\n- Field names in error context prefer the json struct tag, falling back to the Go\n field name\n- Error codes: `ErrInvalidInput` for constraint failures, `ErrInternal` for\n non-struct arguments — both returned as `*xerrors.Err`\n\n### Design Notes\n\n1. **Contracts as the source of truth.** `launcher` accepts `lifecycle.Component`\n and `logging.Logger` from `contracts`. It does not define its own lifecycle\n interface. Any type that satisfies the contracts interface is directly compatible —\n no adapters needed.\n\n2. **Duck typing replaced by explicit interfaces.** micro-lib's `logz` detected\n enrichable errors via private `errorWithCode`/`errorWithContext` interfaces.\n `core/logz` detects them via `contracts/errs.CodedError` and `contracts/errs.ContextualError`.\n The decoupling (logz does not import xerrors) is preserved; the contract is now\n visible. See [ADR-002](docs/adr/ADR-002-logz-contracts-errs.md).\n\n3. **Complete gRPC error code set.** micro-lib's `xerrors` had 13 codes. `core/xerrors`\n adds the three missing gRPC codes (`OUT_OF_RANGE`, `ABORTED`, `DATA_LOSS`) and\n provides a named convenience constructor for every code — `New()` and `Wrap()` are\n reserved for edge cases.\n\n4. **One module, four sub-packages.** The consolidation eliminates four-way version\n coordination for a set of packages that always ship and upgrade together.\n See [ADR-001](docs/adr/ADR-001-core-module-composition.md).\n\n5. **Startup banner.** The launcher prints an ASCII art banner to stdout before any\n structured log output. It is disabled via `EINHERJAR_BANNER=off`, not via code\n changes, so production deployments can suppress it without modifying the service.\n\n---\n\n[1.0.0]: https://code.nochebuena.dev/einherjar/core/releases/tag/v1.0.0\n" + "readme": "# einherjar/core\n\n[![version](https://img.shields.io/badge/version-v1.4.0-5C4EE5?style=flat-square)](https://code.nochebuena.dev/einherjar/core)\n[![license](https://img.shields.io/badge/license-AGPL--3.0-22863A?style=flat-square)](LICENSE)\n[![go](https://img.shields.io/badge/Go-1.26+-00ADD8?style=flat-square\u0026logo=go\u0026logoColor=white)](https://go.dev)\n\n\u003e The chosen warriors do not choose their weapons. They forge them.\n\n`code.nochebuena.dev/einherjar/core` is the foundational implementation module of the\nEinherjar framework. It sits directly above `contracts` in the dependency graph and\nprovides the concrete tools every service needs before anything else can start:\na lifecycle runner, a structured logger, typed errors, and struct validation.\n\n---\n\n## What Is Einherjar?\n\nIn Norse mythology, the Einherjar are the chosen warriors of Valhalla — selected not\nfor glory, but to be ready for what comes after. They train. They prepare. They build\nthe capability that others will rely on.\n\nThis framework is named for that purpose. Every module is a piece of that preparation:\nbuilt carefully, documented for those who were never in the room, and designed to hold\nunder pressure.\n\n---\n\n## Sub-packages\n\n| Package | Import path | Purpose |\n|---|---|---|\n| `launcher` | `.../core/launcher` | Application lifecycle — init, start, shutdown |\n| `logz` | `.../core/logz` | Structured, leveled logging via `log/slog` |\n| `xerrors` | `.../core/xerrors` | Typed error codes with context enrichment |\n| `valid` | `.../core/valid` | Struct validation with pluggable i18n messages |\n\nAll four are in one module because they ship together in every Einherjar service.\nSee [ADR-001](docs/adr/ADR-001-core-module-composition.md).\n\n---\n\n## Usage\n\n### Launcher\n\n```go\nimport (\n \"code.nochebuena.dev/einherjar/core/launcher\"\n \"code.nochebuena.dev/einherjar/core/logz\"\n)\n\nlogger := logz.New(logz.Config{JSON: true, StaticArgs: []any{\"service\", \"api\"}})\n\nlc := launcher.New(logger)\nlc.Append(db, cache, server)\nlc.BeforeStart(func() error {\n return server.RegisterRoutes(db, cache)\n})\n\nif err := lc.Run(); err != nil {\n logger.Error(\"launcher failed\", err)\n os.Exit(1)\n}\n```\n\nEnvironment variables (uses `caarlos0/env` tag syntax — application supplies the loader):\n\n| Variable | Default | Effect |\n|---|---|---|\n| `EINHERJAR_BANNER` | _(on)_ | Set to `off` or `false` to suppress the startup banner |\n| `EINHERJAR_COMPONENT_STOP_TIMEOUT` | `15s` | Maximum time per component `OnStop` |\n\n### Logger\n\n```go\nimport \"code.nochebuena.dev/einherjar/core/logz\"\n\nlogger := logz.New(logz.Config{Level: slog.LevelDebug, JSON: true})\n\n// Attach request context in middleware\nctx = logz.WithRequestID(ctx, requestID)\nctx = logz.WithField(ctx, \"user_id\", userID)\n\n// Enrich logger from context in handlers\nreqLogger := logger.WithContext(ctx)\nreqLogger.Info(\"handling request\", \"path\", r.URL.Path)\n\n// Error enrichment is automatic — no extra code needed\nreqLogger.Error(\"query failed\", err) // appends error_code and context fields\n```\n\nEnvironment variables:\n\n| Variable | Default | Effect |\n|---|---|---|\n| `EINHERJAR_LOG_LEVEL` | `INFO` | Minimum log level (`DEBUG`, `INFO`, `WARN`, `ERROR`) |\n| `EINHERJAR_LOG_JSON` | `false` | JSON output when `true` |\n\n### Errors\n\n```go\nimport \"code.nochebuena.dev/einherjar/core/xerrors\"\n\n// Named constructors for common cases\nerr := xerrors.NotFound(\"user %s not found\", userID)\nerr := xerrors.InvalidInput(\"email is required\")\nerr := xerrors.Aborted(\"order modified by another session\")\n\n// Builder pattern for structured context\nerr := xerrors.New(xerrors.ErrInvalidInput, \"validation failed\").\n WithContext(\"field\", \"email\").\n WithContext(\"rule\", \"required\").\n WithError(cause)\n\n// Inspecting errors\nvar e *xerrors.Err\nif errors.As(err, \u0026e) {\n switch e.Code() {\n case xerrors.ErrNotFound: // HTTP 404\n case xerrors.ErrUnauthorized: // HTTP 401\n case xerrors.ErrInvalidInput: // HTTP 400\n }\n}\n```\n\n### Validation\n\n```go\nimport \"code.nochebuena.dev/einherjar/core/valid\"\n\ntype CreateUserReq struct {\n Email string `json:\"email\" validate:\"required,email\"`\n Age int `json:\"age\" validate:\"gte=18\"`\n}\n\nv := valid.New() // English messages\n// v := valid.New(valid.WithMessageProvider(valid.SpanishMessages))\n\nif err := v.Struct(req); err != nil {\n var xe *xerrors.Err\n errors.As(err, \u0026xe)\n // xe.Code() == xerrors.ErrInvalidInput\n // xe.Fields() == {\"field\": \"email\", \"tag\": \"required\"}\n // xe.Message() == \"field 'email' is required\"\n}\n```\n\nAll go-playground/validator built-in tags have specific messages in both `DefaultMessages`\nand `SpanishMessages`. The generic fallback only fires for unknown tags.\n\n#### Custom validators\n\n```go\nimport \"strings\"\n\nv := valid.New(\n valid.WithCustomValidator(\"nohttp\", func(fl valid.FieldLevel) bool {\n return !strings.HasPrefix(fl.Field().String(), \"http://\")\n }),\n valid.WithMessageProvider(valid.OverrideProvider(\n map[string]func(field, param string) string{\n \"nohttp\": func(field, _ string) string {\n return fmt.Sprintf(\"field '%s' must not use http://\", field)\n },\n },\n valid.DefaultMessages,\n )),\n)\n```\n\n`OverrideProvider` chains a tag→message map with a fallback provider, so custom tag\nmessages are handled without re-implementing all built-ins.\n\n---\n\n## Error Codes\n\n`xerrors` provides the full gRPC canonical error code set plus HTTP 410 (`ErrGone`):\n\n| Constant | Wire value | HTTP | When to use |\n|---|---|---|---|\n| `ErrInvalidInput` | `INVALID_ARGUMENT` | 400 | Malformed or invalid request data |\n| `ErrOutOfRange` | `OUT_OF_RANGE` | 400 | Valid value but outside accepted bounds |\n| `ErrUnauthorized` | `UNAUTHENTICATED` | 401 | Missing or invalid credentials |\n| `ErrPermissionDenied` | `PERMISSION_DENIED` | 403 | Authenticated but not authorised |\n| `ErrNotFound` | `NOT_FOUND` | 404 | Resource does not exist |\n| `ErrAlreadyExists` | `ALREADY_EXISTS` | 409 | Creation conflict (duplicate) |\n| `ErrAborted` | `ABORTED` | 409 | Concurrent modification; retry may succeed |\n| `ErrGone` | `GONE` | 410 | Resource permanently deleted |\n| `ErrPreconditionFailed` | `FAILED_PRECONDITION` | 412 | Business rule blocks the operation |\n| `ErrRateLimited` | `RESOURCE_EXHAUSTED` | 429 | Rate limit or quota exceeded |\n| `ErrCancelled` | `CANCELLED` | 499 | Request cancelled by the caller |\n| `ErrInternal` | `INTERNAL` | 500 | Unexpected server-side failure |\n| `ErrDataLoss` | `DATA_LOSS` | 500 | Unrecoverable data corruption |\n| `ErrNotImplemented` | `UNIMPLEMENTED` | 501 | Operation not implemented |\n| `ErrUnavailable` | `UNAVAILABLE` | 503 | Service temporarily unavailable |\n| `ErrDeadlineExceeded` | `DEADLINE_EXCEEDED` | 504 | Operation timed out |\n\nWire values are stable across versions and safe to persist, send over the network,\nor switch on in client code.\n\n---\n\n## Dependency Rules\n\n```\ncontracts (zero dependencies)\n ↑\n core (depends on contracts only)\n ↑\n starters (depend on core + contracts)\n ↑\n your app\n```\n\n`core` imports `contracts`. Nothing above `core` in this chain may import `core`\ndirectly — they depend on starters, which compose core's sub-packages behind\nframework-specific APIs.\n\n---\n\n## Verification\n\n```bash\ncd core/\ngo build ./... # must compile clean\ngo vet ./... # no warnings\ngo test ./... # structural + behavioural compliance passes\ngofmt -l . # no output\n```\n\n---\n\n## Architecture Decisions\n\n| ADR | Title |\n|---|---|\n| [ADR-001](docs/adr/ADR-001-core-module-composition.md) | Four sub-packages in one Go module |\n| [ADR-002](docs/adr/ADR-002-logz-contracts-errs.md) | logz adopts contracts/errs instead of private duck typing |\n| [ADR-003](docs/adr/ADR-003-config-env-tags.md) | Config naming convention and caarlos0/env tag standard |\n\n---\n\n\u003e *They were not chosen because they were the strongest.*\n\u003e *They were chosen because they understood what they were building toward.*\n", + "changelog": "# Changelog — einherjar/core\n\nAll notable changes to this module are documented here.\nFormat follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).\nThis module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n---\n\n## [1.4.0] — 2026-08-09\n\nMinor — coordinated framework release: dependency refresh + lockstep version alignment.\n\n### Changed\n\n- Refreshed dependencies to their latest minor/patch where available.\n- Bumped `contracts` to v1.4.0.\n\n## [1.3.0] — 2026-08-08\n\nMinor — coordinated framework version alignment. No code or API changes in this module.\n\n### Changed\n\n- Bumped `contracts` to v1.3.0.\n\n## [1.2.0] — 2026-08-08\n\nMinor — coordinated framework version alignment. No code or API changes in this module.\n\n### Changed\n\n- Bumped `contracts` to v1.2.0.\n\n## [1.1.3] — 2026-08-08\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (\\`contracts\\`) to v1.1.3. No code or API changes.## [1.1.2] — 2026-08-08\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped `contracts` to v1.1.2. No code or API changes.\n\n## [1.1.1] — 2026-08-07\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped `contracts` to v1.1.1 (framework version alignment). No code or API changes.\n\n## [1.1.0] — 2026-08-07\n\nCoordinated framework release. Documentation fix plus the framework version bump.\n\n### Fixed\n\n- **Package doc examples referenced the retired `logz.Options`.** `logz.New` takes\n `logz.Config`; the `launcher` and `logz` package docs showed `logz.New(logz.Options{…})`,\n which does not compile. Both corrected to `logz.Config`, verified by compiling the example\n patterns against the real API.\n\n### Changed\n\n- Bumped `contracts` to v1.1.0 (framework version alignment).\n\n## [1.0.0] — 2026-05-28\n\n### Added\n\n#### `launcher`\n\n- `Launcher` interface — `Append`, `BeforeStart`, `Run`, `Shutdown`\n- `New(logger logging.Logger, opts ...Options) Launcher` — constructs the lifecycle\n orchestrator; takes `contracts/logging.Logger` (not the concrete `logz` type)\n- `Hook` type (`func() error`) — assembly-phase callback registered via `BeforeStart`\n- `Config` struct — `ComponentStopTimeout time.Duration` with `env:\"EINHERJAR_COMPONENT_STOP_TIMEOUT\" envDefault:\"15s\"` (`caarlos0/env` syntax)\n- Startup banner — printed to stdout before any slog output; disabled via\n `EINHERJAR_BANNER=off` or `EINHERJAR_BANNER=false`\n- Components accepted as `lifecycle.Component` from `contracts` — not a locally\n defined interface; any type that satisfies `contracts/lifecycle.Component` is\n directly compatible\n\n#### `logz`\n\n- `Config` struct — `Level slog.Level`, `JSON bool`, `StaticArgs []any`, `Writer io.Writer`\n - `Level` and `JSON` carry `env` and `envDefault` tags (`caarlos0/env` syntax); `StaticArgs` and `Writer` are programmatic-only fields without tags\n - Env vars: `EINHERJAR_LOG_LEVEL` (default `INFO`), `EINHERJAR_LOG_JSON` (default `false`)\n- `New(cfg Config) logging.Logger` — returns `contracts/logging.Logger`; the\n concrete `slogLogger` struct is unexported\n- Error enrichment — when the error passed to `Logger.Error` satisfies\n `errs.CodedError` or `errs.ContextualError` (from `contracts/errs`), the\n corresponding `error_code` field and context key-value pairs are automatically\n appended to the log record; this replaces the private duck-typed bridge used in\n micro-lib's `logz`\n- Context helpers — `WithRequestID`, `GetRequestID`, `WithField`, `WithFields`\n- `Logger.WithContext(ctx)` — extracts `request_id` and extra fields from context\n and attaches them to every subsequent log record\n\n#### `xerrors`\n\n- `Code` type (stable string wire values, gRPC-aligned)\n- **16 error code constants** — complete gRPC canonical set plus `ErrGone` (HTTP 410)\n - New over micro-lib: `ErrOutOfRange` (gRPC OUT_OF_RANGE, HTTP 400),\n `ErrAborted` (gRPC ABORTED, HTTP 409), `ErrDataLoss` (gRPC DATA_LOSS, HTTP 500)\n- `Code.Description()` — human-readable description for each code\n- `Err` struct — `code`, `message`, `err` (cause), `fields` (context), `platformCode`\n- Base constructors: `New(code, message)`, `Wrap(code, message, err)`\n- **16 convenience constructors** (one per code): `InvalidInput`, `OutOfRange`,\n `Unauthorized`, `PermissionDenied`, `NotFound`, `AlreadyExists`, `Aborted`,\n `Gone`, `PreconditionFailed`, `RateLimited`, `Cancelled`, `Internal`, `DataLoss`,\n `NotImplemented`, `Unavailable`, `DeadlineExceeded`\n- Builder methods: `WithContext`, `WithError`, `WithPlatformCode`\n- Accessors: `Code()`, `Message()`, `Fields()`, `PlatformCode()`, `Detailed()`\n- Standard interfaces: `error`, `Unwrap`, `json.Marshaler`\n- Compile-time assertions: `var _ errs.CodedError = (*Err)(nil)` and\n `var _ errs.ContextualError = (*Err)(nil)` — formalises the duck-type bridge\n from micro-lib into an explicit, verifiable contract\n\n#### `valid`\n\n- `Validator` interface — `Struct(v any) error`\n- `New(opts ...Option) Validator` — constructs a validator backed by\n `go-playground/validator/v10` (backend is hidden; never exposed in the public API)\n- `MessageProvider` interface — `Message(field, tag, param string) string`\n- `DefaultMessages` — built-in English message provider\n- `SpanishMessages` — opt-in Spanish message provider\n- `Option` type and `WithMessageProvider(mp MessageProvider) Option`\n- `FieldLevel` interface — passed to custom validator functions; exposes `Field() reflect.Value`, `Param() string`, `FieldName() string`; go-playground backend never visible\n- `WithCustomValidator(tag string, fn func(FieldLevel) bool) Option` — registers a custom validation tag at construction time; panics on empty or conflicting tag\n- `OverrideProvider(handlers map[string]func(field, param string) string, base MessageProvider) MessageProvider` — composes a tag→message handler map with a fallback; use for custom tag messages without re-implementing built-ins\n- **Full built-in tag coverage** in `DefaultMessages` and `SpanishMessages` — all go-playground/validator tags have specific messages (fields, network, strings, format, comparisons, other; ~150 tags total)\n- Field names in error context prefer the json struct tag, falling back to the Go\n field name\n- Error codes: `ErrInvalidInput` for constraint failures, `ErrInternal` for\n non-struct arguments — both returned as `*xerrors.Err`\n\n### Design Notes\n\n1. **Contracts as the source of truth.** `launcher` accepts `lifecycle.Component`\n and `logging.Logger` from `contracts`. It does not define its own lifecycle\n interface. Any type that satisfies the contracts interface is directly compatible —\n no adapters needed.\n\n2. **Duck typing replaced by explicit interfaces.** micro-lib's `logz` detected\n enrichable errors via private `errorWithCode`/`errorWithContext` interfaces.\n `core/logz` detects them via `contracts/errs.CodedError` and `contracts/errs.ContextualError`.\n The decoupling (logz does not import xerrors) is preserved; the contract is now\n visible. See [ADR-002](docs/adr/ADR-002-logz-contracts-errs.md).\n\n3. **Complete gRPC error code set.** micro-lib's `xerrors` had 13 codes. `core/xerrors`\n adds the three missing gRPC codes (`OUT_OF_RANGE`, `ABORTED`, `DATA_LOSS`) and\n provides a named convenience constructor for every code — `New()` and `Wrap()` are\n reserved for edge cases.\n\n4. **One module, four sub-packages.** The consolidation eliminates four-way version\n coordination for a set of packages that always ship and upgrade together.\n See [ADR-001](docs/adr/ADR-001-core-module-composition.md).\n\n5. **Startup banner.** The launcher prints an ASCII art banner to stdout before any\n structured log output. It is disabled via `EINHERJAR_BANNER=off`, not via code\n changes, so production deployments can suppress it without modifying the service.\n\n---\n\n[1.0.0]: https://code.nochebuena.dev/einherjar/core/releases/tag/v1.0.0\n" }, { "name": "db-mysql", @@ -5026,8 +5026,8 @@ } ] }, - "readme": "# einherjar/db-mysql\n\n[![version](https://img.shields.io/badge/version-v1.3.0-5C4EE5?style=flat-square)](https://code.nochebuena.dev/einherjar/db-mysql)\n[![license](https://img.shields.io/badge/license-AGPL--3.0-22863A?style=flat-square)](LICENSE)\n[![go](https://img.shields.io/badge/Go-1.26+-00ADD8?style=flat-square\u0026logo=go\u0026logoColor=white)](https://go.dev)\n[![health](https://img.shields.io/badge/health-critical-D73A49?style=flat-square)]()\n\n\u003e A warrior's deeds are committed to stone so that what they built survives the builder.\n\n`code.nochebuena.dev/einherjar/db-mysql` is the MySQL/MariaDB database component of the Einherjar framework. It wraps `database/sql` with `go-sql-driver/mysql` behind a lifecycle-aware `Component`, exposes a uniform `Executor` interface for queries and transactions, and provides a `UnitOfWork` that injects the active transaction into context — so repositories never need to know whether they are inside a transaction or not.\n\n---\n\n## Usage\n\n### Setup\n\n```go\nimport dbmysql \"code.nochebuena.dev/einherjar/db-mysql\"\n\ndb := dbmysql.New(logger, dbmysql.DefaultConfig())\nlc.Append(db) // OnInit opens pool; OnStop closes it\n// db is observability.Checkable (PING health check, LevelCritical):\nsrv.Get(\"/health\", health.NewHandler(logger, db).ServeHTTP)\n```\n\n### Querying\n\n```go\n// GetExecutor returns the pool when called outside a UnitOfWork.\nrows, err := db.GetExecutor(ctx).QueryContext(ctx, \"SELECT id, name FROM users WHERE active = ?\", true)\ndefer rows.Close()\n\nvar name string\nerr := db.GetExecutor(ctx).QueryRowContext(ctx, \"SELECT name FROM users WHERE id = ?\", id).Scan(\u0026name)\n```\n\n### Manual transaction\n\n```go\ntx, err := db.Begin(ctx)\nif err != nil {\n return err\n}\ndefer tx.Rollback()\n\n_, err = tx.ExecContext(ctx, \"UPDATE accounts SET balance = balance - ? WHERE id = ?\", amount, fromID)\nif err != nil {\n return err\n}\nreturn tx.Commit()\n```\n\n`BeginTx` is available when you need explicit isolation level control:\n\n```go\ntx, err := db.BeginTx(ctx, \u0026sql.TxOptions{Isolation: sql.LevelSerializable})\n```\n\n### Unit of work (recommended)\n\n`UnitOfWork` wraps the transaction in context so every call to `GetExecutor` inside `uow.Do` automatically returns the active transaction. Repositories require no changes.\n\n```go\nuow := dbmysql.NewUnitOfWork(logger, db)\n\nerr := uow.Do(ctx, func(ctx context.Context) error {\n _, err := db.GetExecutor(ctx).ExecContext(ctx, \"INSERT INTO orders (...) VALUES (...)\", ...)\n return err\n})\n```\n\nIf the function returns an error, the transaction is rolled back. If it returns nil, the transaction is committed.\n\n### Error handling\n\n```go\nif err := db.HandleError(someErr); err != nil {\n // MySQL error numbers mapped to xerrors:\n // 1062 ER_DUP_ENTRY → ErrAlreadyExists\n // 1216 ER_NO_REFERENCED_ROW → ErrPreconditionFailed\n // 1048 ER_BAD_NULL_ERROR → ErrInvalidInput\n // context.Canceled → ErrCancelled\n // context.DeadlineExceeded → ErrDeadlineExceeded\n}\n```\n\n`HandleError` is also available as a package-level function: `dbmysql.HandleError(err)`.\n\n---\n\n## Environment variables\n\n| Variable | Required | Default | Description |\n|---|---|---|---|\n| `EINHERJAR_MYSQL_HOST` | Yes | — | MySQL host |\n| `EINHERJAR_MYSQL_PORT` | No | `3306` | Listen port |\n| `EINHERJAR_MYSQL_USER` | Yes | — | Database user |\n| `EINHERJAR_MYSQL_PASSWORD` | Yes | — | Database password |\n| `EINHERJAR_MYSQL_NAME` | Yes | — | Database name |\n| `EINHERJAR_MYSQL_MAX_CONNS` | No | `5` | Maximum open connections |\n| `EINHERJAR_MYSQL_MIN_CONNS` | No | `2` | Minimum idle connections |\n| `EINHERJAR_MYSQL_MAX_CONN_LIFETIME` | No | `1h` | Maximum connection lifetime |\n| `EINHERJAR_MYSQL_MAX_CONN_IDLE_TIME` | No | `30m` | Maximum idle time before connection is closed |\n| `EINHERJAR_MYSQL_CHARSET` | No | `utf8mb4` | Connection charset |\n| `EINHERJAR_MYSQL_LOC` | No | `UTC` | Timezone location |\n| `EINHERJAR_MYSQL_PARSE_TIME` | No | `true` | Parse `DATE`/`DATETIME` as `time.Time` |\n\n\u003e **Note:** Set collation at the schema level (DDL), not in the DSN. MariaDB 11.4+ collation names exceed the 1-byte handshake limit in `go-sql-driver`.\n\n---\n\n## Dependency graph\n\n```\ncontracts (zero dependencies)\n ↑\n core\n ↑\ndb-mysql (contracts, core, go-sql-driver/mysql)\n ↑\n your app\n```\n\n---\n\n## Verification\n\n```bash\ncd db-mysql/\ngo build ./...\ngo vet ./...\ngo test ./...\ngofmt -l .\n```\n\n---\n\n\u003e *The hall is built before the warriors arrive.*\n\u003e *That is the only guarantee worth making.*\n", - "changelog": "# Changelog — einherjar/db-mysql\n\nAll notable changes to this module are documented here.\nFormat follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).\nThis module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n---\n\n## [1.3.0] — 2026-08-08\n\nMinor — coordinated framework version alignment. No code or API changes in this module.\n\n### Changed\n\n- Bumped `contracts`, `core` to v1.3.0.\n\n## [1.2.0] — 2026-08-08\n\nMinor — coordinated framework version alignment. No code or API changes in this module.\n\n### Changed\n\n- Bumped `contracts`, `core` to v1.2.0.\n\n## [1.1.3] — 2026-08-08\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (\\`contracts\\`, \\`core\\`) to v1.1.3. No code or API changes.## [1.1.2] — 2026-08-08\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`, `core`) to v1.1.2. No code or API changes.\n\n## [1.1.1] — 2026-08-07\n\nPatch — corrected package-doc wiring examples plus framework version alignment.\n\n### Fixed\n\n- Doc examples used a fictional `launcher.Register` / `health.Register` API; corrected to\n `lc.Append` + `web/health.NewHandler(...).ServeHTTP`.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`, `core`) to v1.1.1.\n\n## [1.1.0] — 2026-08-07\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`, `core`) to v1.1.0.\n\n## [1.0.0] — 2026-05-28\n\nInitial release. See the README for the full API and the `v1.0.0` git tag for the source.\n" + "readme": "# einherjar/db-mysql\n\n[![version](https://img.shields.io/badge/version-v1.4.0-5C4EE5?style=flat-square)](https://code.nochebuena.dev/einherjar/db-mysql)\n[![license](https://img.shields.io/badge/license-AGPL--3.0-22863A?style=flat-square)](LICENSE)\n[![go](https://img.shields.io/badge/Go-1.26+-00ADD8?style=flat-square\u0026logo=go\u0026logoColor=white)](https://go.dev)\n[![health](https://img.shields.io/badge/health-critical-D73A49?style=flat-square)]()\n\n\u003e A warrior's deeds are committed to stone so that what they built survives the builder.\n\n`code.nochebuena.dev/einherjar/db-mysql` is the MySQL/MariaDB database component of the Einherjar framework. It wraps `database/sql` with `go-sql-driver/mysql` behind a lifecycle-aware `Component`, exposes a uniform `Executor` interface for queries and transactions, and provides a `UnitOfWork` that injects the active transaction into context — so repositories never need to know whether they are inside a transaction or not.\n\n---\n\n## Usage\n\n### Setup\n\n```go\nimport dbmysql \"code.nochebuena.dev/einherjar/db-mysql\"\n\ndb := dbmysql.New(logger, dbmysql.DefaultConfig())\nlc.Append(db) // OnInit opens pool; OnStop closes it\n// db is observability.Checkable (PING health check, LevelCritical):\nsrv.Get(\"/health\", health.NewHandler(logger, db).ServeHTTP)\n```\n\n### Querying\n\n```go\n// GetExecutor returns the pool when called outside a UnitOfWork.\nrows, err := db.GetExecutor(ctx).QueryContext(ctx, \"SELECT id, name FROM users WHERE active = ?\", true)\ndefer rows.Close()\n\nvar name string\nerr := db.GetExecutor(ctx).QueryRowContext(ctx, \"SELECT name FROM users WHERE id = ?\", id).Scan(\u0026name)\n```\n\n### Manual transaction\n\n```go\ntx, err := db.Begin(ctx)\nif err != nil {\n return err\n}\ndefer tx.Rollback()\n\n_, err = tx.ExecContext(ctx, \"UPDATE accounts SET balance = balance - ? WHERE id = ?\", amount, fromID)\nif err != nil {\n return err\n}\nreturn tx.Commit()\n```\n\n`BeginTx` is available when you need explicit isolation level control:\n\n```go\ntx, err := db.BeginTx(ctx, \u0026sql.TxOptions{Isolation: sql.LevelSerializable})\n```\n\n### Unit of work (recommended)\n\n`UnitOfWork` wraps the transaction in context so every call to `GetExecutor` inside `uow.Do` automatically returns the active transaction. Repositories require no changes.\n\n```go\nuow := dbmysql.NewUnitOfWork(logger, db)\n\nerr := uow.Do(ctx, func(ctx context.Context) error {\n _, err := db.GetExecutor(ctx).ExecContext(ctx, \"INSERT INTO orders (...) VALUES (...)\", ...)\n return err\n})\n```\n\nIf the function returns an error, the transaction is rolled back. If it returns nil, the transaction is committed.\n\n### Error handling\n\n```go\nif err := db.HandleError(someErr); err != nil {\n // MySQL error numbers mapped to xerrors:\n // 1062 ER_DUP_ENTRY → ErrAlreadyExists\n // 1216 ER_NO_REFERENCED_ROW → ErrPreconditionFailed\n // 1048 ER_BAD_NULL_ERROR → ErrInvalidInput\n // context.Canceled → ErrCancelled\n // context.DeadlineExceeded → ErrDeadlineExceeded\n}\n```\n\n`HandleError` is also available as a package-level function: `dbmysql.HandleError(err)`.\n\n---\n\n## Environment variables\n\n| Variable | Required | Default | Description |\n|---|---|---|---|\n| `EINHERJAR_MYSQL_HOST` | Yes | — | MySQL host |\n| `EINHERJAR_MYSQL_PORT` | No | `3306` | Listen port |\n| `EINHERJAR_MYSQL_USER` | Yes | — | Database user |\n| `EINHERJAR_MYSQL_PASSWORD` | Yes | — | Database password |\n| `EINHERJAR_MYSQL_NAME` | Yes | — | Database name |\n| `EINHERJAR_MYSQL_MAX_CONNS` | No | `5` | Maximum open connections |\n| `EINHERJAR_MYSQL_MIN_CONNS` | No | `2` | Minimum idle connections |\n| `EINHERJAR_MYSQL_MAX_CONN_LIFETIME` | No | `1h` | Maximum connection lifetime |\n| `EINHERJAR_MYSQL_MAX_CONN_IDLE_TIME` | No | `30m` | Maximum idle time before connection is closed |\n| `EINHERJAR_MYSQL_CHARSET` | No | `utf8mb4` | Connection charset |\n| `EINHERJAR_MYSQL_LOC` | No | `UTC` | Timezone location |\n| `EINHERJAR_MYSQL_PARSE_TIME` | No | `true` | Parse `DATE`/`DATETIME` as `time.Time` |\n\n\u003e **Note:** Set collation at the schema level (DDL), not in the DSN. MariaDB 11.4+ collation names exceed the 1-byte handshake limit in `go-sql-driver`.\n\n---\n\n## Dependency graph\n\n```\ncontracts (zero dependencies)\n ↑\n core\n ↑\ndb-mysql (contracts, core, go-sql-driver/mysql)\n ↑\n your app\n```\n\n---\n\n## Verification\n\n```bash\ncd db-mysql/\ngo build ./...\ngo vet ./...\ngo test ./...\ngofmt -l .\n```\n\n---\n\n\u003e *The hall is built before the warriors arrive.*\n\u003e *That is the only guarantee worth making.*\n", + "changelog": "# Changelog — einherjar/db-mysql\n\nAll notable changes to this module are documented here.\nFormat follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).\nThis module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n---\n\n## [1.4.0] — 2026-08-09\n\nMinor — coordinated framework release: dependency refresh + lockstep version alignment.\n\n### Changed\n\n- Refreshed dependencies to their latest minor/patch where available.\n- Bumped `contracts`, `core` to v1.4.0.\n\n## [1.3.0] — 2026-08-08\n\nMinor — coordinated framework version alignment. No code or API changes in this module.\n\n### Changed\n\n- Bumped `contracts`, `core` to v1.3.0.\n\n## [1.2.0] — 2026-08-08\n\nMinor — coordinated framework version alignment. No code or API changes in this module.\n\n### Changed\n\n- Bumped `contracts`, `core` to v1.2.0.\n\n## [1.1.3] — 2026-08-08\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (\\`contracts\\`, \\`core\\`) to v1.1.3. No code or API changes.## [1.1.2] — 2026-08-08\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`, `core`) to v1.1.2. No code or API changes.\n\n## [1.1.1] — 2026-08-07\n\nPatch — corrected package-doc wiring examples plus framework version alignment.\n\n### Fixed\n\n- Doc examples used a fictional `launcher.Register` / `health.Register` API; corrected to\n `lc.Append` + `web/health.NewHandler(...).ServeHTTP`.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`, `core`) to v1.1.1.\n\n## [1.1.0] — 2026-08-07\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`, `core`) to v1.1.0.\n\n## [1.0.0] — 2026-05-28\n\nInitial release. See the README for the full API and the `v1.0.0` git tag for the source.\n" }, { "name": "db-postgres", @@ -5858,8 +5858,8 @@ } ] }, - "readme": "# einherjar/db-postgres\n\n[![version](https://img.shields.io/badge/version-v1.3.0-5C4EE5?style=flat-square)](https://code.nochebuena.dev/einherjar/db-postgres)\n[![license](https://img.shields.io/badge/license-AGPL--3.0-22863A?style=flat-square)](LICENSE)\n[![go](https://img.shields.io/badge/Go-1.26+-00ADD8?style=flat-square\u0026logo=go\u0026logoColor=white)](https://go.dev)\n[![health](https://img.shields.io/badge/health-critical-D73A49?style=flat-square)]()\n\n\u003e The runes carved in stone do not fade when the runemaster dies. They outlast the hand that carved them.\n\n`code.nochebuena.dev/einherjar/db-postgres` is the PostgreSQL database component of the Einherjar framework. It wraps `pgxpool` behind a lifecycle-aware `Component`, exposes a uniform `Executor` interface for queries and transactions, and provides a `UnitOfWork` that injects the active transaction into context — so repositories never need to know whether they are inside a transaction or not.\n\n---\n\n## Usage\n\n### Setup\n\n```go\nimport dbpg \"code.nochebuena.dev/einherjar/db-postgres\"\n\ndb := dbpg.New(logger, dbpg.DefaultConfig())\nlc.Append(db) // OnInit opens pool; OnStop closes it\n// db is observability.Checkable (PING health check, LevelCritical):\nsrv.Get(\"/health\", health.NewHandler(logger, db).ServeHTTP)\n```\n\n### Querying\n\n```go\n// GetExecutor returns the pool when called outside a UnitOfWork.\nrows, err := db.GetExecutor(ctx).Query(ctx, \"SELECT id, name FROM users WHERE active = $1\", true)\ndefer rows.Close()\n\nvar name string\nerr := db.GetExecutor(ctx).QueryRow(ctx, \"SELECT name FROM users WHERE id = $1\", id).Scan(\u0026name)\n```\n\n### Manual transaction\n\n```go\ntx, err := db.Begin(ctx)\nif err != nil {\n return err\n}\ndefer tx.Rollback(ctx)\n\n_, err = tx.Exec(ctx, \"UPDATE accounts SET balance = balance - $1 WHERE id = $2\", amount, fromID)\nif err != nil {\n return err\n}\nreturn tx.Commit(ctx)\n```\n\n`BeginTx` is available when you need explicit isolation level control:\n\n```go\ntx, err := db.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.Serializable})\n```\n\n### Unit of work (recommended)\n\n`UnitOfWork` wraps the transaction in context so every call to `GetExecutor` inside `uow.Do` automatically returns the active transaction. Repositories require no changes.\n\n```go\nuow := dbpg.NewUnitOfWork(logger, db)\n\nerr := uow.Do(ctx, func(ctx context.Context) error {\n _, err := db.GetExecutor(ctx).Exec(ctx, \"INSERT INTO orders (...) VALUES (...)\", ...)\n return err\n})\n```\n\nIf the function returns an error, the transaction is rolled back. If it returns nil, the transaction is committed.\n\n### Error handling\n\n```go\nif err := db.HandleError(someErr); err != nil {\n // pgconn error codes mapped to xerrors:\n // 23505 unique_violation → ErrAlreadyExists\n // 23503 foreign_key_violation → ErrPreconditionFailed\n // 23502 not_null_violation → ErrInvalidInput\n // context.Canceled → ErrCancelled\n // context.DeadlineExceeded → ErrDeadlineExceeded\n}\n```\n\n`HandleError` is also available as a package-level function: `dbpg.HandleError(err)`.\n\n---\n\n## Environment variables\n\n| Variable | Required | Default | Description |\n|---|---|---|---|\n| `EINHERJAR_PG_HOST` | Yes | — | PostgreSQL host |\n| `EINHERJAR_PG_PORT` | No | `5432` | Listen port |\n| `EINHERJAR_PG_USER` | Yes | — | Database user |\n| `EINHERJAR_PG_PASSWORD` | Yes | — | Database password |\n| `EINHERJAR_PG_NAME` | Yes | — | Database name |\n| `EINHERJAR_PG_SSL_MODE` | No | `disable` | `disable`, `require`, `verify-full` |\n| `EINHERJAR_PG_TIMEZONE` | No | `UTC` | Session timezone |\n| `EINHERJAR_PG_MAX_CONNS` | No | `5` | Maximum connections in pool |\n| `EINHERJAR_PG_MIN_CONNS` | No | `2` | Minimum idle connections |\n| `EINHERJAR_PG_MAX_CONN_LIFETIME` | No | `1h` | Maximum connection lifetime |\n| `EINHERJAR_PG_MAX_CONN_IDLE_TIME` | No | `30m` | Maximum idle time before connection is closed |\n| `EINHERJAR_PG_HEALTH_CHECK_PERIOD` | No | `1m` | pgxpool background health check interval |\n\n---\n\n## Dependency graph\n\n```\ncontracts (zero dependencies)\n ↑\n core\n ↑\ndb-postgres (contracts, core, pgx/v5, pgerrcode)\n ↑\n your app\n```\n\n---\n\n## Verification\n\n```bash\ncd db-postgres/\ngo build ./...\ngo vet ./...\ngo test ./...\ngofmt -l .\n```\n\n---\n\n\u003e *The hall is built before the warriors arrive.*\n\u003e *That is the only guarantee worth making.*\n", - "changelog": "# Changelog — einherjar/db-postgres\n\nAll notable changes to this module are documented here.\nFormat follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).\nThis module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n---\n\n## [1.3.0] — 2026-08-08\n\nMinor — coordinated framework version alignment. No code or API changes in this module.\n\n### Changed\n\n- Bumped `contracts`, `core` to v1.3.0.\n\n## [1.2.0] — 2026-08-08\n\nMinor — coordinated framework version alignment. No code or API changes in this module.\n\n### Changed\n\n- Bumped `contracts`, `core` to v1.2.0.\n\n## [1.1.3] — 2026-08-08\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (\\`contracts\\`, \\`core\\`) to v1.1.3. No code or API changes.## [1.1.2] — 2026-08-08\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`, `core`) to v1.1.2. No code or API changes.\n\n## [1.1.1] — 2026-08-07\n\nPatch — corrected package-doc wiring examples plus framework version alignment.\n\n### Fixed\n\n- Doc examples used a fictional `launcher.Register` / `health.Register` API; corrected to\n `lc.Append` + `web/health.NewHandler(...).ServeHTTP`.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`, `core`) to v1.1.1.\n\n## [1.1.0] — 2026-08-07\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`, `core`) to v1.1.0.\n\n## [1.0.0] — 2026-05-28\n\nInitial release. See the README for the full API and the `v1.0.0` git tag for the source.\n" + "readme": "# einherjar/db-postgres\n\n[![version](https://img.shields.io/badge/version-v1.4.0-5C4EE5?style=flat-square)](https://code.nochebuena.dev/einherjar/db-postgres)\n[![license](https://img.shields.io/badge/license-AGPL--3.0-22863A?style=flat-square)](LICENSE)\n[![go](https://img.shields.io/badge/Go-1.26+-00ADD8?style=flat-square\u0026logo=go\u0026logoColor=white)](https://go.dev)\n[![health](https://img.shields.io/badge/health-critical-D73A49?style=flat-square)]()\n\n\u003e The runes carved in stone do not fade when the runemaster dies. They outlast the hand that carved them.\n\n`code.nochebuena.dev/einherjar/db-postgres` is the PostgreSQL database component of the Einherjar framework. It wraps `pgxpool` behind a lifecycle-aware `Component`, exposes a uniform `Executor` interface for queries and transactions, and provides a `UnitOfWork` that injects the active transaction into context — so repositories never need to know whether they are inside a transaction or not.\n\n---\n\n## Usage\n\n### Setup\n\n```go\nimport dbpg \"code.nochebuena.dev/einherjar/db-postgres\"\n\ndb := dbpg.New(logger, dbpg.DefaultConfig())\nlc.Append(db) // OnInit opens pool; OnStop closes it\n// db is observability.Checkable (PING health check, LevelCritical):\nsrv.Get(\"/health\", health.NewHandler(logger, db).ServeHTTP)\n```\n\n### Querying\n\n```go\n// GetExecutor returns the pool when called outside a UnitOfWork.\nrows, err := db.GetExecutor(ctx).Query(ctx, \"SELECT id, name FROM users WHERE active = $1\", true)\ndefer rows.Close()\n\nvar name string\nerr := db.GetExecutor(ctx).QueryRow(ctx, \"SELECT name FROM users WHERE id = $1\", id).Scan(\u0026name)\n```\n\n### Manual transaction\n\n```go\ntx, err := db.Begin(ctx)\nif err != nil {\n return err\n}\ndefer tx.Rollback(ctx)\n\n_, err = tx.Exec(ctx, \"UPDATE accounts SET balance = balance - $1 WHERE id = $2\", amount, fromID)\nif err != nil {\n return err\n}\nreturn tx.Commit(ctx)\n```\n\n`BeginTx` is available when you need explicit isolation level control:\n\n```go\ntx, err := db.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.Serializable})\n```\n\n### Unit of work (recommended)\n\n`UnitOfWork` wraps the transaction in context so every call to `GetExecutor` inside `uow.Do` automatically returns the active transaction. Repositories require no changes.\n\n```go\nuow := dbpg.NewUnitOfWork(logger, db)\n\nerr := uow.Do(ctx, func(ctx context.Context) error {\n _, err := db.GetExecutor(ctx).Exec(ctx, \"INSERT INTO orders (...) VALUES (...)\", ...)\n return err\n})\n```\n\nIf the function returns an error, the transaction is rolled back. If it returns nil, the transaction is committed.\n\n### Error handling\n\n```go\nif err := db.HandleError(someErr); err != nil {\n // pgconn error codes mapped to xerrors:\n // 23505 unique_violation → ErrAlreadyExists\n // 23503 foreign_key_violation → ErrPreconditionFailed\n // 23502 not_null_violation → ErrInvalidInput\n // context.Canceled → ErrCancelled\n // context.DeadlineExceeded → ErrDeadlineExceeded\n}\n```\n\n`HandleError` is also available as a package-level function: `dbpg.HandleError(err)`.\n\n---\n\n## Environment variables\n\n| Variable | Required | Default | Description |\n|---|---|---|---|\n| `EINHERJAR_PG_HOST` | Yes | — | PostgreSQL host |\n| `EINHERJAR_PG_PORT` | No | `5432` | Listen port |\n| `EINHERJAR_PG_USER` | Yes | — | Database user |\n| `EINHERJAR_PG_PASSWORD` | Yes | — | Database password |\n| `EINHERJAR_PG_NAME` | Yes | — | Database name |\n| `EINHERJAR_PG_SSL_MODE` | No | `disable` | `disable`, `require`, `verify-full` |\n| `EINHERJAR_PG_TIMEZONE` | No | `UTC` | Session timezone |\n| `EINHERJAR_PG_MAX_CONNS` | No | `5` | Maximum connections in pool |\n| `EINHERJAR_PG_MIN_CONNS` | No | `2` | Minimum idle connections |\n| `EINHERJAR_PG_MAX_CONN_LIFETIME` | No | `1h` | Maximum connection lifetime |\n| `EINHERJAR_PG_MAX_CONN_IDLE_TIME` | No | `30m` | Maximum idle time before connection is closed |\n| `EINHERJAR_PG_HEALTH_CHECK_PERIOD` | No | `1m` | pgxpool background health check interval |\n\n---\n\n## Dependency graph\n\n```\ncontracts (zero dependencies)\n ↑\n core\n ↑\ndb-postgres (contracts, core, pgx/v5, pgerrcode)\n ↑\n your app\n```\n\n---\n\n## Verification\n\n```bash\ncd db-postgres/\ngo build ./...\ngo vet ./...\ngo test ./...\ngofmt -l .\n```\n\n---\n\n\u003e *The hall is built before the warriors arrive.*\n\u003e *That is the only guarantee worth making.*\n", + "changelog": "# Changelog — einherjar/db-postgres\n\nAll notable changes to this module are documented here.\nFormat follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).\nThis module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n---\n\n## [1.4.0] — 2026-08-09\n\nMinor — coordinated framework release: dependency refresh + lockstep version alignment.\n\n### Changed\n\n- Refreshed dependencies to their latest minor/patch where available.\n- Bumped `contracts`, `core` to v1.4.0.\n\n## [1.3.0] — 2026-08-08\n\nMinor — coordinated framework version alignment. No code or API changes in this module.\n\n### Changed\n\n- Bumped `contracts`, `core` to v1.3.0.\n\n## [1.2.0] — 2026-08-08\n\nMinor — coordinated framework version alignment. No code or API changes in this module.\n\n### Changed\n\n- Bumped `contracts`, `core` to v1.2.0.\n\n## [1.1.3] — 2026-08-08\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (\\`contracts\\`, \\`core\\`) to v1.1.3. No code or API changes.## [1.1.2] — 2026-08-08\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`, `core`) to v1.1.2. No code or API changes.\n\n## [1.1.1] — 2026-08-07\n\nPatch — corrected package-doc wiring examples plus framework version alignment.\n\n### Fixed\n\n- Doc examples used a fictional `launcher.Register` / `health.Register` API; corrected to\n `lc.Append` + `web/health.NewHandler(...).ServeHTTP`.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`, `core`) to v1.1.1.\n\n## [1.1.0] — 2026-08-07\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`, `core`) to v1.1.0.\n\n## [1.0.0] — 2026-05-28\n\nInitial release. See the README for the full API and the `v1.0.0` git tag for the source.\n" }, { "name": "db-sqlite", @@ -6667,8 +6667,8 @@ } ] }, - "readme": "# einherjar/db-sqlite\n\n[![version](https://img.shields.io/badge/version-v1.3.0-5C4EE5?style=flat-square)](https://code.nochebuena.dev/einherjar/db-sqlite)\n[![license](https://img.shields.io/badge/license-AGPL--3.0-22863A?style=flat-square)](LICENSE)\n[![go](https://img.shields.io/badge/Go-1.26+-00ADD8?style=flat-square\u0026logo=go\u0026logoColor=white)](https://go.dev)\n[![health](https://img.shields.io/badge/health-degraded-E36209?style=flat-square)]()\n\n\u003e Not every hall needs pillars that reach the sky. Sometimes what matters fits in a single room, carried wherever the warrior goes.\n\n`code.nochebuena.dev/einherjar/db-sqlite` is the SQLite database component of the Einherjar framework. It uses the pure-Go `modernc.org/sqlite` driver (no CGO, cross-compilation works without a C toolchain), wraps `database/sql` behind a lifecycle-aware `Component`, and serializes writes through a mutex to prevent `SQLITE_BUSY` under concurrent goroutines. WAL mode is enabled by default.\n\n---\n\n## Usage\n\n### Setup\n\n```go\nimport dbsqlite \"code.nochebuena.dev/einherjar/db-sqlite\"\n\ndb := dbsqlite.New(logger, dbsqlite.DefaultConfig())\nlc.Append(db) // OnInit opens database; OnStop closes it\n// db is observability.Checkable (LevelDegraded):\nsrv.Get(\"/health\", health.NewHandler(logger, db).ServeHTTP)\n```\n\n### Querying\n\n```go\n// GetExecutor returns the pool when called outside a UnitOfWork.\nrows, err := db.GetExecutor(ctx).QueryContext(ctx, \"SELECT id, name FROM users WHERE active = ?\", true)\ndefer rows.Close()\n\nvar name string\nerr := db.GetExecutor(ctx).QueryRowContext(ctx, \"SELECT name FROM users WHERE id = ?\", id).Scan(\u0026name)\n```\n\n### Manual transaction\n\n```go\ntx, err := db.Begin(ctx)\nif err != nil {\n return err\n}\ndefer tx.Rollback()\n\n_, err = tx.ExecContext(ctx, \"UPDATE accounts SET balance = balance - ? WHERE id = ?\", amount, fromID)\nif err != nil {\n return err\n}\nreturn tx.Commit()\n```\n\nNote: `Commit` and `Rollback` do not accept a context — this is a `database/sql` limitation.\n\n### Unit of work (recommended)\n\n`UnitOfWork` wraps the transaction in context so every call to `GetExecutor` inside `uow.Do` automatically returns the active transaction. The internal write mutex prevents concurrent `SQLITE_BUSY` errors; only one goroutine can write at a time.\n\n```go\nuow := dbsqlite.NewUnitOfWork(logger, db)\n\nerr := uow.Do(ctx, func(ctx context.Context) error {\n _, err := db.GetExecutor(ctx).ExecContext(ctx, \"INSERT INTO orders (...) VALUES (...)\", ...)\n return err\n})\n```\n\nIf the function returns an error, the transaction is rolled back. If it returns nil, the transaction is committed.\n\n### Error handling\n\n```go\nif err := db.HandleError(someErr); err != nil {\n // SQLite error codes mapped to xerrors:\n // UNIQUE constraint failed → ErrAlreadyExists\n // FOREIGN KEY constraint → ErrPreconditionFailed\n // NOT NULL constraint → ErrInvalidInput\n // context.Canceled → ErrCancelled\n // context.DeadlineExceeded → ErrDeadlineExceeded\n}\n```\n\n`HandleError` is also available as a package-level function: `dbsqlite.HandleError(err)`.\n\n---\n\n## Environment variables\n\n| Variable | Required | Default | Description |\n|---|---|---|---|\n| `EINHERJAR_SQLITE_PATH` | Yes | — | Path to the SQLite database file |\n| `EINHERJAR_SQLITE_MAX_OPEN_CONNS` | No | `1` | Maximum open connections (keep at 1 for writes) |\n| `EINHERJAR_SQLITE_MAX_IDLE_CONNS` | No | `1` | Maximum idle connections |\n| `EINHERJAR_SQLITE_PRAGMAS` | No | `?_journal=WAL\u0026_timeout=5000\u0026_fk=true` | DSN pragma string |\n\nThe default pragma string enables WAL mode (better concurrent reads), a 5-second busy timeout, and foreign key enforcement.\n\n---\n\n## Dependency graph\n\n```\ncontracts (zero dependencies)\n ↑\n core\n ↑\ndb-sqlite (contracts, core, modernc.org/sqlite)\n ↑\n your app\n```\n\nNo CGO. Cross-compiles to any GOOS/GOARCH without a C toolchain.\n\n---\n\n## Verification\n\n```bash\ncd db-sqlite/\ngo build ./...\ngo vet ./...\ngo test ./...\ngofmt -l .\n```\n\n---\n\n\u003e *The hall is built before the warriors arrive.*\n\u003e *That is the only guarantee worth making.*\n", - "changelog": "# Changelog — einherjar/db-sqlite\n\nAll notable changes to this module are documented here.\nFormat follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).\nThis module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n---\n\n## [1.3.0] — 2026-08-08\n\nMinor — coordinated framework version alignment. No code or API changes in this module.\n\n### Changed\n\n- Bumped `contracts`, `core` to v1.3.0.\n\n## [1.2.0] — 2026-08-08\n\nMinor — coordinated framework version alignment. No code or API changes in this module.\n\n### Changed\n\n- Bumped `contracts`, `core` to v1.2.0.\n\n## [1.1.3] — 2026-08-08\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (\\`contracts\\`, \\`core\\`) to v1.1.3. No code or API changes.## [1.1.2] — 2026-08-08\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`, `core`) to v1.1.2. No code or API changes.\n\n## [1.1.1] — 2026-08-07\n\nPatch — corrected package-doc wiring examples plus framework version alignment.\n\n### Fixed\n\n- Doc examples used a fictional `launcher.Register` / `health.Register` API; corrected to\n `lc.Append` + `web/health.NewHandler(...).ServeHTTP`.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`, `core`) to v1.1.1.\n\n## [1.1.0] — 2026-08-07\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`, `core`) to v1.1.0.\n\n## [1.0.0] — 2026-05-28\n\nInitial release. See the README for the full API and the `v1.0.0` git tag for the source.\n" + "readme": "# einherjar/db-sqlite\n\n[![version](https://img.shields.io/badge/version-v1.4.0-5C4EE5?style=flat-square)](https://code.nochebuena.dev/einherjar/db-sqlite)\n[![license](https://img.shields.io/badge/license-AGPL--3.0-22863A?style=flat-square)](LICENSE)\n[![go](https://img.shields.io/badge/Go-1.26+-00ADD8?style=flat-square\u0026logo=go\u0026logoColor=white)](https://go.dev)\n[![health](https://img.shields.io/badge/health-degraded-E36209?style=flat-square)]()\n\n\u003e Not every hall needs pillars that reach the sky. Sometimes what matters fits in a single room, carried wherever the warrior goes.\n\n`code.nochebuena.dev/einherjar/db-sqlite` is the SQLite database component of the Einherjar framework. It uses the pure-Go `modernc.org/sqlite` driver (no CGO, cross-compilation works without a C toolchain), wraps `database/sql` behind a lifecycle-aware `Component`, and serializes writes through a mutex to prevent `SQLITE_BUSY` under concurrent goroutines. WAL mode is enabled by default.\n\n---\n\n## Usage\n\n### Setup\n\n```go\nimport dbsqlite \"code.nochebuena.dev/einherjar/db-sqlite\"\n\ndb := dbsqlite.New(logger, dbsqlite.DefaultConfig())\nlc.Append(db) // OnInit opens database; OnStop closes it\n// db is observability.Checkable (LevelDegraded):\nsrv.Get(\"/health\", health.NewHandler(logger, db).ServeHTTP)\n```\n\n### Querying\n\n```go\n// GetExecutor returns the pool when called outside a UnitOfWork.\nrows, err := db.GetExecutor(ctx).QueryContext(ctx, \"SELECT id, name FROM users WHERE active = ?\", true)\ndefer rows.Close()\n\nvar name string\nerr := db.GetExecutor(ctx).QueryRowContext(ctx, \"SELECT name FROM users WHERE id = ?\", id).Scan(\u0026name)\n```\n\n### Manual transaction\n\n```go\ntx, err := db.Begin(ctx)\nif err != nil {\n return err\n}\ndefer tx.Rollback()\n\n_, err = tx.ExecContext(ctx, \"UPDATE accounts SET balance = balance - ? WHERE id = ?\", amount, fromID)\nif err != nil {\n return err\n}\nreturn tx.Commit()\n```\n\nNote: `Commit` and `Rollback` do not accept a context — this is a `database/sql` limitation.\n\n### Unit of work (recommended)\n\n`UnitOfWork` wraps the transaction in context so every call to `GetExecutor` inside `uow.Do` automatically returns the active transaction. The internal write mutex prevents concurrent `SQLITE_BUSY` errors; only one goroutine can write at a time.\n\n```go\nuow := dbsqlite.NewUnitOfWork(logger, db)\n\nerr := uow.Do(ctx, func(ctx context.Context) error {\n _, err := db.GetExecutor(ctx).ExecContext(ctx, \"INSERT INTO orders (...) VALUES (...)\", ...)\n return err\n})\n```\n\nIf the function returns an error, the transaction is rolled back. If it returns nil, the transaction is committed.\n\n### Error handling\n\n```go\nif err := db.HandleError(someErr); err != nil {\n // SQLite error codes mapped to xerrors:\n // UNIQUE constraint failed → ErrAlreadyExists\n // FOREIGN KEY constraint → ErrPreconditionFailed\n // NOT NULL constraint → ErrInvalidInput\n // context.Canceled → ErrCancelled\n // context.DeadlineExceeded → ErrDeadlineExceeded\n}\n```\n\n`HandleError` is also available as a package-level function: `dbsqlite.HandleError(err)`.\n\n---\n\n## Environment variables\n\n| Variable | Required | Default | Description |\n|---|---|---|---|\n| `EINHERJAR_SQLITE_PATH` | Yes | — | Path to the SQLite database file |\n| `EINHERJAR_SQLITE_MAX_OPEN_CONNS` | No | `1` | Maximum open connections (keep at 1 for writes) |\n| `EINHERJAR_SQLITE_MAX_IDLE_CONNS` | No | `1` | Maximum idle connections |\n| `EINHERJAR_SQLITE_PRAGMAS` | No | `?_journal=WAL\u0026_timeout=5000\u0026_fk=true` | DSN pragma string |\n\nThe default pragma string enables WAL mode (better concurrent reads), a 5-second busy timeout, and foreign key enforcement.\n\n---\n\n## Dependency graph\n\n```\ncontracts (zero dependencies)\n ↑\n core\n ↑\ndb-sqlite (contracts, core, modernc.org/sqlite)\n ↑\n your app\n```\n\nNo CGO. Cross-compiles to any GOOS/GOARCH without a C toolchain.\n\n---\n\n## Verification\n\n```bash\ncd db-sqlite/\ngo build ./...\ngo vet ./...\ngo test ./...\ngofmt -l .\n```\n\n---\n\n\u003e *The hall is built before the warriors arrive.*\n\u003e *That is the only guarantee worth making.*\n", + "changelog": "# Changelog — einherjar/db-sqlite\n\nAll notable changes to this module are documented here.\nFormat follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).\nThis module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n---\n\n## [1.4.0] — 2026-08-09\n\nMinor — coordinated framework release: dependency refresh + lockstep version alignment.\n\n### Changed\n\n- Refreshed dependencies to their latest minor/patch where available.\n- Bumped `contracts`, `core` to v1.4.0.\n\n## [1.3.0] — 2026-08-08\n\nMinor — coordinated framework version alignment. No code or API changes in this module.\n\n### Changed\n\n- Bumped `contracts`, `core` to v1.3.0.\n\n## [1.2.0] — 2026-08-08\n\nMinor — coordinated framework version alignment. No code or API changes in this module.\n\n### Changed\n\n- Bumped `contracts`, `core` to v1.2.0.\n\n## [1.1.3] — 2026-08-08\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (\\`contracts\\`, \\`core\\`) to v1.1.3. No code or API changes.## [1.1.2] — 2026-08-08\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`, `core`) to v1.1.2. No code or API changes.\n\n## [1.1.1] — 2026-08-07\n\nPatch — corrected package-doc wiring examples plus framework version alignment.\n\n### Fixed\n\n- Doc examples used a fictional `launcher.Register` / `health.Register` API; corrected to\n `lc.Append` + `web/health.NewHandler(...).ServeHTTP`.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`, `core`) to v1.1.1.\n\n## [1.1.0] — 2026-08-07\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`, `core`) to v1.1.0.\n\n## [1.0.0] — 2026-05-28\n\nInitial release. See the README for the full API and the `v1.0.0` git tag for the source.\n" }, { "name": "httpclient", @@ -7063,8 +7063,8 @@ } ] }, - "readme": "# einherjar/httpclient\n\n[![version](https://img.shields.io/badge/version-v1.3.0-5C4EE5?style=flat-square)](https://code.nochebuena.dev/einherjar/httpclient)\n[![license](https://img.shields.io/badge/license-AGPL--3.0-22863A?style=flat-square)](LICENSE)\n[![go](https://img.shields.io/badge/Go-1.26+-00ADD8?style=flat-square\u0026logo=go\u0026logoColor=white)](https://go.dev)\n\n\u003e To cross the realms, one must know the road — and how to wait when the bridge is down.\n\n`code.nochebuena.dev/einherjar/httpclient` is the outbound HTTP client component of the Einherjar framework. It composes retry (via `avast/retry-go`) and a circuit breaker (via `sony/gobreaker`) behind a single `Provider` interface with one method: `Do`. Generic helpers `DoJSON` and `DoJSONRequest` reduce boilerplate for JSON APIs without hiding the underlying client.\n\n---\n\n## Usage\n\n### Setup\n\n```go\nimport \"code.nochebuena.dev/einherjar/httpclient\"\n\n// With env-var config\nclient := httpclient.New(logger, httpclient.DefaultConfig())\n\n// Or zero-config with defaults\nclient := httpclient.NewWithDefaults(logger)\n```\n\n`httpclient` is not a `lifecycle.Component` — it is stateless and requires no registration with the launcher.\n\n### Sending requests\n\n```go\nreq, err := http.NewRequestWithContext(ctx, http.MethodGet, \"https://api.example.com/users\", nil)\nif err != nil {\n return err\n}\n\nresp, err := client.Do(req)\nif err != nil {\n return err\n}\ndefer resp.Body.Close()\n```\n\n### JSON GET helper\n\n```go\ntype User struct {\n ID string `json:\"id\"`\n Name string `json:\"name\"`\n}\n\nreq, _ := http.NewRequestWithContext(ctx, http.MethodGet, \"https://api.example.com/users/123\", nil)\nuser, err := httpclient.DoJSON[User](ctx, client, req)\n// user is *User on success\n```\n\n### JSON POST helper\n\n```go\ntype CreateReq struct {\n Name string `json:\"name\"`\n Email string `json:\"email\"`\n}\ntype CreateResp struct {\n ID string `json:\"id\"`\n}\n\nresp, err := httpclient.DoJSONRequest[CreateReq, CreateResp](\n ctx, client,\n http.MethodPost, \"https://api.example.com/users\",\n CreateReq{Name: \"Alice\", Email: \"alice@example.com\"},\n)\n// resp is *CreateResp on success\n```\n\n### Error mapping\n\n```go\n// Map HTTP status codes to xerrors for consistent error handling\nerr := httpclient.MapStatusToError(resp.StatusCode, \"upstream error\")\n// 400 → ErrInvalidInput\n// 401 → ErrUnauthorized\n// 403 → ErrPermissionDenied\n// 404 → ErrNotFound\n// 409 → ErrAlreadyExists\n// 429 → ErrRateLimited\n// 503 → ErrUnavailable\n```\n\n---\n\n## Environment variables\n\n| Variable | Required | Default | Description |\n|---|---|---|---|\n| `EINHERJAR_HTTP_CLIENT_NAME` | No | `http` | Circuit breaker name (appears in logs) |\n| `EINHERJAR_HTTP_TIMEOUT` | No | `30s` | Total request timeout |\n| `EINHERJAR_HTTP_DIAL_TIMEOUT` | No | `5s` | TCP connection timeout |\n| `EINHERJAR_HTTP_MAX_RETRIES` | No | `3` | Maximum retry attempts |\n| `EINHERJAR_HTTP_RETRY_DELAY` | No | `1s` | Delay between retries |\n| `EINHERJAR_HTTP_CB_THRESHOLD` | No | `10` | Consecutive failures before circuit opens |\n| `EINHERJAR_HTTP_CB_TIMEOUT` | No | `1m` | Time before circuit attempts half-open |\n\n---\n\n## Dependency graph\n\n```\ncontracts (zero dependencies)\n ↑\n core\n ↑\nhttpclient (contracts, core, retry-go, gobreaker)\n ↑\n your app\n```\n\n---\n\n## Verification\n\n```bash\ncd httpclient/\ngo build ./...\ngo vet ./...\ngo test ./...\ngofmt -l .\n```\n\n---\n\n\u003e *A warrior who cannot reach the other realm is useless to the battle.*\n\u003e *Build the bridge. Make it resilient. Know when to wait.*\n", - "changelog": "# Changelog — einherjar/httpclient\n\nAll notable changes to this module are documented here.\nFormat follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).\nThis module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n---\n\n## [1.3.0] — 2026-08-08\n\nMinor — coordinated framework version alignment. No code or API changes in this module.\n\n### Changed\n\n- Bumped `contracts`, `core` to v1.3.0.\n\n## [1.2.0] — 2026-08-08\n\nMinor — coordinated framework version alignment. No code or API changes in this module.\n\n### Changed\n\n- Bumped `contracts`, `core` to v1.2.0.\n\n## [1.1.3] — 2026-08-08\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (\\`contracts\\`, \\`core\\`) to v1.1.3. No code or API changes.## [1.1.2] — 2026-08-08\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`, `core`) to v1.1.2. No code or API changes.\n\n## [1.1.1] — 2026-08-07\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`, `core`) to v1.1.1. No code or API changes.\n\n## [1.1.0] — 2026-08-07\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`, `core`) to v1.1.0.\n\n## [1.0.0] — 2026-05-28\n\nInitial release. See the README for the full API and the `v1.0.0` git tag for the source.\n" + "readme": "# einherjar/httpclient\n\n[![version](https://img.shields.io/badge/version-v1.4.0-5C4EE5?style=flat-square)](https://code.nochebuena.dev/einherjar/httpclient)\n[![license](https://img.shields.io/badge/license-AGPL--3.0-22863A?style=flat-square)](LICENSE)\n[![go](https://img.shields.io/badge/Go-1.26+-00ADD8?style=flat-square\u0026logo=go\u0026logoColor=white)](https://go.dev)\n\n\u003e To cross the realms, one must know the road — and how to wait when the bridge is down.\n\n`code.nochebuena.dev/einherjar/httpclient` is the outbound HTTP client component of the Einherjar framework. It composes retry (via `avast/retry-go`) and a circuit breaker (via `sony/gobreaker`) behind a single `Provider` interface with one method: `Do`. Generic helpers `DoJSON` and `DoJSONRequest` reduce boilerplate for JSON APIs without hiding the underlying client.\n\n---\n\n## Usage\n\n### Setup\n\n```go\nimport \"code.nochebuena.dev/einherjar/httpclient\"\n\n// With env-var config\nclient := httpclient.New(logger, httpclient.DefaultConfig())\n\n// Or zero-config with defaults\nclient := httpclient.NewWithDefaults(logger)\n```\n\n`httpclient` is not a `lifecycle.Component` — it is stateless and requires no registration with the launcher.\n\n### Sending requests\n\n```go\nreq, err := http.NewRequestWithContext(ctx, http.MethodGet, \"https://api.example.com/users\", nil)\nif err != nil {\n return err\n}\n\nresp, err := client.Do(req)\nif err != nil {\n return err\n}\ndefer resp.Body.Close()\n```\n\n### JSON GET helper\n\n```go\ntype User struct {\n ID string `json:\"id\"`\n Name string `json:\"name\"`\n}\n\nreq, _ := http.NewRequestWithContext(ctx, http.MethodGet, \"https://api.example.com/users/123\", nil)\nuser, err := httpclient.DoJSON[User](ctx, client, req)\n// user is *User on success\n```\n\n### JSON POST helper\n\n```go\ntype CreateReq struct {\n Name string `json:\"name\"`\n Email string `json:\"email\"`\n}\ntype CreateResp struct {\n ID string `json:\"id\"`\n}\n\nresp, err := httpclient.DoJSONRequest[CreateReq, CreateResp](\n ctx, client,\n http.MethodPost, \"https://api.example.com/users\",\n CreateReq{Name: \"Alice\", Email: \"alice@example.com\"},\n)\n// resp is *CreateResp on success\n```\n\n### Error mapping\n\n```go\n// Map HTTP status codes to xerrors for consistent error handling\nerr := httpclient.MapStatusToError(resp.StatusCode, \"upstream error\")\n// 400 → ErrInvalidInput\n// 401 → ErrUnauthorized\n// 403 → ErrPermissionDenied\n// 404 → ErrNotFound\n// 409 → ErrAlreadyExists\n// 429 → ErrRateLimited\n// 503 → ErrUnavailable\n```\n\n---\n\n## Environment variables\n\n| Variable | Required | Default | Description |\n|---|---|---|---|\n| `EINHERJAR_HTTP_CLIENT_NAME` | No | `http` | Circuit breaker name (appears in logs) |\n| `EINHERJAR_HTTP_TIMEOUT` | No | `30s` | Total request timeout |\n| `EINHERJAR_HTTP_DIAL_TIMEOUT` | No | `5s` | TCP connection timeout |\n| `EINHERJAR_HTTP_MAX_RETRIES` | No | `3` | Maximum retry attempts |\n| `EINHERJAR_HTTP_RETRY_DELAY` | No | `1s` | Delay between retries |\n| `EINHERJAR_HTTP_CB_THRESHOLD` | No | `10` | Consecutive failures before circuit opens |\n| `EINHERJAR_HTTP_CB_TIMEOUT` | No | `1m` | Time before circuit attempts half-open |\n\n---\n\n## Dependency graph\n\n```\ncontracts (zero dependencies)\n ↑\n core\n ↑\nhttpclient (contracts, core, retry-go, gobreaker)\n ↑\n your app\n```\n\n---\n\n## Verification\n\n```bash\ncd httpclient/\ngo build ./...\ngo vet ./...\ngo test ./...\ngofmt -l .\n```\n\n---\n\n\u003e *A warrior who cannot reach the other realm is useless to the battle.*\n\u003e *Build the bridge. Make it resilient. Know when to wait.*\n", + "changelog": "# Changelog — einherjar/httpclient\n\nAll notable changes to this module are documented here.\nFormat follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).\nThis module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n---\n\n## [1.4.0] — 2026-08-09\n\nMinor — coordinated framework release: dependency refresh + lockstep version alignment.\n\n### Changed\n\n- Refreshed dependencies to their latest minor/patch where available.\n- Bumped `contracts`, `core` to v1.4.0.\n\n## [1.3.0] — 2026-08-08\n\nMinor — coordinated framework version alignment. No code or API changes in this module.\n\n### Changed\n\n- Bumped `contracts`, `core` to v1.3.0.\n\n## [1.2.0] — 2026-08-08\n\nMinor — coordinated framework version alignment. No code or API changes in this module.\n\n### Changed\n\n- Bumped `contracts`, `core` to v1.2.0.\n\n## [1.1.3] — 2026-08-08\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (\\`contracts\\`, \\`core\\`) to v1.1.3. No code or API changes.## [1.1.2] — 2026-08-08\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`, `core`) to v1.1.2. No code or API changes.\n\n## [1.1.1] — 2026-08-07\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`, `core`) to v1.1.1. No code or API changes.\n\n## [1.1.0] — 2026-08-07\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`, `core`) to v1.1.0.\n\n## [1.0.0] — 2026-05-28\n\nInitial release. See the README for the full API and the `v1.0.0` git tag for the source.\n" }, { "name": "smtp", @@ -7765,8 +7765,8 @@ } ] }, - "readme": "# einherjar/smtp\n\n[![version](https://img.shields.io/badge/version-v1.3.0-5C4EE5?style=flat-square)](https://code.nochebuena.dev/einherjar/smtp)\n[![license](https://img.shields.io/badge/license-AGPL--3.0-22863A?style=flat-square)](LICENSE)\n[![go](https://img.shields.io/badge/Go-1.26+-00ADD8?style=flat-square\u0026logo=go\u0026logoColor=white)](https://go.dev)\n[![health](https://img.shields.io/badge/health-degraded-E36209?style=flat-square)]()\n\n\u003e A raven sent from Valhalla reaches its destination. The sender does not wait at the window.\n\n`code.nochebuena.dev/einherjar/smtp` is the email sender component of the Einherjar framework. It is built entirely on the Go standard library (`net/smtp`, `mime/multipart`, `html/template`) with no external dependencies. When `EINHERJAR_SMTP_HOST` is empty, `New` returns a silent no-op — email absence never blocks a transaction, user registration, or order placement. Health priority is `LevelDegraded`.\n\n---\n\n## Usage\n\n### Setup\n\n```go\nimport \"code.nochebuena.dev/einherjar/smtp\"\n\nmailer := smtp.New(logger, smtp.DefaultConfig())\nlc.Append(mailer) // OnInit verifies credentials; OnStop is a no-op\n// mailer is observability.Checkable (dial check, LevelDegraded):\nsrv.Get(\"/health\", health.NewHandler(logger, mailer).ServeHTTP)\n```\n\nWhen `cfg.Host` is empty, `New` returns a no-op that logs every `Send` call at debug level and always returns nil. No code changes are needed between environments.\n\n### Sending a plain-text email\n\n```go\nerr := mailer.Send(ctx, smtp.Message{\n To: []string{\"user@example.com\"},\n Subject: \"Welcome to the service\",\n Body: \"Your account is ready.\",\n})\n```\n\n### Sending HTML with attachments\n\n```go\nerr := mailer.Send(ctx, smtp.Message{\n To: []string{\"user@example.com\"},\n CC: []string{\"support@example.com\"},\n BCC: []string{\"audit@example.com\"}, // envelope only — never in headers\n Subject: \"Your invoice\",\n Body: renderedHTML,\n ContentType: \"text/html\",\n Attachments: []smtp.Attachment{\n {\n Name: \"invoice.pdf\",\n ContentType: \"application/pdf\",\n Data: pdfReader, // consumed once; do not reuse\n },\n },\n})\n```\n\n### Template rendering\n\nTemplate rendering is intentionally separate from `Send`. Render to a string first, then assign to `Message.Body`. This keeps the transport and rendering concerns independent.\n\n```go\ntmpl, err := smtp.ParseFS(os.DirFS(\"templates\"), \"*.html\")\nif err != nil {\n return err\n}\n\nbody, err := tmpl.Render(\"welcome.html\", map[string]any{\n \"Name\": user.Name,\n \"URL\": activationURL,\n})\nif err != nil {\n return err\n}\n\nerr = mailer.Send(ctx, smtp.Message{\n To: []string{user.Email},\n Subject: \"Activate your account\",\n Body: body,\n ContentType: \"text/html\",\n})\n```\n\n---\n\n## Environment variables\n\n| Variable | Required | Default | Description |\n|---|---|---|---|\n| `EINHERJAR_SMTP_HOST` | No | `\"\"` | SMTP host. Empty → no-op sender |\n| `EINHERJAR_SMTP_PORT` | No | `587` | SMTP port |\n| `EINHERJAR_SMTP_USER` | No | `\"\"` | Auth username |\n| `EINHERJAR_SMTP_PASSWORD` | No | `\"\"` | Auth password |\n| `EINHERJAR_SMTP_FROM` | No | `\"\"` | Default From address |\n\n---\n\n## Dependency graph\n\n```\ncontracts (zero dependencies)\n ↑\n core\n ↑\nsmtp (contracts, core, stdlib only)\n ↑\n your app\n```\n\nNo external dependencies beyond the Go standard library.\n\n---\n\n## Verification\n\n```bash\ncd smtp/\ngo build ./...\ngo vet ./...\ngo test ./...\ngofmt -l .\n```\n\n---\n\n\u003e *The message matters. The raven is only the path.*\n\u003e *Make the path reliable. Do not let it stop the battle.*\n", - "changelog": "# Changelog — einherjar/smtp\n\nAll notable changes to this module are documented here.\nFormat follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).\nThis module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n---\n\n## [1.3.0] — 2026-08-08\n\nMinor — coordinated framework version alignment. No code or API changes in this module.\n\n### Changed\n\n- Bumped `contracts`, `core` to v1.3.0.\n\n## [1.2.0] — 2026-08-08\n\nMinor — coordinated framework version alignment. No code or API changes in this module.\n\n### Changed\n\n- Bumped `contracts`, `core` to v1.2.0.\n\n## [1.1.3] — 2026-08-08\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (\\`contracts\\`, \\`core\\`) to v1.1.3. No code or API changes.## [1.1.2] — 2026-08-08\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`, `core`) to v1.1.2. No code or API changes.\n\n## [1.1.1] — 2026-08-07\n\nPatch — corrected package-doc wiring examples plus framework version alignment.\n\n### Fixed\n\n- Doc examples used a fictional `launcher.Register` / `health.Register` API; corrected to\n `lc.Append` + `web/health.NewHandler(...).ServeHTTP`.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`, `core`) to v1.1.1.\n\n## [1.1.0] — 2026-08-07\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`, `core`) to v1.1.0.\n\n## [1.0.0] — 2026-05-28\n\nInitial release. See the README for the full API and the `v1.0.0` git tag for the source.\n" + "readme": "# einherjar/smtp\n\n[![version](https://img.shields.io/badge/version-v1.4.0-5C4EE5?style=flat-square)](https://code.nochebuena.dev/einherjar/smtp)\n[![license](https://img.shields.io/badge/license-AGPL--3.0-22863A?style=flat-square)](LICENSE)\n[![go](https://img.shields.io/badge/Go-1.26+-00ADD8?style=flat-square\u0026logo=go\u0026logoColor=white)](https://go.dev)\n[![health](https://img.shields.io/badge/health-degraded-E36209?style=flat-square)]()\n\n\u003e A raven sent from Valhalla reaches its destination. The sender does not wait at the window.\n\n`code.nochebuena.dev/einherjar/smtp` is the email sender component of the Einherjar framework. It is built entirely on the Go standard library (`net/smtp`, `mime/multipart`, `html/template`) with no external dependencies. When `EINHERJAR_SMTP_HOST` is empty, `New` returns a silent no-op — email absence never blocks a transaction, user registration, or order placement. Health priority is `LevelDegraded`.\n\n---\n\n## Usage\n\n### Setup\n\n```go\nimport \"code.nochebuena.dev/einherjar/smtp\"\n\nmailer := smtp.New(logger, smtp.DefaultConfig())\nlc.Append(mailer) // OnInit verifies credentials; OnStop is a no-op\n// mailer is observability.Checkable (dial check, LevelDegraded):\nsrv.Get(\"/health\", health.NewHandler(logger, mailer).ServeHTTP)\n```\n\nWhen `cfg.Host` is empty, `New` returns a no-op that logs every `Send` call at debug level and always returns nil. No code changes are needed between environments.\n\n### Sending a plain-text email\n\n```go\nerr := mailer.Send(ctx, smtp.Message{\n To: []string{\"user@example.com\"},\n Subject: \"Welcome to the service\",\n Body: \"Your account is ready.\",\n})\n```\n\n### Sending HTML with attachments\n\n```go\nerr := mailer.Send(ctx, smtp.Message{\n To: []string{\"user@example.com\"},\n CC: []string{\"support@example.com\"},\n BCC: []string{\"audit@example.com\"}, // envelope only — never in headers\n Subject: \"Your invoice\",\n Body: renderedHTML,\n ContentType: \"text/html\",\n Attachments: []smtp.Attachment{\n {\n Name: \"invoice.pdf\",\n ContentType: \"application/pdf\",\n Data: pdfReader, // consumed once; do not reuse\n },\n },\n})\n```\n\n### Template rendering\n\nTemplate rendering is intentionally separate from `Send`. Render to a string first, then assign to `Message.Body`. This keeps the transport and rendering concerns independent.\n\n```go\ntmpl, err := smtp.ParseFS(os.DirFS(\"templates\"), \"*.html\")\nif err != nil {\n return err\n}\n\nbody, err := tmpl.Render(\"welcome.html\", map[string]any{\n \"Name\": user.Name,\n \"URL\": activationURL,\n})\nif err != nil {\n return err\n}\n\nerr = mailer.Send(ctx, smtp.Message{\n To: []string{user.Email},\n Subject: \"Activate your account\",\n Body: body,\n ContentType: \"text/html\",\n})\n```\n\n---\n\n## Environment variables\n\n| Variable | Required | Default | Description |\n|---|---|---|---|\n| `EINHERJAR_SMTP_HOST` | No | `\"\"` | SMTP host. Empty → no-op sender |\n| `EINHERJAR_SMTP_PORT` | No | `587` | SMTP port |\n| `EINHERJAR_SMTP_USER` | No | `\"\"` | Auth username |\n| `EINHERJAR_SMTP_PASSWORD` | No | `\"\"` | Auth password |\n| `EINHERJAR_SMTP_FROM` | No | `\"\"` | Default From address |\n\n---\n\n## Dependency graph\n\n```\ncontracts (zero dependencies)\n ↑\n core\n ↑\nsmtp (contracts, core, stdlib only)\n ↑\n your app\n```\n\nNo external dependencies beyond the Go standard library.\n\n---\n\n## Verification\n\n```bash\ncd smtp/\ngo build ./...\ngo vet ./...\ngo test ./...\ngofmt -l .\n```\n\n---\n\n\u003e *The message matters. The raven is only the path.*\n\u003e *Make the path reliable. Do not let it stop the battle.*\n", + "changelog": "# Changelog — einherjar/smtp\n\nAll notable changes to this module are documented here.\nFormat follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).\nThis module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n---\n\n## [1.4.0] — 2026-08-09\n\nMinor — coordinated framework release: dependency refresh + lockstep version alignment.\n\n### Changed\n\n- Refreshed dependencies to their latest minor/patch where available.\n- Bumped `contracts`, `core` to v1.4.0.\n\n## [1.3.0] — 2026-08-08\n\nMinor — coordinated framework version alignment. No code or API changes in this module.\n\n### Changed\n\n- Bumped `contracts`, `core` to v1.3.0.\n\n## [1.2.0] — 2026-08-08\n\nMinor — coordinated framework version alignment. No code or API changes in this module.\n\n### Changed\n\n- Bumped `contracts`, `core` to v1.2.0.\n\n## [1.1.3] — 2026-08-08\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (\\`contracts\\`, \\`core\\`) to v1.1.3. No code or API changes.## [1.1.2] — 2026-08-08\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`, `core`) to v1.1.2. No code or API changes.\n\n## [1.1.1] — 2026-08-07\n\nPatch — corrected package-doc wiring examples plus framework version alignment.\n\n### Fixed\n\n- Doc examples used a fictional `launcher.Register` / `health.Register` API; corrected to\n `lc.Append` + `web/health.NewHandler(...).ServeHTTP`.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`, `core`) to v1.1.1.\n\n## [1.1.0] — 2026-08-07\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`, `core`) to v1.1.0.\n\n## [1.0.0] — 2026-05-28\n\nInitial release. See the README for the full API and the `v1.0.0` git tag for the source.\n" }, { "name": "spa-server", @@ -8089,8 +8089,8 @@ } ] }, - "readme": "# einherjar/spa-server\n\n[![version](https://img.shields.io/badge/version-v1.3.0-5C4EE5?style=flat-square)](https://code.nochebuena.dev/einherjar/spa-server)\n[![license](https://img.shields.io/badge/license-AGPL--3.0-22863A?style=flat-square)](LICENSE)\n[![go](https://img.shields.io/badge/Go-1.26+-00ADD8?style=flat-square\u0026logo=go\u0026logoColor=white)](https://go.dev)\n\n\u003e A shield wall holds because every warrior knows their position. The SPA asks only for the wall — not for every soldier's name.\n\n`code.nochebuena.dev/einherjar/spa-server` is a container-first HTTP server for single-page applications and progressive web apps. It serves static assets directly and falls back to `index.html` for any path that does not resolve to a file on disk — the standard SPA routing contract.\n\nThe module ships as a ready-to-use Docker base image. Deploying a SPA to a container requires a single `COPY` instruction. No nginx, no custom configuration, no index.html redirect logic to maintain.\n\n---\n\n## Container usage\n\n```dockerfile\nFROM code.nochebuena.dev/einherjar/spa-server:v1.0.0\nCOPY dist/ /srv/www/\n```\n\nThat is the complete Dockerfile for a production SPA container.\n\n---\n\n## Environment variables\n\n| Variable | Default | Description |\n|---|---|---|\n| `EINHERJAR_SPA_PORT` | `8080` | TCP port the server listens on |\n| `EINHERJAR_SPA_STATIC_DIR` | `/srv/www` | Path to the directory containing `index.html` and assets |\n| `EINHERJAR_LOG_LEVEL` | `INFO` | Log level: `DEBUG`, `INFO`, `WARN`, `ERROR` |\n\n---\n\n## Health endpoint\n\n`GET /health` returns `200 OK` with a JSON body consistent with all Einherjar health responses:\n\n```json\n{\"status\":\"UP\",\"components\":{}}\n```\n\n`503 Service Unavailable` is never returned — if the process is alive, it is healthy. Wire `/health` directly to your container liveness and readiness probes.\n\n---\n\n## Routing behaviour\n\n| Request | Result |\n|---|---|\n| `/app.js` — file exists | Served directly with correct `Content-Type` |\n| `/assets/logo.png` — file exists | Served directly |\n| `/dashboard` — no matching file | `index.html` served (SPA router handles it) |\n| `/` — directory | `index.html` served (directory listing is disabled) |\n\n---\n\n## Dependency graph\n\n```\ncontracts (zero dependencies)\n ↑\n core\n ↑\nspa-server (contracts, core, stdlib only)\n```\n\nNo external dependencies beyond the Go standard library.\n\n---\n\n## Verification\n\n```bash\ncd spa-server/\ngo build ./...\ngo vet ./...\ngo test ./...\ngofmt -l .\n```\n\n---\n\n\u003e *A shield wall holds because every warrior knows their position.*\n\u003e *The SPA asks only for the wall — not for every soldier's name.*\n", - "changelog": "# Changelog — einherjar/spa-server\n\nAll notable changes to this module are documented here.\nFormat follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).\nThis module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n---\n\n## [1.3.0] — 2026-08-08\n\nMinor — coordinated framework version alignment. No code or API changes in this module.\n\n### Changed\n\n- Bumped `contracts`, `core` to v1.3.0.\n\n## [1.2.0] — 2026-08-08\n\nMinor — coordinated framework version alignment. No code or API changes in this module.\n\n### Changed\n\n- Bumped `contracts`, `core` to v1.2.0.\n\n## [1.1.3] — 2026-08-08\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (\\`contracts\\`, \\`core\\`) to v1.1.3. No code or API changes.## [1.1.2] — 2026-08-08\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`, `core`) to v1.1.2. No code or API changes.\n\n## [1.1.1] — 2026-08-07\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`, `core`) to v1.1.1. No code or API changes.\n\n## [1.1.0] — 2026-08-07\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`, `core`) to v1.1.0.\n\n## [1.0.0] — 2026-05-28\n\nInitial release. See the README for the full API and the `v1.0.0` git tag for the source.\n" + "readme": "# einherjar/spa-server\n\n[![version](https://img.shields.io/badge/version-v1.4.0-5C4EE5?style=flat-square)](https://code.nochebuena.dev/einherjar/spa-server)\n[![license](https://img.shields.io/badge/license-AGPL--3.0-22863A?style=flat-square)](LICENSE)\n[![go](https://img.shields.io/badge/Go-1.26+-00ADD8?style=flat-square\u0026logo=go\u0026logoColor=white)](https://go.dev)\n\n\u003e A shield wall holds because every warrior knows their position. The SPA asks only for the wall — not for every soldier's name.\n\n`code.nochebuena.dev/einherjar/spa-server` is a container-first HTTP server for single-page applications and progressive web apps. It serves static assets directly and falls back to `index.html` for any path that does not resolve to a file on disk — the standard SPA routing contract.\n\nThe module ships as a ready-to-use Docker base image. Deploying a SPA to a container requires a single `COPY` instruction. No nginx, no custom configuration, no index.html redirect logic to maintain.\n\n---\n\n## Container usage\n\n```dockerfile\nFROM code.nochebuena.dev/einherjar/spa-server:v1.0.0\nCOPY dist/ /srv/www/\n```\n\nThat is the complete Dockerfile for a production SPA container.\n\n---\n\n## Environment variables\n\n| Variable | Default | Description |\n|---|---|---|\n| `EINHERJAR_SPA_PORT` | `8080` | TCP port the server listens on |\n| `EINHERJAR_SPA_STATIC_DIR` | `/srv/www` | Path to the directory containing `index.html` and assets |\n| `EINHERJAR_LOG_LEVEL` | `INFO` | Log level: `DEBUG`, `INFO`, `WARN`, `ERROR` |\n\n---\n\n## Health endpoint\n\n`GET /health` returns `200 OK` with a JSON body consistent with all Einherjar health responses:\n\n```json\n{\"status\":\"UP\",\"components\":{}}\n```\n\n`503 Service Unavailable` is never returned — if the process is alive, it is healthy. Wire `/health` directly to your container liveness and readiness probes.\n\n---\n\n## Routing behaviour\n\n| Request | Result |\n|---|---|\n| `/app.js` — file exists | Served directly with correct `Content-Type` |\n| `/assets/logo.png` — file exists | Served directly |\n| `/dashboard` — no matching file | `index.html` served (SPA router handles it) |\n| `/` — directory | `index.html` served (directory listing is disabled) |\n\n---\n\n## Dependency graph\n\n```\ncontracts (zero dependencies)\n ↑\n core\n ↑\nspa-server (contracts, core, stdlib only)\n```\n\nNo external dependencies beyond the Go standard library.\n\n---\n\n## Verification\n\n```bash\ncd spa-server/\ngo build ./...\ngo vet ./...\ngo test ./...\ngofmt -l .\n```\n\n---\n\n\u003e *A shield wall holds because every warrior knows their position.*\n\u003e *The SPA asks only for the wall — not for every soldier's name.*\n", + "changelog": "# Changelog — einherjar/spa-server\n\nAll notable changes to this module are documented here.\nFormat follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).\nThis module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n---\n\n## [1.4.0] — 2026-08-09\n\nMinor — coordinated framework release: dependency refresh + lockstep version alignment.\n\n### Changed\n\n- Refreshed dependencies to their latest minor/patch where available.\n- Bumped `contracts`, `core` to v1.4.0.\n\n## [1.3.0] — 2026-08-08\n\nMinor — coordinated framework version alignment. No code or API changes in this module.\n\n### Changed\n\n- Bumped `contracts`, `core` to v1.3.0.\n\n## [1.2.0] — 2026-08-08\n\nMinor — coordinated framework version alignment. No code or API changes in this module.\n\n### Changed\n\n- Bumped `contracts`, `core` to v1.2.0.\n\n## [1.1.3] — 2026-08-08\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (\\`contracts\\`, \\`core\\`) to v1.1.3. No code or API changes.## [1.1.2] — 2026-08-08\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`, `core`) to v1.1.2. No code or API changes.\n\n## [1.1.1] — 2026-08-07\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`, `core`) to v1.1.1. No code or API changes.\n\n## [1.1.0] — 2026-08-07\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`, `core`) to v1.1.0.\n\n## [1.0.0] — 2026-05-28\n\nInitial release. See the README for the full API and the `v1.0.0` git tag for the source.\n" }, { "name": "storage-minio", @@ -8715,8 +8715,8 @@ } ] }, - "readme": "# einherjar/storage-minio\n\n[![version](https://img.shields.io/badge/version-v1.3.0-5C4EE5?style=flat-square)](https://code.nochebuena.dev/einherjar/storage-minio)\n[![license](https://img.shields.io/badge/license-AGPL--3.0-22863A?style=flat-square)](LICENSE)\n[![go](https://img.shields.io/badge/Go-1.26+-00ADD8?style=flat-square\u0026logo=go\u0026logoColor=white)](https://go.dev)\n[![health](https://img.shields.io/badge/health-critical-D73A49?style=flat-square)]()\n\n\u003e The shield does not care who forged it. It holds what it is given and gives it back unchanged.\n\n`code.nochebuena.dev/einherjar/storage-minio` is the MinIO/S3 object storage component of the Einherjar framework. It wraps `minio-go/v7` behind a lifecycle-aware `Component` with four common operations — upload, download, delete, and presigned URLs. For anything beyond that scope, `Native()` returns the raw `*miniogo.Client`.\n\n---\n\n## Usage\n\n### Setup\n\n```go\nimport storageminio \"code.nochebuena.dev/einherjar/storage-minio\"\n\ns := storageminio.New(logger, storageminio.DefaultConfig())\nlc.Append(s) // OnInit connects; OnStop is a no-op (stateless client)\n// s is observability.Checkable (BucketExists check, LevelCritical):\nsrv.Get(\"/health\", health.NewHandler(logger, s).ServeHTTP)\n```\n\n### Uploading\n\n```go\nimport miniogo \"github.com/minio/minio-go/v7\"\n\ninfo, err := s.PutObject(ctx, \"my-bucket\", \"uploads/photo.jpg\", reader, size, miniogo.PutObjectOptions{\n ContentType: \"image/jpeg\",\n})\n```\n\n### Downloading\n\n```go\nobj, err := s.GetObject(ctx, \"my-bucket\", \"uploads/photo.jpg\", miniogo.GetObjectOptions{})\nif err != nil {\n return s.HandleError(err)\n}\ndefer obj.Close()\n```\n\n### Presigned URL (time-limited public access)\n\n```go\nurl, err := s.PresignedGetObject(ctx, \"my-bucket\", \"uploads/photo.jpg\", 15*time.Minute, nil)\n// url is a *url.URL — call url.String() to get the string form\n```\n\n### Deleting\n\n```go\nerr := s.RemoveObject(ctx, \"my-bucket\", \"uploads/photo.jpg\", miniogo.RemoveObjectOptions{})\n```\n\n### Native escape hatch\n\nFor multipart uploads, bucket management, or any operation not in `Provider`, use the raw client:\n\n```go\nnative := s.Native() // *miniogo.Client\n```\n\nCallers that use `Native()` must import `github.com/minio/minio-go/v7` directly.\n\n### Error handling\n\n```go\nif err := s.HandleError(someErr); err != nil {\n // minio-go error responses mapped to xerrors:\n // NoSuchKey / NoSuchBucket → ErrNotFound\n // AccessDenied → ErrPermissionDenied\n // context.Canceled → ErrCancelled\n // context.DeadlineExceeded → ErrDeadlineExceeded\n}\n```\n\n`HandleError` is also available as a package-level function: `storageminio.HandleError(err)`.\n\n---\n\n## Environment variables\n\n| Variable | Required | Default | Description |\n|---|---|---|---|\n| `EINHERJAR_MINIO_ENDPOINT` | Yes | — | MinIO/S3 endpoint (host:port or domain) |\n| `EINHERJAR_MINIO_ACCESS_KEY` | Yes | — | Access key ID |\n| `EINHERJAR_MINIO_SECRET_KEY` | Yes | — | Secret access key |\n| `EINHERJAR_MINIO_BUCKET` | Yes | — | Default bucket for health check |\n| `EINHERJAR_MINIO_USE_SSL` | No | `false` | Use TLS |\n| `EINHERJAR_MINIO_REGION` | No | `us-east-1` | Bucket region |\n\n---\n\n## Dependency graph\n\n```\ncontracts (zero dependencies)\n ↑\n core\n ↑\nstorage-minio (contracts, core, minio-go/v7)\n ↑\n your app\n```\n\n---\n\n## Verification\n\n```bash\ncd storage-minio/\ngo build ./...\ngo vet ./...\ngo test ./...\ngofmt -l .\n```\n\n---\n\n\u003e *The artifact survives the battle that created it.*\n\u003e *Store it well. Someone will need it after you are gone.*\n", - "changelog": "# Changelog — einherjar/storage-minio\n\nAll notable changes to this module are documented here.\nFormat follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).\nThis module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n---\n\n## [1.3.0] — 2026-08-08\n\nMinor — coordinated framework version alignment. No code or API changes in this module.\n\n### Changed\n\n- Bumped `contracts`, `core` to v1.3.0.\n\n## [1.2.0] — 2026-08-08\n\nMinor — coordinated framework version alignment. No code or API changes in this module.\n\n### Changed\n\n- Bumped `contracts`, `core` to v1.2.0.\n\n## [1.1.3] — 2026-08-08\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (\\`contracts\\`, \\`core\\`) to v1.1.3. No code or API changes.## [1.1.2] — 2026-08-08\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`, `core`) to v1.1.2. No code or API changes.\n\n## [1.1.1] — 2026-08-07\n\nPatch — corrected package-doc wiring examples plus framework version alignment.\n\n### Fixed\n\n- Doc examples used a fictional `launcher.Register` / `health.Register` API; corrected to\n `lc.Append` + `web/health.NewHandler(...).ServeHTTP`.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`, `core`) to v1.1.1.\n\n## [1.1.0] — 2026-08-07\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`, `core`) to v1.1.0.\n\n## [1.0.0] — 2026-05-28\n\nInitial release. See the README for the full API and the `v1.0.0` git tag for the source.\n" + "readme": "# einherjar/storage-minio\n\n[![version](https://img.shields.io/badge/version-v1.4.0-5C4EE5?style=flat-square)](https://code.nochebuena.dev/einherjar/storage-minio)\n[![license](https://img.shields.io/badge/license-AGPL--3.0-22863A?style=flat-square)](LICENSE)\n[![go](https://img.shields.io/badge/Go-1.26+-00ADD8?style=flat-square\u0026logo=go\u0026logoColor=white)](https://go.dev)\n[![health](https://img.shields.io/badge/health-critical-D73A49?style=flat-square)]()\n\n\u003e The shield does not care who forged it. It holds what it is given and gives it back unchanged.\n\n`code.nochebuena.dev/einherjar/storage-minio` is the MinIO/S3 object storage component of the Einherjar framework. It wraps `minio-go/v7` behind a lifecycle-aware `Component` with four common operations — upload, download, delete, and presigned URLs. For anything beyond that scope, `Native()` returns the raw `*miniogo.Client`.\n\n---\n\n## Usage\n\n### Setup\n\n```go\nimport storageminio \"code.nochebuena.dev/einherjar/storage-minio\"\n\ns := storageminio.New(logger, storageminio.DefaultConfig())\nlc.Append(s) // OnInit connects; OnStop is a no-op (stateless client)\n// s is observability.Checkable (BucketExists check, LevelCritical):\nsrv.Get(\"/health\", health.NewHandler(logger, s).ServeHTTP)\n```\n\n### Uploading\n\n```go\nimport miniogo \"github.com/minio/minio-go/v7\"\n\ninfo, err := s.PutObject(ctx, \"my-bucket\", \"uploads/photo.jpg\", reader, size, miniogo.PutObjectOptions{\n ContentType: \"image/jpeg\",\n})\n```\n\n### Downloading\n\n```go\nobj, err := s.GetObject(ctx, \"my-bucket\", \"uploads/photo.jpg\", miniogo.GetObjectOptions{})\nif err != nil {\n return s.HandleError(err)\n}\ndefer obj.Close()\n```\n\n### Presigned URL (time-limited public access)\n\n```go\nurl, err := s.PresignedGetObject(ctx, \"my-bucket\", \"uploads/photo.jpg\", 15*time.Minute, nil)\n// url is a *url.URL — call url.String() to get the string form\n```\n\n### Deleting\n\n```go\nerr := s.RemoveObject(ctx, \"my-bucket\", \"uploads/photo.jpg\", miniogo.RemoveObjectOptions{})\n```\n\n### Native escape hatch\n\nFor multipart uploads, bucket management, or any operation not in `Provider`, use the raw client:\n\n```go\nnative := s.Native() // *miniogo.Client\n```\n\nCallers that use `Native()` must import `github.com/minio/minio-go/v7` directly.\n\n### Error handling\n\n```go\nif err := s.HandleError(someErr); err != nil {\n // minio-go error responses mapped to xerrors:\n // NoSuchKey / NoSuchBucket → ErrNotFound\n // AccessDenied → ErrPermissionDenied\n // context.Canceled → ErrCancelled\n // context.DeadlineExceeded → ErrDeadlineExceeded\n}\n```\n\n`HandleError` is also available as a package-level function: `storageminio.HandleError(err)`.\n\n---\n\n## Environment variables\n\n| Variable | Required | Default | Description |\n|---|---|---|---|\n| `EINHERJAR_MINIO_ENDPOINT` | Yes | — | MinIO/S3 endpoint (host:port or domain) |\n| `EINHERJAR_MINIO_ACCESS_KEY` | Yes | — | Access key ID |\n| `EINHERJAR_MINIO_SECRET_KEY` | Yes | — | Secret access key |\n| `EINHERJAR_MINIO_BUCKET` | Yes | — | Default bucket for health check |\n| `EINHERJAR_MINIO_USE_SSL` | No | `false` | Use TLS |\n| `EINHERJAR_MINIO_REGION` | No | `us-east-1` | Bucket region |\n\n---\n\n## Dependency graph\n\n```\ncontracts (zero dependencies)\n ↑\n core\n ↑\nstorage-minio (contracts, core, minio-go/v7)\n ↑\n your app\n```\n\n---\n\n## Verification\n\n```bash\ncd storage-minio/\ngo build ./...\ngo vet ./...\ngo test ./...\ngofmt -l .\n```\n\n---\n\n\u003e *The artifact survives the battle that created it.*\n\u003e *Store it well. Someone will need it after you are gone.*\n", + "changelog": "# Changelog — einherjar/storage-minio\n\nAll notable changes to this module are documented here.\nFormat follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).\nThis module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n---\n\n## [1.4.0] — 2026-08-09\n\nMinor — coordinated framework release: dependency refresh + lockstep version alignment.\n\n### Changed\n\n- Refreshed dependencies to their latest minor/patch where available.\n- Bumped `contracts`, `core` to v1.4.0.\n\n## [1.3.0] — 2026-08-08\n\nMinor — coordinated framework version alignment. No code or API changes in this module.\n\n### Changed\n\n- Bumped `contracts`, `core` to v1.3.0.\n\n## [1.2.0] — 2026-08-08\n\nMinor — coordinated framework version alignment. No code or API changes in this module.\n\n### Changed\n\n- Bumped `contracts`, `core` to v1.2.0.\n\n## [1.1.3] — 2026-08-08\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (\\`contracts\\`, \\`core\\`) to v1.1.3. No code or API changes.## [1.1.2] — 2026-08-08\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`, `core`) to v1.1.2. No code or API changes.\n\n## [1.1.1] — 2026-08-07\n\nPatch — corrected package-doc wiring examples plus framework version alignment.\n\n### Fixed\n\n- Doc examples used a fictional `launcher.Register` / `health.Register` API; corrected to\n `lc.Append` + `web/health.NewHandler(...).ServeHTTP`.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`, `core`) to v1.1.1.\n\n## [1.1.0] — 2026-08-07\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`, `core`) to v1.1.0.\n\n## [1.0.0] — 2026-05-28\n\nInitial release. See the README for the full API and the `v1.0.0` git tag for the source.\n" }, { "name": "telemetry", @@ -9231,8 +9231,8 @@ } ] }, - "readme": "# einherjar/telemetry\n\n[![version](https://img.shields.io/badge/version-v1.3.0-5C4EE5?style=flat-square)](https://code.nochebuena.dev/einherjar/telemetry)\n[![license](https://img.shields.io/badge/license-AGPL--3.0-22863A?style=flat-square)](LICENSE)\n[![go](https://img.shields.io/badge/Go-1.26+-00ADD8?style=flat-square\u0026logo=go\u0026logoColor=white)](https://go.dev)\n\n\u003e Huginn and Muninn fly each day over the world. They see everything. They report back.\n\n`code.nochebuena.dev/einherjar/telemetry` is the OpenTelemetry bootstrap component of the Einherjar framework. It initializes traces, metrics, and structured logs via OTLP over gRPC — vendor-neutral, compatible with Grafana, Jaeger, Tempo, Datadog, and Honeycomb. A console mode is available for local development without a collector.\n\nTelemetry is **not** a `lifecycle.Component`. It must be initialized before the launcher and shut down after all components stop — the returned shutdown function handles this cleanly with a `defer`.\n\n---\n\n## Usage\n\n### Production (OTLP/gRPC)\n\n```go\nimport (\n \"code.nochebuena.dev/einherjar/telemetry\"\n \"code.nochebuena.dev/einherjar/core/launcher\"\n \"code.nochebuena.dev/einherjar/core/logz\"\n)\n\nctx := context.Background()\nlogger := logz.New(logz.Config{JSON: true})\n\n// Initialize telemetry BEFORE the launcher.\n// Defer the shutdown BEFORE launcher.Run() so it fires after the launcher stops.\nshutdown, err := telemetry.New(ctx, telemetry.DefaultConfig())\nif err != nil {\n logger.Error(\"telemetry init failed\", err)\n os.Exit(1)\n}\ndefer shutdown(ctx)\n\nlc := launcher.New(logger)\nlc.Append(db, cache, srv)\nlc.Run()\n```\n\n### Development (console / structured log output)\n\nNo collector required. Spans and metrics are emitted to the configured `logging.Logger`.\n\n```go\nshutdown, err := telemetry.NewConsole(ctx, logger, telemetry.DefaultConsoleConfig())\nif err != nil {\n logger.Error(\"telemetry init failed\", err)\n os.Exit(1)\n}\ndefer shutdown(ctx)\n```\n\n---\n\n## Environment variables\n\n### Production (`telemetry.New`)\n\n| Variable | Required | Default | Description |\n|---|---|---|---|\n| `EINHERJAR_OTEL_SERVICE_NAME` | Yes | — | Service name reported to the collector |\n| `EINHERJAR_OTEL_EXPORTER_ENDPOINT` | Yes | — | OTLP gRPC endpoint (e.g. `otel-collector:4317`) |\n| `EINHERJAR_OTEL_SERVICE_VERSION` | No | `unknown` | Service version tag |\n| `EINHERJAR_OTEL_ENVIRONMENT` | No | `development` | Deployment environment tag |\n| `EINHERJAR_OTEL_EXPORTER_INSECURE` | No | `false` | Disable TLS for the exporter (dev/local) |\n\n### Development (`telemetry.NewConsole`)\n\n| Variable | Required | Default | Description |\n|---|---|---|---|\n| `EINHERJAR_OTEL_SERVICE_NAME` | Yes | — | Service name |\n| `EINHERJAR_OTEL_SERVICE_VERSION` | No | `unknown` | Service version tag |\n| `EINHERJAR_OTEL_ENVIRONMENT` | No | `development` | Deployment environment tag |\n\n---\n\n## Dependency graph\n\n```\ncontracts (zero dependencies)\n ↑\n core\n ↑\ntelemetry (contracts, core, otel SDK + OTLP exporters)\n ↑\n your app (initialized before launcher.Run)\n```\n\n---\n\n## Verification\n\n```bash\ncd telemetry/\ngo build ./...\ngo vet ./...\ngo test ./...\ngofmt -l .\n```\n\n---\n\n\u003e *Odin gave an eye for wisdom.*\n\u003e *Observability is the eye that watches the living system.*\n", - "changelog": "# Changelog — einherjar/telemetry\n\nAll notable changes to this module are documented here.\nFormat follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).\nThis module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n---\n\n## [1.3.0] — 2026-08-08\n\nMinor — coordinated framework version alignment. No code or API changes in this module.\n\n### Changed\n\n- Bumped `contracts`, `core` to v1.3.0.\n\n## [1.2.0] — 2026-08-08\n\nMinor — coordinated framework version alignment. No code or API changes in this module.\n\n### Changed\n\n- Bumped `contracts`, `core` to v1.2.0.\n\n## [1.1.3] — 2026-08-08\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (\\`contracts\\`, \\`core\\`) to v1.1.3. No code or API changes.## [1.1.2] — 2026-08-08\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`, `core`) to v1.1.2. No code or API changes.\n\n## [1.1.1] — 2026-08-07\n\nPatch — corrected package-doc wiring examples plus framework version alignment.\n\n### Fixed\n\n- Doc examples used a fictional `launcher.Register` / `health.Register` API; corrected to\n `lc.Append` + `web/health.NewHandler(...).ServeHTTP`.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`, `core`) to v1.1.1.\n\n## [1.1.0] — 2026-08-07\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`, `core`) to v1.1.0.\n\n## [1.0.0] — 2026-05-28\n\nInitial release. See the README for the full API and the `v1.0.0` git tag for the source.\n" + "readme": "# einherjar/telemetry\n\n[![version](https://img.shields.io/badge/version-v1.4.0-5C4EE5?style=flat-square)](https://code.nochebuena.dev/einherjar/telemetry)\n[![license](https://img.shields.io/badge/license-AGPL--3.0-22863A?style=flat-square)](LICENSE)\n[![go](https://img.shields.io/badge/Go-1.26+-00ADD8?style=flat-square\u0026logo=go\u0026logoColor=white)](https://go.dev)\n\n\u003e Huginn and Muninn fly each day over the world. They see everything. They report back.\n\n`code.nochebuena.dev/einherjar/telemetry` is the OpenTelemetry bootstrap component of the Einherjar framework. It initializes traces, metrics, and structured logs via OTLP over gRPC — vendor-neutral, compatible with Grafana, Jaeger, Tempo, Datadog, and Honeycomb. A console mode is available for local development without a collector.\n\nTelemetry is **not** a `lifecycle.Component`. It must be initialized before the launcher and shut down after all components stop — the returned shutdown function handles this cleanly with a `defer`.\n\n---\n\n## Usage\n\n### Production (OTLP/gRPC)\n\n```go\nimport (\n \"code.nochebuena.dev/einherjar/telemetry\"\n \"code.nochebuena.dev/einherjar/core/launcher\"\n \"code.nochebuena.dev/einherjar/core/logz\"\n)\n\nctx := context.Background()\nlogger := logz.New(logz.Config{JSON: true})\n\n// Initialize telemetry BEFORE the launcher.\n// Defer the shutdown BEFORE launcher.Run() so it fires after the launcher stops.\nshutdown, err := telemetry.New(ctx, telemetry.DefaultConfig())\nif err != nil {\n logger.Error(\"telemetry init failed\", err)\n os.Exit(1)\n}\ndefer shutdown(ctx)\n\nlc := launcher.New(logger)\nlc.Append(db, cache, srv)\nlc.Run()\n```\n\n### Development (console / structured log output)\n\nNo collector required. Spans and metrics are emitted to the configured `logging.Logger`.\n\n```go\nshutdown, err := telemetry.NewConsole(ctx, logger, telemetry.DefaultConsoleConfig())\nif err != nil {\n logger.Error(\"telemetry init failed\", err)\n os.Exit(1)\n}\ndefer shutdown(ctx)\n```\n\n---\n\n## Environment variables\n\n### Production (`telemetry.New`)\n\n| Variable | Required | Default | Description |\n|---|---|---|---|\n| `EINHERJAR_OTEL_SERVICE_NAME` | Yes | — | Service name reported to the collector |\n| `EINHERJAR_OTEL_EXPORTER_ENDPOINT` | Yes | — | OTLP gRPC endpoint (e.g. `otel-collector:4317`) |\n| `EINHERJAR_OTEL_SERVICE_VERSION` | No | `unknown` | Service version tag |\n| `EINHERJAR_OTEL_ENVIRONMENT` | No | `development` | Deployment environment tag |\n| `EINHERJAR_OTEL_EXPORTER_INSECURE` | No | `false` | Disable TLS for the exporter (dev/local) |\n\n### Development (`telemetry.NewConsole`)\n\n| Variable | Required | Default | Description |\n|---|---|---|---|\n| `EINHERJAR_OTEL_SERVICE_NAME` | Yes | — | Service name |\n| `EINHERJAR_OTEL_SERVICE_VERSION` | No | `unknown` | Service version tag |\n| `EINHERJAR_OTEL_ENVIRONMENT` | No | `development` | Deployment environment tag |\n\n---\n\n## Dependency graph\n\n```\ncontracts (zero dependencies)\n ↑\n core\n ↑\ntelemetry (contracts, core, otel SDK + OTLP exporters)\n ↑\n your app (initialized before launcher.Run)\n```\n\n---\n\n## Verification\n\n```bash\ncd telemetry/\ngo build ./...\ngo vet ./...\ngo test ./...\ngofmt -l .\n```\n\n---\n\n\u003e *Odin gave an eye for wisdom.*\n\u003e *Observability is the eye that watches the living system.*\n", + "changelog": "# Changelog — einherjar/telemetry\n\nAll notable changes to this module are documented here.\nFormat follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).\nThis module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n---\n\n## [1.4.0] — 2026-08-09\n\nMinor — coordinated framework release: dependency refresh + lockstep version alignment.\n\n### Changed\n\n- Refreshed dependencies to their latest minor/patch where available.\n- Bumped `contracts`, `core` to v1.4.0.\n\n## [1.3.0] — 2026-08-08\n\nMinor — coordinated framework version alignment. No code or API changes in this module.\n\n### Changed\n\n- Bumped `contracts`, `core` to v1.3.0.\n\n## [1.2.0] — 2026-08-08\n\nMinor — coordinated framework version alignment. No code or API changes in this module.\n\n### Changed\n\n- Bumped `contracts`, `core` to v1.2.0.\n\n## [1.1.3] — 2026-08-08\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (\\`contracts\\`, \\`core\\`) to v1.1.3. No code or API changes.## [1.1.2] — 2026-08-08\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`, `core`) to v1.1.2. No code or API changes.\n\n## [1.1.1] — 2026-08-07\n\nPatch — corrected package-doc wiring examples plus framework version alignment.\n\n### Fixed\n\n- Doc examples used a fictional `launcher.Register` / `health.Register` API; corrected to\n `lc.Append` + `web/health.NewHandler(...).ServeHTTP`.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`, `core`) to v1.1.1.\n\n## [1.1.0] — 2026-08-07\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`, `core`) to v1.1.0.\n\n## [1.0.0] — 2026-05-28\n\nInitial release. See the README for the full API and the `v1.0.0` git tag for the source.\n" }, { "name": "web", @@ -9263,7 +9263,7 @@ { "name": "mw", "importPath": "code.nochebuena.dev/einherjar/web/mw", - "doc": "Package mw provides transport-level HTTP middleware for Einherjar services.\n\nAll middleware functions return func(http.Handler) http.Handler and are\ncomposed via [server.WithMiddleware] or chi's Use method.\n\n# Recommended middleware order (outermost first)\n\n\tserver.WithMiddleware(\n\t mw.Recover(logger),\n\t mw.RequestID(uuid.NewString),\n\t mw.RequestLogger(logger),\n\t mw.CORS([]string{\"https://example.com\"}),\n\t)\n\n# Rate limiting\n\n\t// In-memory (default — no extra dependencies)\n\tstore := mw.NewInMemoryRateLimiterStore(100, 20)\n\tsrv.Use(mw.IPRateLimit(store, logger))\n\n\t// Distributed — swap store, middleware unchanged\n\tstore := valkeymw.NewRateLimiterStore(client, 100, 20)\n\tsrv.Use(mw.IPRateLimit(store, logger))" + "doc": "Package mw provides transport-level HTTP middleware for Einherjar services.\n\nAll middleware functions return func(http.Handler) http.Handler and are\ncomposed via [server.WithMiddleware] or chi's Use method.\n\n# Recommended middleware order (outermost first)\n\n\tserver.WithMiddleware(\n\t mw.Recover(logger),\n\t mw.RequestID(uuid.NewString),\n\t mw.RequestLogger(logger),\n\t mw.CORS([]string{\"https://example.com\"}),\n\t)\n\n# Request IDs\n\n[RequestID] always generates a fresh ID. To continue a correlation ID a client\nalready sent — so a distributed trace survives this boundary — use [RequestIDFrom]\nand read the ID off the request in your resolver. The framework does not read the\nheader or validate the value: what is acceptable is per-service (a typed audit\ncolumn rejects what an opaque log accepts), so that policy stays with the caller.\n\n\tmw.RequestIDFrom(func(r *http.Request) string {\n\t if id, err := uuid.Parse(r.Header.Get(\"X-Request-ID\")); err == nil {\n\t return id.String() // continue the client's id\n\t }\n\t return uuid.NewString() // otherwise mint one\n\t})\n\n# Rate limiting\n\n\t// In-memory (default — no extra dependencies)\n\tstore := mw.NewInMemoryRateLimiterStore(100, 20)\n\tsrv.Use(mw.IPRateLimit(store, logger))\n\n\t// Distributed — swap store, middleware unchanged\n\tstore := valkeymw.NewRateLimiterStore(client, 100, 20)\n\tsrv.Use(mw.IPRateLimit(store, logger))" }, { "name": "server", @@ -9749,9 +9749,19 @@ "kind": "func", "name": "RequestID", "signature": "func RequestID(generator func() string) func(http.Handler) http.Handler", - "doc": "RequestID injects a unique request ID into the context (via [logz.WithRequestID])\nand sets the X-Request-ID response header.\ngenerator is called once per request — pass uuid.NewString or a custom function.", + "doc": "RequestID injects a freshly generated request ID into the context (via\n[logz.WithRequestID]) and sets the X-Request-ID response header. generator is\ncalled once per request — pass uuid.NewString or a custom function.\n\nIt always generates and ignores any inbound X-Request-ID. To continue a\ncorrelation ID the client supplied, use [RequestIDFrom] with a resolver that\nreads and validates it.", "file": "mw/requestid.go", - "line": 12 + "line": 46 + }, + { + "module": "web", + "subPackage": "mw", + "kind": "func", + "name": "RequestIDFrom", + "signature": "func RequestIDFrom(resolve func(r *http.Request) string) func(http.Handler) http.Handler", + "doc": "RequestIDFrom injects a per-request ID into the context (via [logz.WithRequestID])\nand the X-Request-ID response header, using the ID that resolve returns for the\nrequest.\n\nresolve receives the request so the application can decide the ID from it — most\nimportantly, to continue a correlation ID a client already sent, so a distributed\ntrace survives this boundary. The framework deliberately does not read a header,\nchoose a header name, or validate the value: acceptability is per-service. A\nservice that persists the ID in a typed column must reject what it cannot store\nand mint its own; a service that only logs an opaque string need not care. Reading\nthe header here would accept, on a service's behalf, a value that service may be\nunable to store — so resolution is the application's to own, and generation is\nmerely the fallback branch a resolver takes when there is no usable inbound ID.\n\nresolve is called exactly once per request and is expected to return a non-empty\nID. When it returns \"\", no ID is attached — the response header is omitted and the\ncontext carries none — rather than propagating an empty value; supplying a resolver\nthat can resolve to \"\" (e.g. one with no generation fallback) is a caller error.", + "file": "mw/requestid.go", + "line": 27 }, { "module": "web", @@ -10270,8 +10280,8 @@ } ] }, - "readme": "# einherjar/web\n\n[![version](https://img.shields.io/badge/version-v1.3.0-5C4EE5?style=flat-square)](https://code.nochebuena.dev/einherjar/web)\n[![license](https://img.shields.io/badge/license-AGPL--3.0-22863A?style=flat-square)](LICENSE)\n[![go](https://img.shields.io/badge/Go-1.26+-00ADD8?style=flat-square\u0026logo=go\u0026logoColor=white)](https://go.dev)\n\n\u003e The gate is not a barrier. It is the point where the outside world meets order.\n\n`code.nochebuena.dev/einherjar/web` is the HTTP layer of the Einherjar framework.\nIt sits above `core` in the dependency graph and provides everything a service needs\nto receive, process, and respond to HTTP requests: a lifecycle-aware server, a\ncomposable middleware stack, type-safe generic handlers, and a concurrent health\nendpoint.\n\n---\n\n## Sub-packages\n\n| Package | Import path | Purpose |\n|---|---|---|\n| `server` | `.../web/server` | Lifecycle-aware HTTP server (chi router + `lifecycle.Component`) |\n| `mw` | `.../web/mw` | Middleware: Recover, RequestID, RequestLogger, CORS, rate limiting |\n| `httputil` | `.../web/httputil` | Generic handler adapters: decode → validate → call → encode |\n| `health` | `.../web/health` | Concurrent health-check endpoint consuming `observability.Checkable` |\n\nAll four are in one module because they compose together and ship in every\nEinherjar HTTP service.\n\n---\n\n## Usage\n\n### Tier 1 — Happy path (`web.New`)\n\nZero config, safe defaults, all env vars respected:\n\n```go\nimport (\n \"code.nochebuena.dev/einherjar/core/logz\"\n \"code.nochebuena.dev/einherjar/web\"\n \"code.nochebuena.dev/einherjar/web/health\"\n)\n\nlogger := logz.New(logz.Config{JSON: true, StaticArgs: []any{\"service\", \"api\"}})\n\nsrv := web.New(logger)\n// Pre-wired stack: Recover → RequestID (UUID v7/v4) → RequestLogger → [CORS]\n\nsrv.Get(\"/health\", health.NewHandler(logger, db, cache).ServeHTTP)\n\nlc := launcher.New(logger)\nlc.Append(srv)\nlc.BeforeStart(func() error {\n srv.Mount(\"/v1\", myRouter)\n return nil\n})\nlc.Run()\n```\n\nWith origins set in code (CORS auto-applied). `Server.CORSOrigins` is the single\nsource of truth — normally it loads from `EINHERJAR_SERVER_CORS_ORIGINS`, but you\ncan set it directly to override without the env var:\n\n```go\nsrv := web.New(logger, web.Config{\n Server: server.Config{\n Port: 9090,\n CORSOrigins: []string{\"https://example.com\"},\n },\n})\n```\n\nEnvironment variables for `web.New`:\n\n| Variable | Default | Effect |\n|---|---|---|\n| `EINHERJAR_SERVER_HOST` | `0.0.0.0` | Bind address |\n| `EINHERJAR_SERVER_PORT` | `8080` | Listen port |\n| `EINHERJAR_SERVER_READ_TIMEOUT` | `5s` | HTTP read timeout |\n| `EINHERJAR_SERVER_WRITE_TIMEOUT` | `10s` | HTTP write timeout |\n| `EINHERJAR_SERVER_IDLE_TIMEOUT` | `120s` | Keep-alive idle timeout |\n| `EINHERJAR_SERVER_SHUTDOWN_TIMEOUT` | `10s` | Graceful shutdown budget |\n| `EINHERJAR_SERVER_CORS_ORIGINS` | _(empty — CORS off)_ | Comma-separated allowed origins (`*` is rejected — use `mw.CORSAllowAll()` in code for allow-all) |\n\n### Tier 2 — Full control (`server.New`)\n\nExplicit middleware composition:\n\n```go\nimport (\n \"code.nochebuena.dev/einherjar/web/mw\"\n \"code.nochebuena.dev/einherjar/web/server\"\n)\n\nsrv := server.New(logger, server.Config{Port: 9090},\n server.WithMiddleware(\n mw.Recover(logger),\n mw.RequestID(myIDGenerator),\n mw.CORS([]string{\"https://example.com\"}),\n mw.RequestLogger(logger),\n myOwnMiddleware,\n ),\n)\n```\n\nBoth tiers share the same `server.Config`, env variables, and `lifecycle.Component`\ncontract. The only difference is how much wiring is automated.\n\n---\n\n### Middleware\n\n```go\nimport \"code.nochebuena.dev/einherjar/web/mw\"\n\n// Rate limiting — in-memory token bucket (swap for distributed store at scale)\nlimiter := mw.NewInMemoryRateLimiterStore(100, 20) // rps=100, burst=20\nsrv.Use(mw.IPRateLimit(limiter, logger))\nsrv.Use(mw.UserRateLimit(limiter, logger))\n\n// Scale: swap store without changing middleware\n// valkeyLimiter := valkeymw.NewRateLimiterStore(valkey, 100, 20) // implements mw.RateLimiterStore\n// srv.Use(mw.IPRateLimit(valkeyLimiter, logger))\n```\n\n`IPRateLimit` uses `X-Forwarded-For` → `RemoteAddr` as the rate-limit key.\n`UserRateLimit` uses the authenticated user ID from `security.Identity`; falls back\nto client IP when no identity is present in context.\n\nBoth middlewares fail **open** on store error — the request is allowed, the error\nis logged. This keeps the service available when the rate-limit store is degraded.\n\n---\n\n### Generic handlers\n\n```go\nimport (\n \"code.nochebuena.dev/einherjar/core/valid\"\n \"code.nochebuena.dev/einherjar/web/httputil\"\n)\n\ntype CreateUserReq struct {\n Email string `json:\"email\" validate:\"required,email\"`\n Name string `json:\"name\" validate:\"required\"`\n}\ntype CreateUserRes struct {\n ID string `json:\"id\"`\n}\n\nv := valid.New()\n\n// POST /users — decode body → validate → call service → encode response\nsrv.Post(\"/users\", httputil.Handle(v, func(ctx context.Context, req CreateUserReq) (CreateUserRes, error) {\n id, err := userService.Create(ctx, req.Email, req.Name)\n if err != nil {\n return CreateUserRes{}, err\n }\n return CreateUserRes{ID: id}, nil\n}))\n\n// GET /users/{id} — no request body\nsrv.Get(\"/users/{id}\", httputil.HandleNoBody(func(ctx context.Context) (CreateUserRes, error) {\n // ...\n}))\n\n// DELETE /users/{id} — no response body\nsrv.Delete(\"/users/{id}\", httputil.HandleEmpty(v, func(ctx context.Context, req DeleteReq) error {\n return userService.Delete(ctx, req.ID)\n}))\n```\n\nValidation failures return 400 with a structured JSON error. All `*xerrors.Err`\nvalues are mapped to their canonical HTTP status codes (full 16-code table below).\n\n---\n\n### Health endpoint\n\n```go\nimport \"code.nochebuena.dev/einherjar/web/health\"\n\n// db and cache implement observability.Checkable\nsrv.Get(\"/health\", health.NewHandler(logger, db, cache).ServeHTTP)\n\n// Response shape:\n// {\"status\":\"UP\",\"components\":{\"db\":{\"status\":\"UP\",\"latency\":\"1.2ms\"}}}\n// {\"status\":\"DEGRADED\",\"components\":{\"cache\":{\"status\":\"DEGRADED\",\"latency\":\"50ms\",\"error\":\"timeout\"}}}\n// {\"status\":\"DOWN\",\"components\":{\"db\":{\"status\":\"DOWN\",\"latency\":\"5s\",\"error\":\"connection refused\"}}}\n```\n\nAll checks run concurrently within a configurable timeout (default 5s).\n`DOWN` (critical priority failure) → HTTP 503. `DEGRADED` (degraded priority failure) → HTTP 200.\n\nEnvironment variables:\n\n| Variable | Default | Effect |\n|---|---|---|\n| `EINHERJAR_HEALTH_CHECK_TIMEOUT` | `5s` | Maximum time to wait for all checks |\n\n---\n\n## HTTP Status Code Mapping\n\n`httputil.Error` maps `*xerrors.Err` codes to HTTP status:\n\n| Code | HTTP |\n|---|---|\n| `ErrInvalidInput`, `ErrOutOfRange` | 400 |\n| `ErrUnauthorized` | 401 |\n| `ErrPermissionDenied` | 403 |\n| `ErrNotFound` | 404 |\n| `ErrAlreadyExists`, `ErrAborted` | 409 |\n| `ErrGone` | 410 |\n| `ErrPreconditionFailed` | 412 |\n| `ErrRateLimited` | 429 |\n| `ErrCancelled` | 499 |\n| `ErrInternal`, `ErrDataLoss` | 500 |\n| `ErrNotImplemented` | 501 |\n| `ErrUnavailable` | 503 |\n| `ErrDeadlineExceeded` | 504 |\n\n---\n\n## Dependency Graph\n\n```\ncontracts (zero dependencies)\n ↑\n core (contracts)\n ↑\n web (contracts, core, chi/v5, uuid, x/time)\n ↑\n your app\n```\n\n`db-*`, `cache-*`, and `storage-*` starters never import `web` — they only need\n`contracts` and `core`. Repositories do not know HTTP exists.\n\n---\n\n## Verification\n\n```bash\ncd web/\ngo build ./... # must compile clean\ngo vet ./... # no warnings\ngo test ./... # structural + behavioural compliance passes\ngofmt -l . # no output\n```\n\n---\n\n\u003e *The gate does not decide who passes.*\n\u003e *It decides that passing has consequences.*\n", - "changelog": "# Changelog — einherjar/web\n\nAll notable changes to this module are documented here.\nFormat follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).\nThis module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n---\n\n## [1.3.0] — 2026-08-08\n\nMinor release carrying a **breaking API change** to CORS configuration. The framework is\nprivate with controlled consumers, so this ships in the 1.x line with a loud compile break\ninstead of a v2 module-path (`/v2`) migration.\n\n### Removed\n\n- **⚠️ BREAKING: `web.Config.AllowedOrigins` removed.** CORS origins now have a single\n home: `server.Config.CORSOrigins` (env `EINHERJAR_SERVER_CORS_ORIGINS`). The field was\n env-backed through v1.1.x and a code-only override in v1.2.0 — reading it after the env\n tag moved silently served *no* CORS. Removing it turns that runtime trap into a compile\n error.\n\n **Migration:** replace `cfg.Web.AllowedOrigins` with `cfg.Server.CORSOrigins`, and\n `web.Config{AllowedOrigins: o}` with `web.Config{Server: server.Config{CORSOrigins: o}}`\n — or just let `web.New` read `EINHERJAR_SERVER_CORS_ORIGINS`. The MCP flags any leftover\n reference (`validate_snippet` rule `web.allowedorigins-removed`).\n\n### Changed\n\n- Bumped `contracts`, `core` to v1.3.0.\n\n## [1.2.0] — 2026-08-08\n\nMinor — CORS configuration moved to its rightful struct; `web.New` made safe-by-default.\n\n### Changed\n\n- **`CORSOrigins` now lives on `server.Config`** (env var `EINHERJAR_SERVER_CORS_ORIGINS`), the\n struct its name advertises — it previously loaded into `web.Config`. `web.Config.AllowedOrigins`\n remains as a code-only override (no env tag). Wiring via `web.New` or the env var is unaffected.\n- Bumped `contracts`, `core` to v1.2.0.\n\n### Added\n\n- `web.New` logs a warning when no CORS origins are configured, instead of silently disabling CORS.\n- Package docs (`web`, `web/server`) document when to use `web.New` vs `server.New`, with compiling\n examples and the env-gated allow-all CORS convention.\n\n## [1.1.3] — 2026-08-08\n\nPatch — CORS documentation discoverability.\n\n### Fixed\n\n- `mw.CORS` and `CORSAllowAll` doc comments now document the `\"*\"` rejection (panic) and the\n env-gated CORS convention (`local -\u003e CORSAllowAll`, else `mw.CORS(origins)`), so `search_symbols`\n surfaces it — previously the convention lived only in code comments and the wire example.\n\n### Changed\n\n- Bumped `contracts`, `core` to v1.1.3.\n\n## [1.1.2] — 2026-08-08\n\nPatch — CORS wildcard hardening plus documentation fixes.\n\n### Changed\n\n- **`mw.CORS` now rejects `\"*\"` (panics at construction)** instead of silently no-op'ing it.\n `\"*\"` matched nothing (exact-match only), so a service passing it ran with CORS effectively\n off — a silent trap. Fail loud at boot; use `mw.CORSAllowAll()` (development) or list explicit origins.\n- Bumped `contracts`, `core` to v1.1.2.\n\n### Fixed\n\n- README Go examples now compile: `mw.Recover(logger)`, `health.NewHandler(...).ServeHTTP`, and the\n `mw.CORS` example no longer passes `\"*\"`. Corrected the `CORSAllowAll` description.\n\n## [1.1.1] — 2026-08-07\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped `contracts` and `core` to v1.1.1 (framework version alignment). No code or API changes.\n\n## [1.1.0] — 2026-08-07\n\nCoordinated framework release. Documentation fixes plus the framework version bump\n(which finally makes the previously-drafted `contracts` v1.1.0 pin real).\n\n### Fixed\n\n- **Package doc examples didn't compile.** Verified by compiling the example patterns\n against the real API:\n - `mw.Recover()` -\u003e `mw.Recover(logger)` — the recover middleware takes a `logging.Logger`\n (`server`, `mw` package docs and `server.go`).\n - `health.NewHandler(logger, …)` -\u003e `health.NewHandler(logger, …).ServeHTTP` — the handler\n returns `http.Handler`, but chi's `Get` takes `http.HandlerFunc`; same for\n `NewHandlerWithConfig` (`server`, `web`, `health` package docs).\n\n### Changed\n\n- Bumped `contracts` and `core` to v1.1.0 (framework version alignment).\n\n---\n\n## [1.0.0] — 2026-05-28\n\n### Added\n\n#### `server`\n\n- `Server` interface — embeds `lifecycle.Component` (from `contracts/lifecycle`) and\n `chi.Router` (from `go-chi/chi/v5`); any type that satisfies both is directly\n compatible\n- `Config` struct — `Host`, `Port`, `ReadTimeout`, `WriteTimeout`, `IdleTimeout`,\n `ShutdownTimeout`; all fields carry `env:\"EINHERJAR_SERVER_*\"` and `envDefault`\n tags (`caarlos0/env` syntax)\n- `New(logger logging.Logger, cfg Config, opts ...Option) Server` — constructs the\n unexported `impl` struct; embeds `chi.NewRouter()`\n- `Option` type + `WithMiddleware(mw ...func(http.Handler) http.Handler) Option` —\n variadic option for middleware composition\n- `impl.OnInit()` — applies registered middleware via `chi.Use`\n- `impl.OnStart()` — binds TCP listener synchronously (`net.Listen`), starts\n `http.Server.Serve` in a goroutine; port binding failure returns immediately\n- `impl.OnStop(ctx)` — graceful `http.Server.Shutdown(ctx)` with `ShutdownTimeout`\n (fallback: `defaultShutdownTimeout = 10s`)\n- `var _ Server = (*impl)(nil)` — compile-time assertion\n\n#### `mw`\n\n- `StatusRecorder` struct — wraps `http.ResponseWriter`, captures written status code\n- `Recover() func(http.Handler) http.Handler` — catches panics, writes 500, logs\n stack trace via `runtime/debug.Stack()`\n- `RequestID(generator func() string) func(http.Handler) http.Handler` — injects a\n request ID via `logz.WithRequestID`; reads existing `X-Request-ID` header if present\n- `RequestLogger(logger logging.Logger) func(http.Handler) http.Handler` — structured\n request logging: method, path, status, latency; uses `StatusRecorder` to capture code\n- `CORS(origins []string) func(http.Handler) http.Handler` — sets\n `Access-Control-Allow-Origin` for listed origins; supports preflight (`OPTIONS`)\n- `CORSAllowAll() func(http.Handler) http.Handler` — allows any origin by reflecting the request `Origin` (no `Access-Control-Allow-Credentials`); development only\n- `RateLimiterStore` interface — `Allow(ctx context.Context, key string) (bool, error)`;\n pluggable backend; `error` return allows infrastructure failures to surface; fail-open\n contract: non-nil error allows the request\n- `InMemoryRateLimiterStore` struct — per-key token bucket via `golang.org/x/time/rate`;\n `sync.Map` for concurrent access; background goroutine evicts idle entries after 5\n minutes via `time.Ticker`; `Allow` always returns `(bool, nil)`\n- `NewInMemoryRateLimiterStore(rps float64, burst int) *InMemoryRateLimiterStore`\n- `IPRateLimit(store RateLimiterStore, logger logging.Logger) func(http.Handler) http.Handler`\n — limits by client IP (`X-Forwarded-For` → `RemoteAddr` fallback); returns 429 JSON\n on exceeded limit; fails open on store error\n- `UserRateLimit(store RateLimiterStore, logger logging.Logger) func(http.Handler) http.Handler`\n — limits by authenticated user ID from `security.FromContext`; falls back to client IP\n when no identity present; same 429 + fail-open behaviour\n\n#### `httputil`\n\n- `HandlerFunc` type — `func(w http.ResponseWriter, r *http.Request) error`; implements\n `http.Handler` via `ServeHTTP`\n- `Handle[Req, Res any](v valid.Validator, fn func(ctx context.Context, req Req) (Res, error)) http.HandlerFunc`\n — decodes JSON body, validates struct, calls `fn`, encodes response; 400 on validation\n failure, mapped status on `*xerrors.Err`\n- `HandleNoBody[Res any](fn func(ctx context.Context) (Res, error)) http.HandlerFunc`\n — no body decoding/validation; encodes response directly\n- `HandleEmpty[Req any](v valid.Validator, fn func(ctx context.Context, req Req) error) http.HandlerFunc`\n — decodes and validates body, calls `fn`, returns 204 on success\n- `JSON(w http.ResponseWriter, status int, v any)` — writes JSON response\n- `NoContent(w http.ResponseWriter)` — writes 204 with no body\n- `Error(w http.ResponseWriter, err error)` — maps `*xerrors.Err` to HTTP status and\n writes `{\"code\":\"\u003cwire_value\u003e\",\"message\":\"\u003cmsg\u003e\"}` JSON body; complete 16-code mapping\n\n#### `health`\n\n- `Config` struct — `CheckTimeout time.Duration` with\n `env:\"EINHERJAR_HEALTH_CHECK_TIMEOUT\" envDefault:\"5s\"` (`caarlos0/env` syntax)\n- `Response` struct — `Status string`, `Components map[string]ComponentStatus`\n- `ComponentStatus` struct — `Status`, `Latency` (omitempty), `Error` (omitempty)\n- `NewHandler(logger logging.Logger, checks ...observability.Checkable) http.Handler`\n — shorthand with default 5s timeout\n- `NewHandlerWithConfig(logger logging.Logger, cfg Config, checks ...observability.Checkable) http.Handler`\n — all checks run concurrently in goroutines with a shared context timeout; results\n collected via buffered channel; `DOWN` (critical priority) → 503; `DEGRADED`\n (degraded priority) → 200; `UP` → 200\n- Accepts `observability.Checkable` from `contracts` directly — no local redefinition\n\n#### Root package (`web`)\n\n- `Config` struct — `Server server.Config`, `AllowedOrigins []string` with\n `env:\"EINHERJAR_SERVER_CORS_ORIGINS\" envSeparator:\",\"`\n- `New(logger logging.Logger, cfg ...Config) server.Server` — pre-wires recommended\n middleware stack: Recover → RequestID (UUID v7 with v4 fallback) → RequestLogger →\n CORS (only when `AllowedOrigins` non-empty)\n- Unexported `newRequestID()` — uses `uuid.NewV7()` (time-ordered), falls back to\n `uuid.NewString()` (v4) on generation error\n\n### Design Notes\n\n1. **Progressive disclosure.** `web.New` is the happy path — one call, all middleware\n pre-wired, env vars respected. `server.New` is the escape hatch — every choice\n explicit. Both tiers share the same config structs, env variables, and lifecycle\n contract.\n\n2. **`RateLimiterStore` interface.** The pluggable backend design lets developers start\n with `InMemoryRateLimiterStore` (zero extra dependencies) and swap to a distributed\n store (e.g., `cache-valkey`) at scale without touching middleware wiring. The store\n satisfies the interface via Go duck typing — `cache-valkey` never imports `web/mw`.\n\n3. **Fail-open rate limiting.** When the store returns an error (e.g., Valkey\n unavailable), the request is allowed. Availability is preferred over hard\n enforcement during infrastructure degradation.\n\n4. **`observability.Checkable` from contracts.** `health.NewHandler` accepts\n `observability.Checkable` directly from `contracts/observability`. Any starter\n (`db-*`, `cache-*`, `storage-*`) that implements the contracts interface plugs in\n without an adapter — no `web` import required by those starters.\n\n5. **`last_seen` excluded.** Session tracking is an application-domain concern, not\n transport-level middleware. It requires knowing which entity to track and where to\n persist it. Developers who need it can write it in ~15 lines in their own wiring\n package. Will be revisited if `einherjar/worker` provides a fire-and-forget\n primitive.\n\n6. **UUID v7 for request IDs.** Time-ordered UUIDs embed a millisecond-precision\n timestamp, enabling request IDs to sort chronologically in log aggregation systems.\n UUID v4 fallback ensures ID generation never fails.\n\n---\n\n[1.0.0]: https://code.nochebuena.dev/einherjar/web/releases/tag/v1.0.0\n" + "readme": "# einherjar/web\n\n[![version](https://img.shields.io/badge/version-v1.4.0-5C4EE5?style=flat-square)](https://code.nochebuena.dev/einherjar/web)\n[![license](https://img.shields.io/badge/license-AGPL--3.0-22863A?style=flat-square)](LICENSE)\n[![go](https://img.shields.io/badge/Go-1.26+-00ADD8?style=flat-square\u0026logo=go\u0026logoColor=white)](https://go.dev)\n\n\u003e The gate is not a barrier. It is the point where the outside world meets order.\n\n`code.nochebuena.dev/einherjar/web` is the HTTP layer of the Einherjar framework.\nIt sits above `core` in the dependency graph and provides everything a service needs\nto receive, process, and respond to HTTP requests: a lifecycle-aware server, a\ncomposable middleware stack, type-safe generic handlers, and a concurrent health\nendpoint.\n\n---\n\n## Sub-packages\n\n| Package | Import path | Purpose |\n|---|---|---|\n| `server` | `.../web/server` | Lifecycle-aware HTTP server (chi router + `lifecycle.Component`) |\n| `mw` | `.../web/mw` | Middleware: Recover, RequestID, RequestLogger, CORS, rate limiting |\n| `httputil` | `.../web/httputil` | Generic handler adapters: decode → validate → call → encode |\n| `health` | `.../web/health` | Concurrent health-check endpoint consuming `observability.Checkable` |\n\nAll four are in one module because they compose together and ship in every\nEinherjar HTTP service.\n\n---\n\n## Usage\n\n### Tier 1 — Happy path (`web.New`)\n\nZero config, safe defaults, all env vars respected:\n\n```go\nimport (\n \"code.nochebuena.dev/einherjar/core/logz\"\n \"code.nochebuena.dev/einherjar/web\"\n \"code.nochebuena.dev/einherjar/web/health\"\n)\n\nlogger := logz.New(logz.Config{JSON: true, StaticArgs: []any{\"service\", \"api\"}})\n\nsrv := web.New(logger)\n// Pre-wired stack: Recover → RequestID (UUID v7/v4) → RequestLogger → [CORS]\n\nsrv.Get(\"/health\", health.NewHandler(logger, db, cache).ServeHTTP)\n\nlc := launcher.New(logger)\nlc.Append(srv)\nlc.BeforeStart(func() error {\n srv.Mount(\"/v1\", myRouter)\n return nil\n})\nlc.Run()\n```\n\nWith origins set in code (CORS auto-applied). `Server.CORSOrigins` is the single\nsource of truth — normally it loads from `EINHERJAR_SERVER_CORS_ORIGINS`, but you\ncan set it directly to override without the env var:\n\n```go\nsrv := web.New(logger, web.Config{\n Server: server.Config{\n Port: 9090,\n CORSOrigins: []string{\"https://example.com\"},\n },\n})\n```\n\nEnvironment variables for `web.New`:\n\n| Variable | Default | Effect |\n|---|---|---|\n| `EINHERJAR_SERVER_HOST` | `0.0.0.0` | Bind address |\n| `EINHERJAR_SERVER_PORT` | `8080` | Listen port |\n| `EINHERJAR_SERVER_READ_TIMEOUT` | `5s` | HTTP read timeout |\n| `EINHERJAR_SERVER_WRITE_TIMEOUT` | `10s` | HTTP write timeout |\n| `EINHERJAR_SERVER_IDLE_TIMEOUT` | `120s` | Keep-alive idle timeout |\n| `EINHERJAR_SERVER_SHUTDOWN_TIMEOUT` | `10s` | Graceful shutdown budget |\n| `EINHERJAR_SERVER_CORS_ORIGINS` | _(empty — CORS off)_ | Comma-separated allowed origins (`*` is rejected — use `mw.CORSAllowAll()` in code for allow-all) |\n\n### Tier 2 — Full control (`server.New`)\n\nExplicit middleware composition:\n\n```go\nimport (\n \"code.nochebuena.dev/einherjar/web/mw\"\n \"code.nochebuena.dev/einherjar/web/server\"\n)\n\nsrv := server.New(logger, server.Config{Port: 9090},\n server.WithMiddleware(\n mw.Recover(logger),\n mw.RequestID(myIDGenerator),\n mw.CORS([]string{\"https://example.com\"}),\n mw.RequestLogger(logger),\n myOwnMiddleware,\n ),\n)\n```\n\nBoth tiers share the same `server.Config`, env variables, and `lifecycle.Component`\ncontract. The only difference is how much wiring is automated.\n\n---\n\n### Middleware\n\n```go\nimport \"code.nochebuena.dev/einherjar/web/mw\"\n\n// Rate limiting — in-memory token bucket (swap for distributed store at scale)\nlimiter := mw.NewInMemoryRateLimiterStore(100, 20) // rps=100, burst=20\nsrv.Use(mw.IPRateLimit(limiter, logger))\nsrv.Use(mw.UserRateLimit(limiter, logger))\n\n// Scale: swap store without changing middleware\n// valkeyLimiter := valkeymw.NewRateLimiterStore(valkey, 100, 20) // implements mw.RateLimiterStore\n// srv.Use(mw.IPRateLimit(valkeyLimiter, logger))\n```\n\n`IPRateLimit` uses `X-Forwarded-For` → `RemoteAddr` as the rate-limit key.\n`UserRateLimit` uses the authenticated user ID from `security.Identity`; falls back\nto client IP when no identity is present in context.\n\nBoth middlewares fail **open** on store error — the request is allowed, the error\nis logged. This keeps the service available when the rate-limit store is degraded.\n\n---\n\n### Generic handlers\n\n```go\nimport (\n \"code.nochebuena.dev/einherjar/core/valid\"\n \"code.nochebuena.dev/einherjar/web/httputil\"\n)\n\ntype CreateUserReq struct {\n Email string `json:\"email\" validate:\"required,email\"`\n Name string `json:\"name\" validate:\"required\"`\n}\ntype CreateUserRes struct {\n ID string `json:\"id\"`\n}\n\nv := valid.New()\n\n// POST /users — decode body → validate → call service → encode response\nsrv.Post(\"/users\", httputil.Handle(v, func(ctx context.Context, req CreateUserReq) (CreateUserRes, error) {\n id, err := userService.Create(ctx, req.Email, req.Name)\n if err != nil {\n return CreateUserRes{}, err\n }\n return CreateUserRes{ID: id}, nil\n}))\n\n// GET /users/{id} — no request body\nsrv.Get(\"/users/{id}\", httputil.HandleNoBody(func(ctx context.Context) (CreateUserRes, error) {\n // ...\n}))\n\n// DELETE /users/{id} — no response body\nsrv.Delete(\"/users/{id}\", httputil.HandleEmpty(v, func(ctx context.Context, req DeleteReq) error {\n return userService.Delete(ctx, req.ID)\n}))\n```\n\nValidation failures return 400 with a structured JSON error. All `*xerrors.Err`\nvalues are mapped to their canonical HTTP status codes (full 16-code table below).\n\n---\n\n### Health endpoint\n\n```go\nimport \"code.nochebuena.dev/einherjar/web/health\"\n\n// db and cache implement observability.Checkable\nsrv.Get(\"/health\", health.NewHandler(logger, db, cache).ServeHTTP)\n\n// Response shape:\n// {\"status\":\"UP\",\"components\":{\"db\":{\"status\":\"UP\",\"latency\":\"1.2ms\"}}}\n// {\"status\":\"DEGRADED\",\"components\":{\"cache\":{\"status\":\"DEGRADED\",\"latency\":\"50ms\",\"error\":\"timeout\"}}}\n// {\"status\":\"DOWN\",\"components\":{\"db\":{\"status\":\"DOWN\",\"latency\":\"5s\",\"error\":\"connection refused\"}}}\n```\n\nAll checks run concurrently within a configurable timeout (default 5s).\n`DOWN` (critical priority failure) → HTTP 503. `DEGRADED` (degraded priority failure) → HTTP 200.\n\nEnvironment variables:\n\n| Variable | Default | Effect |\n|---|---|---|\n| `EINHERJAR_HEALTH_CHECK_TIMEOUT` | `5s` | Maximum time to wait for all checks |\n\n---\n\n## HTTP Status Code Mapping\n\n`httputil.Error` maps `*xerrors.Err` codes to HTTP status:\n\n| Code | HTTP |\n|---|---|\n| `ErrInvalidInput`, `ErrOutOfRange` | 400 |\n| `ErrUnauthorized` | 401 |\n| `ErrPermissionDenied` | 403 |\n| `ErrNotFound` | 404 |\n| `ErrAlreadyExists`, `ErrAborted` | 409 |\n| `ErrGone` | 410 |\n| `ErrPreconditionFailed` | 412 |\n| `ErrRateLimited` | 429 |\n| `ErrCancelled` | 499 |\n| `ErrInternal`, `ErrDataLoss` | 500 |\n| `ErrNotImplemented` | 501 |\n| `ErrUnavailable` | 503 |\n| `ErrDeadlineExceeded` | 504 |\n\n---\n\n## Dependency Graph\n\n```\ncontracts (zero dependencies)\n ↑\n core (contracts)\n ↑\n web (contracts, core, chi/v5, uuid, x/time)\n ↑\n your app\n```\n\n`db-*`, `cache-*`, and `storage-*` starters never import `web` — they only need\n`contracts` and `core`. Repositories do not know HTTP exists.\n\n---\n\n## Verification\n\n```bash\ncd web/\ngo build ./... # must compile clean\ngo vet ./... # no warnings\ngo test ./... # structural + behavioural compliance passes\ngofmt -l . # no output\n```\n\n---\n\n\u003e *The gate does not decide who passes.*\n\u003e *It decides that passing has consequences.*\n", + "changelog": "# Changelog — einherjar/web\n\nAll notable changes to this module are documented here.\nFormat follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).\nThis module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n---\n\n## [1.4.0] — 2026-08-09\n\nMinor — resolvable request IDs, plus a dependency refresh.\n\n### Added\n\n- **`mw.RequestIDFrom(resolve func(*http.Request) string)`** — the resolver sees the request,\n so a service can continue a correlation ID a client already sent (a distributed trace survives\n this boundary). The framework provides plumbing only: it does not read a header, choose a header\n name, or validate the value — that policy is the application's, because a value the framework\n accepts on a service's behalf may be one that service cannot store. Generation becomes the\n fallback branch of resolution rather than a separate mode.\n\n### Changed\n\n- `mw.RequestID(generator func() string)` is unchanged in signature and behaviour (always\n generates, ignores inbound); it is now expressed as `RequestIDFrom` with a request-ignoring resolver.\n- Refreshed dependencies (`go-chi/chi/v5` v5.3.1, `golang.org/x/time` v0.15.0).\n- Bumped `contracts`, `core` to v1.4.0.\n\n### Notes\n\n- An empty resolver result attaches no ID (header omitted, context carries none) rather than a\n silently-empty value; a resolver that can return \"\" is a caller error.\n\n## [1.3.0] — 2026-08-08\n\nMinor release carrying a **breaking API change** to CORS configuration. The framework is\nprivate with controlled consumers, so this ships in the 1.x line with a loud compile break\ninstead of a v2 module-path (`/v2`) migration.\n\n### Removed\n\n- **⚠️ BREAKING: `web.Config.AllowedOrigins` removed.** CORS origins now have a single\n home: `server.Config.CORSOrigins` (env `EINHERJAR_SERVER_CORS_ORIGINS`). The field was\n env-backed through v1.1.x and a code-only override in v1.2.0 — reading it after the env\n tag moved silently served *no* CORS. Removing it turns that runtime trap into a compile\n error.\n\n **Migration:** replace `cfg.Web.AllowedOrigins` with `cfg.Server.CORSOrigins`, and\n `web.Config{AllowedOrigins: o}` with `web.Config{Server: server.Config{CORSOrigins: o}}`\n — or just let `web.New` read `EINHERJAR_SERVER_CORS_ORIGINS`. The MCP flags any leftover\n reference (`validate_snippet` rule `web.allowedorigins-removed`).\n\n### Changed\n\n- Bumped `contracts`, `core` to v1.3.0.\n\n## [1.2.0] — 2026-08-08\n\nMinor — CORS configuration moved to its rightful struct; `web.New` made safe-by-default.\n\n### Changed\n\n- **`CORSOrigins` now lives on `server.Config`** (env var `EINHERJAR_SERVER_CORS_ORIGINS`), the\n struct its name advertises — it previously loaded into `web.Config`. `web.Config.AllowedOrigins`\n remains as a code-only override (no env tag). Wiring via `web.New` or the env var is unaffected.\n- Bumped `contracts`, `core` to v1.2.0.\n\n### Added\n\n- `web.New` logs a warning when no CORS origins are configured, instead of silently disabling CORS.\n- Package docs (`web`, `web/server`) document when to use `web.New` vs `server.New`, with compiling\n examples and the env-gated allow-all CORS convention.\n\n## [1.1.3] — 2026-08-08\n\nPatch — CORS documentation discoverability.\n\n### Fixed\n\n- `mw.CORS` and `CORSAllowAll` doc comments now document the `\"*\"` rejection (panic) and the\n env-gated CORS convention (`local -\u003e CORSAllowAll`, else `mw.CORS(origins)`), so `search_symbols`\n surfaces it — previously the convention lived only in code comments and the wire example.\n\n### Changed\n\n- Bumped `contracts`, `core` to v1.1.3.\n\n## [1.1.2] — 2026-08-08\n\nPatch — CORS wildcard hardening plus documentation fixes.\n\n### Changed\n\n- **`mw.CORS` now rejects `\"*\"` (panics at construction)** instead of silently no-op'ing it.\n `\"*\"` matched nothing (exact-match only), so a service passing it ran with CORS effectively\n off — a silent trap. Fail loud at boot; use `mw.CORSAllowAll()` (development) or list explicit origins.\n- Bumped `contracts`, `core` to v1.1.2.\n\n### Fixed\n\n- README Go examples now compile: `mw.Recover(logger)`, `health.NewHandler(...).ServeHTTP`, and the\n `mw.CORS` example no longer passes `\"*\"`. Corrected the `CORSAllowAll` description.\n\n## [1.1.1] — 2026-08-07\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped `contracts` and `core` to v1.1.1 (framework version alignment). No code or API changes.\n\n## [1.1.0] — 2026-08-07\n\nCoordinated framework release. Documentation fixes plus the framework version bump\n(which finally makes the previously-drafted `contracts` v1.1.0 pin real).\n\n### Fixed\n\n- **Package doc examples didn't compile.** Verified by compiling the example patterns\n against the real API:\n - `mw.Recover()` -\u003e `mw.Recover(logger)` — the recover middleware takes a `logging.Logger`\n (`server`, `mw` package docs and `server.go`).\n - `health.NewHandler(logger, …)` -\u003e `health.NewHandler(logger, …).ServeHTTP` — the handler\n returns `http.Handler`, but chi's `Get` takes `http.HandlerFunc`; same for\n `NewHandlerWithConfig` (`server`, `web`, `health` package docs).\n\n### Changed\n\n- Bumped `contracts` and `core` to v1.1.0 (framework version alignment).\n\n---\n\n## [1.0.0] — 2026-05-28\n\n### Added\n\n#### `server`\n\n- `Server` interface — embeds `lifecycle.Component` (from `contracts/lifecycle`) and\n `chi.Router` (from `go-chi/chi/v5`); any type that satisfies both is directly\n compatible\n- `Config` struct — `Host`, `Port`, `ReadTimeout`, `WriteTimeout`, `IdleTimeout`,\n `ShutdownTimeout`; all fields carry `env:\"EINHERJAR_SERVER_*\"` and `envDefault`\n tags (`caarlos0/env` syntax)\n- `New(logger logging.Logger, cfg Config, opts ...Option) Server` — constructs the\n unexported `impl` struct; embeds `chi.NewRouter()`\n- `Option` type + `WithMiddleware(mw ...func(http.Handler) http.Handler) Option` —\n variadic option for middleware composition\n- `impl.OnInit()` — applies registered middleware via `chi.Use`\n- `impl.OnStart()` — binds TCP listener synchronously (`net.Listen`), starts\n `http.Server.Serve` in a goroutine; port binding failure returns immediately\n- `impl.OnStop(ctx)` — graceful `http.Server.Shutdown(ctx)` with `ShutdownTimeout`\n (fallback: `defaultShutdownTimeout = 10s`)\n- `var _ Server = (*impl)(nil)` — compile-time assertion\n\n#### `mw`\n\n- `StatusRecorder` struct — wraps `http.ResponseWriter`, captures written status code\n- `Recover() func(http.Handler) http.Handler` — catches panics, writes 500, logs\n stack trace via `runtime/debug.Stack()`\n- `RequestID(generator func() string) func(http.Handler) http.Handler` — injects a\n request ID via `logz.WithRequestID`; reads existing `X-Request-ID` header if present\n- `RequestLogger(logger logging.Logger) func(http.Handler) http.Handler` — structured\n request logging: method, path, status, latency; uses `StatusRecorder` to capture code\n- `CORS(origins []string) func(http.Handler) http.Handler` — sets\n `Access-Control-Allow-Origin` for listed origins; supports preflight (`OPTIONS`)\n- `CORSAllowAll() func(http.Handler) http.Handler` — allows any origin by reflecting the request `Origin` (no `Access-Control-Allow-Credentials`); development only\n- `RateLimiterStore` interface — `Allow(ctx context.Context, key string) (bool, error)`;\n pluggable backend; `error` return allows infrastructure failures to surface; fail-open\n contract: non-nil error allows the request\n- `InMemoryRateLimiterStore` struct — per-key token bucket via `golang.org/x/time/rate`;\n `sync.Map` for concurrent access; background goroutine evicts idle entries after 5\n minutes via `time.Ticker`; `Allow` always returns `(bool, nil)`\n- `NewInMemoryRateLimiterStore(rps float64, burst int) *InMemoryRateLimiterStore`\n- `IPRateLimit(store RateLimiterStore, logger logging.Logger) func(http.Handler) http.Handler`\n — limits by client IP (`X-Forwarded-For` → `RemoteAddr` fallback); returns 429 JSON\n on exceeded limit; fails open on store error\n- `UserRateLimit(store RateLimiterStore, logger logging.Logger) func(http.Handler) http.Handler`\n — limits by authenticated user ID from `security.FromContext`; falls back to client IP\n when no identity present; same 429 + fail-open behaviour\n\n#### `httputil`\n\n- `HandlerFunc` type — `func(w http.ResponseWriter, r *http.Request) error`; implements\n `http.Handler` via `ServeHTTP`\n- `Handle[Req, Res any](v valid.Validator, fn func(ctx context.Context, req Req) (Res, error)) http.HandlerFunc`\n — decodes JSON body, validates struct, calls `fn`, encodes response; 400 on validation\n failure, mapped status on `*xerrors.Err`\n- `HandleNoBody[Res any](fn func(ctx context.Context) (Res, error)) http.HandlerFunc`\n — no body decoding/validation; encodes response directly\n- `HandleEmpty[Req any](v valid.Validator, fn func(ctx context.Context, req Req) error) http.HandlerFunc`\n — decodes and validates body, calls `fn`, returns 204 on success\n- `JSON(w http.ResponseWriter, status int, v any)` — writes JSON response\n- `NoContent(w http.ResponseWriter)` — writes 204 with no body\n- `Error(w http.ResponseWriter, err error)` — maps `*xerrors.Err` to HTTP status and\n writes `{\"code\":\"\u003cwire_value\u003e\",\"message\":\"\u003cmsg\u003e\"}` JSON body; complete 16-code mapping\n\n#### `health`\n\n- `Config` struct — `CheckTimeout time.Duration` with\n `env:\"EINHERJAR_HEALTH_CHECK_TIMEOUT\" envDefault:\"5s\"` (`caarlos0/env` syntax)\n- `Response` struct — `Status string`, `Components map[string]ComponentStatus`\n- `ComponentStatus` struct — `Status`, `Latency` (omitempty), `Error` (omitempty)\n- `NewHandler(logger logging.Logger, checks ...observability.Checkable) http.Handler`\n — shorthand with default 5s timeout\n- `NewHandlerWithConfig(logger logging.Logger, cfg Config, checks ...observability.Checkable) http.Handler`\n — all checks run concurrently in goroutines with a shared context timeout; results\n collected via buffered channel; `DOWN` (critical priority) → 503; `DEGRADED`\n (degraded priority) → 200; `UP` → 200\n- Accepts `observability.Checkable` from `contracts` directly — no local redefinition\n\n#### Root package (`web`)\n\n- `Config` struct — `Server server.Config`, `AllowedOrigins []string` with\n `env:\"EINHERJAR_SERVER_CORS_ORIGINS\" envSeparator:\",\"`\n- `New(logger logging.Logger, cfg ...Config) server.Server` — pre-wires recommended\n middleware stack: Recover → RequestID (UUID v7 with v4 fallback) → RequestLogger →\n CORS (only when `AllowedOrigins` non-empty)\n- Unexported `newRequestID()` — uses `uuid.NewV7()` (time-ordered), falls back to\n `uuid.NewString()` (v4) on generation error\n\n### Design Notes\n\n1. **Progressive disclosure.** `web.New` is the happy path — one call, all middleware\n pre-wired, env vars respected. `server.New` is the escape hatch — every choice\n explicit. Both tiers share the same config structs, env variables, and lifecycle\n contract.\n\n2. **`RateLimiterStore` interface.** The pluggable backend design lets developers start\n with `InMemoryRateLimiterStore` (zero extra dependencies) and swap to a distributed\n store (e.g., `cache-valkey`) at scale without touching middleware wiring. The store\n satisfies the interface via Go duck typing — `cache-valkey` never imports `web/mw`.\n\n3. **Fail-open rate limiting.** When the store returns an error (e.g., Valkey\n unavailable), the request is allowed. Availability is preferred over hard\n enforcement during infrastructure degradation.\n\n4. **`observability.Checkable` from contracts.** `health.NewHandler` accepts\n `observability.Checkable` directly from `contracts/observability`. Any starter\n (`db-*`, `cache-*`, `storage-*`) that implements the contracts interface plugs in\n without an adapter — no `web` import required by those starters.\n\n5. **`last_seen` excluded.** Session tracking is an application-domain concern, not\n transport-level middleware. It requires knowing which entity to track and where to\n persist it. Developers who need it can write it in ~15 lines in their own wiring\n package. Will be revisited if `einherjar/worker` provides a fire-and-forget\n primitive.\n\n6. **UUID v7 for request IDs.** Time-ordered UUIDs embed a millisecond-precision\n timestamp, enabling request IDs to sort chronologically in log aggregation systems.\n UUID v4 fallback ensures ID generation never fails.\n\n---\n\n[1.0.0]: https://code.nochebuena.dev/einherjar/web/releases/tag/v1.0.0\n" }, { "name": "worker", @@ -10692,8 +10702,8 @@ } ] }, - "readme": "# einherjar/worker\n\n[![version](https://img.shields.io/badge/version-v1.3.0-5C4EE5?style=flat-square)](https://code.nochebuena.dev/einherjar/worker)\n[![license](https://img.shields.io/badge/license-AGPL--3.0-22863A?style=flat-square)](LICENSE)\n[![go](https://img.shields.io/badge/Go-1.26+-00ADD8?style=flat-square\u0026logo=go\u0026logoColor=white)](https://go.dev)\n\n\u003e In Valhalla, there is no rest between battles. There is only preparation for the next.\n\n`code.nochebuena.dev/einherjar/worker` is the background task pool component of the Einherjar framework. It manages a bounded goroutine pool with a buffered work queue. On shutdown, it drains all queued tasks within the configured timeout before stopping. `Dispatch` returns `false` when the buffer is full — the caller decides whether to drop, queue elsewhere, or surface an error.\n\n---\n\n## Usage\n\n### Setup\n\n```go\nimport \"code.nochebuena.dev/einherjar/worker\"\n\nw := worker.New(logger, worker.DefaultConfig())\nlc.Append(w) // OnInit starts pool; OnStop drains and stops\n```\n\n### Dispatching tasks\n\n```go\n// Task is func(ctx context.Context) error\ndispatched := w.Dispatch(func(ctx context.Context) error {\n return sendWelcomeEmail(ctx, userID)\n})\n\nif !dispatched {\n // Buffer full — decide: drop, queue elsewhere, or return an error.\n logger.Warn(\"worker buffer full, task dropped\", nil)\n}\n```\n\n### Checking queue depth\n\n```go\ndepth := w.Len() // number of tasks currently buffered (not yet picked up)\n```\n\n### Checking pool saturation\n\n```go\nif w.Len() \u003e= cfg.BufferSize {\n // Near capacity — consider shedding load.\n}\n```\n\n---\n\n## Environment variables\n\n| Variable | Required | Default | Description |\n|---|---|---|---|\n| `EINHERJAR_WORKER_POOL_SIZE` | No | `5` | Number of goroutines in the pool |\n| `EINHERJAR_WORKER_BUFFER_SIZE` | No | `100` | Buffered task queue capacity |\n| `EINHERJAR_WORKER_TASK_TIMEOUT` | No | `0s` | Per-task deadline; `0` means no deadline |\n| `EINHERJAR_WORKER_SHUTDOWN_TIMEOUT` | No | `30s` | Maximum drain time on stop |\n\nA `PoolSize` of 5 and `BufferSize` of 100 means up to 105 tasks can be in flight (5 executing + 100 waiting) before `Dispatch` returns `false`.\n\n---\n\n## Dependency graph\n\n```\ncontracts (zero dependencies)\n ↑\n worker (contracts only)\n ↑\n your app\n```\n\n`worker` does not depend on `core`. It is the lightest lifecycle component in the framework.\n\n---\n\n## Verification\n\n```bash\ncd worker/\ngo build ./...\ngo vet ./...\ngo test ./...\ngofmt -l .\n```\n\n---\n\n\u003e *A warrior who does not prepare between battles is not a warrior.*\n\u003e *Build the pool. Fill it with work. Let it run.*\n", - "changelog": "# Changelog — einherjar/worker\n\nAll notable changes to this module are documented here.\nFormat follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).\nThis module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n---\n\n## [1.3.0] — 2026-08-08\n\nMinor — coordinated framework version alignment. No code or API changes in this module.\n\n### Changed\n\n- Bumped `contracts` to v1.3.0.\n\n## [1.2.0] — 2026-08-08\n\nMinor — coordinated framework version alignment. No code or API changes in this module.\n\n### Changed\n\n- Bumped `contracts` to v1.2.0.\n\n## [1.1.3] — 2026-08-08\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (\\`contracts\\`) to v1.1.3. No code or API changes.## [1.1.2] — 2026-08-08\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`) to v1.1.2. No code or API changes.\n\n## [1.1.1] — 2026-08-07\n\nPatch — corrected package-doc wiring examples plus framework version alignment.\n\n### Fixed\n\n- Doc examples used a fictional `launcher.Register` / `health.Register` API; corrected to\n `lc.Append` + `web/health.NewHandler(...).ServeHTTP`.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`) to v1.1.1.\n\n## [1.1.0] — 2026-08-07\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`) to v1.1.0.\n\n## [1.0.0] — 2026-05-28\n\nInitial release. See the README for the full API and the `v1.0.0` git tag for the source.\n" + "readme": "# einherjar/worker\n\n[![version](https://img.shields.io/badge/version-v1.4.0-5C4EE5?style=flat-square)](https://code.nochebuena.dev/einherjar/worker)\n[![license](https://img.shields.io/badge/license-AGPL--3.0-22863A?style=flat-square)](LICENSE)\n[![go](https://img.shields.io/badge/Go-1.26+-00ADD8?style=flat-square\u0026logo=go\u0026logoColor=white)](https://go.dev)\n\n\u003e In Valhalla, there is no rest between battles. There is only preparation for the next.\n\n`code.nochebuena.dev/einherjar/worker` is the background task pool component of the Einherjar framework. It manages a bounded goroutine pool with a buffered work queue. On shutdown, it drains all queued tasks within the configured timeout before stopping. `Dispatch` returns `false` when the buffer is full — the caller decides whether to drop, queue elsewhere, or surface an error.\n\n---\n\n## Usage\n\n### Setup\n\n```go\nimport \"code.nochebuena.dev/einherjar/worker\"\n\nw := worker.New(logger, worker.DefaultConfig())\nlc.Append(w) // OnInit starts pool; OnStop drains and stops\n```\n\n### Dispatching tasks\n\n```go\n// Task is func(ctx context.Context) error\ndispatched := w.Dispatch(func(ctx context.Context) error {\n return sendWelcomeEmail(ctx, userID)\n})\n\nif !dispatched {\n // Buffer full — decide: drop, queue elsewhere, or return an error.\n logger.Warn(\"worker buffer full, task dropped\", nil)\n}\n```\n\n### Checking queue depth\n\n```go\ndepth := w.Len() // number of tasks currently buffered (not yet picked up)\n```\n\n### Checking pool saturation\n\n```go\nif w.Len() \u003e= cfg.BufferSize {\n // Near capacity — consider shedding load.\n}\n```\n\n---\n\n## Environment variables\n\n| Variable | Required | Default | Description |\n|---|---|---|---|\n| `EINHERJAR_WORKER_POOL_SIZE` | No | `5` | Number of goroutines in the pool |\n| `EINHERJAR_WORKER_BUFFER_SIZE` | No | `100` | Buffered task queue capacity |\n| `EINHERJAR_WORKER_TASK_TIMEOUT` | No | `0s` | Per-task deadline; `0` means no deadline |\n| `EINHERJAR_WORKER_SHUTDOWN_TIMEOUT` | No | `30s` | Maximum drain time on stop |\n\nA `PoolSize` of 5 and `BufferSize` of 100 means up to 105 tasks can be in flight (5 executing + 100 waiting) before `Dispatch` returns `false`.\n\n---\n\n## Dependency graph\n\n```\ncontracts (zero dependencies)\n ↑\n worker (contracts only)\n ↑\n your app\n```\n\n`worker` does not depend on `core`. It is the lightest lifecycle component in the framework.\n\n---\n\n## Verification\n\n```bash\ncd worker/\ngo build ./...\ngo vet ./...\ngo test ./...\ngofmt -l .\n```\n\n---\n\n\u003e *A warrior who does not prepare between battles is not a warrior.*\n\u003e *Build the pool. Fill it with work. Let it run.*\n", + "changelog": "# Changelog — einherjar/worker\n\nAll notable changes to this module are documented here.\nFormat follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).\nThis module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n---\n\n## [1.4.0] — 2026-08-09\n\nMinor — coordinated framework release: dependency refresh + lockstep version alignment.\n\n### Changed\n\n- Refreshed dependencies to their latest minor/patch where available.\n- Bumped `contracts` to v1.4.0.\n\n## [1.3.0] — 2026-08-08\n\nMinor — coordinated framework version alignment. No code or API changes in this module.\n\n### Changed\n\n- Bumped `contracts` to v1.3.0.\n\n## [1.2.0] — 2026-08-08\n\nMinor — coordinated framework version alignment. No code or API changes in this module.\n\n### Changed\n\n- Bumped `contracts` to v1.2.0.\n\n## [1.1.3] — 2026-08-08\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (\\`contracts\\`) to v1.1.3. No code or API changes.## [1.1.2] — 2026-08-08\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`) to v1.1.2. No code or API changes.\n\n## [1.1.1] — 2026-08-07\n\nPatch — corrected package-doc wiring examples plus framework version alignment.\n\n### Fixed\n\n- Doc examples used a fictional `launcher.Register` / `health.Register` API; corrected to\n `lc.Append` + `web/health.NewHandler(...).ServeHTTP`.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`) to v1.1.1.\n\n## [1.1.0] — 2026-08-07\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`) to v1.1.0.\n\n## [1.0.0] — 2026-05-28\n\nInitial release. See the README for the full API and the `v1.0.0` git tag for the source.\n" }, { "name": "wire", @@ -10744,7 +10754,7 @@ "module": "wire", "subPackage": "", "title": "wire.go — Run()", - "code": "package 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}", + "code": "package 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\t// Recover outermost, time-ordered request ID, then logging — same order\n\t\t\t// as web.New. corsMW (env-gated allow-all) sits before auth so preflight\n\t\t\t// OPTIONS short-circuit without hitting the auth middleware.\n\t\t\tmw.Recover(logger),\n\t\t\tmw.RequestID(newRequestID),\n\t\t\tmw.RequestLogger(logger),\n\t\t\tcorsMW,\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// newRequestID returns a time-ordered UUID v7 (falling back to v4), matching web.New.\nfunc newRequestID() string {\n\tid, err := uuid.NewV7()\n\tif err != nil {\n\t\treturn uuid.NewString()\n\t}\n\treturn id.String()\n}", "language": "go" }, { @@ -10794,7 +10804,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. **`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" + "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\t// Recover outermost, time-ordered request ID, then logging — same order\n\t\t\t// as web.New. corsMW (env-gated allow-all) sits before auth so preflight\n\t\t\t// OPTIONS short-circuit without hitting the auth middleware.\n\t\t\tmw.Recover(logger),\n\t\t\tmw.RequestID(newRequestID),\n\t\t\tmw.RequestLogger(logger),\n\t\t\tcorsMW,\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// newRequestID returns a time-ordered UUID v7 (falling back to v4), matching web.New.\nfunc newRequestID() string {\n\tid, err := uuid.NewV7()\n\tif err != nil {\n\t\treturn uuid.NewString()\n\t}\n\treturn id.String()\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 a8b3323..83a5ff8 100644 --- a/internal/index/builtins/README.md +++ b/internal/index/builtins/README.md @@ -348,7 +348,7 @@ func withUsers( repo := userrepo.New(db) uow := postgres.NewUnitOfWork(logger, db) svc := usersvc.New(repo, uow) - h := userhandler.New(svc, v) + h := userhandler.New(svc, v, logger) // handler carries v + logger for httputil // Literal-segment routes register BEFORE parametrised siblings. srv.Put("/api/v1/users/me/password", h.ChangeOwnPassword) @@ -384,6 +384,53 @@ srv.Put("/api/v1/users/{user_id}", h.UpdateUser) srv.Put("/api/v1/users/me/password", h.ChangeOwnPassword) ``` +## HTTP handlers (httputil) + +Handler methods (`h.CreateUser`, `h.ListUsers`, …) wrap `web/httputil`, which does the +decode → validate → call → encode. The method body is just the typed business call: + +```go +import ( + "net/http" + + "code.nochebuena.dev/einherjar/web/httputil" +) + +// POST that creates a resource → 201 Created via WithStatus. +func (h *Handler) CreateUser(w http.ResponseWriter, r *http.Request) { + httputil.Handle(h.v, h.logger, func(ctx context.Context, req CreateUserReq) (CreateUserRes, error) { + id, err := h.svc.Create(ctx, req) + if err != nil { + return CreateUserRes{}, err // xerror → HTTP status is automatic (see below) + } + return CreateUserRes{ID: id}, nil + }, httputil.WithStatus(http.StatusCreated))(w, r) +} +``` + +**Adapters and their default success status:** + +| Adapter | Body | Default | Use for | +|---|---|---|---| +| `httputil.Handle(v, logger, fn, opts…)` | req + res | **200** | POST/PUT returning a body | +| `httputil.HandleNoBody(logger, fn, opts…)` | res only | **200** | GET/HEAD | +| `httputil.HandleEmpty(v, logger, fn, opts…)` | req only | **204** | DELETE / body-less PUT | + +**Override the success status with `httputil.WithStatus(code)`** — `WithStatus(http.StatusCreated)` +(201) on a create, `WithStatus(http.StatusAccepted)` (202) on an async `HandleEmpty`. The code **must +be 2xx**: these adapters own only the success path, so a non-2xx code is a routing mistake and +**panics at wiring** (the service fails to boot rather than emit a wrong status at runtime). Never +pass a 4xx/5xx to `WithStatus`. + +**Error status is automatic — never set it by hand.** Return the right `*xerrors.Err` +(`xerrors.NotFound(…)`, `xerrors.InvalidInput(…)`, `xerrors.PermissionDenied(…)`, …) and +`httputil.Error` maps it to the HTTP status and logs at the derived level (5xx→Error, 4xx→Warn, +499→Info). Validation failures become 400 automatically. + +**Escape hatch:** for a response the adapters don't cover, write a raw `http.HandlerFunc` and call +`httputil.JSON(w, status, v)` or `httputil.NoContent(w)` yourself — that is the only place you pass +a status literal. + ## Authorization Every protected route registers with `.With(authz(provider, resource, grant))`: