diff --git a/.env.example b/.env.example index 49c2d1c..b3f6ba6 100644 --- a/.env.example +++ b/.env.example @@ -6,7 +6,9 @@ SERVER_PORT=8080 POSTGRES_USER=capuchin_user POSTGRES_PASSWORD=capuchin POSTGRES_DB=capuchin_dev -POSTGRES_HOST=capuchin-db POSTGRES_PORT=5432 +# Only needed when running the backend outside Docker (e.g. `go run` or `air` directly). +# In compose, the host is hardcoded to the postgres container name (capuchin-db). +# POSTGRES_HOST=localhost JWT_SECRET=your_jwt_secret_here diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9edadf5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,73 @@ +name: CI + +on: + pull_request: + branches: + - main + push: + branches: + - main + - dev + # Weekly run includes migration integration tests (needs Docker via testcontainers) + schedule: + - cron: "0 3 * * 1" # Every Monday at 03:00 UTC + +jobs: + backend: + name: Backend — build, vet, unit tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.25.5" + cache-dependency-path: backend/go.sum + + - name: Build + working-directory: backend + run: go build ./... + + - name: Vet + working-directory: backend + run: go vet ./... + + - name: Unit tests (no Docker required) + working-directory: backend + run: go test ./... + + migration: + name: Migration module — build & vet + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.25.5" + cache-dependency-path: backend/migration/go.sum + + - name: Build + working-directory: backend/migration + run: go build ./... + + - name: Vet + working-directory: backend/migration + run: go vet ./... + + migration-integration: + name: Migration integration tests (Docker) + runs-on: ubuntu-latest + # Only run on schedule (weekly) — these are slow due to testcontainers + if: github.event_name == 'schedule' + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.25.5" + cache-dependency-path: backend/migration/go.sum + + - name: Integration tests (requires Docker) + working-directory: backend/migration + run: go test ./... -v -timeout 10m diff --git a/.github/workflows/migrate-manual.yml b/.github/workflows/migrate-manual.yml new file mode 100644 index 0000000..7538abe --- /dev/null +++ b/.github/workflows/migrate-manual.yml @@ -0,0 +1,49 @@ +name: Run Migrations (Manual) + +on: + workflow_dispatch: + inputs: + environment: + description: "Target environment" + required: true + default: "production" + type: choice + options: + - production + - staging + regenerate_init_sql: + description: "Regenerate init.sql after migration" + required: true + default: true + type: boolean + +jobs: + migrate: + name: Run migrations (${{ inputs.environment }}) + runs-on: ubuntu-latest + environment: ${{ inputs.environment }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.25.5" + cache-dependency-path: backend/migration/go.sum + + - name: Run migration + working-directory: backend/migration + env: + POSTGRES_HOST: ${{ secrets.POSTGRES_HOST }} + POSTGRES_USER: ${{ secrets.POSTGRES_USER }} + POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }} + POSTGRES_DB: ${{ secrets.POSTGRES_DB }} + POSTGRES_PORT: ${{ secrets.POSTGRES_PORT }} + run: go run ./cmd/migrate + + regenerate-init-sql: + uses: ./.github/workflows/regenerate-init-sql.yml + needs: migrate + if: ${{ inputs.regenerate_init_sql }} + with: + environment: ${{ inputs.environment }} + secrets: inherit diff --git a/.github/workflows/regenerate-init-sql.yml b/.github/workflows/regenerate-init-sql.yml new file mode 100644 index 0000000..55aa2ba --- /dev/null +++ b/.github/workflows/regenerate-init-sql.yml @@ -0,0 +1,50 @@ +name: Regenerate init.sql + +on: + workflow_call: + inputs: + environment: + required: true + type: string + +jobs: + regenerate-init-sql: + name: Regenerate init.sql + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Install PostgreSQL client + run: sudo apt-get install -y postgresql-client + + - name: Dump schema from DB + env: + PGPASSWORD: ${{ secrets.POSTGRES_PASSWORD }} + run: | + pg_dump \ + --host=${{ secrets.POSTGRES_HOST }} \ + --port=${{ secrets.POSTGRES_PORT }} \ + --username=${{ secrets.POSTGRES_USER }} \ + --dbname=${{ secrets.POSTGRES_DB }} \ + --schema-only \ + --no-owner \ + --no-privileges \ + --exclude-table=goose_db_version \ + > backend/db/init.sql + + - name: Prepend auto-generated header + run: | + HEADER="-- Auto-generated schema snapshot. DO NOT EDIT MANUALLY.\n-- Regenerated by the release pipeline after each successful goose migration run.\n-- Used by Docker to bootstrap a fresh dev database container.\n-- Source of truth for schema changes remains backend/migration/db/migrations/*.sql\n" + echo -e "$HEADER$(cat backend/db/init.sql)" > backend/db/init.sql + + - name: Commit updated init.sql + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add backend/db/init.sql + git diff --staged --quiet || git commit -m "chore: regenerate init.sql after migration [${{ inputs.environment }}] [skip ci]" + git push diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..7d5d20f --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,55 @@ +name: Release + +on: + push: + branches: + - main + - dev + tags: + - "v*.*.*" + +jobs: + migrate: + name: Run migrations + runs-on: ubuntu-latest + environment: production + outputs: + migration_success: ${{ steps.run_migration.outputs.success }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.25.5" + cache-dependency-path: backend/migration/go.sum + + - name: Run migration + id: run_migration + working-directory: backend/migration + env: + POSTGRES_HOST: ${{ secrets.POSTGRES_HOST }} + POSTGRES_USER: ${{ secrets.POSTGRES_USER }} + POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }} + POSTGRES_DB: ${{ secrets.POSTGRES_DB }} + POSTGRES_PORT: ${{ secrets.POSTGRES_PORT }} + run: | + go run ./cmd/migrate + echo "success=true" >> $GITHUB_OUTPUT + + regenerate-init-sql: + uses: ./.github/workflows/regenerate-init-sql.yml + needs: migrate + with: + environment: production + secrets: inherit + + deploy: + name: Deploy backend + runs-on: ubuntu-latest + needs: migrate + environment: production + steps: + - uses: actions/checkout@v4 + + - name: Deployment placeholder + run: echo "Deploy capuchin-backend:${{ github.sha }} to production" diff --git a/.gitignore b/.gitignore index de2736c..01365c4 100644 --- a/.gitignore +++ b/.gitignore @@ -44,6 +44,7 @@ dist-ssr/ # Test binary, built with `go test -c` *.test server +test.sh # Go workspace file go.work @@ -97,3 +98,8 @@ crash.*.log # personal docs/ideas.md backup/ +.kiro + + +# removing for now +.github/ diff --git a/Makefile b/Makefile index c355356..e00d379 100644 --- a/Makefile +++ b/Makefile @@ -6,34 +6,56 @@ ifneq (, $(shell command -v docker 2> /dev/null)) CONTAINER_RUNTIME := docker endif -# Docker Dev Mode (Hot Reload) -dev: +.DEFAULT_GOAL := help + +help: ## Show available targets + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-20s\033[0m %s\n", $$1, $$2}' + +# ── Dev (hot reload via Docker) ─────────────────────────────────────────────── + +dev: ## Start all services in dev mode (hot reload) $(CONTAINER_RUNTIME) compose --env-file .env.example -f compose-dev.yml up --build -d -dev-logs: - $(CONTAINER_RUNTIME) compose -f compose-dev.yml logs +dev-logs: ## Tail dev logs + $(CONTAINER_RUNTIME) compose -f compose-dev.yml logs -f -dev-down: +dev-down: ## Stop dev services $(CONTAINER_RUNTIME) compose -f compose-dev.yml down -clean: + +clean: ## Stop dev services and remove volumes, images, orphans $(CONTAINER_RUNTIME) compose -f compose-dev.yml down --volumes --remove-orphans --rmi all +# ── Prod ────────────────────────────────────────────────────────────────────── -prod: - $(CONTAINER_RUNTIME) compose --env-file .env -f compose.yml up +prod: ## Start all services in prod mode (detached) + $(CONTAINER_RUNTIME) compose --env-file .env -f compose.yml up -d -logs: +logs: ## Tail prod logs $(CONTAINER_RUNTIME) compose -f compose.yml logs -f -down: +down: ## Stop prod services $(CONTAINER_RUNTIME) compose -f compose.yml down +# ── Local dev (outside Docker) ──────────────────────────────────────────────── -frontend: +frontend: ## Start frontend dev server cd frontend && npm run dev -backend: +backend: ## Start backend with hot reload (requires air: go install github.com/air-verse/air@v1.61.7) cd backend && air -.PHONY: dev dev-logs dev-down prod logs down +# ── Database ────────────────────────────────────────────────────────────────── + +migrate: ## Run migrations against localhost DB (reads .env for credentials) + @set -a && . ./.env.example && set +a && export POSTGRES_HOST=localhost && cd backend/migration && go run ./cmd/migrate up + +migrate-down: ## Roll back the last migration against localhost DB + @set -a && . ./.env.example && set +a && export POSTGRES_HOST=localhost && cd backend/migration && go run ./cmd/migrate down + +seed: ## Seed dev database with sample data (reads .env.example for credentials) + @set -a && . ./.env.example && set +a && export POSTGRES_HOST=localhost && cd backend && go run ./cmd/seed + +migrate-build: ## Build migration Docker image + docker build -f backend/migration/Dockerfile -t capuchin-migration ./backend +.PHONY: help dev dev-logs dev-down clean prod logs down frontend backend migrate migrate-down seed migrate-build diff --git a/backend/.dockerignore b/backend/.dockerignore index 03192e5..712faca 100644 --- a/backend/.dockerignore +++ b/backend/.dockerignore @@ -2,6 +2,7 @@ vendor bin server +tmp *.exe *.exe~ *.dll diff --git a/backend/.gitignore b/backend/.gitignore deleted file mode 100644 index 73d41de..0000000 --- a/backend/.gitignore +++ /dev/null @@ -1 +0,0 @@ -test.sh diff --git a/backend/Dockerfile b/backend/Dockerfile index ac91466..9988de8 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,41 +1,37 @@ -FROM golang:1.25.5-alpine AS deps +FROM golang:1.26-alpine AS deps WORKDIR /app -# Download dependencies COPY go.mod go.sum ./ RUN go mod download # Build Stage FROM deps AS builder -# Copy source code COPY . . -# Build the application # CGO_ENABLED=0 ensures a statically linked binary RUN CGO_ENABLED=0 GOOS=linux go build -o server cmd/server/main.go -# Development Stage +# Development Stage - pinned air version for reproducible dev builds FROM deps AS dev -RUN go install github.com/air-verse/air@latest +RUN go install github.com/air-verse/air@v1.61.7 CMD ["air", "-c", "air.toml"] - -# Final Stage +# Final Stage - minimal image, non-root user for security FROM scratch -# Set working directory to the app root WORKDIR /app -# Copy the binary from the builder stage +# Copy passwd so the non-root user exists in scratch +COPY --from=builder /etc/passwd /etc/passwd + COPY --from=builder /app/server ./ -# Expose the application port EXPOSE 8080 -# Run the application -CMD ["./server"] +USER nobody +CMD ["./server"] diff --git a/backend/air.toml b/backend/air.toml index 38dc962..01d1c2f 100644 --- a/backend/air.toml +++ b/backend/air.toml @@ -3,8 +3,8 @@ tmp_dir = "tmp" [build] cmd = "go build -o ./tmp/main ./cmd/server/main.go" - bin = "./tmp/main" - full_bin = "" + bin = "" + entrypoint = "./tmp/main" include_ext = ["go", "tpl", "tmpl", "html"] exclude_dir = ["assets", "tmp", "vendor"] include_dir = [] diff --git a/backend/cmd/seed/main.go b/backend/cmd/seed/main.go new file mode 100644 index 0000000..561d0d5 --- /dev/null +++ b/backend/cmd/seed/main.go @@ -0,0 +1,101 @@ +// seed populates the database with deterministic development data. +// It is idempotent: running it multiple times will not create duplicates. +// +// Usage: +// +// go run ./cmd/seed +package main + +import ( + "capuchin/internal/config" + "capuchin/internal/database" + "log" + "os" + "time" + + "golang.org/x/crypto/bcrypt" +) + +type seedUser struct { + id int64 + email string + password string +} + +type seedTodo struct { + id int64 + userID int64 + item string + completed bool +} + +func main() { + if os.Getenv("APP_ENV") == "production" { + log.Fatal("seed must not be run in production") + } + + database.Connect(config.Config) + + // Wait for the background goroutine to establish the DB connection. + for range 30 { + if database.IsHealthy() { + break + } + log.Println("seed: database not ready - waiting...") + time.Sleep(1 * time.Second) + } + if !database.IsHealthy() { + log.Fatal("database not available after 30 seconds") + } + + users := []seedUser{ + {id: 1, email: "alice@example.com", password: "password123"}, + {id: 2, email: "bob@example.com", password: "password123"}, + } + + todos := []seedTodo{ + {id: 1, userID: 1, item: "Buy groceries", completed: false}, + {id: 2, userID: 1, item: "Read a book", completed: true}, + {id: 3, userID: 2, item: "Go for a run", completed: false}, + } + + seedUsers(users) + seedTodos(todos) + + log.Println("seed: complete") +} + +func seedUsers(users []seedUser) { + for _, u := range users { + hash, err := bcrypt.GenerateFromPassword([]byte(u.password), bcrypt.DefaultCost) + if err != nil { + log.Fatalf("bcrypt error for %s: %v", u.email, err) + } + + _, err = database.DB.Exec(` + INSERT INTO users (id, email, password_hash) + VALUES ($1, $2, $3) + ON CONFLICT (id) DO NOTHING`, + u.id, u.email, string(hash), + ) + if err != nil { + log.Fatalf("failed to seed user %s: %v", u.email, err) + } + log.Printf("seed: user inserted: %s", u.email) + } +} + +func seedTodos(todos []seedTodo) { + for _, t := range todos { + _, err := database.DB.Exec(` + INSERT INTO todos (id, item, completed, user_id) + VALUES ($1, $2, $3, $4) + ON CONFLICT (id) DO NOTHING`, + t.id, t.item, t.completed, t.userID, + ) + if err != nil { + log.Fatalf("failed to seed todo %q: %v", t.item, err) + } + log.Printf("seed: todo inserted: %s", t.item) + } +} diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 563a919..070d7ec 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -1,32 +1,31 @@ package main import ( + "capuchin/internal/config" "capuchin/internal/database" "capuchin/internal/handlers" + "capuchin/internal/middleware" "capuchin/internal/routes" "capuchin/internal/services" "log" + "net/http" "time" "github.com/gin-gonic/gin" ) func main() { - // Bootstrapping schema at startup to keep local/dev deployments self-contained. - database.Connect() - database.InitSchema() + database.Connect(config.Config) - // Periodic cleanup prevents the revoked-token table from growing forever. go func() { ticker := time.NewTicker(1 * time.Hour) for range ticker.C { if err := database.CleanupTokens(); err != nil { - log.Printf("Error cleaning up expired tokens: %v", err) + log.Printf("token cleanup: error: %v", err) } } }() - // Handlers depend on interfaces so business logic can be swapped in tests. authService := services.NewAuthService() todoService := services.NewTodoService() @@ -35,21 +34,20 @@ func main() { r := gin.Default() - // Allow cross-origin requests so a separately hosted frontend can call this API. - // Restrict this in production to trusted origins. + // Restrict Access-Control-Allow-Origin to trusted origins in production. r.Use(func(c *gin.Context) { c.Writer.Header().Set("Access-Control-Allow-Origin", "*") c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT, DELETE, PATCH") c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") if c.Request.Method == "OPTIONS" { - // Short-circuit preflight checks to avoid running downstream handlers. - c.AbortWithStatus(204) + c.AbortWithStatus(http.StatusNoContent) return } c.Next() }) - // Keep route wiring centralized so auth boundaries are easy to audit. + r.Use(middleware.DBHealthCheck()) + routes.SetupRoutes(r, authHandler, todoHandler) r.Run(":8080") diff --git a/backend/db/init.sql b/backend/db/init.sql index eca94f4..2813173 100644 --- a/backend/db/init.sql +++ b/backend/db/init.sql @@ -1,17 +1,25 @@ +-- Auto-generated schema snapshot. DO NOT EDIT MANUALLY. +-- Regenerated by CI after each successful goose migration run. +-- Dual purpose: +-- 1. Used by Docker Compose to bootstrap a fresh local dev database container. +-- 2. Referenced by migration integration tests (TestInitSQLMatchesMigrationEndState) +-- as the schema reference snapshot. +-- Source of truth for schema changes: backend/migration/db/migrations/*.sql + CREATE TABLE IF NOT EXISTS users ( - id UUID PRIMARY KEY, - email TEXT UNIQUE NOT NULL, - password_hash TEXT NOT NULL + id BIGSERIAL PRIMARY KEY, + email TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS todos ( - id UUID PRIMARY KEY, - item TEXT NOT NULL, - completed BOOLEAN DEFAULT FALSE, - user_id UUID REFERENCES users(id) + id BIGSERIAL PRIMARY KEY, + item TEXT NOT NULL, + completed BOOLEAN NOT NULL DEFAULT FALSE, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS blacklisted_tokens ( - token TEXT PRIMARY KEY, - expired_at TIMESTAMP NOT NULL + token TEXT PRIMARY KEY, + expired_at TIMESTAMPTZ NOT NULL ); diff --git a/backend/go.mod b/backend/go.mod index 0a580fc..271c614 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -1,11 +1,10 @@ module capuchin -go 1.25.5 +go 1.26 require ( github.com/gin-gonic/gin v1.11.0 github.com/golang-jwt/jwt/v5 v5.3.1 - github.com/google/uuid v1.6.0 github.com/joho/godotenv v1.5.1 github.com/lib/pq v1.11.2 golang.org/x/crypto v0.48.0 @@ -36,7 +35,7 @@ require ( github.com/ugorji/go/codec v1.3.1 // indirect go.uber.org/mock v0.6.0 // indirect golang.org/x/arch v0.23.0 // indirect - golang.org/x/net v0.49.0 // indirect + golang.org/x/net v0.50.0 // indirect golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.34.0 // indirect google.golang.org/protobuf v1.36.11 // indirect diff --git a/backend/go.sum b/backend/go.sum index cd59fd3..0b2ffab 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -32,8 +32,6 @@ github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArs github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= @@ -80,8 +78,8 @@ golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg= golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= -golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= -golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index bb2537a..de8f579 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -1,23 +1,16 @@ package config import ( - "errors" - "fmt" "log" "os" - "path/filepath" - "strconv" - "strings" - - "github.com/joho/godotenv" ) type AppConfig struct { - POSTGRES_PASSWORD string - POSTGRES_USER string - POSTGRES_DB string - POSTGRES_HOST string - POSTGRES_PORT int + PostgresPassword string + PostgresUser string + PostgresDB string + PostgresHost string + PostgresPort int } var Config AppConfig @@ -31,12 +24,17 @@ func init() { log.Fatal(err) } + postgresHost := os.Getenv("POSTGRES_HOST") + if postgresHost == "" { + postgresHost = "localhost" + } + Config = AppConfig{ - POSTGRES_PASSWORD: os.Getenv("POSTGRES_PASSWORD"), - POSTGRES_USER: os.Getenv("POSTGRES_USER"), - POSTGRES_DB: os.Getenv("POSTGRES_DB"), - POSTGRES_HOST: os.Getenv("POSTGRES_HOST"), - POSTGRES_PORT: postgresPort, + PostgresPassword: os.Getenv("POSTGRES_PASSWORD"), + PostgresUser: os.Getenv("POSTGRES_USER"), + PostgresDB: os.Getenv("POSTGRES_DB"), + PostgresHost: postgresHost, + PostgresPort: postgresPort, } if err := validateDatabaseConfig(Config); err != nil { @@ -45,93 +43,13 @@ func init() { jwtSecret := os.Getenv("JWT_SECRET") if jwtSecret == "" { - jwtSecret = "secret" - log.Println("WARNING: JWT_SECRET not set or empty; using default insecure secret. Set JWT_SECRET in production.") - } - JWTKey = []byte(jwtSecret) - - log.Println("Configuration loaded successfully.") -} - -func loadEnvFile() { - cwd, err := os.Getwd() - if err != nil { - log.Fatalf("failed to determine current working directory: %v", err) - } - - envPath, err := findEnvFile(cwd) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - log.Println("No .env file found in current or parent directories; using existing environment variables.") - return - } - log.Fatalf("failed to locate .env file: %v", err) - } - - if err := godotenv.Load(envPath); err != nil { - log.Fatalf("failed to load .env file %q: %v", envPath, err) - } - - log.Printf("Loaded environment variables from %s", envPath) -} - -func findEnvFile(startDir string) (string, error) { - dir := startDir - for { - candidate := filepath.Join(dir, ".env") - info, err := os.Stat(candidate) - if err == nil && !info.IsDir() { - return candidate, nil - } - if err != nil && !errors.Is(err, os.ErrNotExist) { - return "", err + if os.Getenv("APP_ENV") == "production" { + log.Fatal("JWT_SECRET must be set in production") } - - parent := filepath.Dir(dir) - if parent == dir { - break - } - dir = parent - } - - return "", os.ErrNotExist -} - -func postgresPortFromEnv() (int, error) { - rawPort := os.Getenv("POSTGRES_PORT") - if rawPort == "" { - return 5432, nil - } - - port, err := strconv.Atoi(rawPort) - if err != nil { - return 0, fmt.Errorf("invalid POSTGRES_PORT %q: must be numeric", rawPort) - } - if port <= 0 { - return 0, fmt.Errorf("invalid POSTGRES_PORT %q: must be greater than 0", rawPort) - } - - return port, nil -} - -func validateDatabaseConfig(cfg AppConfig) error { - missing := make([]string, 0, 4) - - if cfg.POSTGRES_USER == "" { - missing = append(missing, "POSTGRES_USER") - } - if cfg.POSTGRES_PASSWORD == "" { - missing = append(missing, "POSTGRES_PASSWORD") - } - if cfg.POSTGRES_DB == "" { - missing = append(missing, "POSTGRES_DB") - } - if cfg.POSTGRES_HOST == "" { - missing = append(missing, "POSTGRES_HOST") - } - if len(missing) > 0 { - return fmt.Errorf("missing required database config: %s", strings.Join(missing, ", ")) + jwtSecret = "dev-insecure-secret" + log.Println("config: JWT_SECRET unset - insecure default in use. Set JWT_SECRET in production.") } + JWTKey = []byte(jwtSecret) - return nil + log.Println("config: loaded") } diff --git a/backend/internal/config/loader.go b/backend/internal/config/loader.go new file mode 100644 index 0000000..d9806bc --- /dev/null +++ b/backend/internal/config/loader.go @@ -0,0 +1,77 @@ +package config + +import ( + "errors" + "fmt" + "log" + "os" + "path/filepath" + "strconv" + + "github.com/joho/godotenv" +) + +func loadEnvFile() { + if os.Getenv("APP_ENV") == "production" { + return + } + + cwd, err := os.Getwd() + if err != nil { + log.Fatalf("failed to determine current working directory: %v", err) + } + + envPath, err := findEnvFile(cwd) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + log.Println("config: no .env file found - environment variables used as-is") + return + } + log.Fatalf("failed to locate .env file: %v", err) + } + + if err := godotenv.Load(envPath); err != nil { + log.Fatalf("failed to load .env file %q: %v", envPath, err) + } + + log.Printf("config: env loaded from %s", envPath) +} + +func findEnvFile(startDir string) (string, error) { + dir := startDir + for { + candidate := filepath.Join(dir, ".env") + info, err := os.Stat(candidate) + if err == nil && !info.IsDir() { + return candidate, nil + } + if err != nil && !errors.Is(err, os.ErrNotExist) { + return "", err + } + + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + + return "", os.ErrNotExist +} + +func postgresPortFromEnv() (int, error) { + rawPort := os.Getenv("POSTGRES_PORT") + if rawPort == "" { + return 5432, nil + } + + port, err := strconv.Atoi(rawPort) + if err != nil { + return 0, fmt.Errorf("invalid POSTGRES_PORT %q: must be numeric", rawPort) + } + if port <= 0 { + return 0, fmt.Errorf("invalid POSTGRES_PORT %q: must be greater than 0", rawPort) + } + + return port, nil +} diff --git a/backend/internal/config/validation.go b/backend/internal/config/validation.go new file mode 100644 index 0000000..8dc124f --- /dev/null +++ b/backend/internal/config/validation.go @@ -0,0 +1,28 @@ +package config + +import ( + "fmt" + "strings" +) + +func validateDatabaseConfig(cfg AppConfig) error { + missing := make([]string, 0, 4) + + if cfg.PostgresUser == "" { + missing = append(missing, "POSTGRES_USER") + } + if cfg.PostgresPassword == "" { + missing = append(missing, "POSTGRES_PASSWORD") + } + if cfg.PostgresDB == "" { + missing = append(missing, "POSTGRES_DB") + } + if cfg.PostgresHost == "" { + missing = append(missing, "POSTGRES_HOST") + } + if len(missing) > 0 { + return fmt.Errorf("missing required database config: %s", strings.Join(missing, ", ")) + } + + return nil +} diff --git a/backend/internal/database/db.go b/backend/internal/database/db.go index 6e9f8cb..bc062f9 100644 --- a/backend/internal/database/db.go +++ b/backend/internal/database/db.go @@ -5,46 +5,92 @@ import ( "database/sql" "fmt" "log" + "sync/atomic" "time" _ "github.com/lib/pq" ) +// DB is the shared connection pool. Nil until the background goroutine +// successfully connects for the first time. var DB *sql.DB -func Connect() { - connStr := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%d sslmode=disable", - config.Config.POSTGRES_HOST, - config.Config.POSTGRES_USER, - config.Config.POSTGRES_PASSWORD, - config.Config.POSTGRES_DB, - config.Config.POSTGRES_PORT, +// dbHealthy is 1 when DB is reachable, 0 otherwise. +// Accessed exclusively via sync/atomic. +var dbHealthy int32 + +const retryInterval = 5 * time.Second + +// Connect launches a background goroutine that attempts to open and ping +// Postgres on a fixed interval. It returns immediately without blocking the +// caller - the HTTP server starts before the DB is necessarily ready. +// The backend process never exits due to DB unavailability. +func Connect(cfg config.AppConfig) { + connStr := fmt.Sprintf( + "host=%s user=%s password=%s dbname=%s port=%d sslmode=disable", + cfg.PostgresHost, + cfg.PostgresUser, + cfg.PostgresPassword, + cfg.PostgresDB, + cfg.PostgresPort, ) - var err error - DB, err = sql.Open("postgres", connStr) - if err != nil { - log.Fatal(err) - } - if err = DB.Ping(); err != nil { - log.Fatal("Could not connect to database:", err) - } + go func() { + for { + db, err := sql.Open("postgres", connStr) + if err != nil { + log.Printf("database: connection open failed: %v - retry in %s", err, retryInterval) + atomic.StoreInt32(&dbHealthy, 0) + time.Sleep(retryInterval) + continue + } + + if err := db.Ping(); err != nil { + log.Printf("database: ping failed: %v - retry in %s", err, retryInterval) + atomic.StoreInt32(&dbHealthy, 0) + _ = db.Close() + time.Sleep(retryInterval) + continue + } + + db.SetMaxOpenConns(25) + db.SetMaxIdleConns(5) + db.SetConnMaxLifetime(5 * time.Minute) - // Conservative pool settings avoid exhausting DB connections in small deployments. - DB.SetMaxOpenConns(25) - DB.SetMaxIdleConns(5) - // Recycling connections helps recover from stale network state over long uptimes. - DB.SetConnMaxLifetime(5 * time.Minute) + DB = db + atomic.StoreInt32(&dbHealthy, 1) + log.Println("database: connection established") + + watchConnection(db) + } + }() } -func InitSchema() { - // Schema is assumed to be pre-initialized (e.g., via CI/CD pipelines). - //TODO: remove after actual implementation - log.Println("Database connection initialized. Assuming schema is already present.") +// watchConnection pings the DB on a fixed interval until the connection is lost. +func watchConnection(db *sql.DB) { + for { + time.Sleep(retryInterval) + if err := db.Ping(); err != nil { + log.Printf("database: connection lost: %v - reconnecting", err) + atomic.StoreInt32(&dbHealthy, 0) + _ = db.Close() + DB = nil + return + } + atomic.StoreInt32(&dbHealthy, 1) + } } +// IsHealthy reports whether the last DB ping succeeded. +func IsHealthy() bool { + return atomic.LoadInt32(&dbHealthy) == 1 +} + +// CleanupTokens deletes expired blacklisted tokens. func CleanupTokens() error { - // Expired tokens can be dropped because JWT expiration already invalidates them. - _, err := DB.Exec("DELETE FROM blacklisted_tokens WHERE expired_at < $1", time.Now()) + if DB == nil { + return fmt.Errorf("database: not connected") + } + _, err := DB.Exec("DELETE FROM blacklisted_tokens WHERE expired_at < NOW()") return err } diff --git a/backend/internal/database/db_health_test.go b/backend/internal/database/db_health_test.go new file mode 100644 index 0000000..3a9b32d --- /dev/null +++ b/backend/internal/database/db_health_test.go @@ -0,0 +1,38 @@ +package database + +// TestDBHealthFlag_ReflectsConnectionState verifies that IsHealthy() reflects +// the atomic dbHealthy flag correctly without requiring a real Postgres instance. +// +// Feature: migration-module-separation, Property 7: DB health flag reflects connection state. + +import ( + "testing" +) + +func TestDBHealthFlag_ReflectsConnectionState(t *testing.T) { + t.Cleanup(func() { dbHealthy = 0 }) + + // Initially unhealthy. + dbHealthy = 0 + if IsHealthy() { + t.Error("expected IsHealthy() == false when dbHealthy=0") + } + + // Simulate successful ping. + dbHealthy = 1 + if !IsHealthy() { + t.Error("expected IsHealthy() == true when dbHealthy=1") + } + + // Simulate lost connection. + dbHealthy = 0 + if IsHealthy() { + t.Error("expected IsHealthy() == false after dbHealthy reset to 0") + } + + // Simulate recovery. + dbHealthy = 1 + if !IsHealthy() { + t.Error("expected IsHealthy() == true after recovery") + } +} diff --git a/backend/internal/database/testing.go b/backend/internal/database/testing.go new file mode 100644 index 0000000..95ae034 --- /dev/null +++ b/backend/internal/database/testing.go @@ -0,0 +1,13 @@ +//go:build !production + +package database + +// SetHealthForTest directly sets the DB health flag. +// Only compiled in non-production builds - use in tests only. +func SetHealthForTest(healthy bool) { + if healthy { + dbHealthy = 1 + } else { + dbHealthy = 0 + } +} diff --git a/backend/internal/handlers/auth.go b/backend/internal/handlers/auth.go index a0d7f74..8a7a5f2 100644 --- a/backend/internal/handlers/auth.go +++ b/backend/internal/handlers/auth.go @@ -2,6 +2,7 @@ package handlers import ( "capuchin/internal/services" + "net/http" "github.com/gin-gonic/gin" ) @@ -14,51 +15,54 @@ func NewAuthHandler(svc services.AuthService) *AuthHandler { return &AuthHandler{authService: svc} } -func (h *AuthHandler) Signup(c *gin.Context) { - var reqBody struct { - Email string `json:"email" binding:"required,email"` - Password string `json:"password" binding:"required,min=8"` - } +type signupRequest struct { + Email string `json:"email" binding:"required,email"` + Password string `json:"password" binding:"required,min=8"` +} - if err := c.ShouldBindJSON(&reqBody); err != nil { - c.JSON(400, gin.H{"error": "Invalid request: missing fields or invalid format. Password must be >= 8 characters."}) +type loginRequest struct { + Email string `json:"email"` + Password string `json:"password"` +} + +func (h *AuthHandler) Signup(c *gin.Context) { + var req signupRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: missing fields or invalid format. Password must be >= 8 characters."}) return } - _, err := h.authService.Signup(reqBody.Email, reqBody.Password) + _, err := h.authService.Signup(req.Email, req.Password) if err != nil { if err == services.ErrUserExists { - c.JSON(409, gin.H{"error": "User with this email already exists"}) + c.JSON(http.StatusConflict, gin.H{"error": "User with this email already exists"}) return } - c.JSON(500, gin.H{"error": "Failed to create user"}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"}) return } - c.JSON(201, gin.H{"message": "User created successfully"}) + c.JSON(http.StatusCreated, gin.H{"message": "User created successfully"}) } func (h *AuthHandler) Login(c *gin.Context) { - var reqBody struct { - Email string `json:"email"` - Password string `json:"password"` - } - if err := c.ShouldBindJSON(&reqBody); err != nil { - c.JSON(400, gin.H{"error": err.Error()}) + var req loginRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - tokenString, err := h.authService.Login(reqBody.Email, reqBody.Password) + tokenString, err := h.authService.Login(req.Email, req.Password) if err != nil { if err == services.ErrInvalidCredentials { - c.JSON(401, gin.H{"error": "Invalid credentials"}) + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"}) return } - c.JSON(500, gin.H{"error": "Login failed"}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Login failed"}) return } - c.JSON(200, gin.H{"token": tokenString}) + c.JSON(http.StatusOK, gin.H{"token": tokenString}) } func (h *AuthHandler) Logout(c *gin.Context) { @@ -67,12 +71,12 @@ func (h *AuthHandler) Logout(c *gin.Context) { err := h.authService.Logout(tokenStr) if err != nil { if err == services.ErrInvalidToken { - c.JSON(400, gin.H{"error": "Invalid token components"}) + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid token components"}) return } - c.JSON(500, gin.H{"error": "Failed to logout"}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to logout"}) return } - c.JSON(200, gin.H{"message": "Logged out successfully"}) + c.JSON(http.StatusOK, gin.H{"message": "Logged out successfully"}) } diff --git a/backend/internal/handlers/todo.go b/backend/internal/handlers/todo.go index bea25ed..9cdff5a 100644 --- a/backend/internal/handlers/todo.go +++ b/backend/internal/handlers/todo.go @@ -2,9 +2,10 @@ package handlers import ( "capuchin/internal/services" + "net/http" + "strconv" "github.com/gin-gonic/gin" - "github.com/google/uuid" ) type TodoHandler struct { @@ -15,83 +16,85 @@ func NewTodoHandler(svc services.TodoService) *TodoHandler { return &TodoHandler{todoService: svc} } +type addTodoRequest struct { + Item string `json:"item" binding:"required"` + Completed bool `json:"completed"` +} + +type updateTodoRequest struct { + Item *string `json:"item"` + Completed *bool `json:"completed"` +} + func (h *TodoHandler) GetTodos(c *gin.Context) { - userID := c.MustGet("userID").(uuid.UUID) + userID := c.MustGet("userID").(int64) todos, err := h.todoService.GetTodos(userID) if err != nil { - c.JSON(500, gin.H{"error": "Failed to get todos"}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get todos"}) return } - c.JSON(200, todos) + c.JSON(http.StatusOK, todos) } func (h *TodoHandler) AddTodo(c *gin.Context) { - userID := c.MustGet("userID").(uuid.UUID) - var req struct { - Item string `json:"item" binding:"required"` - Completed bool `json:"completed"` - } + userID := c.MustGet("userID").(int64) + var req addTodoRequest if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(400, gin.H{"error": err.Error()}) + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } todo, err := h.todoService.AddTodo(userID, req.Item, req.Completed) if err != nil { - c.JSON(500, gin.H{"error": "Failed to add todo"}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to add todo"}) return } - c.JSON(200, todo) + c.JSON(http.StatusOK, todo) } func (h *TodoHandler) UpdateTodo(c *gin.Context) { - userID := c.MustGet("userID").(uuid.UUID) - idParam := c.Param("id") - id, err := uuid.Parse(idParam) + userID := c.MustGet("userID").(int64) + id, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { - c.JSON(400, gin.H{"error": "invalid id"}) + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) return } - var req struct { - Item *string `json:"item"` - Completed *bool `json:"completed"` - } + var req updateTodoRequest if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(400, gin.H{"error": err.Error()}) + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } todo, err := h.todoService.UpdateTodo(userID, id, req.Item, req.Completed) if err != nil { if err == services.ErrTodoNotFound { - c.JSON(404, gin.H{"error": "Todo not found"}) + c.JSON(http.StatusNotFound, gin.H{"error": "Todo not found"}) return } - c.JSON(500, gin.H{"error": "Failed to update todo"}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update todo"}) return } - c.JSON(200, todo) + c.JSON(http.StatusOK, todo) } func (h *TodoHandler) DeleteTodo(c *gin.Context) { - userID := c.MustGet("userID").(uuid.UUID) - idParam := c.Param("id") - id, err := uuid.Parse(idParam) + userID := c.MustGet("userID").(int64) + id, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { - c.JSON(400, gin.H{"error": "invalid id"}) + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) return } err = h.todoService.DeleteTodo(userID, id) if err != nil { if err == services.ErrTodoNotFound { - c.JSON(404, gin.H{"error": "Todo not found"}) + c.JSON(http.StatusNotFound, gin.H{"error": "Todo not found"}) return } - c.JSON(500, gin.H{"error": "Failed to delete todo"}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete todo"}) return } - c.JSON(200, gin.H{"message": "Todo deleted successfully"}) + c.JSON(http.StatusOK, gin.H{"message": "Todo deleted successfully"}) } diff --git a/backend/internal/middleware/auth.go b/backend/internal/middleware/auth.go index 49d3c9f..4659bfc 100644 --- a/backend/internal/middleware/auth.go +++ b/backend/internal/middleware/auth.go @@ -3,60 +3,51 @@ package middleware import ( "capuchin/internal/config" "capuchin/internal/database" + "net/http" "strings" "github.com/gin-gonic/gin" "github.com/golang-jwt/jwt/v5" - "github.com/google/uuid" ) func AuthRequired() gin.HandlerFunc { return func(c *gin.Context) { tokenStr := c.GetHeader("Authorization") if tokenStr == "" { - c.AbortWithStatusJSON(401, gin.H{"error": "Authorization header required"}) + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authorization header required"}) return } - // Accept standard Authorization header format without forcing clients to preprocess it. tokenStr = strings.TrimPrefix(tokenStr, "Bearer ") var exists bool - // Check revocation before claim extraction so logout takes effect immediately. err := database.DB.QueryRow("SELECT EXISTS(SELECT 1 FROM blacklisted_tokens WHERE token=$1)", tokenStr).Scan(&exists) if err == nil && exists { - c.AbortWithStatusJSON(401, gin.H{"error": "Token has been revoked"}) + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Token has been revoked"}) return } - // Restrict acceptable algorithms and require exp to reduce token confusion attacks. token, err := jwt.Parse(tokenStr, func(token *jwt.Token) (interface{}, error) { return config.JWTKey, nil }, jwt.WithValidMethods([]string{"HS256"}), jwt.WithExpirationRequired()) if err != nil || !token.Valid { - c.AbortWithStatusJSON(401, gin.H{"error": "Invalid or expired token"}) + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid or expired token"}) return } if claims, ok := token.Claims.(jwt.MapClaims); ok { - - raw, ok := claims["user_id"].(string) + // JWT numbers are decoded as float64 by encoding/json. + raw, ok := claims["user_id"].(float64) if !ok { - c.AbortWithStatusJSON(401, gin.H{"error": "invalid token claims"}) - return - } - // Parse into UUID once so handlers can rely on a strongly typed user identity. - uid, err := uuid.Parse(raw) - if err != nil { - c.AbortWithStatusJSON(401, gin.H{"error": "invalid user id in token"}) + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token claims"}) return } - c.Set("userID", uid) + c.Set("userID", int64(raw)) c.Next() } else { - c.AbortWithStatusJSON(401, gin.H{"error": "Invalid token claims"}) + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid token claims"}) return } } diff --git a/backend/internal/middleware/db_health.go b/backend/internal/middleware/db_health.go new file mode 100644 index 0000000..051bfbf --- /dev/null +++ b/backend/internal/middleware/db_health.go @@ -0,0 +1,23 @@ +package middleware + +import ( + "capuchin/internal/database" + "net/http" + + "github.com/gin-gonic/gin" +) + +// DBHealthCheck returns a Gin middleware that responds 503 Service Unavailable +// when the database is not reachable, preventing handlers from executing +// against a nil or unhealthy DB connection. +func DBHealthCheck() gin.HandlerFunc { + return func(c *gin.Context) { + if !database.IsHealthy() { + c.AbortWithStatusJSON(http.StatusServiceUnavailable, gin.H{ + "error": "database unavailable", + }) + return + } + c.Next() + } +} diff --git a/backend/internal/middleware/db_health_test.go b/backend/internal/middleware/db_health_test.go new file mode 100644 index 0000000..e9cce78 --- /dev/null +++ b/backend/internal/middleware/db_health_test.go @@ -0,0 +1,74 @@ +package middleware_test + +// Tests for DBHealthCheck middleware. +// +// Feature: migration-module-separation, Property 8: 503 returned while unhealthy. +// No Docker or real Postgres required - health state is set directly via +// database.SetHealthForTest. + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "capuchin/internal/database" + "capuchin/internal/middleware" + + "github.com/gin-gonic/gin" +) + +func init() { + gin.SetMode(gin.TestMode) +} + +func TestDBHealthMiddleware_Returns503WhenUnhealthy(t *testing.T) { + database.SetHealthForTest(false) + t.Cleanup(func() { database.SetHealthForTest(false) }) + + r := gin.New() + r.Use(middleware.DBHealthCheck()) + r.GET("/test", func(c *gin.Context) { + c.Status(http.StatusOK) + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/test", nil) + r.ServeHTTP(w, req) + + if w.Code != http.StatusServiceUnavailable { + t.Errorf("expected 503, got %d", w.Code) + } + + var body map[string]string + if err := json.NewDecoder(w.Body).Decode(&body); err != nil { + t.Fatalf("failed to decode response body: %v", err) + } + if body["error"] != "database unavailable" { + t.Errorf("expected error='database unavailable', got %q", body["error"]) + } +} + +func TestDBHealthMiddleware_PassesWhenHealthy(t *testing.T) { + database.SetHealthForTest(true) + t.Cleanup(func() { database.SetHealthForTest(false) }) + + handlerCalled := false + r := gin.New() + r.Use(middleware.DBHealthCheck()) + r.GET("/test", func(c *gin.Context) { + handlerCalled = true + c.Status(http.StatusOK) + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/test", nil) + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } + if !handlerCalled { + t.Error("expected handler to be called when DB is healthy") + } +} diff --git a/backend/internal/middleware/error.go b/backend/internal/middleware/error.go index bdc840c..e8b6770 100644 --- a/backend/internal/middleware/error.go +++ b/backend/internal/middleware/error.go @@ -11,7 +11,7 @@ func ErrorHandler() gin.HandlerFunc { return func(c *gin.Context) { defer func() { if err := recover(); err != nil { - log.Printf("Panic recovered: %v", err) + log.Printf("panic recovered: %v", err) c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{ "error": "Internal Server Error", "success": false, diff --git a/backend/internal/models/models.go b/backend/internal/models/models.go index 4c9c0fa..50a9c27 100644 --- a/backend/internal/models/models.go +++ b/backend/internal/models/models.go @@ -1,16 +1,14 @@ package models -import "github.com/google/uuid" - type Todo struct { - ID uuid.UUID `json:"id"` - Item string `json:"item"` - Completed bool `json:"completed"` - UserID uuid.UUID `json:"-"` + ID int64 `json:"id"` + Item string `json:"item"` + Completed bool `json:"completed"` + UserID int64 `json:"-"` } type User struct { - ID uuid.UUID `json:"id"` - Email string `json:"email"` - PasswordHash string `json:"-"` + ID int64 `json:"id"` + Email string `json:"email"` + PasswordHash string `json:"-"` } diff --git a/backend/internal/routes/routes.go b/backend/internal/routes/routes.go index dd901a4..d399b72 100644 --- a/backend/internal/routes/routes.go +++ b/backend/internal/routes/routes.go @@ -3,6 +3,7 @@ package routes import ( "capuchin/internal/handlers" "capuchin/internal/middleware" + "net/http" "github.com/gin-gonic/gin" ) @@ -12,7 +13,7 @@ func SetupRoutes(router *gin.Engine, authHandler *handlers.AuthHandler, todoHand router.Use(middleware.ErrorHandler()) router.GET("/health", func(c *gin.Context) { - c.JSON(200, gin.H{"status": "ok"}) + c.JSON(http.StatusOK, gin.H{"status": "ok"}) }) router.POST("/signup", authHandler.Signup) router.POST("/login", authHandler.Login) diff --git a/backend/internal/services/auth_service.go b/backend/internal/services/auth_service.go index 7b6da99..dc2a59f 100644 --- a/backend/internal/services/auth_service.go +++ b/backend/internal/services/auth_service.go @@ -9,7 +9,6 @@ import ( "time" "github.com/golang-jwt/jwt/v5" - "github.com/google/uuid" "golang.org/x/crypto/bcrypt" ) @@ -39,15 +38,13 @@ func (s *authService) Signup(email, password string) (*models.User, error) { } u := &models.User{ - ID: uuid.New(), Email: email, PasswordHash: string(hash), } - _, err = database.DB.Exec("INSERT INTO users (id, email, password_hash) VALUES ($1, $2, $3)", u.ID, u.Email, u.PasswordHash) + err = database.DB.QueryRow("INSERT INTO users (email, password_hash) VALUES ($1, $2) RETURNING id", u.Email, u.PasswordHash).Scan(&u.ID) if err != nil { errStr := err.Error() - // Convert storage-specific duplicate key errors into a stable domain error for handlers. if strings.Contains(errStr, "unique constraint") || strings.Contains(errStr, "duplicate key value") { return nil, ErrUserExists } @@ -61,20 +58,18 @@ func (s *authService) Login(email, password string) (string, error) { var u models.User err := database.DB.QueryRow("SELECT id, email, password_hash FROM users WHERE email=$1", email).Scan(&u.ID, &u.Email, &u.PasswordHash) if err != nil { - // Use one response for unknown user and wrong password to avoid account enumeration. + // Same error for unknown user and wrong password to avoid account enumeration. return "", ErrInvalidCredentials } if err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)); err != nil { - // Keep the same error shape to avoid leaking which check failed. return "", ErrInvalidCredentials } token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ - "user_id": u.ID.String(), + "user_id": u.ID, "email": u.Email, - // Short-lived tokens reduce blast radius if a token is leaked. - "exp": time.Now().Add(time.Hour * 72).Unix(), + "exp": time.Now().Add(time.Hour * 72).Unix(), }) tokenString, err := token.SignedString(config.JWTKey) if err != nil { @@ -89,12 +84,10 @@ func (s *authService) Logout(tokenStr string) error { return ErrInvalidToken } - // Accept either raw JWTs or Authorization header values for caller flexibility. if len(tokenStr) > 7 && tokenStr[:7] == "Bearer " { tokenStr = tokenStr[7:] } - // Parse to extract expiry so blacklist rows can be garbage-collected safely. token, _ := jwt.Parse(tokenStr, func(token *jwt.Token) (interface{}, error) { return config.JWTKey, nil }) @@ -108,14 +101,12 @@ func (s *authService) Logout(tokenStr string) error { if exp, ok := claims["exp"].(float64); ok { expTime = time.Unix(int64(exp), 0) } else { - // Fail-safe TTL keeps blacklist entries finite even for malformed claim types. expTime = time.Now().Add(72 * time.Hour) } } else { return ErrInvalidToken } - // Idempotent logout avoids surfacing harmless duplicate requests as server errors. _, err := database.DB.Exec("INSERT INTO blacklisted_tokens (token, expired_at) VALUES ($1, $2) ON CONFLICT (token) DO NOTHING", tokenStr, expTime) if err != nil { return ErrDatabase diff --git a/backend/internal/services/todo_service.go b/backend/internal/services/todo_service.go index df36148..dabfdc2 100644 --- a/backend/internal/services/todo_service.go +++ b/backend/internal/services/todo_service.go @@ -5,8 +5,6 @@ import ( "capuchin/internal/models" "database/sql" "errors" - - "github.com/google/uuid" ) var ( @@ -14,10 +12,10 @@ var ( ) type TodoService interface { - GetTodos(userID uuid.UUID) ([]models.Todo, error) - AddTodo(userID uuid.UUID, item string, completed bool) (*models.Todo, error) - UpdateTodo(userID, todoID uuid.UUID, item *string, completed *bool) (*models.Todo, error) - DeleteTodo(userID, todoID uuid.UUID) error + GetTodos(userID int64) ([]models.Todo, error) + AddTodo(userID int64, item string, completed bool) (*models.Todo, error) + UpdateTodo(userID, todoID int64, item *string, completed *bool) (*models.Todo, error) + DeleteTodo(userID, todoID int64) error } type todoService struct{} @@ -26,8 +24,7 @@ func NewTodoService() TodoService { return &todoService{} } -func (s *todoService) GetTodos(userID uuid.UUID) ([]models.Todo, error) { - // Scope every read by user_id so one user can never read another user's todos. +func (s *todoService) GetTodos(userID int64) ([]models.Todo, error) { rows, err := database.DB.Query("SELECT id, item, completed FROM todos WHERE user_id=$1", userID) if err != nil { return nil, ErrDatabase @@ -38,7 +35,6 @@ func (s *todoService) GetTodos(userID uuid.UUID) ([]models.Todo, error) { for rows.Next() { var t models.Todo if err := rows.Scan(&t.ID, &t.Item, &t.Completed); err != nil { - // Skip malformed rows instead of failing the whole response for a single bad record. continue } todos = append(todos, t) @@ -51,24 +47,25 @@ func (s *todoService) GetTodos(userID uuid.UUID) ([]models.Todo, error) { return todos, nil } -func (s *todoService) AddTodo(userID uuid.UUID, item string, completed bool) (*models.Todo, error) { +func (s *todoService) AddTodo(userID int64, item string, completed bool) (*models.Todo, error) { t := &models.Todo{ - ID: uuid.New(), UserID: userID, Item: item, Completed: completed, } - _, err := database.DB.Exec("INSERT INTO todos (id, item, completed, user_id) VALUES ($1, $2, $3, $4)", t.ID, t.Item, t.Completed, t.UserID) + err := database.DB.QueryRow( + "INSERT INTO todos (item, completed, user_id) VALUES ($1, $2, $3) RETURNING id", + t.Item, t.Completed, t.UserID, + ).Scan(&t.ID) if err != nil { return nil, ErrDatabase } return t, nil } -func (s *todoService) UpdateTodo(userID, todoID uuid.UUID, item *string, completed *bool) (*models.Todo, error) { +func (s *todoService) UpdateTodo(userID, todoID int64, item *string, completed *bool) (*models.Todo, error) { if item == nil && completed == nil { - // Empty PATCH requests are treated as a read to keep the endpoint idempotent. var t models.Todo err := database.DB.QueryRow("SELECT id, item, completed FROM todos WHERE id=$1 AND user_id=$2", todoID, userID).Scan(&t.ID, &t.Item, &t.Completed) if err != nil { @@ -82,11 +79,10 @@ func (s *todoService) UpdateTodo(userID, todoID uuid.UUID, item *string, complet var t models.Todo err := database.DB.QueryRow(` - UPDATE todos - -- COALESCE preserves existing values when fields are omitted from PATCH payloads. - SET item = COALESCE($1, item), + UPDATE todos + SET item = COALESCE($1, item), completed = COALESCE($2, completed) - WHERE id=$3 AND user_id=$4 + WHERE id=$3 AND user_id=$4 RETURNING id, item, completed`, item, completed, todoID, userID).Scan(&t.ID, &t.Item, &t.Completed) if err != nil { @@ -98,7 +94,7 @@ func (s *todoService) UpdateTodo(userID, todoID uuid.UUID, item *string, complet return &t, nil } -func (s *todoService) DeleteTodo(userID, todoID uuid.UUID) error { +func (s *todoService) DeleteTodo(userID, todoID int64) error { res, err := database.DB.Exec("DELETE FROM todos WHERE id=$1 AND user_id=$2", todoID, userID) if err != nil { return ErrDatabase @@ -106,7 +102,6 @@ func (s *todoService) DeleteTodo(userID, todoID uuid.UUID) error { rowsAffected, _ := res.RowsAffected() if rowsAffected == 0 { - // Distinguish "not found" from successful deletion for better API semantics. return ErrTodoNotFound } diff --git a/backend/migration/Dockerfile b/backend/migration/Dockerfile new file mode 100644 index 0000000..7b57391 --- /dev/null +++ b/backend/migration/Dockerfile @@ -0,0 +1,25 @@ +FROM golang:1.26-alpine AS builder + +WORKDIR /app + +# Build context is backend/ - migration module lives at backend/migration/ +COPY migration/go.mod migration/go.sum ./ +RUN go mod download + +COPY migration/ . + +RUN CGO_ENABLED=0 GOOS=linux go build -o migrate cmd/migrate/main.go + +# Final Stage - minimal image, non-root user for security +FROM scratch + +WORKDIR /app + +# Copy passwd so the non-root user exists in scratch +COPY --from=builder /etc/passwd /etc/passwd + +COPY --from=builder /app/migrate ./ + +USER nobody + +CMD ["./migrate"] diff --git a/backend/migration/cmd/migrate/main.go b/backend/migration/cmd/migrate/main.go new file mode 100644 index 0000000..23fd629 --- /dev/null +++ b/backend/migration/cmd/migrate/main.go @@ -0,0 +1,86 @@ +// migrate applies or rolls back database migrations and exits. +// Run this as a one-off job (CI/CD step or init container) before deploying +// app server instances. +// +// Usage: +// +// go run ./cmd/migrate [up|down] +package main + +import ( + "database/sql" + "fmt" + "log" + "os" + + migrationdb "capuchin-migration/db" + + _ "github.com/lib/pq" + "github.com/pressly/goose/v3" +) + +func main() { + host := os.Getenv("POSTGRES_HOST") + if host == "" { + log.Fatal("POSTGRES_HOST environment variable is required") + } + + user := os.Getenv("POSTGRES_USER") + if user == "" { + log.Fatal("POSTGRES_USER environment variable is required") + } + + password := os.Getenv("POSTGRES_PASSWORD") + if password == "" { + log.Fatal("POSTGRES_PASSWORD environment variable is required") + } + + dbName := os.Getenv("POSTGRES_DB") + if dbName == "" { + log.Fatal("POSTGRES_DB environment variable is required") + } + + port := os.Getenv("POSTGRES_PORT") + if port == "" { + port = "5432" + } + + dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%s sslmode=disable", + host, user, password, dbName, port) + + db, err := sql.Open("postgres", dsn) + if err != nil { + log.Fatal("failed to open database connection:", err) + } + defer db.Close() + + if err := db.Ping(); err != nil { + log.Fatal("failed to ping database:", err) + } + + goose.SetBaseFS(migrationdb.Migrations) + + if err := goose.SetDialect("postgres"); err != nil { + log.Fatal("goose dialect error:", err) + } + + cmd := "up" + if len(os.Args) > 1 { + cmd = os.Args[1] + } + + switch cmd { + case "up": + if err := goose.Up(db, "versions"); err != nil { + log.Fatal("goose migration error:", err) + } + log.Println("migrate: migrations applied") + case "down": + if err := goose.Down(db, "versions"); err != nil { + log.Fatal("goose migration error:", err) + } + log.Println("migrate: rolled back one migration") + default: + log.Fatalf("unknown command %q — use 'up' or 'down'", cmd) + } +} diff --git a/backend/migration/db/embed.go b/backend/migration/db/embed.go new file mode 100644 index 0000000..9fcce2d --- /dev/null +++ b/backend/migration/db/embed.go @@ -0,0 +1,10 @@ +// Package db exposes the embedded migration files so they can be used by +// the migration runner and tests without duplicating the embed directive. +package db + +import "embed" + +// Migrations holds all goose SQL migration files embedded at compile time. +// +//go:embed versions/*.sql +var Migrations embed.FS diff --git a/backend/migration/db/versions/00001_init_schema.sql b/backend/migration/db/versions/00001_init_schema.sql new file mode 100644 index 0000000..197b71f --- /dev/null +++ b/backend/migration/db/versions/00001_init_schema.sql @@ -0,0 +1,23 @@ +-- +goose Up +CREATE TABLE IF NOT EXISTS users ( + id BIGSERIAL PRIMARY KEY, + email TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS todos ( + id BIGSERIAL PRIMARY KEY, + item TEXT NOT NULL, + completed BOOLEAN NOT NULL DEFAULT FALSE, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS blacklisted_tokens ( + token TEXT PRIMARY KEY, + expired_at TIMESTAMPTZ NOT NULL +); + +-- +goose Down +DROP TABLE IF EXISTS blacklisted_tokens; +DROP TABLE IF EXISTS todos; +DROP TABLE IF EXISTS users; diff --git a/backend/migration/go.mod b/backend/migration/go.mod new file mode 100644 index 0000000..a8774b0 --- /dev/null +++ b/backend/migration/go.mod @@ -0,0 +1,68 @@ +module capuchin-migration + +go 1.26 + +require ( + github.com/lib/pq v1.10.9 + github.com/pressly/goose/v3 v3.24.3 + github.com/testcontainers/testcontainers-go v0.42.0 + github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 + golang.org/x/crypto v0.48.0 + pgregory.net/rapid v1.2.0 +) + +require ( + dario.cat/mergo v1.0.2 // indirect + github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/containerd/platforms v0.2.1 // indirect + github.com/cpuguy83/dockercfg v0.3.2 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/go-connections v0.6.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/ebitengine/purego v0.10.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/klauspost/compress v1.18.5 // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect + github.com/magiconair/properties v1.8.10 // indirect + github.com/mfridman/interpolate v0.0.2 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/go-archive v0.2.0 // indirect + github.com/moby/moby/api v1.54.1 // indirect + github.com/moby/moby/client v0.4.0 // indirect + github.com/moby/patternmatcher v0.6.1 // indirect + github.com/moby/sys/sequential v0.6.0 // indirect + github.com/moby/sys/user v0.4.0 // indirect + github.com/moby/sys/userns v0.1.0 // indirect + github.com/moby/term v0.5.2 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect + github.com/sethvargo/go-retry v0.3.0 // indirect + github.com/shirou/gopsutil/v4 v4.26.3 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect + github.com/stretchr/testify v1.11.1 // indirect + github.com/tklauser/go-sysconf v0.3.16 // indirect + github.com/tklauser/numcpus v0.11.0 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect + go.opentelemetry.io/otel v1.41.0 // indirect + go.opentelemetry.io/otel/metric v1.41.0 // indirect + go.opentelemetry.io/otel/trace v1.41.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.42.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/backend/migration/go.sum b/backend/migration/go.sum new file mode 100644 index 0000000..74397c7 --- /dev/null +++ b/backend/migration/go.sum @@ -0,0 +1,179 @@ +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/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/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +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/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/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= +github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= +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/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= +github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +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.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/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/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +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.7.4 h1:9wKznZrhWa2QiHL+NjTSPP6yjl3451BX3imWDnokYlg= +github.com/jackc/pgx/v5 v5.7.4/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= +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/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= +github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= +github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o= +github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= +github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= +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/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= +github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU= +github.com/moby/moby/api v1.54.1 h1:TqVzuJkOLsgLDDwNLmYqACUuTehOHRGKiPhvH8V3Nn4= +github.com/moby/moby/api v1.54.1/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= +github.com/moby/moby/client v0.4.0 h1:S+2XegzHQrrvTCvF6s5HFzcrywWQmuVnhOXe2kiWjIw= +github.com/moby/moby/client v0.4.0/go.mod h1:QWPbvWchQbxBNdaLSpoKpCdf5E+WxFAgNHogCWDoa7g= +github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U= +github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= +github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +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.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +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/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/pressly/goose/v3 v3.24.3 h1:DSWWNwwggVUsYZ0X2VitiAa9sKuqtBfe+Jr9zFGwWlM= +github.com/pressly/goose/v3 v3.24.3/go.mod h1:v9zYL4xdViLHCUUJh/mhjnm6JrK7Eul8AS93IxiZM4E= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= +github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= +github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc= +github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44XtK+qhjat1nI9cneBbUY= +github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30= +github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 h1:GCbb1ndrF7OTDiIvxXyItaDab4qkzTFJ48LKFdM7EIo= +github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0/go.mod h1:IRPBaI8jXdrNfD0e4Zm7Fbcgaz5shKxOQv4axiL09xs= +github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= +github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= +github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= +github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ= +go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c= +go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE= +go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ= +go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps= +go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY= +go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg= +go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o= +go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w= +go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= +go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI= +golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= +modernc.org/libc v1.65.0 h1:e183gLDnAp9VJh6gWKdTy0CThL9Pt7MfcR/0bgb7Y1Y= +modernc.org/libc v1.65.0/go.mod h1:7m9VzGq7APssBTydds2zBcxGREwvIGpuUBaKTXdm2Qs= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.10.0 h1:fzumd51yQ1DxcOxSO+S6X7+QTuVU+n8/Aj7swYjFfC4= +modernc.org/memory v1.10.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI= +modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM= +pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= +pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= diff --git a/backend/migration/init_sql_test.go b/backend/migration/init_sql_test.go new file mode 100644 index 0000000..453aee6 --- /dev/null +++ b/backend/migration/init_sql_test.go @@ -0,0 +1,216 @@ +package migration_test + +// TestInitSQLMatchesMigrationEndState verifies that migration/db/init.sql +// produces a schema structurally identical to the one produced by running all +// goose migrations. This catches cases where init.sql was not regenerated after +// a new migration was added, or was accidentally hand-edited. +// +// Feature: migration-module-separation, Property 9: init.sql schema matches goose migration end state. + +import ( + "context" + "database/sql" + "fmt" + "os" + "sort" + "strings" + "testing" + "time" + + _ "github.com/lib/pq" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/modules/postgres" + "github.com/testcontainers/testcontainers-go/wait" +) + +// newNamedTestDB spins up a fresh postgres testcontainer with the given DB name. +func newNamedTestDB(t *testing.T, dbName string) *sql.DB { + t.Helper() + ctx := context.Background() + pgc, err := postgres.Run(ctx, + "postgres:17-alpine", + postgres.WithDatabase(dbName), + postgres.WithUsername("test"), + postgres.WithPassword("test"), + testcontainers.WithWaitStrategy( + wait.ForLog("database system is ready to accept connections"). + WithOccurrence(2). + WithStartupTimeout(30*time.Second), + ), + ) + if err != nil { + t.Fatalf("failed to start postgres container (%s): %v", dbName, err) + } + t.Cleanup(func() { _ = pgc.Terminate(ctx) }) + + connStr, err := pgc.ConnectionString(ctx, "sslmode=disable") + if err != nil { + t.Fatalf("failed to get connection string (%s): %v", dbName, err) + } + db, err := sql.Open("postgres", connStr) + if err != nil { + t.Fatalf("failed to open db (%s): %v", dbName, err) + } + t.Cleanup(func() { _ = db.Close() }) + return db +} + +// tableSchema holds the structural description of a single table. +type tableSchema struct { + columns []columnDef + constraints []constraintDef +} + +type columnDef struct { + name string + dataType string + isNullable string +} + +type constraintDef struct { + name string + constraintType string +} + +// dumpSchema queries information_schema for all user tables, their columns, +// and their constraints, returning a normalised map keyed by table name. +func dumpSchema(t *testing.T, db *sql.DB) map[string]tableSchema { + t.Helper() + + // Fetch tables. + tableRows, err := db.Query(` + SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_type = 'BASE TABLE' + AND table_name NOT LIKE 'goose_%' + ORDER BY table_name`) + if err != nil { + t.Fatalf("dumpSchema: query tables: %v", err) + } + defer tableRows.Close() + + schema := make(map[string]tableSchema) + for tableRows.Next() { + var name string + if err := tableRows.Scan(&name); err != nil { + t.Fatalf("dumpSchema: scan table name: %v", err) + } + schema[name] = tableSchema{} + } + if err := tableRows.Err(); err != nil { + t.Fatalf("dumpSchema: table rows error: %v", err) + } + + // Fetch columns for each table. + for tableName, ts := range schema { + colRows, err := db.Query(` + SELECT column_name, data_type, is_nullable + FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = $1 + ORDER BY ordinal_position`, tableName) + if err != nil { + t.Fatalf("dumpSchema: query columns for %s: %v", tableName, err) + } + var cols []columnDef + for colRows.Next() { + var c columnDef + if err := colRows.Scan(&c.name, &c.dataType, &c.isNullable); err != nil { + colRows.Close() + t.Fatalf("dumpSchema: scan column: %v", err) + } + cols = append(cols, c) + } + colRows.Close() + if err := colRows.Err(); err != nil { + t.Fatalf("dumpSchema: column rows error: %v", err) + } + ts.columns = cols + + // Fetch constraints. + conRows, err := db.Query(` + SELECT constraint_name, constraint_type + FROM information_schema.table_constraints + WHERE table_schema = 'public' AND table_name = $1 + ORDER BY constraint_name`, tableName) + if err != nil { + t.Fatalf("dumpSchema: query constraints for %s: %v", tableName, err) + } + var cons []constraintDef + for conRows.Next() { + var c constraintDef + if err := conRows.Scan(&c.name, &c.constraintType); err != nil { + conRows.Close() + t.Fatalf("dumpSchema: scan constraint: %v", err) + } + cons = append(cons, c) + } + conRows.Close() + if err := conRows.Err(); err != nil { + t.Fatalf("dumpSchema: constraint rows error: %v", err) + } + ts.constraints = cons + schema[tableName] = ts + } + + return schema +} + +// schemaKey produces a deterministic string representation of a schema map +// for easy diffing in test output. +func schemaKey(schema map[string]tableSchema) string { + tables := make([]string, 0, len(schema)) + for t := range schema { + tables = append(tables, t) + } + sort.Strings(tables) + + var sb strings.Builder + for _, t := range tables { + ts := schema[t] + fmt.Fprintf(&sb, "TABLE %s\n", t) + for _, c := range ts.columns { + fmt.Fprintf(&sb, " COL %s %s nullable=%s\n", c.name, c.dataType, c.isNullable) + } + cons := make([]string, len(ts.constraints)) + for i, c := range ts.constraints { + cons[i] = fmt.Sprintf("%s:%s", c.constraintType, c.name) + } + sort.Strings(cons) + for _, c := range cons { + fmt.Fprintf(&sb, " CON %s\n", c) + } + } + return sb.String() +} + +func TestInitSQLMatchesMigrationEndState(t *testing.T) { + // Feature: migration-module-separation, Property 9: init.sql schema matches goose migration end state + + // Read init.sql from disk (relative to the migration/ module root). + initSQL, err := os.ReadFile("../../db/init.sql") + if err != nil { + t.Fatalf("failed to read db/init.sql: %v", err) + } + + // DB 1: bootstrapped via init.sql + dbInit := newNamedTestDB(t, "testinit") + if _, err := dbInit.Exec(string(initSQL)); err != nil { + t.Fatalf("failed to apply init.sql: %v", err) + } + + // DB 2: bootstrapped via goose migrations + dbGoose := newNamedTestDB(t, "testgoose") + migrateDB(t, dbGoose) + + // Compare schemas. + schemaInit := dumpSchema(t, dbInit) + schemaGoose := dumpSchema(t, dbGoose) + + keyInit := schemaKey(schemaInit) + keyGoose := schemaKey(schemaGoose) + + if keyInit != keyGoose { + t.Errorf("init.sql schema does not match goose migration end state\n\n--- init.sql ---\n%s\n--- goose ---\n%s", keyInit, keyGoose) + } +} diff --git a/backend/migration/migrate_test.go b/backend/migration/migrate_test.go new file mode 100644 index 0000000..832fbc7 --- /dev/null +++ b/backend/migration/migrate_test.go @@ -0,0 +1,384 @@ +package migration_test + +import ( + "context" + "database/sql" + "fmt" + "io/fs" + "log" + "regexp" + "strconv" + "strings" + "sync" + "testing" + "time" + + capuchindb "capuchin-migration/db" + + _ "github.com/lib/pq" + "github.com/pressly/goose/v3" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/modules/postgres" + "github.com/testcontainers/testcontainers-go/wait" + "golang.org/x/crypto/bcrypt" + "pgregory.net/rapid" +) + +// migrateDB applies all pending goose migrations to db using the embedded FS. +// This is the same logic as cmd/migrate - kept here so tests don't depend on +// the application package for migration concerns. +func migrateDB(t *testing.T, db *sql.DB) { + t.Helper() + if err := migrateDBErr(db); err != nil { + t.Fatalf("migrateDB: %v", err) + } +} + +// migrateDBErr applies migrations and returns any error, safe to call from goroutines. +func migrateDBErr(db *sql.DB) error { + goose.SetBaseFS(capuchindb.Migrations) + if err := goose.SetDialect("postgres"); err != nil { + return fmt.Errorf("goose dialect: %w", err) + } + if err := goose.Up(db, "versions"); err != nil { + return fmt.Errorf("goose up: %w", err) + } + return nil +} + +// newTestDB spins up a testcontainers postgres instance and returns a *sql.DB. +func newTestDB(t *testing.T) *sql.DB { + t.Helper() + ctx := context.Background() + pgc, err := postgres.Run(ctx, + "postgres:17-alpine", + postgres.WithDatabase("testdb"), + postgres.WithUsername("test"), + postgres.WithPassword("test"), + testcontainers.WithWaitStrategy( + wait.ForLog("database system is ready to accept connections"). + WithOccurrence(2). + WithStartupTimeout(30*time.Second), + ), + ) + if err != nil { + t.Fatalf("failed to start postgres container: %v", err) + } + t.Cleanup(func() { _ = pgc.Terminate(ctx) }) + + connStr, err := pgc.ConnectionString(ctx, "sslmode=disable") + if err != nil { + t.Fatalf("failed to get connection string: %v", err) + } + db, err := sql.Open("postgres", connStr) + if err != nil { + t.Fatalf("failed to open db: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + return db +} + +// TestP1_MigrationFileStructuralInvariants checks naming convention, goose +// annotations, and CREATE TABLE IF NOT EXISTS for every migration file. +// +// Feature: db-migrations-seeding, Property 1: For any .sql file in +// migration/db/migrations/, the filename must have a zero-padded five-digit +// numeric prefix strictly greater than all preceding files, the file must +// contain both a -- +goose Up block and a -- +goose Down block, and any +// CREATE TABLE statement must use CREATE TABLE IF NOT EXISTS. +func TestP1_MigrationFileStructuralInvariants(t *testing.T) { + // Feature: db-migrations-seeding, Property 1: Migration file structural invariants + filenameRe := regexp.MustCompile(`^\d{5}_[a-z0-9_]+\.sql$`) + createTableSafeRe := regexp.MustCompile(`(?i)CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS`) + createTableAnyRe := regexp.MustCompile(`(?i)CREATE\s+TABLE\b`) + + entries, err := fs.ReadDir(capuchindb.Migrations, "versions") + if err != nil { + t.Fatalf("failed to read migrations dir: %v", err) + } + if len(entries) == 0 { + t.Fatal("no migration files found") + } + + prevPrefix := -1 + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + + if !filenameRe.MatchString(name) { + t.Errorf("filename %q does not match pattern ^\\d{5}_[a-z0-9_]+\\.sql$", name) + } + + prefix, err := strconv.Atoi(name[:5]) + if err != nil { + t.Errorf("filename %q has non-numeric prefix: %v", name, err) + continue + } + if prefix <= prevPrefix { + t.Errorf("filename %q prefix %d is not strictly greater than previous %d", name, prefix, prevPrefix) + } + prevPrefix = prefix + + content, err := capuchindb.Migrations.ReadFile("versions/" + name) + if err != nil { + t.Fatalf("failed to read migration file %q: %v", name, err) + } + text := string(content) + + if !strings.Contains(text, "-- +goose Up") { + t.Errorf("migration %q missing '-- +goose Up'", name) + } + if !strings.Contains(text, "-- +goose Down") { + t.Errorf("migration %q missing '-- +goose Down'", name) + } + + all := createTableAnyRe.FindAllString(text, -1) + safe := createTableSafeRe.FindAllString(text, -1) + if len(all) != len(safe) { + t.Errorf("migration %q has CREATE TABLE without IF NOT EXISTS (total=%d safe=%d)", name, len(all), len(safe)) + } + } +} + +// TestP2_MigrationApplicationRoundTrip applies migrations to a fresh DB and +// verifies goose_db_version records version 1 as applied. +// +// Feature: db-migrations-seeding, Property 2: For any pending migration +// version, after goose.Up completes successfully, querying goose_db_version +// must return a row with that version's version_id and is_applied = true. +func TestP2_MigrationApplicationRoundTrip(t *testing.T) { + // Feature: db-migrations-seeding, Property 2: Migration application round-trip + // Spin up one container and reuse it - container startup dominates test time. + db := newTestDB(t) + migrateDB(t, db) + + rapid.Check(t, func(rt *rapid.T) { + var versionID int64 + var isApplied bool + err := db.QueryRow( + `SELECT version_id, is_applied FROM goose_db_version WHERE version_id = 1`, + ).Scan(&versionID, &isApplied) + if err != nil { + rt.Fatalf("failed to query goose_db_version: %v", err) + } + if versionID != 1 { + rt.Errorf("expected version_id 1, got %d", versionID) + } + if !isApplied { + rt.Errorf("expected is_applied = true for version 1, got false") + } + }) +} + +// TestP3_MigrationIdempotency applies migrations twice and asserts the +// goose_db_version row count is unchanged on the second run. +// +// Feature: db-migrations-seeding, Property 3: For any database state where all +// migrations are already applied, invoking goose.Up again must produce no +// schema changes and the count of rows in goose_db_version must be the same +// before and after the second invocation. +func TestP3_MigrationIdempotency(t *testing.T) { + // Feature: db-migrations-seeding, Property 3: Migration idempotency + // Spin up one container - idempotency check doesn't need a fresh DB per iteration. + db := newTestDB(t) + migrateDB(t, db) + + rapid.Check(t, func(rt *rapid.T) { + var countBefore int + if err := db.QueryRow(`SELECT COUNT(*) FROM goose_db_version`).Scan(&countBefore); err != nil { + rt.Fatalf("failed to count goose_db_version rows: %v", err) + } + + migrateDB(t, db) + + var countAfter int + if err := db.QueryRow(`SELECT COUNT(*) FROM goose_db_version`).Scan(&countAfter); err != nil { + rt.Fatalf("failed to count goose_db_version rows after second run: %v", err) + } + if countAfter != countBefore { + rt.Errorf("goose_db_version row count changed: before=%d after=%d", countBefore, countAfter) + } + }) +} + +// TestP4_AppServerDoesNotMigrate asserts that a fresh DB with no migrations run +// does not have the goose_db_version table - proving the app server (which never +// calls goose) would not have this table. +// +// Feature: db-migrations-seeding, Property 4: The application server must +// never call goose.Up or any migration function. Migration is exclusively the +// responsibility of the dedicated migrate binary run in CI/CD. +func TestP4_AppServerDoesNotMigrate(t *testing.T) { + // Feature: db-migrations-seeding, Property 4: App server does not migrate + // One fresh DB is sufficient - the invariant is structural, not data-dependent. + db := newTestDB(t) + + rapid.Check(t, func(rt *rapid.T) { + // Verify the DB is reachable (app server would call Ping, not goose.Up). + if err := db.Ping(); err != nil { + rt.Fatalf("failed to ping db: %v", err) + } + + // goose_db_version must not exist - migrations were never run by the app. + var exists bool + err := db.QueryRow(` + SELECT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_name = 'goose_db_version' + )`).Scan(&exists) + if err != nil { + rt.Fatalf("failed to check for goose_db_version: %v", err) + } + if exists { + rt.Error("goose_db_version exists - app server must not run migrations") + } + + log.Println("confirmed: app server did not trigger migrations") + }) +} + +// TestP5_SeedRunnerIdempotency runs seed inserts twice and asserts row counts +// are identical after both runs. +// +// Feature: db-migrations-seeding, Property 5: For any database state, running +// the Seed_Runner twice in sequence must produce the same set of rows as +// running it once - no duplicate rows, no errors on the second run. +func TestP5_SeedRunnerIdempotency(t *testing.T) { + // Feature: db-migrations-seeding, Property 5: Seed runner idempotency + // Spin up one container and reuse - seed inserts are idempotent via ON CONFLICT DO NOTHING. + db := newTestDB(t) + migrateDB(t, db) + + runSeed := func() { + user1ID := int64(1) + user2ID := int64(2) + + type seedUser struct { + id int64 + email string + password string + } + for _, u := range []seedUser{ + {id: user1ID, email: "alice@example.com", password: "password123"}, + {id: user2ID, email: "bob@example.com", password: "password123"}, + } { + hash, err := bcrypt.GenerateFromPassword([]byte(u.password), bcrypt.DefaultCost) + if err != nil { + t.Fatalf("bcrypt error: %v", err) + } + if _, err = db.Exec(` + INSERT INTO users (id, email, password_hash) + VALUES ($1, $2, $3) ON CONFLICT (id) DO NOTHING`, + u.id, u.email, string(hash)); err != nil { + t.Fatalf("seed user %s: %v", u.email, err) + } + } + + type seedTodo struct { + id int64 + userID int64 + item string + completed bool + } + for _, td := range []seedTodo{ + {1, user1ID, "Buy groceries", false}, + {2, user1ID, "Read a book", true}, + {3, user2ID, "Go for a run", false}, + } { + if _, err := db.Exec(` + INSERT INTO todos (id, item, completed, user_id) + VALUES ($1, $2, $3, $4) ON CONFLICT (id) DO NOTHING`, + td.id, td.item, td.completed, td.userID); err != nil { + t.Fatalf("seed todo %q: %v", td.item, err) + } + } + } + + rapid.Check(t, func(rt *rapid.T) { + runSeed() + var usersBefore, todosBefore int + if err := db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&usersBefore); err != nil { + rt.Fatalf("count users: %v", err) + } + if err := db.QueryRow(`SELECT COUNT(*) FROM todos`).Scan(&todosBefore); err != nil { + rt.Fatalf("count todos: %v", err) + } + + runSeed() + var usersAfter, todosAfter int + if err := db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&usersAfter); err != nil { + rt.Fatalf("count users after: %v", err) + } + if err := db.QueryRow(`SELECT COUNT(*) FROM todos`).Scan(&todosAfter); err != nil { + rt.Fatalf("count todos after: %v", err) + } + + if usersAfter != usersBefore { + rt.Errorf("users count changed after second seed: before=%d after=%d", usersBefore, usersAfter) + } + if todosAfter != todosBefore { + rt.Errorf("todos count changed after second seed: before=%d after=%d", todosBefore, todosAfter) + } + }) +} + +// TestP6_ConcurrentMigrationSafety launches two goroutines both running +// migrations simultaneously and asserts version 1 appears exactly once. +// +// Feature: db-migrations-seeding, Property 6: For any two Migration_Runner +// processes started simultaneously against the same database, each migration +// version must appear in goose_db_version with is_applied = true exactly once. +func TestP6_ConcurrentMigrationSafety(t *testing.T) { + // Feature: db-migrations-seeding, Property 6: Concurrent migration safety + // One container per test - concurrency is exercised within each rapid iteration. + db := newTestDB(t) + + rapid.Check(t, func(rt *rapid.T) { + // Reset goose state between iterations by dropping and recreating the version table. + _, _ = db.Exec(`DROP TABLE IF EXISTS goose_db_version`) + _, _ = db.Exec(`DROP TABLE IF EXISTS users`) + _, _ = db.Exec(`DROP TABLE IF EXISTS todos`) + _, _ = db.Exec(`DROP TABLE IF EXISTS blacklisted_tokens`) + + errs := make(chan error, 2) + var wg sync.WaitGroup + wg.Add(2) + go func() { defer wg.Done(); errs <- migrateDBErr(db) }() + go func() { defer wg.Done(); errs <- migrateDBErr(db) }() + wg.Wait() + close(errs) + + for err := range errs { + if err != nil { + rt.Logf("concurrent migrate error (expected on race): %v", err) + } + } + + rows, err := db.Query(`SELECT version_id, is_applied FROM goose_db_version WHERE version_id = 1`) + if err != nil { + rt.Fatalf("query goose_db_version: %v", err) + } + defer rows.Close() + + var count int + for rows.Next() { + var vid int64 + var applied bool + if err := rows.Scan(&vid, &applied); err != nil { + rt.Fatalf("scan: %v", err) + } + if !applied { + rt.Errorf("version %d has is_applied = false", vid) + } + count++ + } + if err := rows.Err(); err != nil { + rt.Fatalf("rows error: %v", err) + } + if count != 1 { + rt.Errorf("expected version 1 exactly once in goose_db_version, got %d", count) + } + }) +} diff --git a/compose-dev.yml b/compose-dev.yml index de4b465..858549b 100644 --- a/compose-dev.yml +++ b/compose-dev.yml @@ -1,45 +1,40 @@ services: - backend: - build: - context: ./backend - dockerfile: Dockerfile - target: dev - container_name: capuchin-server - depends_on: - capuchin-db: - condition: service_healthy - environment: - MODE: dev - POSTGRES_USER: ${POSTGRES_USER} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} - POSTGRES_DB: ${POSTGRES_DB} - POSTGRES_HOST: ${POSTGRES_HOST} - ports: - - "${SERVER_PORT:-8080}:8080" - restart: unless-stopped - volumes: - - ./backend:/app - capuchin-db: container_name: capuchin-db environment: POSTGRES_USER: ${POSTGRES_USER} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_DB: ${POSTGRES_DB} - POSTGRES_HOST: ${POSTGRES_HOST} - ports: - "5432:5432" - healthcheck: interval: 5s retries: 5 - test: [ "CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}" ] + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] timeout: 5s image: postgres:17-alpine volumes: - - ./backup:/var/lib/postgresql - - ./backend/db/init.sql:/docker-entrypoint-initdb.d/init.sql + # Separate dev volume - keeps dev data isolated from prod backup/data + - capuchin-dev-data:/var/lib/postgresql/data + - ./backend/db/init.sql:/docker-entrypoint-initdb.d/init.sql + + backend: + build: + context: ./backend + dockerfile: Dockerfile + target: dev + container_name: capuchin-server + environment: + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_HOST: capuchin-db + JWT_SECRET: ${JWT_SECRET} + ports: + - "${SERVER_PORT:-8080}:8080" + restart: unless-stopped + volumes: + - ./backend:/app frontend: build: @@ -53,6 +48,8 @@ services: - "${CLIENT_PORT:-5173}:5173" restart: unless-stopped volumes: - - ./frontend:/app - - /app/node_modules + - ./frontend:/app + - /app/node_modules +volumes: + capuchin-dev-data: diff --git a/compose.yml b/compose.yml index 1332d08..9b527b6 100644 --- a/compose.yml +++ b/compose.yml @@ -5,50 +5,39 @@ services: POSTGRES_USER: ${POSTGRES_USER} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_DB: ${POSTGRES_DB} - POSTGRES_HOST: ${POSTGRES_HOST} + # Healthcheck is informational - backend manages its own DB connection retry. healthcheck: interval: 5s retries: 5 - test: [ "CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}" ] + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] timeout: 5s image: postgres:17-alpine volumes: - - ./backup:/var/lib/postgresql - - ./backend/db/init.sql:/docker-entrypoint-initdb.d/init.sql - + - ./backup/data:/var/lib/postgresql/data backend: container_name: capuchin-server - build: context: ./backend dockerfile: Dockerfile ports: - "${SERVER_PORT:-8080}:8080" - volumes: - - ./backend/db:/app/db restart: unless-stopped - environment: POSTGRES_USER: ${POSTGRES_USER} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_DB: ${POSTGRES_DB} - POSTGRES_HOST: ${POSTGRES_HOST} - - depends_on: - capuchin-db: - condition: service_healthy + POSTGRES_HOST: capuchin-db + JWT_SECRET: ${JWT_SECRET} frontend: container_name: capuchin-client - build: context: ./frontend dockerfile: Dockerfile target: prod args: VITE_API_URL: ${VITE_API_URL} - ports: - "${CLIENT_PORT:-8000}:80" restart: unless-stopped diff --git a/docs/backend_api.md b/docs/backend_api.md deleted file mode 100644 index 05625b9..0000000 --- a/docs/backend_api.md +++ /dev/null @@ -1,137 +0,0 @@ -# Capuchin Backend API Contract - -This document provides a comprehensive overview of all available backend endpoints, their expected JSON payloads, requirements, and responses. - -## Base URL -When running locally: `http://localhost:8080` - -All endpoints return JSON responses. Errors are formatted as `{"error": "description"}`. - ---- - -## Public Endpoints - -### 1. Health Check -Checks if the server is running. -- **URL**: `/health` -- **Method**: `GET` -- **Auth Required**: No -- **Response**: `200 OK` - ```json - {"status": "ok"} - ``` - -### 2. User Signup -Registers a new user account. -- **URL**: `/signup` -- **Method**: `POST` -- **Auth Required**: No -- **Payload**: - ```json - { - "email": "user@example.com", // Required, must be valid email - "password": "strongpassword123" // Required, min 8 characters - } - ``` -- **Responses**: - - `201 Created`: `{"message": "User created successfully"}` - - `400 Bad Request`: Validation failure (missing fields or password < 8 chars) - - `409 Conflict`: User with the specified email already exists - -### 3. User Login -Authenticates a user and returns a JWT token. -- **URL**: `/login` -- **Method**: `POST` -- **Auth Required**: No -- **Payload**: - ```json - { - "email": "user@example.com", - "password": "strongpassword123" - } - ``` -- **Responses**: - - `200 OK`: `{"token": "ey..."}` (Use this token as a Bearer token in subsequent Protected requests) - - `401 Unauthorized`: Invalid credentials - ---- - -## Protected Endpoints -All protected endpoints are grouped under `/api/user/`. They require a valid JWT token in the `Authorization` header. -**Header Format:** `Authorization: Bearer ` - -### 4. Logout User (Revoke Token) -Logs the current user out by adding their JWT to a blacklist. -- **URL**: `/api/user/logout` -- **Method**: `POST` -- **Auth Required**: Yes -- **Responses**: - - `200 OK`: `{"message": "Logged out successfully"}` - - `401 Unauthorized`: Token is missing, expired, or already revoked - -### 5. Get All Todos -Retrieves all todo items belonging strictly to the authenticated user. -- **URL**: `/api/user/todo` -- **Method**: `GET` -- **Auth Required**: Yes -- **Responses**: - - `200 OK`: - ```json - [ - { - "id": "uuid-string", - "item": "Buy groceries", - "completed": false - } - ] - ``` - -### 6. Create Todo -Adds a new todo item for the authenticated user. -- **URL**: `/api/user/todo` -- **Method**: `POST` -- **Auth Required**: Yes -- **Payload**: - ```json - { - "item": "Review pull requests", // Required - "completed": false // Optional, defaults to false - } - ``` -- **Responses**: - - `200 OK`: - ```json - { - "id": "new-uuid-string", - "item": "Review pull requests", - "completed": false - } - ``` - - `400 Bad Request`: Missing the required `item` field - -### 7. Partially Update Todo (`PATCH`) -Updates specific fields (the text content, the completion status, or both) of an existing todo. It strictly enforces that the todo `id` provided in the path belongs to the authenticated user. -- **URL**: `/api/user/todo/:id` (Replace `:id` with the UUID of the todo) -- **Method**: `PATCH` -- **Auth Required**: Yes -- **Payload**: Provide one or both fields. - ```json - { - "item": "Review 5 pull requests", // Optional - "completed": true // Optional - } - ``` -- **Responses**: - - `200 OK`: Returns the updated todo schema as seen in GET. - - `400 Bad Request`: Invalid UUID format in URL or invalid JSON - - `404 Not Found`: Todo does not exist or does not belong to the user - -### 8. Delete Todo -Deletes a specific todo belonging strictly to the authenticated user. -- **URL**: `/api/user/todo/:id` (Replace `:id` with the UUID of the todo) -- **Method**: `DELETE` -- **Auth Required**: Yes -- **Responses**: - - `200 OK`: `{"message": "Todo deleted successfully"}` - - `400 Bad Request`: Invalid UUID format in URL - - `404 Not Found`: Todo does not exist or does not belong to the user diff --git a/docs/backend_architecture.md b/docs/backend_architecture.md deleted file mode 100644 index d77b135..0000000 --- a/docs/backend_architecture.md +++ /dev/null @@ -1,75 +0,0 @@ -# Capuchin Backend Architecture - -This document outlines the architectural design and structural patterns used in the Capuchin Go backend. - -## Overview - -The backend is built using **Go** and the **Gin Web Framework**. It follows a variation of the **Clean Architecture** and the **Standard Go Project Layout**, ensuring separation of concerns, scalability, and maintainability. - -The application interacts with a **PostgreSQL** database using the standard `database/sql` library and uses **JWT (JSON Web Tokens)** for stateless authentication. - -## Directory Structure - -The codebase is strictly divided into `cmd` for entry points and `internal` for private application code, preventing external imported usage of our core logic. - -```text -backend/ -├── cmd/ -│ └── server/ -│ └── main.go # Application entry point. Wires dependencies. -├── internal/ -│ ├── config/ # Environment loading and validation -│ ├── database/ # Global DB connection pool and schema init -│ ├── handlers/ # HTTP transport layer (Controllers) -│ ├── middleware/ # HTTP intercepts (Auth, Error Recovery) -│ ├── models/ # Domain data structures -│ ├── routes/ # Centralized route registration -│ └── services/ # Core business logic -└── ... -``` - -## Layered Architecture - -The application handles requests through three primary layers: - -1. **Routing Layer (`internal/routes`)** - - Registers all endpoints to their corresponding handler functions. - - Applies necessary middlewares (e.g., `AuthRequired`) to protected routes. - -2. **Transport / Handler Layer (`internal/handlers`)** - - Extracts and validates incoming HTTP requests (JSON body, Path params, Headers). - - Calls the appropriate Service methods. - - Formats the response (JSON) and returns appropriate HTTP status codes (200, 400, 404, 500). - - **Rule:** Handlers contain *no business logic* or direct database queries. - -3. **Service Layer (`internal/services`)** - - Contains all the core business logic. - - Enforces business rules (e.g., hashing passwords, verifying credentials, associating items). - - Communicates directly with the data store (`internal/database`). - - Returns business-level errors (e.g., `ErrUserExists`, `ErrTodoNotFound`) decoupled from HTTP transport. - -## Dependency Injection - -The application uses constructor injection to pass dependencies down the chain. This is primarily seen in the relationship between Handlers and Services: - -```go -// main.go initializes components and wires them together -todoService := services.NewTodoService() -todoHandler := handlers.NewTodoHandler(todoService) -``` - -This decouples the handler from a strictly concrete service implementation, paving the way for easier unit testing via mocked services in the future. - -## Database & Persistence - -- **Connection Pool:** A centralized `sql.DB` connection pool (`database.DB`) is initialized at startup. It configures connection lifetimes, max open, and max idle connections to prevent resource exhaustion. -- **Relational Integrity:** Uses standard PostgreSQL relations (e.g., `todos.user_id REFERENCES users(id)`). -- **UUIDs:** Primary keys are decentralized using UUIDs. - -## Authentication Flow - -Authentication is stateless and managed via JWTs: - -1. **Login:** A user logs in, the service verifies the hashed password via `bcrypt`, and generates an HS256 JWT containing the `user_id` and an expiration time. -2. **Authorization:** Protected routes use `middleware.AuthRequired()`, which intercepts requests, strictly validates the `Authorization` bearer token against the signing key, enforces the signing method, and extracts the `user_id` into the Gin context. -3. **Logout:** The application tracks revoked tokens using a database table `blacklisted_tokens`. When a user logs out, their specific token is inserted into this table. The auth middleware inherently rejects any blacklisted tokens. A background goroutine cleans up expired tokens hourly. diff --git a/docs/backend_best_practices.md b/docs/backend_best_practices.md deleted file mode 100644 index dd5579c..0000000 --- a/docs/backend_best_practices.md +++ /dev/null @@ -1,48 +0,0 @@ -# Capuchin Backend Best Practices - -This document outlines the coding standards, patterns, and best practices strictly enforced across the Go backend codebase. - -## 1. Centralized Configuration -Environment variables should never be accessed arbitrarily via `os.Getenv` throughout the business logic. -- All environment variables are loaded, parsed, and validated cleanly within `internal/config`. -- Missing required configurations immediately trigger a `log.Fatal()`, preventing the application from booting into a broken state. - -## 2. Interface-Driven Services -Services are defined using Go interfaces. -```go -type TodoService interface { - GetTodos(userID uuid.UUID) ([]models.Todo, error) - // ... -} -``` -This enables decoupled abstractions. If we decide to swap the database layer out for an ORM or a NoSQL database, we only rewrite the struct that satisfies the interface, leaving the handlers untouched. It also allows for generating mock services for unit testing the handler layer. - -## 3. Strong Typing and Struct Binding -We utilize Gin's `ShouldBindJSON` alongside struct tags to strictly map and validate incoming requests before processing them. We refuse requests with an HTTP 400 Bad Request if they violate validation tags (e.g., `binding:"required,min=8"` for passwords). - -```go -var reqBody struct { - Email string `json:"email" binding:"required,email"` - Password string `json:"password" binding:"required,min=8"` -} -``` - -## 4. Centralized Domain Errors -The Service layer does not return HTTP status codes or Gin contexts. Instead, it returns standard Go `error` types defined natively within the package. -```go -var ( - ErrUserExists = errors.New("user with this email already exists") - ErrInvalidCredentials = errors.New("invalid credentials") -) -``` -The Handler layer is responsible for translating these domain errors into the correct semantic HTTP response codes (e.g., 409 Conflict, 401 Unauthorized, 404 Not Found). - -## 5. Security Practices -- **Password Hashing:** Passwords are never stored or logged in plain text. We utilize the industry-standard `golang.org/x/crypto/bcrypt` to hash and salt passwords with an appropriate computational cost. -- **JWT Hardening:** The JWT middleware strictly forces the `jwt.WithValidMethods([]string{"HS256"})` and `jwt.WithExpirationRequired()` validators to prevent token tampering or downgrade attacks. -- **Data Isolation:** All protected routes fetch the user ID strictly from the verified JWT token (`c.MustGet("userID")`) injected by the middleware. We never trust `user_id` passed in the HTTP body, effectively preventing lateral data access (IDOR). -- **Graceful Error Recovery:** A global error recovery middleware traps unhandled panics, logs them securely on the server-side, and returns a generic `500 Internal Server Error` to the client, preventing stack trace exposure. - -## 6. Resource Management -- **Database Iterator Safety:** When iterating through `rows.Next()`, we explicitly check `rows.Err()` afterward. This catches scenarios where the iteration abruptly halted due to mid-network disconnects or corruption. -- **Background Cleanup:** Dead data (expired logout tokens) is swept away gracefully by an isolated Go routine initialized at startup `go func() { ... }()`, preventing table bloat over time. diff --git a/docs/backend_schema.md b/docs/backend_schema.md deleted file mode 100644 index 56b3daa..0000000 --- a/docs/backend_schema.md +++ /dev/null @@ -1,64 +0,0 @@ -# Capuchin Backend Database Schema - -This document outlines the data structures, tables, and relational constraints defined within the backend's PostgreSQL database. The schema is automatically initialized when the backend server boots via `database.InitSchema()`. - -## Tables Overview - -The application utilizes three primary tables: `users`, `todos`, and `blacklisted_tokens`. - ---- - -### 1. `users` -Stores all registered user accounts and their authentication data. - -| Column | Type | Constraints | Description | -| :--- | :--- | :--- | :--- | -| `id` | `UUID` | `PRIMARY KEY` | Unique identifier generated on server during signup. | -| `email` | `TEXT` | `UNIQUE NOT NULL` | The user's email address. Uniqueness is enforced at the DB level prevent race condition duplicate signups. | -| `password_hash` | `TEXT` | `NOT NULL` | The bcrypt-hashed representation of the user's password. Plain-text is never stored. | - ---- - -### 2. `todos` -Stores the individual to-do list items, referencing their owning user. - -| Column | Type | Constraints | Description | -| :--- | :--- | :--- | :--- | -| `id` | `UUID` | `PRIMARY KEY` | Unique identifier generated on server when todo is created. | -| `item` | `TEXT` | `NOT NULL` | The actual text content/task description. | -| `completed` | `BOOLEAN` | `DEFAULT FALSE` | Status flag denoting if the task is finished. | -| `user_id` | `UUID` | `REFERENCES users(id)` | **Foreign Key** linking the item to its owner. Enforces data ownership and multi-tenancy rules at the database level. | - ---- - -### 3. `blacklisted_tokens` -Stores JWT tokens that have been explicitly revoked by users logging out before the tokens' natural expiration time. This forms the backbone of the backend's stateless logout logic. - -| Column | Type | Constraints | Description | -| :--- | :--- | :--- | :--- | -| `token` | `TEXT` | `PRIMARY KEY` | The raw JWT string that has been logged out. | -| `expired_at` | `TIMESTAMP` | `NOT NULL` | The exact time the token would have naturally expired. A backend goroutine runs hourly discarding any rows where `expired_at < time.Now()` to prevent database bloat. | - ---- - -## Entity-Relationship Diagram (ERD) - -```mermaid -erDiagram - USERS ||--o{ TODOS : owns - USERS { - uuid id PK - text email UK - text password_hash - } - TODOS { - uuid id PK - text item - boolean completed - uuid user_id FK - } - BLACKLISTED_TOKENS { - text token PK - timestamp expired_at - } -``` diff --git a/docs/readme.md b/docs/readme.md deleted file mode 100644 index b108ede..0000000 --- a/docs/readme.md +++ /dev/null @@ -1,172 +0,0 @@ -## 📜 Capuchin: A basic Todo app -A basic full-stack todo list application with a Go (Golang) REST API backend and a React frontend with a professional-grade storage architecture. - -## 🚀 Features Implemented - -* **Backend (Go + Gin):** RESTful API with distinct layers (Handlers, Services, DB) and robust error handling. -* **Authentication:** Secure Signup, Login, and Logout using JWT tokens. -* **Database (PostgreSQL):** Relational persistence using `database/sql` with schema initialization on startup. -* **Frontend (React + Vite):** Modern reactive UI with Hooks (useState, useEffect). -* **Styling (Tailwind CSS):** Dark-mode interface with optimistic UI. -* **Architecture:** Clean architecture enforcing separation of concerns in 'internal'. -* **Containerization:** Docker & Docker Compose for Dev/Prod. - -## 📂 Project Structure - -``` -capuchin/ -├── backend/ -│ ├── cmd/ -│ │ └── server/ -│ │ └── main.go # Entry point -│ ├── internal/ -│ │ ├── config/ # Environment & Config setup -│ │ ├── database/ # PostgreSQL connection & init -│ │ ├── handlers/ # HTTP Route handlers -│ │ ├── middleware/ # Auth & Error middleware -│ │ ├── models/ # Data structures -│ │ ├── routes/ # API route definitions -│ │ └── services/ # Core business logic -│ ├── Dockerfile # Backend Container -│ ├── air.toml # Hot Reload Config -│ ├── go.mod # Dependencies -│ └── go.sum -├── frontend/ -│ ├── src/ -│ │ ├── App.tsx -│ │ ├── App.css -│ │ └── main.tsx -│ ├── Dockerfile # Frontend Container -│ ├── vite.config.ts # Build Config -│ └── package.json -├── compose.yml # Prod Orchestration -├── compose-dev.yml # Dev Mode Overrides -└── Makefile # Command shortcuts -└── package.json - -``` - -## 💻 Tech Stack -## 💻 Tech Stack -* **Backend:** Go (REST API, Clean Architecture) -* **Backend Framework:** Gin -* **Frontend:** React, TypeScript -* **Containerize:** Docker -* **Database:** PostgreSQL - -## 🛠️ How to Run - -### Method 1: In separate terminals - - - -#### Backend: - -Open Terminal 1 -``` Bash -cd backend -go run cmd/server/main.go -``` -`Server runs on localhost:8080` - -#### Frontend: - -Open Terminal 2 -``` Bash -cd frontend -npm run dev -``` -`Client opens at localhost:5173` - - ---- - -### Method 2: Using npm Script (In project home directory) - -Install npm packages -``` Bash -npm i -``` -Run npx script - -``` Bash -npx concurrently "cd ./backend/cmd/server && go run main.go" "npm run dev --prefix ./frontend" -``` - -- **Frontend**: http://localhost:5173 -- **Health Check**: http://localhost:8080/health -- **Backend API**: http://localhost:8080/todos - - ---- - - -### Method 3: Docker (In project home directory) - -We support two modes: **Development** (Hot-Reload) and **Production** (Lean Static Builds). - -#### Development Mode -Runs the backend with `Air` (Go hot-reload) and Frontend with `Vite` (HMR). Changes to code are reflected instantly. - -```bash -make dev -# OR -docker compose --env-file .env.example -f compose-dev.yml up --build -``` -- **Frontend**: http://localhost:5173 -- **Health Check**: http://localhost:8080/health -- **Backend API**: http://localhost:8080/todos - -#### Production Mode -Runs a lean, production-ready build (`scratch` image for Go, `nginx` for React). - -```bash -make prod -# OR -docker compose --env-file .env -f compose.yml up --build -``` -- **App**: http://localhost -- **Health Check**: http://localhost:8080/health -- **Backend API**: http://localhost:8080/todos - -#### Stop Containers -```bash -make down -# OR -#in active terminal -ctrl+c or cmd+c -``` - ---- - - - - -## 🧠 Key Concepts Implemented (can be seen in comments) - -For an in-depth dive into the structure and patterns, please refer to our dedicated documentation: -- [Backend Architecture Reference](backend_architecture.md) -- [Backend Best Practices](backend_best_practices.md) -- [Backend API Contract](backend_api.md) -- [Backend Database Schema](backend_schema.md) - -* **Go:** Structs, Slices, JSON Marshalling, Modules, Package Exporting, Clean Architecture. -* **React:** Functional Components, Hooks, API Integration (fetch, async/await), Controlled Inputs. -* **Testing:** Included a robust `backend/verify_backend.sh` shell script to instantly orchestrate E2E integration tests against all API endpoints. -* **Docker:** Multi-stage builds, Scratch images, Docker Compose overrides. -* **General:** REST API Design, CORS, JSON Persistence, Refactoring,TypeScript(for styling), axios (for API calls) - - -Long term plans: - -folder todo -collaborators -real time update -organization -authentication -groups and access -sharelink -auth login -schedule with reminder -version control -mcp server diff --git a/frontend/src/hooks/useTodos.ts b/frontend/src/hooks/useTodos.ts index de48467..41438fa 100644 --- a/frontend/src/hooks/useTodos.ts +++ b/frontend/src/hooks/useTodos.ts @@ -3,7 +3,11 @@ import { todosApi } from "@/lib/api" import type { Todo, FilterType } from "@/types" const GUEST_KEY = "capuchin_guest_todos" -const genId = () => crypto.randomUUID() +let _guestIdCounter = 0 +const genId = () => { + _guestIdCounter -= 1 + return String(_guestIdCounter) +} const loadGuestTodos = (): Todo[] => { try { diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index b4e28b6..c3281dd 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -37,7 +37,7 @@ export const authApi = { // Todo const normalise = (raw: Record): Todo => ({ - id: String(raw.id ?? raw.ID), + id: Number(raw.id ?? raw.ID), item: String(raw.item ?? raw.Item ?? ""), completed: Boolean(raw.completed ?? raw.Completed ?? false), }) diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index d7c8b04..0180e2d 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -1,5 +1,5 @@ export interface Todo { - id: string + id: number | string item: string completed: boolean } diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..692379c --- /dev/null +++ b/readme.md @@ -0,0 +1,115 @@ +## 📜 Capuchin: A robust Todo application +A feature-rich full-stack todo list application with a Go (Golang) REST API backend and a React/Vite frontend using a professional-grade decoupled architecture. + +## 🚀 Features Implemented + +* **Backend (Go + Gin):** RESTful API with distinct layers (`handlers`, `services`, `database`, `middleware`) and robust error handling. +* **Authentication:** Secure Signup, Login, and Logout using short-lived JWT tokens with a database-backed token blacklisting mechanism. +* **Database (PostgreSQL):** Relational persistence mapped implicitly to user context to enforce cross-tenant data isolation. +* **Frontend (React + Vite):** Modern reactive UI with custom asynchronous Hooks (`useTodos`, `useAuth`) abstracting away native `fetch` requests. +* **Offline-friendly mode:** Supports an unauthenticated Guest mode backed tightly by `localStorage`. +* **Containerization:** Clean Docker Compose multi-stage orchestrations covering both isolated local development profiles and production scratch-image deployment. + +## 📂 Project Structure + +```text +capuchin/ +├── backend/ +│ ├── cmd/ +│ │ ├── server/ # Entry point for the REST server +│ │ ├── migrate/ # Standalone binary runner for schema definitions +│ │ └── seed/ # Dev DB seed runner +│ ├── internal/ +│ │ ├── config/ # Environment & Config map parsing +│ │ ├── database/ # PostgreSQL driver configuration & pooling limits +│ │ ├── handlers/ # HTTP Route logic & payload validation +│ │ ├── middleware/ # Identity resolution & security guards +│ │ ├── models/ # Data structures +│ │ ├── routes/ # Mux mappings setup +│ │ └── services/ # Identity and persistence core logic workflows +│ ├── Dockerfile # Multi-stage Backend Container +│ ├── air.toml # Hot Reload configs +│ ├── go.mod # Go Dependencies +│ └── test.sh # Integration / E2E endpoint bash test harness +├── frontend/ +│ ├── src/ +│ │ ├── components/ # Presentational layout components +│ │ ├── hooks/ # Primary React state workflows (`useAuth`, `useTodos`) +│ │ ├── lib/ # Core native-fetch wrapper API logic +│ │ ├── pages/ # Page-level route views +│ │ ├── types/ # TypeScript definitions +│ │ ├── App.tsx +│ │ └── main.tsx +│ ├── Dockerfile # Nginx + React Multi-stage Frontend Container +│ ├── vite.config.ts # Vite bundling settings +│ └── package.json +├── compose.yml # Lean Production Orchestration +├── compose-dev.yml # Dev Mode (Air/Vite) overrides +└── Makefile # Command shortcuts +``` + +## 💻 Tech Stack +* **Backend:** Go (REST API, Clean Architecture) +* **Backend Framework:** Gin +* **Frontend:** React, TypeScript, Vite +* **Runtime Orchestration:** Docker, Make +* **Database:** PostgreSQL +* **Migrations:** Goose v3 (Inside Docker) + +## 🛠️ How to Run + +### Method 1: Docker (Recommended) +This approach encapsulates all dependencies securely via Docker Engine configurations. + +#### For Development (Hot-Reloading) +Runs the Go backend natively through Air for hot-schema reload mappings, and the React frontend via Vite HMR. +```sh +make dev +# OR +docker compose --env-file .env.example -f compose-dev.yml up --build +``` +- **Frontend App**: `http://localhost:5173` +- **Backend API Base**: `http://localhost:8080` + +#### For Production +Runs a lean production-ready sequence packaging the Go engine natively in a `scratch` container, and distributing the React codebase via `nginx`. +```bash +make prod +# OR +docker compose --env-file .env -f compose.yml up --build +``` + +### Method 2: Native via NPM script +Requires Go, Node.js, and Postgres installed natively on your machine! +Ensure your root `.env` accurately targets your native Postgres installation. +```bash +npm i +npx concurrently "cd ./backend/cmd/server && go run main.go" "npm run dev --prefix ./frontend" +``` + +--- + +## 🧠 Documentation & Key Concepts + +For an in-depth dive into the structure, API contract, database schema, and best practices, please refer to our full documentation on the **[GitHub Wiki](https://github.com/the-monkeys/capuchin/wiki)**. + +Key concepts utilized: +* **Go:** Structs, Slices, JSON Marshalling, Clean Architecture. +* **React:** Functional Components, Custom Hooks (`useTodos`, `useAuth`), fetch wrappers. +* **Testing:** `backend/test.sh` for E2E integration tests against API endpoints. +* **Docker:** Multi-stage builds, Scratch images, Docker Compose overrides. +* **General:** REST API Design, JWT Auth isolation, Postgres parameterization. + +Long term plans: + +folder todo +collaborators +real time update +organization +authentication +groups and access +sharelink +auth login +schedule with reminder +version control +mcp server