Imported from yusufkecer/body-metrics-backend (
AGENTS.md). Install upstream withnpx skills add yusufkecer/body-metrics-backend. Copyright stays with the author.
AGENTS.md — BodyMetrics Backend
This file is auto-read by Claude Code at session start. It provides AI agents with everything needed to understand, navigate, and contribute to this repository.
Repository / Depo
Language Note / Dil Notu
- EN: Keep technical definitions in English; add concise Turkish equivalents for user-facing explanations when useful.
- TR: Teknik terimleri İngilizce koru; kullanıcıya dönük açıklamalarda gerektiğinde kısa Türkçe karşılık ekle.
1) Project Goal
REST API backend for the BodyMetrics Flutter app. Written in Go, uses MySQL for persistent storage, deployed via Docker. Provides:
- JWT-based authentication (register/login)
- Password reset via 6-digit OTP over Resend HTTP API
- API key validation for app-level security
- Rate limiting + security headers + CORS
- User profile CRUD
- Health metrics (weight, BMI) storage and retrieval
2) Architecture
Stack: Go 1.23 + gorilla/mux + MySQL 8.0 + JWT (HS256) + Docker
body-metrics-backend/
├── cmd/
│ └── server/
│ └── main.go # Entry point: config → DB → migrations → repos → handlers → router → serve
├── internal/
│ ├── config/
│ │ └── config.go # Environment variable loading (DB, JWT, Resend, CORS)
│ ├── db/
│ │ ├── mysql.go # Connection pool (25 open, 5 idle, 5min lifetime)
│ │ └── migration.go # Versioned, transactional migrations
│ ├── domain/
│ │ ├── user.go # User struct
│ │ ├── metric.go # UserMetric struct
│ │ ├── auth.go # TokenRequest / TokenResponse DTOs
│ │ └── password_reset.go # ForgotPasswordRequest, ResetPasswordRequest, PasswordResetToken
│ ├── handler/
│ │ ├── auth_handler.go # register, login, forgot-password, reset-password
│ │ ├── user_handler.go # POST/GET/PATCH /users
│ │ ├── metric_handler.go # POST/GET /users/{id}/metrics
│ │ └── response.go # writeJSON, writeError helpers
│ ├── middleware/
│ │ ├── auth.go # JWT validation middleware + token generation
│ │ ├── apikey.go # API key validation middleware (X-API-Key header)
│ │ ├── ratelimit.go # Sliding window rate limiter (sync.Map, zero deps)
│ │ └── security.go # SecurityHeaders + CORSMiddleware
│ ├── repository/
│ │ ├── account_repo.go # Account CRUD (email + password_hash + UpdatePassword)
│ │ ├── user_repo.go # User CRUD (Create, GetByID, GetAll, Update)
│ │ ├── metric_repo.go # Metric CRUD (Create, GetByUserID)
│ │ └── reset_token_repo.go # PasswordResetToken CRUD
│ └── service/
│ └── email_service.go # Resend HTTP API email sender
├── Dockerfile # Multi-stage: golang:1.23-alpine → alpine:3.20
├── docker-compose.yml # MySQL + API + PhpMyAdmin
├── .env / .env.example # Environment configuration
├── go.mod / go.sum
└── README.md
Layers:
config/→ Environment variable loadingdb/→ Connection pool + migrationsdomain/→ Pure data structures (no logic)repository/→ SQL queries (prepared statements only)service/→ External integrations (email)handler/→ HTTP request/response handlingmiddleware/→ Cross-cutting concerns (auth, API key, rate limit, security, CORS)
3) API Endpoints
Base Path: /api/v1
Public (No Auth Required, API Key Required)
| Method | Path | Handler | Rate Limit | Description |
|---|---|---|---|---|
| POST | /auth/register |
AuthHandler.Register |
— | Register with email + password → returns JWT |
| POST | /auth/login |
AuthHandler.Login |
5 req / 15 min / IP | Login with email + password → returns JWT |
| POST | /auth/forgot-password |
AuthHandler.ForgotPassword |
3 req / 60 min / IP | Send 6-digit OTP to email (always 200, anti-enumeration) |
| POST | /auth/reset-password |
AuthHandler.ResetPassword |
— | Verify OTP + set new password |
Protected (JWT + API Key Required)
| Method | Path | Handler | Description |
|---|---|---|---|
| POST | /users |
UserHandler.Create |
Create user profile for authenticated account |
| GET | /users |
UserHandler.GetAll |
List users for authenticated account |
| GET | /users/{id} |
UserHandler.GetByID |
Get user by ID (must belong to authenticated account) |
| PATCH | /users/{id} |
UserHandler.Update |
Partial update (must belong to authenticated account) |
| POST | /users/{id}/metrics |
MetricHandler.Create |
Add metric (user must belong to authenticated account) |
| GET | /users/{id}/metrics |
MetricHandler.GetByUserID |
List metrics (user must belong to authenticated account) |
Response Format
// Success
{"id": 1, "name": "John", ...}
// Error
{"error": "error message here"}
4) Authentication & Security
Global Middleware Chain
Request → CORSMiddleware → SecurityHeaders → MaxBytesReader(1MB) → APIKeyMiddleware → ...
Security Headers (every response)
X-Content-Type-Options: nosniffX-Frame-Options: DENYX-XSS-Protection: 1; mode=blockReferrer-Policy: strict-origin-when-cross-originStrict-Transport-Security: max-age=31536000; includeSubDomains
Rate Limiting
- Sliding window, in-memory (
sync.Map), zero external dependencies - Login: 5 req / 15 min per IP
- Forgot-password: 3 req / 60 min per IP
X-Forwarded-Forheader respected (behind proxy/Railway)
Two-Layer Auth
Layer 1 — API Key (App-Level)
- Header:
X-API-Key: <key> - Applied to ALL routes (public + protected)
- Stored in
API_KEYenv; if empty, middleware is skipped (dev mode)
Layer 2 — JWT Token (User-Level)
- Header:
Authorization: Bearer <token> - Applied to protected routes only
- Algorithm: HS256
- Expiration: none (no
expclaim) - Claims:
account_id,email,iat - Secret stored in
JWT_SECRETenvironment variable
Password Security
- Algorithm: bcrypt (default cost)
- Minimum length: 6 characters
- Stored as hash in
accounts.password_hash
Password Reset Flow
- Client sends
POST /auth/forgot-passwordwith{"email": "..."} - Server always returns
200 OK(anti-enumeration) - In background goroutine: find account → delete old tokens → generate 6-digit OTP (
crypto/rand) → save with 15-min expiry → send email via Resend HTTP API - Client sends
POST /auth/reset-passwordwith{"email", "token", "password"} - Server validates token (unused + not expired + correct email JOIN) → bcrypt new password → update account → mark token used
5) Database Schema
Database: MySQL 8.0 (bodymetrics)
schema_migrations (Migration Tracking)
CREATE TABLE schema_migrations (
version VARCHAR(255) PRIMARY KEY,
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
accounts (Authentication)
CREATE TABLE accounts (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
)
users (Profiles)
CREATE TABLE users (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
account_id BIGINT UNSIGNED UNIQUE,
name VARCHAR(100),
surname VARCHAR(100),
gender TINYINT, -- 0=male, 1=female
avatar VARCHAR(50), -- pr1, pr2, etc.
height INT, -- cm
birth_of_date VARCHAR(20),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE
)
user_metrics (Health Metrics)
CREATE TABLE user_metrics (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT UNSIGNED NOT NULL,
date VARCHAR(20) NOT NULL, -- dd-MM-yyyy (legacy display)
weight DOUBLE, -- kg
height INT NOT NULL, -- cm
bmi DOUBLE NOT NULL,
weight_diff DOUBLE, -- delta from previous
body_metric VARCHAR(30), -- BMI category enum name
created_at VARCHAR(30), -- ISO8601 (canonical timestamp)
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
Read order: ORDER BY created_at ASC, id ASC
password_reset_tokens (Password Reset OTPs)
CREATE TABLE password_reset_tokens (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
account_id BIGINT UNSIGNED NOT NULL,
token VARCHAR(6) NOT NULL, -- 6-digit OTP (crypto/rand)
expires_at DATETIME NOT NULL, -- 15 minutes from creation
used TINYINT(1) DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE
)
6) Migration System
File: internal/db/migration.go
- Versioned migrations in a Go slice
- Each migration runs in a transaction
- Automatic rollback on failure
schema_migrationstable tracks applied versions- Migrations run on every server start (idempotent)
Adding a new migration:
- Add a new struct to the
migrationsslice inmigration.go - Use a sequential version prefix:
003_description,004_description, etc. - Include both
upSQL and a descriptive version name - Migrations must be idempotent where possible
7) Environment Configuration
File: .env (loaded by docker-compose and config.go)
| Variable | Default | Description |
|---|---|---|
DB_HOST |
localhost |
MySQL host |
DB_PORT |
3306 |
MySQL port |
DB_USER |
bodymetrics |
MySQL user |
DB_PASSWORD |
bodymetrics_pass |
MySQL password |
DB_NAME |
bodymetrics |
Database name |
MYSQL_ROOT_PASSWORD |
— | MySQL root password (docker) |
MYSQL_PORT |
3306 |
Exposed MySQL port (docker) |
PHPMYADMIN_PORT |
8081 |
PhpMyAdmin port (docker) |
JWT_SECRET |
change-me-in-production |
JWT signing secret |
API_KEY |
— | API key for app-level auth (empty = disabled) |
PORT |
8080 |
API server port |
RESEND_API_KEY |
— | Resend API key for email sending |
EMAIL_FROM |
BodyMetrics <noreply@send.bodymetrics.life> |
Sender name + address |
ALLOWED_ORIGINS |
* |
CORS allowed origins (comma-separated or *) |
8) Production Infrastructure
Deployment Platform
Railway — auto-deploys on push to main branch via Dockerfile.
Production URL
https://api.bodymetrics.life/api/v1
Domain & DNS (Namecheap)
| Type | Host | Value | Purpose |
|---|---|---|---|
| CNAME | api |
<railway-service>.railway.app |
API custom domain → Railway |
| TXT | @ |
v=spf1 include:resend.dev ~all |
Email SPF record |
| TXT | resend._domainkey |
p=... (from Resend dashboard) |
Email DKIM record |
| TXT | _dmarc |
v=DMARC1; p=none; |
Email DMARC record |
- Custom domain registered in Railway: Settings → Networking → Custom Domain →
api.bodymetrics.life - SSL certificate is managed automatically by Railway
Email (Resend)
- Domain
bodymetrics.lifeis added and verified in Resend (resend.com → Domains) - Forgot-password OTP emails are sent from
noreply@send.bodymetrics.life - SPF + DKIM + DMARC records are active in Namecheap DNS
Railway Environment Variables
RESEND_API_KEY=re_...
EMAIL_FROM=BodyMetrics <noreply@send.bodymetrics.life>
JWT_SECRET=<strong-secret>
API_KEY=<app-level-key>
ALLOWED_ORIGINS=*
9) Docker Setup
Services:
- mysql — MySQL 8.0 with health check, persistent volume
- api — Go binary (multi-stage alpine build), depends on MySQL health
- phpmyadmin — Database admin UI on port 8081
Commands:
# Start all services
docker compose up -d
# Rebuild after code changes
docker compose up -d --build api
# View logs
docker compose logs -f api
# Stop
docker compose down
# Reset database
docker compose down -v
10) Development Guidelines
Code Conventions
- Layered architecture: domain → repository → handler → middleware
- No business logic in handlers — handlers only parse requests, call repos, write responses
- Prepared statements only — never string-concatenate SQL
- All fields nullable (pointer types in Go) where column allows NULL
- Repository methods return
(result, error)pairs - Handler errors use
writeError(w, status, message)helper - Domain structs have JSON tags matching Flutter model field names
Adding a New Endpoint
- Add domain struct to
internal/domain/if needed - Add repository method to
internal/repository/ - Add handler method to
internal/handler/ - Register route in
cmd/server/main.go - Add migration if new table/column needed
Adding a New Migration
- Open
internal/db/migration.go - Add new entry to
migrationsslice with next version number - Write SQL in the
upfield - Restart server — migration runs automatically
Security Checklist
- Never commit real secrets to
.env(use.env.examplefor templates) - Always use parameterized queries
- Validate input lengths and types in handlers
- Use bcrypt for password hashing (never plain text)
- Keep
JWT_SECRETandAPI_KEYstrong in production - Password reset endpoint always returns 200 (anti-enumeration) — never reveal if email exists
- OTP generation must use
crypto/rand, nevermath/rand
11) Quick Debug Guide
"API returns 401 Unauthorized"
- Check
Authorization: Bearer <token>header is present - Verify token signature is valid and token was signed with current
JWT_SECRET - Check
JWT_SECRETmatches between token generation and validation
"API returns 403 Forbidden"
- Check
X-API-Keyheader is present and matchesAPI_KEYenv variable - If
API_KEYenv is empty, middleware is disabled (dev mode)
"Database connection failed"
- Verify MySQL is running:
docker compose ps - Check
.envcredentials match docker-compose environment - Ensure
DB_HOST=mysql(docker) orDB_HOST=localhost(local)
"Migration failed"
- Check
schema_migrationstable for applied versions - Verify SQL syntax in the failing migration
- Check if table/column already exists (idempotency)
"Flutter app can't connect"
- Android emulator: use
10.0.2.2(notlocalhost) - iOS simulator: use
localhostor127.0.0.1 - Physical device: use machine's LAN IP
- Check firewall allows port 8080
"Forgot password email not arriving"
- Verify
RESEND_API_KEYis set correctly in Railway environment - Check server logs for
[forgot-password] email errorlines bodymetrics.lifedomain is verified in Resend —EMAIL_FROM=BodyMetrics <noreply@send.bodymetrics.life>- Verify SPF/DKIM DNS records are still active in Namecheap (resend.com → Domains →
bodymetrics.life)
"429 Too Many Requests"
- Rate limit hit: login (5/15min) or forgot-password (3/hr) per IP
- Behind a proxy? Check
X-Forwarded-Foris being forwarded correctly - Wait for the window to expire, or restart server (in-memory, resets on restart)
12) Dependency Reference
| Package | Version | Usage |
|---|---|---|
| gorilla/mux | 1.8.1 | HTTP router |
| golang-jwt/jwt | 5.2.1 | JWT auth (HS256) |
| go-sql-driver/mysql | 1.8.1 | MySQL driver |
| golang.org/x/crypto | 0.41.0 | bcrypt password hashing |
| net/http | stdlib | Resend HTTP API email sending |
| crypto/rand | stdlib | Secure OTP generation |
| sync | stdlib | Rate limiter (sync.Map) |
