Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,9 @@ make migrate-version # print current schema version
3. **Migrate** — if the feature needs new or changed tables: `make migrate-create name=<slug>`, write the SQL, `make migrate-up`
4. **Write tests first (TDD)** — write failing tests that describe the intended behaviour before writing any implementation code
5. **Implement** — delegate to `backend` or `web` agent, passing the relevant doc content as context; implementation is complete when all tests pass
6. **Update docs** — delegate to `docs` agent: update `last_verified`, add new topics if introduced
7. **Quality gate** — run `/project:check` before declaring done
6. **Env vars** — if any new env vars were added to `loadConfig()`, add them to `backend/.env.example` with a safe placeholder value and a comment explaining the format
7. **Update docs** — delegate to `docs` agent: update `last_verified`, add new topics if introduced
8. **Quality gate** — run `/project:check` before declaring done

Use `/project:implement` to run this workflow end-to-end.

Expand Down Expand Up @@ -199,3 +200,4 @@ cd backend && make swagger # regenerate docs/swagger/ after annotation changes
- No database mocks in tests.
- No `any` in TypeScript.
- **No new feature without tests.** Every new route, component, utility function, use case, and Composable must ship with tests.
- **Every new env var must appear in `backend/.env.example`** with a safe placeholder value and a comment. Never leave a new `loadConfig()` variable undocumented in the example file.
4 changes: 3 additions & 1 deletion backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,6 @@ BLUEPRINT_DB_USERNAME=
BLUEPRINT_DB_PASSWORD=
BLUEPRINT_DB_SCHEMA=public
BLUEPRINT_DB_SSLMODE=disable
REDIS_URL=
REDIS_URL=
RATE_LIMIT_RPS=100 # requests/sec per IP (omit or 0 = disabled)
RATE_LIMIT_BURST=500 # burst capacity (defaults to RPS×5)
Comment thread
GRACENOBLE marked this conversation as resolved.
7 changes: 4 additions & 3 deletions backend/docs/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ The `docs` agent reads this index first to locate the right file before diving i
| Startup lifecycle & dependency initialisation | [bootstrap.md](bootstrap.md) | `internal/bootstrap/bootstrap.go`, `internal/server/server.go`, `cmd/api/main.go` |
| Database connection & query patterns | [database.md](database.md) | `internal/infrastructure/database/postgres/db.go`, `internal/infrastructure/database/postgres/health_repository.go`, `internal/domain/health.go`, `internal/usecase/health_usecase.go` |
| Schema migrations (goose) | [migrations.md](migrations.md) | `cmd/migrate/main.go`, `internal/infrastructure/database/migrations/`, `Makefile` |
| HTTP routing & handler patterns | [routing.md](routing.md) | `internal/handler/handler.go`, `internal/handler/routes.go`, `internal/handler/hello_handler.go`, `internal/handler/health_handler.go`, `internal/server/server.go` |
| HTTP routing & handler patterns | [routing.md](routing.md) | `internal/transport/handlers/handler.go`, `internal/transport/handlers/routes.go`, `internal/transport/handlers/hello_handler.go`, `internal/transport/handlers/health_handler.go`, `internal/transport/middleware/logger.go`, `internal/server/server.go` |
| Testing patterns (unit, handler, Redis, bootstrap) | [testing.md](testing.md) | `internal/infrastructure/database/postgres/health_repository_test.go`, `internal/transport/handlers/hello_handler_test.go`, `internal/transport/handlers/health_handler_test.go`, `internal/transport/middleware/logger_test.go`, `internal/usecase/health_usecase_test.go`, `internal/infrastructure/cache/redis/cache_test.go`, `internal/bootstrap/bootstrap_test.go` |
| Error handling conventions | [error-handling.md](error-handling.md) | `internal/repository/postgres/health_repository.go`, `internal/handler/health_handler.go`, `cmd/api/main.go` |
| Environment variables | [environment.md](environment.md) | `.env`, `internal/bootstrap/bootstrap.go`, `internal/repository/postgres/db.go` |
| Error handling conventions | [error-handling.md](error-handling.md) | `internal/infrastructure/database/postgres/health_repository.go`, `internal/transport/handlers/health_handler.go`, `cmd/api/main.go` |
| Environment variables | [environment.md](environment.md) | `.env`, `internal/bootstrap/bootstrap.go`, `internal/infrastructure/database/postgres/db.go` |
| Middleware (logger, rate limiter) | [middleware.md](middleware.md) | `internal/transport/middleware/logger.go`, `internal/transport/middleware/ratelimit.go`, `internal/transport/handlers/routes.go` |
8 changes: 5 additions & 3 deletions backend/docs/environment.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
---
topic: environment
last_verified: 2026-06-14
last_verified: 2026-06-15
sources:
- .env
- internal/bootstrap/bootstrap.go
- internal/repository/postgres/db.go
- internal/infrastructure/database/postgres/db.go
---

# Environment Variables
Expand All @@ -21,14 +21,16 @@ This runs on package init before any env var is read — no explicit `godotenv.L
| Variable | Used in | Default | Description |
|---|---|---|---|
| `PORT` | `bootstrap.go` | `8080` | HTTP server listen port |
| `APP_ENV` | `bootstrap.go` | — | Environment name (`local`, `production`) |
| `ENV` | `bootstrap.go` | — | Environment name (`local`, `production`) |
| `BLUEPRINT_DB_HOST` | `bootstrap.go` | — | Postgres host (**required**) |
| `BLUEPRINT_DB_PORT` | `bootstrap.go` | — | Postgres port (**required**) |
| `BLUEPRINT_DB_DATABASE` | `bootstrap.go` | — | Database name (**required**) |
| `BLUEPRINT_DB_USERNAME` | `bootstrap.go` | — | Postgres username (**required**) |
| `BLUEPRINT_DB_PASSWORD` | `bootstrap.go` | — | Postgres password (**required**) |
| `BLUEPRINT_DB_SCHEMA` | `bootstrap.go` | `public` | Postgres search_path schema |
| `BLUEPRINT_DB_SSLMODE` | `bootstrap.go` | `disable` | Postgres SSL mode (`disable`, `require`, `verify-full`) |
| `RATE_LIMIT_RPS` | `bootstrap.go` | `0` (disabled) | Max requests per second per IP. Set to `0` or omit to disable rate limiting. |
| `RATE_LIMIT_BURST` | `bootstrap.go` | `int(RPS) * 5`, min 1 | Token-bucket burst capacity. Derived as `int(RPS)*5` when omitted; clamped to 1 so fractional RPS values never block all traffic. |

Variables marked **required** are validated by `bootstrap.validateConfig` at startup — the process exits before attempting a DB connection if any are missing.

Expand Down
8 changes: 4 additions & 4 deletions backend/docs/error-handling.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
---
topic: error-handling
last_verified: 2026-06-14
last_verified: 2026-06-15
sources:
- internal/repository/postgres/health_repository.go
- internal/handler/health_handler.go
- internal/infrastructure/database/postgres/health_repository.go
- internal/transport/handlers/health_handler.go
- cmd/api/main.go
---

Expand Down Expand Up @@ -85,4 +85,4 @@ if err := c.ShouldBindJSON(&input); err != nil {
Use `fmt.Errorf("context: %w", err)` when adding context to returned errors so callers can use `errors.Is` / `errors.As`.

## Panic recovery
`gin.Default()` includes the Recovery middleware — panics in handlers are recovered and return 500. Do not rely on this; handle errors explicitly.
`gin.Recovery()` is applied explicitly in `RegisterRoutes()` — panics in handlers are recovered and return 500. Do not rely on this; handle errors explicitly.
43 changes: 43 additions & 0 deletions backend/docs/middleware.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
---
topic: middleware
last_verified: 2026-06-15
sources:
- internal/transport/middleware/logger.go
- internal/transport/middleware/ratelimit.go
- internal/transport/handlers/routes.go
---

# Middleware

All middleware lives in `internal/transport/middleware/` and follows the Gin `HandlerFunc` pattern. Middleware is registered in `RegisterRoutes()` inside `internal/transport/handlers/routes.go`.

## Registration order

```go
// 1. Recovery + logger (debug: gin.Logger, release: middleware.Logger)
r.Use(gin.Recovery(), middleware.Logger())
// 2. Rate limiter (no-op when RPS <= 0)
r.Use(middleware.RateLimit(rps, burst))
// 3. CORS
r.Use(cors.New(...))
```

## Logger

`Logger() gin.HandlerFunc` emits one structured `slog` record per request after `c.Next()` returns. Fields: `status`, `method`, `path`, `latency`, `ip`, and optionally `query` and `errors`.

In debug mode (`ENV` not set to `staging`/`production`) Gin's built-in colorful logger is used instead.

## Rate limiter

`RateLimit(rps float64, burst int) gin.HandlerFunc` limits each client IP to `rps` requests per second using a token-bucket algorithm (`golang.org/x/time/rate`). Each IP gets its own `rate.Limiter` stored in a mutex-guarded map.

- **Disabled** when `rps <= 0` (no-op middleware returned).
- **429 Too Many Requests** returned when the bucket is empty; body: `{"error": "rate limit exceeded"}`.
- Configured via env vars `RATE_LIMIT_RPS` and `RATE_LIMIT_BURST` (see [environment](environment.md)).

## Adding new middleware

1. Create `internal/transport/middleware/<name>.go` with a function returning `gin.HandlerFunc`.
2. Register it in `RegisterRoutes()` in `internal/transport/handlers/routes.go` at the appropriate position in the chain.
3. If it requires configuration, add fields to `bootstrap.Config` and read from env in `loadConfig()`.
31 changes: 23 additions & 8 deletions backend/docs/routing.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
---
topic: routing
last_verified: 2026-06-14
last_verified: 2026-06-15
sources:
- internal/handler/handler.go
- internal/handler/routes.go
- internal/handler/hello_handler.go
- internal/handler/health_handler.go
- internal/transport/handlers/handler.go
- internal/transport/handlers/routes.go
- internal/transport/handlers/hello_handler.go
- internal/transport/handlers/health_handler.go
- internal/transport/middleware/logger.go
- internal/server/server.go
- cmd/api/main.go
---
Expand Down Expand Up @@ -36,7 +37,7 @@ h := handler.NewHandler(healthUC)

return &http.Server{
Addr: fmt.Sprintf(":%d", app.Config.Port),
Handler: h.RegisterRoutes(),
Handler: h.RegisterRoutes(app.Config.RateLimitRPS, app.Config.RateLimitBurst),
IdleTimeout: time.Minute,
ReadTimeout: 10 * time.Second,
WriteTimeout: 30 * time.Second,
Expand All @@ -45,12 +46,26 @@ return &http.Server{

## Route registration
All routes registered in `RegisterRoutes()` on `*Handler`, which returns `http.Handler`.
`rps` and `burst` come from `bootstrap.Config` (env vars `RATE_LIMIT_RPS` / `RATE_LIMIT_BURST`); pass `rps=0` to disable.

```go
func (h *Handler) RegisterRoutes() http.Handler {
r := gin.Default()
func (h *Handler) RegisterRoutes(rps float64, burst int) http.Handler {
r := gin.New()

// Gin's colorful logger locally; structured slog logger in staging/production.
if gin.Mode() == gin.DebugMode {
r.Use(gin.Recovery(), gin.Logger())
} else {
r.Use(gin.Recovery(), middleware.Logger())
}

r.Use(middleware.RateLimit(rps, burst))

r.Use(cors.New(cors.Config{ ... }))

r.GET("/path", h.myHandler)
r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))

Comment thread
coderabbitai[bot] marked this conversation as resolved.
return r
}
```
Expand Down
1 change: 1 addition & 0 deletions backend/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ require (
github.com/swaggo/swag v1.16.6
github.com/testcontainers/testcontainers-go v0.42.0
github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0
golang.org/x/time v0.15.0
)

require (
Expand Down
2 changes: 2 additions & 0 deletions backend/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
Expand Down
24 changes: 17 additions & 7 deletions backend/internal/bootstrap/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,12 @@ type App struct {

// Config holds all validated configuration values read from environment variables.
type Config struct {
Port int
Env string
DB postgres.DBConfig
RedisURL string
Port int
Env string
DB postgres.DBConfig
RedisURL string
RateLimitRPS float64
RateLimitBurst int
}

// ConfigError is returned when required configuration is absent or invalid.
Expand Down Expand Up @@ -125,10 +127,18 @@ func loadConfig() Config {
sslMode = "disable"
}

rps, _ := strconv.ParseFloat(os.Getenv("RATE_LIMIT_RPS"), 64)
burst, _ := strconv.Atoi(os.Getenv("RATE_LIMIT_BURST"))
if burst == 0 && rps > 0 {
burst = int(rps) * 5
}
Comment thread
GRACENOBLE marked this conversation as resolved.

return Config{
Port: port,
Env: os.Getenv("ENV"),
RedisURL: os.Getenv("REDIS_URL"),
Port: port,
Env: os.Getenv("ENV"),
RedisURL: os.Getenv("REDIS_URL"),
RateLimitRPS: rps,
RateLimitBurst: burst,
DB: postgres.DBConfig{
Host: os.Getenv("BLUEPRINT_DB_HOST"),
Port: os.Getenv("BLUEPRINT_DB_PORT"),
Expand Down
2 changes: 1 addition & 1 deletion backend/internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ func NewServer(app *bootstrap.App) *http.Server {

return &http.Server{
Addr: fmt.Sprintf(":%d", app.Config.Port),
Handler: h.RegisterRoutes(),
Handler: h.RegisterRoutes(app.Config.RateLimitRPS, app.Config.RateLimitBurst),
IdleTimeout: time.Minute,
ReadTimeout: 10 * time.Second,
WriteTimeout: 30 * time.Second,
Expand Down
5 changes: 4 additions & 1 deletion backend/internal/transport/handlers/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ import (
)

// RegisterRoutes creates the Gin engine, applies middleware, and registers all routes.
func (h *Handler) RegisterRoutes() http.Handler {
// rps and burst configure IP-based rate limiting; pass rps<=0 to disable.
func (h *Handler) RegisterRoutes(rps float64, burst int) http.Handler {
r := gin.New()

// Use Gin's colorful logger locally; structured slog logger in staging/production.
Expand All @@ -23,6 +24,8 @@ func (h *Handler) RegisterRoutes() http.Handler {
r.Use(gin.Recovery(), middleware.Logger())
}

r.Use(middleware.RateLimit(rps, burst))

r.Use(cors.New(cors.Config{
AllowOrigins: []string{"http://localhost:3000"},
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"},
Expand Down
71 changes: 71 additions & 0 deletions backend/internal/transport/middleware/ratelimit.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package middleware

import (
"net/http"
"sync"
"time"

"github.com/gin-gonic/gin"
"golang.org/x/time/rate"
)

const (
limiterTTL = 10 * time.Minute
cleanupInterval = 5 * time.Minute
)

type ipLimiter struct {
limiter *rate.Limiter
lastSeen time.Time
}

// RateLimit returns a Gin middleware that limits each client IP to rps requests per
// second with the given burst size. When rps <= 0 the middleware is a no-op.
// Burst is clamped to a minimum of 1 so fractional rps values never block all traffic.
// Stale per-IP entries are evicted every 5 minutes.
func RateLimit(rps float64, burst int) gin.HandlerFunc {
if rps <= 0 {
return func(c *gin.Context) { c.Next() }
}
if burst < 1 {
burst = 1
}

var mu sync.Mutex
limiters := make(map[string]*ipLimiter)

go func() {
ticker := time.NewTicker(cleanupInterval)
defer ticker.Stop()
for range ticker.C {
cutoff := time.Now().Add(-limiterTTL)
mu.Lock()
for ip, l := range limiters {
if l.lastSeen.Before(cutoff) {
delete(limiters, ip)
}
}
mu.Unlock()
}
}()

getLimiter := func(ip string) *rate.Limiter {
mu.Lock()
defer mu.Unlock()
l, ok := limiters[ip]
if !ok {
l = &ipLimiter{limiter: rate.NewLimiter(rate.Limit(rps), burst)}
limiters[ip] = l
}
l.lastSeen = time.Now()
return l.limiter
}

return func(c *gin.Context) {
if !getLimiter(c.ClientIP()).Allow() {
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": "rate limit exceeded"})
return
}
c.Next()
}
}
Loading
Loading