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
8 changes: 6 additions & 2 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,9 @@ BLUEPRINT_DB_SSLMODE=disable
REDIS_URL=
RATE_LIMIT_RPS=100 # requests/sec per IP (omit or 0 = disabled)
RATE_LIMIT_BURST=500 # burst capacity (defaults to RPS×5)
FIREBASE_PROJECT_ID=# Firebase project ID — e.g. my-app-12345 (omit to disable auth)
FIREBASE_SERVICE_ACCOUNT_JSON=# Service account key as a single-line JSON string. Get from: Firebase Console → Project Settings → Service Accounts → Generate new private key
# Firebase project ID — e.g. my-app-12345 (omit to disable auth)
FIREBASE_PROJECT_ID=
# Service account key as a single-line JSON string. Get from: Firebase Console → Project Settings → Service Accounts → Generate new private key
FIREBASE_SERVICE_ACCOUNT_JSON=
# Sentry error tracking DSN (optional; leave empty to disable)
SENTRY_DSN=
2 changes: 2 additions & 0 deletions backend/docs/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,5 @@ The `docs` agent reads this index first to locate the right file before diving i
| 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` |
| Firebase Auth (token verification, middleware, MeHandler) | [auth.md](auth.md) | `internal/usecase/auth_usecase.go`, `internal/transport/middleware/auth.go`, `internal/transport/handlers/auth_handler.go`, `pkg/firebase/admin.go`, `internal/bootstrap/bootstrap.go` |
| Observability (Sentry error tracking) | [observability.md](observability.md) | `internal/transport/middleware/sentry.go`, `internal/bootstrap/bootstrap.go`, `internal/transport/handlers/routes.go` |
110 changes: 110 additions & 0 deletions backend/docs/auth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
---
topic: auth
last_verified: 2026-06-15
sources:
- internal/usecase/auth_usecase.go
- internal/transport/middleware/auth.go
- internal/transport/handlers/auth_handler.go
- pkg/firebase/admin.go
- internal/bootstrap/bootstrap.go
---

# Firebase Auth

## Domain type

`FirebaseToken` is defined in `internal/usecase/auth_usecase.go` and holds the claims extracted from a verified Firebase ID token:

```go
type FirebaseToken struct {
UID string `json:"uid"`
Email string `json:"email"`
Name string `json:"name"`
PhotoURL string `json:"photoUrl"`
Claims map[string]any `json:"claims"`
}
```

`UID`, `Email`, `Name`, and `PhotoURL` are promoted from the raw Firebase JWT claims (`uid`, `email`, `name`, `picture`). `Claims` contains the full unmodified payload.

## Usecase interfaces

Both interfaces live in `internal/usecase/auth_usecase.go`.

```go
// FirebaseTokenVerifier can verify a raw Firebase ID token string.
type FirebaseTokenVerifier interface {
VerifyIDToken(ctx context.Context, idToken string) (*FirebaseToken, error)
}

// FirebaseAdminClient extends FirebaseTokenVerifier with user-management operations.
type FirebaseAdminClient interface {
FirebaseTokenVerifier
GetUserByEmail(ctx context.Context, email string) (string, error)
UpdateUserPassword(ctx context.Context, uid, newPassword string) error
}
```

`FirebaseTokenVerifier` is the narrow interface used by the `FirebaseAuth` middleware. `FirebaseAdminClient` is the broader interface stored on `bootstrap.App` and wired into the server.

## pkg/firebase/admin.go

`NewAuthClient` initialises the Firebase Admin SDK and returns a `usecase.FirebaseAdminClient`:

```go
func NewAuthClient(ctx context.Context, projectID, credentialsJSON string) (usecase.FirebaseAdminClient, error)
```

- When `credentialsJSON` is non-empty the SDK is initialised with `option.WithCredentialsJSON` (service account key).
- When `credentialsJSON` is empty the SDK falls back to Application Default Credentials (ADC) — used on GCP.

The returned value is `*authClientAdapter`, a private type that wraps `*auth.Client` from the Firebase Admin SDK. This adapter satisfies both `FirebaseTokenVerifier` and `FirebaseAdminClient` without leaking the SDK type into the application layer.

## FirebaseAuth middleware

Defined in `internal/transport/middleware/auth.go`.

```go
const FirebaseClaimsKey = "firebase_claims"

func FirebaseAuth(verifier usecase.FirebaseTokenVerifier) gin.HandlerFunc
```

For every request the middleware:
1. Reads the `Authorization` header; aborts with `401` if the header is missing or does not start with `Bearer `.
2. Calls `verifier.VerifyIDToken(ctx, idToken)`.
3. On success: stores `*usecase.FirebaseToken` in the Gin context under `FirebaseClaimsKey` and calls `c.Next()`.
4. On error: aborts with `401` and `{"error": "invalid or expired token"}`.

Retrieve claims inside a handler:
```go
val, _ := c.Get(middleware.FirebaseClaimsKey)
token, ok := val.(*usecase.FirebaseToken)
```

## MeHandler (GET /api/v1/me)

Defined in `internal/transport/handlers/auth_handler.go`.

```go
func (h *Handler) MeHandler(c *gin.Context)
```

- Reads `*usecase.FirebaseToken` from the Gin context (`FirebaseClaimsKey`).
- Returns `200 OK` with the token struct serialised as JSON.
- Returns `401 Unauthorized` with `{"error": "unauthorized"}` if the context value is missing or of the wrong type (should not happen when `FirebaseAuth` is applied to the group).

The handler is registered on the `/api/v1` group in `RegisterRoutes`:
```go
api := r.Group("/api/v1")
if verifier != nil {
api.Use(middleware.FirebaseAuth(verifier))
}
api.GET("/me", h.MeHandler)
```

## Disabling auth in development

When `FIREBASE_PROJECT_ID` is not set `bootstrap.Run` skips Firebase initialisation and `app.Firebase` is `nil`. `server.NewServer` passes `app.Firebase` directly to `RegisterRoutes` as the `verifier` argument. When `verifier` is `nil` the `if verifier != nil` guard in `RegisterRoutes` skips `api.Use(middleware.FirebaseAuth(...))`, so `/api/v1/me` is reachable without a token.

To enable auth locally set both `FIREBASE_PROJECT_ID` and `FIREBASE_SERVICE_ACCOUNT_JSON` in `backend/.env`.
30 changes: 20 additions & 10 deletions backend/docs/bootstrap.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
topic: bootstrap
last_verified: 2026-06-14
last_verified: 2026-06-15
sources:
- internal/bootstrap/bootstrap.go
- internal/server/server.go
Expand All @@ -15,22 +15,30 @@ sources:
## App struct
```go
type App struct {
DB *sql.DB
Config Config
Log *slog.Logger
DB *sql.DB
Cache usecase.CacheService // nil when REDIS_URL is not set
Firebase usecase.FirebaseAdminClient // nil when FIREBASE_PROJECT_ID is not set
Config Config
Log *slog.Logger
}
```
`App` is constructed once by `Run` and passed to `server.NewServer`. Nothing re-initialises dependencies after this point.
`App` is constructed once by `Run` and passed to `server.NewServer`. Nothing re-initialises dependencies after this point. Optional fields (`Cache`, `Firebase`) are nil when their corresponding env vars are absent.

## Config struct
```go
type Config struct {
Port int
AppEnv string
DB postgres.DBConfig
Port int
Env string
DB postgres.DBConfig
RedisURL string
RateLimitRPS float64
RateLimitBurst int
FirebaseProjectID string
FirebaseServiceAccountJSON string
Comment thread
coderabbitai[bot] marked this conversation as resolved.
SentryDSN string
}
```
`loadConfig()` reads all values from environment variables. `PORT` defaults to `8080`; `BLUEPRINT_DB_SCHEMA` defaults to `public`; `BLUEPRINT_DB_SSLMODE` defaults to `disable`.
`loadConfig()` reads all values from environment variables. `PORT` defaults to `8080`; `BLUEPRINT_DB_SCHEMA` defaults to `public`; `BLUEPRINT_DB_SSLMODE` defaults to `disable`. `RateLimitBurst` is derived as `int(RPS)*5` when omitted and RPS is set. Optional fields (`RedisURL`, `FirebaseProjectID`, `FirebaseServiceAccountJSON`) default to empty string — their respective services are skipped when empty.

## Run sequence
`bootstrap.Run(ctx)` executes these steps in order:
Expand All @@ -39,7 +47,9 @@ type Config struct {
2. Validate required fields via `validateConfig()` — fast, no I/O
3. Open `*sql.DB` via `postgres.NewPostgresDB(cfg.DB)`
4. Probe Postgres with `probeWithRetry` under a 60-second total timeout
5. Return `*App` on success; return a non-nil error on any failure
5. Init Redis via `redis.New(cfg.RedisURL)` and probe it — skipped when `REDIS_URL` is empty
6. Init Firebase Admin SDK via `firebase.NewAuthClient(ctx, projectID, credentialsJSON)` — skipped when `FIREBASE_PROJECT_ID` is empty
7. Return `*App` on success; return a non-nil error on any failure

```go
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
Expand Down
3 changes: 3 additions & 0 deletions backend/docs/environment.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ sources:
- .env
- internal/bootstrap/bootstrap.go
- internal/infrastructure/database/postgres/db.go
- pkg/firebase/admin.go
---

# Environment Variables
Expand All @@ -31,6 +32,8 @@ This runs on package init before any env var is read — no explicit `godotenv.L
| `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. |
| `FIREBASE_PROJECT_ID` | `bootstrap.go`, `pkg/firebase/admin.go` | — | Firebase project ID. When omitted the Firebase Admin client is not initialised and `FirebaseAuth` middleware is skipped (auth disabled). |
| `FIREBASE_SERVICE_ACCOUNT_JSON` | `bootstrap.go`, `pkg/firebase/admin.go` | — | Raw JSON content of a Firebase service account key file. When omitted the SDK falls back to Application Default Credentials (ADC) — appropriate for GCP-hosted deployments. Only relevant when `FIREBASE_PROJECT_ID` is set. |

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

Expand Down
37 changes: 37 additions & 0 deletions backend/docs/middleware.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ last_verified: 2026-06-15
sources:
- internal/transport/middleware/logger.go
- internal/transport/middleware/ratelimit.go
- internal/transport/middleware/auth.go
- internal/transport/handlers/routes.go
---

Expand All @@ -20,6 +21,18 @@ r.Use(gin.Recovery(), middleware.Logger())
r.Use(middleware.RateLimit(rps, burst))
// 3. CORS
r.Use(cors.New(...))

// Global routes (no auth):
r.GET("/", h.HelloWorldHandler)
r.GET("/health", h.HealthHandler)
r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))

// Protected group — FirebaseAuth applied when verifier != nil:
api := r.Group("/api/v1")
if verifier != nil {
api.Use(middleware.FirebaseAuth(verifier))
}
api.GET("/me", h.MeHandler)
```

## Logger
Expand All @@ -36,6 +49,30 @@ In debug mode (`ENV` not set to `staging`/`production`) Gin's built-in colorful
- **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)).

## FirebaseAuth

`FirebaseAuth(verifier usecase.FirebaseTokenVerifier) gin.HandlerFunc` validates a Firebase ID token on every request to the routes it guards.

```go
const FirebaseClaimsKey = "firebase_claims"

func FirebaseAuth(verifier usecase.FirebaseTokenVerifier) gin.HandlerFunc
```

Behaviour:
- Expects `Authorization: Bearer <firebase-id-token>` header.
- Calls `verifier.VerifyIDToken(ctx, idToken)` — the concrete implementation is `pkg/firebase.authClientAdapter`.
- On success: stores `*usecase.FirebaseToken` in the Gin context under `FirebaseClaimsKey` and calls `c.Next()`.
- On failure (missing header, malformed header, or token verification error): aborts with `401 Unauthorized` and a JSON body `{"error": "..."}`.

Retrieve verified claims inside a handler:
```go
val, _ := c.Get(middleware.FirebaseClaimsKey)
token, ok := val.(*usecase.FirebaseToken)
```

Pass `nil` as the `verifier` to `RegisterRoutes` to skip Firebase auth entirely (development without credentials).

## Adding new middleware

1. Create `internal/transport/middleware/<name>.go` with a function returning `gin.HandlerFunc`.
Expand Down
72 changes: 72 additions & 0 deletions backend/docs/observability.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
---
topic: observability
last_verified: 2026-06-15
sources:
- internal/transport/middleware/sentry.go
- internal/bootstrap/bootstrap.go
- internal/transport/handlers/routes.go
---

# Observability

## Sentry SDK

| Package | Version |
|---|---|
| `github.com/getsentry/sentry-go` | v0.46.2 |
| `github.com/getsentry/sentry-go/gin` | v0.46.2 |

## SentryMiddleware

`internal/transport/middleware/sentry.go` exports a single function:

```go
func SentryMiddleware(dsn string) gin.HandlerFunc
```

Behavior:
- When `dsn` is empty, returns `func(c *gin.Context) { c.Next() }` — a no-op that adds no overhead.
- When `dsn` is non-empty, calls `sentry.Init(sentry.ClientOptions{Dsn: dsn})` and returns `sentrygin.New(sentrygin.Options{Repanic: true})`.
- `Repanic: true` means the middleware re-panics after capturing, allowing Gin's `Recovery()` to handle the panic response normally.

## Middleware registration order

`RegisterRoutes` in `internal/transport/handlers/routes.go` registers middleware in this order:

1. `SentryMiddleware(sentryDSN)` — first, so it wraps all subsequent handlers
2. `gin.Recovery()` + `gin.Logger()` (debug) or `gin.Recovery()` + `middleware.Logger()` (non-debug)
3. `middleware.RateLimit(rps, burst)`
4. CORS

`RegisterRoutes` signature:

```go
func (h *Handler) RegisterRoutes(rps float64, burst int, verifier usecase.FirebaseTokenVerifier, sentryDSN string) http.Handler
```

The `sentryDSN` parameter is forwarded directly from `Config.SentryDSN`.

## Environment variable

| Variable | Required | Default |
|---|---|---|
| `SENTRY_DSN` | No | `""` (Sentry disabled) |

Loaded in `loadConfig()` in `internal/bootstrap/bootstrap.go`:

```go
SentryDSN: os.Getenv("SENTRY_DSN"),
```

Stored on `Config.SentryDSN`. Not validated — an empty value disables Sentry without error.

## Supplying the DSN

**Local development** — add to `backend/.env`:
```dotenv
SENTRY_DSN=https://<key>@o<org>.ingest.sentry.io/<project>
```

**Production** — set `SENTRY_DSN` as an environment variable in your deployment platform. The app reads it at startup via `godotenv/autoload` (dev) or the process environment (production).

Leave `SENTRY_DSN` empty (or omit it) to run without Sentry. The app starts and serves normally in both cases.
Loading
Loading