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

22
backend/.dockerignore Normal file
View file

@ -0,0 +1,22 @@
# Build artifacts
bin/
*.exe
*.test
*.out
# IDE / OS
.idea/
.vscode/
.DS_Store
Thumbs.db
# Git
.git
.gitignore
# Docker
Dockerfile
.dockerignore
# Docs
*.md

33
backend/Dockerfile Normal file
View file

@ -0,0 +1,33 @@
# syntax=docker/dockerfile:1
# ---- build stage ----
FROM golang:1.26-alpine AS builder
WORKDIR /src
# Cache deps first.
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# Build both binaries statically (pgx speaks the wire protocol, no cgo needed).
RUN CGO_ENABLED=0 GOOS=linux go build \
-ldflags="-s -w" -trimpath \
-o /out/server ./cmd/server && \
CGO_ENABLED=0 GOOS=linux go build \
-ldflags="-s -w" -trimpath \
-o /out/migrate ./cmd/migrate
# ---- runtime stage ----
# distroless/static ships CA certificates (needed for OIDC JWKS over HTTPS)
# and a nonroot user.
FROM gcr.io/distroless/static-debian12:nonroot
WORKDIR /app
COPY --from=builder /out/server /app/server
COPY --from=builder /out/migrate /app/migrate
# The API server runs by default. The one-shot migrate job overrides the
# command in docker-compose to "/app/migrate up".
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/app/server"]

105
backend/cmd/migrate/main.go Normal file
View file

@ -0,0 +1,105 @@
// Package main is the migration runner. It applies embedded SQL migrations to the
// configured database. Intended to run as a one-shot container/job before the API.
//
// Usage: migrate [up|down|version|force <n>]
package main
import (
"context"
"errors"
"fmt"
"log/slog"
"os"
"time"
"github.com/golang-migrate/migrate/v4"
_ "github.com/golang-migrate/migrate/v4/database/postgres"
"github.com/golang-migrate/migrate/v4/source/iofs"
"github.com/mitbringsl/backend/internal/config"
"github.com/mitbringsl/backend/internal/logging"
"github.com/mitbringsl/backend/migrations"
)
func main() {
cfg, err := config.Load()
if err != nil {
fmt.Fprintf(os.Stderr, "config: %v\n", err)
os.Exit(1)
}
logging.Init(cfg.LogLevel)
cmd := "up"
if len(os.Args) > 1 {
cmd = os.Args[1]
}
if err := run(cfg, cmd); err != nil {
slog.Error("migration failed", "cmd", cmd, "error", err)
os.Exit(1)
}
}
func run(cfg *config.Config, cmd string) error {
src, err := iofs.New(migrations.FS, ".")
if err != nil {
return fmt.Errorf("create source: %w", err)
}
// Use the configured DATABASE_URL; golang-migrate accepts the same DSN
// form pgx uses (postgres://...). Allow a short startup grace period.
dbURL := cfg.DatabaseURL
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
var m *migrate.Migrate
var lastErr error
for attempt := 0; attempt < 30; attempt++ {
m, lastErr = migrate.NewWithSourceInstance("iofs", src, dbURL)
if lastErr == nil {
break
}
select {
case <-ctx.Done():
return fmt.Errorf("db not ready: %w", lastErr)
case <-time.After(time.Second):
}
}
if lastErr != nil {
return fmt.Errorf("create migrate instance: %w", lastErr)
}
defer m.Close()
switch cmd {
case "up":
if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
return err
}
slog.Info("migrations applied (up)")
case "down":
if err := m.Down(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
return err
}
slog.Info("migrations applied (down)")
case "version":
v, dirty, err := m.Version()
if err != nil {
return err
}
fmt.Printf("version=%d dirty=%v\n", v, dirty)
case "force":
if len(os.Args) < 3 {
return errors.New("force requires a version argument")
}
var v int
fmt.Sscanf(os.Args[2], "%d", &v)
if err := m.Force(v); err != nil {
return err
}
slog.Info("forced migration version", "version", v)
default:
return fmt.Errorf("unknown command %q (use up|down|version|force)", cmd)
}
return nil
}

View file

@ -0,0 +1,87 @@
// Package main is the entrypoint for the mitbringsl backend HTTP server.
package main
import (
"context"
"errors"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/mitbringsl/backend/internal/config"
"github.com/mitbringsl/backend/internal/httpapi"
"github.com/mitbringsl/backend/internal/logging"
"github.com/mitbringsl/backend/internal/store"
)
func main() {
if err := run(); err != nil {
slog.Error("fatal error", "error", err)
os.Exit(1)
}
}
func run() error {
cfg, err := config.Load()
if err != nil {
return err
}
logging.Init(cfg.LogLevel)
slog.Info("starting mitbringsl backend",
"env", cfg.AppEnv,
"addr", cfg.HTTPAddr,
"google_oidc_enabled", cfg.GoogleOIDC.Enabled,
"generic_oidc_enabled", cfg.GenericOIDC.Enabled,
)
rootCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
pool, err := store.New(rootCtx, cfg)
if err != nil {
return err
}
defer pool.Close()
slog.Info("database connected")
api := httpapi.NewAPI(cfg, pool)
srv := &http.Server{
Addr: cfg.HTTPAddr,
Handler: api.Handler(),
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}
errCh := make(chan error, 1)
go func() {
slog.Info("http server listening", "addr", cfg.HTTPAddr)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
errCh <- err
}
}()
select {
case <-rootCtx.Done():
slog.Info("shutdown signal received")
case err := <-errCh:
return err
}
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
slog.Error("graceful shutdown failed", "error", err)
}
slog.Info("bye")
return nil
}
// keep pgxpool import referenced even when API holds the pool directly (future-proof).
var _ = (*pgxpool.Pool)(nil)

21
backend/go.mod Normal file
View file

@ -0,0 +1,21 @@
module github.com/mitbringsl/backend
go 1.26
require (
github.com/caarlos0/env/v11 v11.4.1
github.com/golang-migrate/migrate/v4 v4.18.2
github.com/jackc/pgx/v5 v5.10.0
)
require (
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/lib/pq v1.10.9 // indirect
go.uber.org/atomic v1.7.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/text v0.29.0 // indirect
)

83
backend/go.sum Normal file
View file

@ -0,0 +1,83 @@
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/caarlos0/env/v11 v11.4.1 h1:fYwH0sWEsBSMPG7t4e/PEfTFzrWrpjyygXyUnWiSwEw=
github.com/caarlos0/env/v11 v11.4.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dhui/dktest v0.4.4 h1:+I4s6JRE1yGuqflzwqG+aIaMdgXIorCf5P98JnaAWa8=
github.com/dhui/dktest v0.4.4/go.mod h1:4+22R4lgsdAXrDyaH4Nqx2JEz2hLp49MqQmm9HLCQhM=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v27.2.0+incompatible h1:Rk9nIVdfH3+Vz4cyI/uhbINhEZ/oLmc+CBXmH6fbNk4=
github.com/docker/docker v27.2.0+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-migrate/migrate/v4 v4.18.2 h1:2VSCMz7x7mjyTXx3m2zPokOY82LTRgxK1yQYKo6wWQ8=
github.com/golang-migrate/migrate/v4 v4.18.2/go.mod h1:2CM6tJvn2kqPXwnXO/d3rAQYiyoIm180VsO8PRX6Rpk=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=
go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8=
go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc=
go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8=
go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4=
go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ=
go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

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
}

View file

@ -0,0 +1,8 @@
-- Reverse of 000001.
DROP TABLE IF EXISTS item_names;
DROP TABLE IF EXISTS op_log;
DROP TABLE IF EXISTS items;
DROP TABLE IF EXISTS list_members;
DROP TABLE IF EXISTS lists;
DROP TABLE IF EXISTS sessions;
DROP TABLE IF EXISTS users;

View file

@ -0,0 +1,114 @@
-- Initial schema for mitbringsl.
-- Design notes:
-- * op_log is the append-only SOURCE OF TRUTH for sync.
-- * users/lists/items are projections of op_log (kept up to date as ops arrive).
-- * Tombstones (deleted_at) prevent resurrection of removed items by late offline ops.
-- * (client_id, client_seq) unique key on op_log makes pushes idempotent.
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE EXTENSION IF NOT EXISTS pg_trgm;
-- ---------------------------------------------------------------------------
-- users
-- ---------------------------------------------------------------------------
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL UNIQUE,
password_hash TEXT, -- NULL for OIDC-only accounts
oidc_subject TEXT, -- IdP subject id
oidc_issuer TEXT, -- which IdP the subject belongs to
display_name TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (oidc_issuer, oidc_subject)
);
-- ---------------------------------------------------------------------------
-- sessions (opaque tokens issued by this backend after login)
-- ---------------------------------------------------------------------------
CREATE TABLE sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE, -- store SHA-256 hash, never the raw token
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ,
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
user_agent TEXT,
ip TEXT
);
CREATE INDEX sessions_user_id_idx ON sessions(user_id);
-- ---------------------------------------------------------------------------
-- lists (projection)
-- ---------------------------------------------------------------------------
CREATE TABLE lists (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ, -- tombstone
hlc_ts BIGINT NOT NULL DEFAULT 0,
version BIGINT NOT NULL DEFAULT 0
);
CREATE INDEX lists_owner_idx ON lists(owner_id) WHERE deleted_at IS NULL;
-- list_members (prepared for shared lists; MVP uses owner only)
CREATE TABLE list_members (
list_id UUID NOT NULL REFERENCES lists(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'member', -- 'owner' | 'member'
PRIMARY KEY (list_id, user_id)
);
-- ---------------------------------------------------------------------------
-- items (projection)
-- ---------------------------------------------------------------------------
CREATE TABLE items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
list_id UUID NOT NULL REFERENCES lists(id) ON DELETE CASCADE,
name TEXT NOT NULL,
quantity TEXT,
checked BOOLEAN NOT NULL DEFAULT FALSE,
sort_order INTEGER,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ, -- tombstone
hlc_ts BIGINT NOT NULL DEFAULT 0,
client_id UUID,
checked_at TIMESTAMPTZ
);
CREATE INDEX items_list_idx ON items(list_id) WHERE deleted_at IS NULL;
CREATE INDEX items_checked_idx ON items(list_id, checked) WHERE deleted_at IS NULL;
-- ---------------------------------------------------------------------------
-- op_log (SOURCE OF TRUTH)
-- ---------------------------------------------------------------------------
CREATE TABLE op_log (
seq BIGSERIAL PRIMARY KEY, -- monotonic server cursor
list_id UUID NOT NULL, -- list the op belongs to (FK added after lists exist; keep loose for tombstone cleanup)
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
client_id UUID NOT NULL,
op_type TEXT NOT NULL, -- list_create|list_rename|list_delete
-- item_add|item_update|item_remove
target_id UUID NOT NULL, -- affected list/item id
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
client_seq BIGINT NOT NULL, -- idempotency key part
hlc_ts BIGINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (client_id, client_seq)
);
CREATE INDEX op_log_list_seq_idx ON op_log(list_id, seq);
CREATE INDEX op_log_target_idx ON op_log(target_id);
-- ---------------------------------------------------------------------------
-- item_names (autocomplete aggregation, all users)
-- ---------------------------------------------------------------------------
CREATE TABLE item_names (
name TEXT PRIMARY KEY, -- lowercased
usage_count INTEGER NOT NULL DEFAULT 0,
last_used_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX item_names_trgm_idx ON item_names USING gin (name gin_trgm_ops);
CREATE INDEX item_names_count_idx ON item_names(usage_count DESC);

View file

@ -0,0 +1,9 @@
// Package migrations embeds the SQL migration files for use by the migrate binary.
package migrations
import "embed"
// FS holds all migration files (*.sql) under this directory.
//
//go:embed *.sql
var FS embed.FS