Backend Phase A: foundation, migrations, Docker setup

- Go backend skeleton: config (caarlos0/env), slog JSON logging,
  pgxpool store, HTTP server with graceful shutdown.
- httpapi: render helpers, Problem errors, middleware chain
  (requestID / logging / recover / CORS), /healthz and /readyz.
- Migrations: full initial schema (users, sessions, lists,
  list_members, items, op_log SOURCE OF TRUTH, item_names) +
  golang-migrate runner binary using source/iofs (embedded).
- Docker: multi-stage Dockerfile (Go 1.26 -> distroless nonroot),
  builds both server and migrate binaries.
- deploy: docker-compose (caddy + backend + migrate + postgres:16),
  Caddyfile (auto-HTTPS), .env.example, pg extensions init script.
- AGENTS.md: project context + roadmap for AI agents.

Verified: image builds, both binaries run in container (smoke test).
This commit is contained in:
Tronax 2026-08-05 15:14:37 +02:00
commit 2899eb205b
Signed by: Tronax
SSH key fingerprint: SHA256:2pKKXDZucWvaF/GzXNz0FY53EAO1YDLN80bqS+TTz/o
23 changed files with 1372 additions and 0 deletions

View file

@ -0,0 +1,70 @@
// Package config holds all runtime configuration for the backend.
// Values are parsed from environment variables via struct tags.
package config
import (
"fmt"
"time"
"github.com/caarlos0/env/v11"
)
// Config is the single source of runtime configuration.
type Config struct {
// HTTPAddr is the address the HTTP server listens on, e.g. ":8080".
HTTPAddr string `env:"HTTP_ADDR" envDefault:":8080"`
// AppEnv: "development" or "production".
AppEnv string `env:"APP_ENV" envDefault:"development"`
// LogLevel: debug | info | warn | error.
LogLevel string `env:"LOG_LEVEL" envDefault:"info"`
// PublicBaseURL is the externally reachable URL (scheme + host), no trailing slash.
// Used e.g. for OIDC redirect URIs. Example: "https://mitbringsl.example.com".
PublicBaseURL string `env:"PUBLIC_BASE_URL" envDefault:"http://localhost:8080"`
// Database
DatabaseURL string `env:"DATABASE_URL,required"`
DBMaxOpenConns int32 `env:"DB_MAX_OPEN_CONNS" envDefault:"25"`
DBMaxIdleConns int32 `env:"DB_MAX_IDLE_CONNS" envDefault:"5"`
DBMaxLifetime time.Duration `env:"DB_MAX_CONN_LIFETIME" envDefault:"30m"`
// Session token config (opaque tokens issued by this backend after login).
SessionTokenBytes int `env:"SESSION_TOKEN_BYTES" envDefault:"32"` // 256-bit
SessionTokenTTL time.Duration `env:"SESSION_TOKEN_TTL" envDefault:"720h"` // 30 days
SessionCookieName string `env:"SESSION_COOKIE_NAME" envDefault:"mitbringsl_session"`
// OIDC providers. Both optional; enable per provider.
GoogleOIDC GoogleOIDCConfig
GenericOIDC GenericOIDCConfig
// CORS allowed origins (comma-separated). Empty = no CORS headers.
CORSAllowedOrigins []string `env:"CORS_ALLOWED_ORIGINS" envSeparator:","`
}
// GoogleOIDCConfig for "Sign in with Google".
type GoogleOIDCConfig struct {
Enabled bool `env:"OIDC_GOOGLE_ENABLED" envDefault:"false"`
ClientID string `env:"OIDC_GOOGLE_CLIENT_ID"` // the audience the verifier accepts
Issuer string `env:"OIDC_GOOGLE_ISSUER" envDefault:"https://accounts.google.com"`
}
// GenericOIDCConfig for any standards-compliant OIDC IdP (Keycloak, Authentik, ...).
type GenericOIDCConfig struct {
Enabled bool `env:"OIDC_GENERIC_ENABLED" envDefault:"false"`
Issuer string `env:"OIDC_GENERIC_ISSUER"`
ClientID string `env:"OIDC_GENERIC_CLIENT_ID"`
}
// Load reads configuration from environment variables and validates basic invariants.
func Load() (*Config, error) {
var cfg Config
if err := env.Parse(&cfg); err != nil {
return nil, fmt.Errorf("parse env: %w", err)
}
return &cfg, nil
}
// IsProduction reports whether the app runs in production mode.
func (c *Config) IsProduction() bool { return c.AppEnv == "production" }

View file

@ -0,0 +1,56 @@
package httpapi
import (
"net/http"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/mitbringsl/backend/internal/config"
)
// API bundles all handler groups and wires the router.
// Handler groups are added in subsequent phases (auth, lists, items, suggestions).
type API struct {
cfg *config.Config
pool *pgxpool.Pool
health *HealthHandler
}
// NewAPI constructs the API with all handler groups.
func NewAPI(cfg *config.Config, pool *pgxpool.Pool) *API {
return &API{
cfg: cfg,
pool: pool,
health: &HealthHandler{Pool: pool},
}
}
// Handler returns the fully wired http.Handler with all middleware applied.
func (a *API) Handler() http.Handler {
mux := http.NewServeMux()
// --- public health endpoints (no auth) ---
mux.HandleFunc("GET /healthz", a.health.Healthz)
mux.HandleFunc("GET /readyz", a.health.Readyz)
// --- auth endpoints (added in Phase B) ---
// mux.HandleFunc("POST /auth/register", a.auth.Register)
// mux.HandleFunc("POST /auth/login", a.auth.Login)
// mux.HandleFunc("POST /auth/oidc", a.auth.OIDC)
// mux.HandleFunc("POST /auth/logout", a.auth.Logout)
// --- authenticated API endpoints (added in Phase C) ---
// mux.HandleFunc("GET /api/lists", a.requireAuth(a.lists.List))
// mux.HandleFunc("GET /api/lists/{id}/ops", a.requireAuth(a.items.PullOps))
// mux.HandleFunc("POST /api/lists/{id}/ops", a.requireAuth(a.items.PushOps))
// mux.HandleFunc("GET /api/suggestions", a.requireAuth(a.suggest.Suggest))
return Chain(
mux,
requestIDMiddleware,
loggingMiddleware,
recoverMiddleware,
corsMiddleware(a.cfg),
)
}

View file

@ -0,0 +1,28 @@
package httpapi
import (
"context"
"net/http"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// HealthHandler exposes /healthz (liveness) and /readyz (readiness + DB ping).
type HealthHandler struct {
Pool *pgxpool.Pool
}
func (h *HealthHandler) Healthz(w http.ResponseWriter, r *http.Request) {
renderJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (h *HealthHandler) Readyz(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
if err := h.Pool.Ping(ctx); err != nil {
renderError(w, http.StatusServiceUnavailable, "Database unavailable", err.Error())
return
}
renderJSON(w, http.StatusOK, map[string]string{"status": "ready"})
}

View file

@ -0,0 +1,125 @@
package httpapi
import (
"context"
"crypto/rand"
"encoding/hex"
"log/slog"
"net/http"
"runtime/debug"
"github.com/mitbringsl/backend/internal/config"
)
// ctxKey is an unexported key type for context values in this package.
type ctxKey int
const (
ctxKeyRequestID ctxKey = iota
ctxKeyUserID
ctxKeySessionID
)
type wrappedWriter struct {
http.ResponseWriter
status int
bytes int
}
func (w *wrappedWriter) WriteHeader(status int) {
w.status = status
w.ResponseWriter.WriteHeader(status)
}
func (w *wrappedWriter) Write(b []byte) (int, error) {
if w.status == 0 {
w.status = http.StatusOK
}
n, err := w.ResponseWriter.Write(b)
w.bytes += n
return n, err
}
// requestIDMiddleware injects a random request id into the request context and response header.
func requestIDMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := r.Header.Get("X-Request-ID")
if id == "" {
b := make([]byte, 8)
_, _ = rand.Read(b)
id = hex.EncodeToString(b)
}
w.Header().Set("X-Request-ID", id)
ctx := context.WithValue(r.Context(), ctxKeyRequestID, id)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// loggingMiddleware logs each request as structured JSON.
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ww := &wrappedWriter{ResponseWriter: w}
next.ServeHTTP(ww, r)
slog.Info("http request",
"method", r.Method,
"path", r.URL.Path,
"status", ww.status,
"bytes", ww.bytes,
"request_id", r.Context().Value(ctxKeyRequestID),
"remote", r.RemoteAddr,
)
})
}
// recoverMiddleware catches panics, logs them and returns 500.
func recoverMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
slog.Error("panic recovered",
"error", rec,
"request_id", r.Context().Value(ctxKeyRequestID),
"stack", string(debug.Stack()),
)
renderError(w, http.StatusInternalServerError, "Internal error", "An unexpected error occurred.")
}
}()
next.ServeHTTP(w, r)
})
}
// corsMiddleware adds permissive-but-scoped CORS headers when configured.
// Native clients (Android) don't need CORS; this only affects browsers.
func corsMiddleware(cfg *config.Config) func(http.Handler) http.Handler {
allowed := map[string]bool{}
for _, o := range cfg.CORSAllowedOrigins {
if o != "" {
allowed[o] = true
}
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if origin != "" && allowed[origin] {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Vary", "Origin")
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
}
next.ServeHTTP(w, r)
})
}
}
// Chain wires middlewares in declaration order: first one runs outermost.
func Chain(h http.Handler, mws ...func(http.Handler) http.Handler) http.Handler {
for i := len(mws) - 1; i >= 0; i-- {
h = mws[i](h)
}
return h
}

View file

@ -0,0 +1,78 @@
// Package httpapi contains HTTP handlers, routing and middleware.
package httpapi
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"github.com/jackc/pgx/v5"
)
// Problem is a small RFC 7807-ish error body.
type Problem struct {
Type string `json:"type,omitempty"`
Title string `json:"title"`
Status int `json:"status"`
Detail string `json:"detail,omitempty"`
}
// renderJSON writes v as JSON with the given status code.
func renderJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
if v == nil {
return
}
if err := json.NewEncoder(w).Encode(v); err != nil {
slog.Error("write json response failed", "error", err)
}
}
// renderError writes a Problem response.
func renderError(w http.ResponseWriter, status int, title, detail string) {
renderJSON(w, status, Problem{
Title: title,
Status: status,
Detail: detail,
})
}
// apiError maps well-known errors to HTTP status codes. Returns true if handled.
func apiError(w http.ResponseWriter, err error) bool {
switch {
case errors.Is(err, pgx.ErrNoRows):
renderError(w, http.StatusNotFound, "Not found", "The requested resource does not exist.")
case errors.Is(err, ErrUnauthorized):
renderError(w, http.StatusUnauthorized, "Unauthorized", err.Error())
case errors.Is(err, ErrForbidden):
renderError(w, http.StatusForbidden, "Forbidden", err.Error())
case errors.Is(err, ErrConflict):
renderError(w, http.StatusConflict, "Conflict", err.Error())
case errors.Is(err, ErrBadRequest):
renderError(w, http.StatusBadRequest, "Bad request", err.Error())
default:
return false
}
return true
}
// Sentinel domain errors mapped by apiError.
var (
ErrBadRequest = errors.New("bad request")
ErrUnauthorized = errors.New("unauthorized")
ErrForbidden = errors.New("forbidden")
ErrConflict = errors.New("conflict")
)
// decodeJSON decodes r.Body into v. Returns false and writes an error on failure.
func decodeJSON(w http.ResponseWriter, r *http.Request, v any) bool {
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(v); err != nil {
renderError(w, http.StatusBadRequest, "Invalid JSON", err.Error())
return false
}
return true
}

View file

@ -0,0 +1,25 @@
// Package logging configures the structured logger (log/slog).
package logging
import (
"log/slog"
"os"
"strings"
)
// Init installs a JSON handler writing to stdout at the given level.
func Init(level string) {
var lvl slog.Level
switch strings.ToLower(level) {
case "debug":
lvl = slog.LevelDebug
case "warn", "warning":
lvl = slog.LevelWarn
case "error":
lvl = slog.LevelError
default:
lvl = slog.LevelInfo
}
h := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: lvl})
slog.SetDefault(slog.New(h))
}

View file

@ -0,0 +1,32 @@
// Package store wraps database access (pgxpool) for the backend.
package store
import (
"context"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/mitbringsl/backend/internal/config"
)
// New creates and configures a pgx connection pool, then pings the DB to verify access.
func New(ctx context.Context, cfg *config.Config) (*pgxpool.Pool, error) {
pcfg, err := pgxpool.ParseConfig(cfg.DatabaseURL)
if err != nil {
return nil, fmt.Errorf("parse database url: %w", err)
}
pcfg.MaxConns = cfg.DBMaxOpenConns
pcfg.MinConns = cfg.DBMaxIdleConns
pcfg.MaxConnLifetime = cfg.DBMaxLifetime
pool, err := pgxpool.NewWithConfig(ctx, pcfg)
if err != nil {
return nil, fmt.Errorf("create pool: %w", err)
}
if err := pool.Ping(ctx); err != nil {
pool.Close()
return nil, fmt.Errorf("ping db: %w", err)
}
return pool, nil
}