# AGENTS.md - Developer Guide for oslstats

This document provides guidelines for AI coding agents and developers working on the oslstats codebase.

## Project Overview

**Module**: `git.haelnorr.com/h/oslstats`  
**Language**: Go 1.25.5  
**Architecture**: Web application with Discord OAuth, PostgreSQL database, templ templates  
**Key Technologies**: Bun ORM, templ, TailwindCSS, custom golib libraries

## Build, Test, and Development Commands

### Building
```bash
# Full production build (tailwind → templ → go generate → go build)
make build

# Build and run
make run

# Clean build artifacts
make clean
```

### Development Mode
```bash
# Watch mode with hot reload (templ, air, tailwindcss in parallel)
make dev

# Development server runs on:
# - Proxy: http://localhost:3000 (use this)
# - App: http://localhost:3333 (internal)
```

### Testing
```bash
# Run all tests
go test ./...

# Run tests for a specific package
go test ./pkg/oauth

# Run a single test function
go test ./pkg/oauth -run TestGenerateState_Success

# Run tests with verbose output
go test -v ./pkg/oauth

# Run tests with coverage
go test -cover ./...
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out
```

### Database Migrations

**oslstats uses Bun's migration framework for safe, incremental schema changes.**

#### Quick Reference

```bash
# Show migration status
make migrate-status

# Preview pending migrations (dry-run)
make migrate-dry-run

# Run pending migrations (with automatic backup)
make migrate

# Rollback last migration group
make migrate-rollback

# Create new migration
make migrate-create NAME=add_email_to_users

# Dev: Run migrations without backup (faster)
make migrate-no-backup

# Dev: Reset database (DESTRUCTIVE - deletes all data)
make reset-db
```

#### Creating a New Migration

**Example: Adding an email field to users table**

1. **Generate migration file:**
   ```bash
   make migrate-create NAME=add_email_to_users
   ```
   Creates: `cmd/oslstats/migrations/20250124150030_add_email_to_users.go`

2. **Edit the migration file:**
   ```go
   package migrations
   
   import (
       "context"
       "github.com/uptrace/bun"
   )
   
   func init() {
       Migrations.MustRegister(
           // UP: Add email column
           func(ctx context.Context, db *bun.DB) error {
               _, err := db.ExecContext(ctx, 
                   "ALTER TABLE users ADD COLUMN email VARCHAR(255)")
               return err
           },
           // DOWN: Remove email column (for rollback)
           func(ctx context.Context, db *bun.DB) error {
               _, err := db.ExecContext(ctx, 
                   "ALTER TABLE users DROP COLUMN email")
               return err
           },
       )
   }
   ```

3. **Update the model** (`internal/db/user.go`):
   ```go
   type User struct {
       bun.BaseModel `bun:"table:users,alias:u"`
       
       ID        int    `bun:"id,pk,autoincrement"`
       Username  string `bun:"username,unique"`
       Email     string `bun:"email"`          // NEW FIELD
       CreatedAt int64  `bun:"created_at"`
       DiscordID string `bun:"discord_id,unique"`
   }
   ```

4. **Preview the migration (optional):**
   ```bash
   make migrate-dry-run
   ```
   Output:
   ```
   [INFO] Pending migrations (dry-run):
     📋 20250124150030_add_email_to_users
   [INFO] Would migrate to group 2
   ```

5. **Apply the migration:**
   ```bash
   make migrate
   ```
   Output:
   ```
   [INFO] Step 1/5: Validating migrations...
   [INFO] Migration validation passed ✓
   [INFO] Step 2/5: Checking for pending migrations...
   [INFO] Step 3/5: Creating backup...
   [INFO] Backup created: backups/20250124_150145_pre_migration.sql.gz (2.3 MB)
   [INFO] Step 4/5: Acquiring migration lock...
   [INFO] Migration lock acquired
   [INFO] Step 5/5: Applying migrations...
   [INFO] Migrated to group 2
     ✅ 20250124150030_add_email_to_users
   [INFO] Migration lock released
   ```

#### Migration Patterns

**Create Table:**
```go
// UP
_, err := db.NewCreateTable().
    Model((*db.Game)(nil)).
    Exec(ctx)
return err

// DOWN
_, err := db.NewDropTable().
    Model((*db.Game)(nil)).
    IfExists().
    Exec(ctx)
return err
```

**Add Column:**
```go
// UP
_, err := db.ExecContext(ctx, 
    "ALTER TABLE users ADD COLUMN email VARCHAR(255)")
return err

// DOWN
_, err := db.ExecContext(ctx, 
    "ALTER TABLE users DROP COLUMN email")
return err
```

**Create Index:**
```go
// UP
_, err := db.NewCreateIndex().
    Model((*db.User)(nil)).
    Index("idx_username").
    Column("username").
    Exec(ctx)
return err

// DOWN
_, err := db.ExecContext(ctx, 
    "DROP INDEX IF EXISTS idx_username")
return err
```

**Data Migration:**
```go
// UP
_, err := db.NewUpdate().
    Model((*db.User)(nil)).
    Set("status = ?", "active").
    Where("status IS NULL").
    Exec(ctx)
return err

// DOWN (if reversible)
_, err := db.NewUpdate().
    Model((*db.User)(nil)).
    Set("status = NULL").
    Where("status = ?", "active").
    Exec(ctx)
return err
```

#### Safety Features

✅ **Automatic Backups**
- Compressed with gzip (saves ~80% space)
- Created before every migration and rollback
- Stored in `backups/` directory
- Retention policy keeps last 10 backups
- Graceful fallback if `pg_dump` not installed

✅ **Migration Locking**
- PostgreSQL advisory locks prevent concurrent migrations
- 5-minute timeout prevents hung locks
- Safe for multi-instance deployments

✅ **Validation**
- Pre-migration `go build` check ensures migrations compile
- Dry-run mode previews changes without applying
- Status command shows exactly what's applied

✅ **Rollback Support**
- Every migration must have a DOWN function
- Rollbacks are grouped (undo last batch together)
- Automatic backup before rollback

#### Environment Variables

```bash
# Backup directory (default: backups)
DB_BACKUP_DIR=backups

# Number of backups to keep (default: 10)
DB_BACKUP_RETENTION=10
```

#### Troubleshooting

**"pg_dump not found"**
- Migrations will still run, but backups will be skipped
- Install PostgreSQL client tools for backups:
  ```bash
  # Ubuntu/Debian
  sudo apt-get install postgresql-client
  
  # macOS
  brew install postgresql
  
  # Arch
  sudo pacman -S postgresql-libs
  ```

**"migration already in progress"**
- Another instance is running migrations
- Wait for it to complete (max 5 minutes)
- If stuck, check for hung database connections

**"migration build failed"**
- Migration file has syntax errors
- Fix the errors and try again
- Use `go build ./cmd/oslstats/migrations` to debug

#### Best Practices

1. **Always test in development first**
   - Use `make migrate-dry-run` to preview
   - Test rollback: `make migrate-rollback`
   - Verify with `make migrate-status`

2. **Write reversible migrations**
   - DOWN function should undo UP changes
   - Test rollbacks work correctly
   - Some operations (data deletion) may not be reversible

3. **Keep migrations focused**
   - One migration = one logical change
   - Don't mix schema changes with data migrations
   - Use descriptive names

4. **Production checklist**
   - ✅ Tested in development
   - ✅ Tested rollback
   - ✅ Verified backup works
   - ✅ Communicated downtime (if any)
   - ✅ Have rollback plan ready

5. **Development workflow**
   - Use `--no-backup` for speed: `make migrate-no-backup`
   - Use `--reset-db` to start fresh (loses data!)
   - Commit migrations to version control

### Configuration Management
```bash
# Generate .env template file
make genenv
# OR with custom output: make genenv OUT=.env.example

# Show environment variable documentation
make envdoc

# Show current environment values
make showenv
```

## Code Style Guidelines

### Import Organization
Organize imports in **3 groups** separated by blank lines:

```go
import (
    // 1. Standard library
    "context"
    "net/http"
    "fmt"
    
    // 2. External dependencies
    "git.haelnorr.com/h/golib/hws"
    "github.com/pkg/errors"
    "github.com/uptrace/bun"
    
    // 3. Internal packages
    "git.haelnorr.com/h/oslstats/internal/config"
    "git.haelnorr.com/h/oslstats/pkg/oauth"
)
```

### Naming Conventions

**Variables**:
- Local: `camelCase` (userAgentKey, httpServer, dbConn)
- Exported: `PascalCase` (Config, User, Token)
- Common abbreviations: `cfg`, `ctx`, `tx`, `db`, `err`, `w`, `r`

**Functions**:
- Exported: `PascalCase` (GetConfig, NewStore, GenerateState)
- Private: `camelCase` (throwError, shouldShowDetails, loadModels)
- HTTP handlers: Return `http.Handler`, use dependency injection pattern
- Database functions: Use `bun.Tx` as parameter for transactions

**Types**:
- Structs/Interfaces: `PascalCase` (Config, User, OAuthSession)
- Use `-er` suffix for interfaces (implied from usage)

**Files**:
- Prefer single word: `config.go`, `oauth.go`, `errors.go`
- Don't use snake_case except for tests: `state_test.go`
- Test files: `*_test.go` alongside source files

### Error Handling

**Always wrap errors** with context using `github.com/pkg/errors`:

```go
if err != nil {
    return errors.Wrap(err, "operation_name")
}
```

**Validate inputs at function start**:
```go
func DoSomething(cfg *Config, data string) error {
    if cfg == nil {
        return errors.New("cfg cannot be nil")
    }
    if data == "" {
        return errors.New("data cannot be empty")
    }
    // ... rest of function
}
```

**HTTP error helpers** (in handlers package):
*Note: `msg` should be a frontend presentable message*
- `throwInternalServiceError(s, w, r, msg, err)` - 500 errors
- `throwBadRequest(s, w, r, msg, err)` - 400 errors
- `throwForbidden(s, w, r, msg, err)` - 403 errors (normal)
- `throwForbiddenSecurity(s, w, r, msg, err)` - 403 security violations (WARN level)
- `throwUnauthorized(s, w, r, msg, err)` - 401 errors (normal)
- `throwUnauthorizedSecurity(s, w, r, msg, err)` - 401 security violations (WARN level)
- `throwNotFound(s, w, r, path)` - 404 errors

### Common Patterns

**HTTP Handler Pattern**:
```go
func HandlerName(server *hws.Server, deps ...) http.Handler {
    return http.HandlerFunc(
        func(w http.ResponseWriter, r *http.Request) {
            // Handler logic here
        },
    )
}
```

**Database Operation Pattern**:
```go
func GetSomething(ctx context.Context, tx bun.Tx, id int) (*Result, error) {
    result := new(Result)
    err := tx.NewSelect().
        Model(result).
        Where("id = ?", id).
        Scan(ctx)
    if err != nil {
        if err.Error() == "sql: no rows in result set" {
            return nil, nil  // Return nil, nil for not found
        }
        return nil, errors.Wrap(err, "tx.Select")
    }
    return result, nil
}
```

**Setup Function Pattern** (returns instance, cleanup func, error):
```go
func setupSomething(ctx context.Context, cfg *Config) (*Type, func() error, error) {
    instance := newInstance()
    
    err := configure(instance)
    if err != nil {
        return nil, nil, errors.Wrap(err, "configure")
    }
    
    return instance, instance.Close, nil
}
```

**Configuration Pattern** (using ezconf):
```go
type Config struct {
    Field string // ENV FIELD_NAME: Description (required/default: value)
}

func ConfigFromEnv() (any, error) {
    cfg := &Config{
        Field: env.String("FIELD_NAME", "default"),
    }
    // Validation here
    return cfg, nil
}
```

### Formatting & Types

**Formatting**:
- Use `gofmt` (standard Go formatting)

**Types**:
- Prefer explicit types over inference when it improves clarity
- Use struct tags for ORM and JSON marshaling:
  ```go
  type User struct {
      bun.BaseModel `bun:"table:users,alias:u"`
      ID           int    `bun:"id,pk,autoincrement"`
      Username     string `bun:"username,unique"`
      AccessToken  string `json:"access_token"`
  }
  ```

**Comments**:
- Document exported functions and types
- Use inline comments for ENV var documentation in Config structs
- Explain security-critical code flows

### Testing

**Test File Location**: Place `*_test.go` files alongside source files

**Test Naming**:
```go
func TestFunctionName_Scenario(t *testing.T)
func TestGenerateState_Success(t *testing.T)
func TestVerifyState_WrongUserAgentKey(t *testing.T)
```

**Test Structure**:
- Use subtests with `t.Run()` for related scenarios
- Use table-driven tests for multiple similar cases
- Create helper functions for common setup (e.g., `testConfig()`)
- Test happy paths, error cases, edge cases, and security properties

**Test Categories** (from pkg/oauth/state_test.go example):
1. Happy path tests
2. Error handling (nil params, empty fields, malformed input)
3. Security tests (MITM, CSRF, replay attacks, tampering)
4. Edge cases (concurrency, constant-time comparison)
5. Integration tests (round-trip verification)

### Security

**Critical Practices**:
- Use `crypto/subtle.ConstantTimeCompare` for cryptographic comparisons
- Implement CSRF protection via state tokens
- Store sensitive cookies as HttpOnly
- Use separate logging levels for security violations (WARN)
- Validate all inputs at function boundaries
- Use parameterized queries (Bun ORM handles this)
- Never commit secrets (.env, keys/ are gitignored)

## Project Structure

```
oslstats/
├── cmd/oslstats/          # Application entry point
│   ├── main.go            # Entry point with flag parsing
│   ├── run.go             # Server initialization & graceful shutdown
│   ├── httpserver.go      # HTTP server setup
│   ├── routes.go          # Route registration
│   ├── middleware.go      # Middleware registration
│   ├── auth.go            # Authentication setup
│   └── db.go              # Database connection & migrations
├── internal/              # Private application code
│   ├── config/            # Configuration aggregation
│   ├── db/                # Database models & queries (Bun ORM)
│   ├── discord/           # Discord OAuth integration
│   ├── handlers/          # HTTP request handlers
│   ├── session/           # Session store (in-memory)
│   └── view/              # Templ templates
│       ├── component/     # Reusable UI components
│       ├── layout/        # Page layouts
│       └── page/          # Full pages
├── pkg/                   # Reusable packages
│   ├── contexts/          # Context key definitions
│   ├── embedfs/           # Embedded static files
│   └── oauth/             # OAuth state management
├── bin/                   # Compiled binaries (gitignored)
├── keys/                  # Private keys (gitignored)
├── tmp/                   # Air hot reload temp files (gitignored)
├── Makefile               # Build automation
├── .air.toml              # Hot reload configuration
└── go.mod                 # Go module definition
```

## Key Dependencies

- **git.haelnorr.com/h/golib/*** - Custom libraries (env, ezconf, hlog, hws, hwsauth, cookies, jwt)
- **github.com/a-h/templ** - Type-safe HTML templating
- **github.com/uptrace/bun** - PostgreSQL ORM
- **github.com/bwmarrin/discordgo** - Discord API client
- **github.com/pkg/errors** - Error wrapping (use this, not fmt.Errorf)
- **github.com/joho/godotenv** - .env file loading

## Notes for AI Agents

1. **Never commit** .env files, keys/, or generated files (*_templ.go, output.css)
2. **Database operations** should use `bun.Tx` for transaction safety
3. **Templates** are written in templ, not Go html/template - run `templ generate` after changes
4. **Static files** are embedded via `//go:embed` - check pkg/embedfs/
5. **Error messages** should be descriptive and use errors.Wrap for context
6. **Security is critical** - especially in OAuth flows (see pkg/oauth/state_test.go for examples)
7. **Air proxy** runs on port 3000 during development; app runs on 3333
8. **Test coverage** is currently limited - prioritize testing security-critical code
9. **Configuration** uses ezconf pattern - see internal/*/ezconf.go files for examples
10. **Graceful shutdown** is implemented in cmd/oslstats/run.go - follow this pattern
11. When in plan mode, always use the interactive question tool if available
