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
74 changes: 61 additions & 13 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,24 +1,72 @@
# ==============================================================================
# Tusk — example environment
# Copy to .env and fill in. The server fails fast on startup if anything
# required here is missing or invalid, rather than misbehaving later.
# ==============================================================================

APP_NAME=Tusk
APP_VERSION=1.0.0
# dev | staging | production. Production changes several defaults — see below.
APP_MODE=dev
SERVER_PORT=8081
ALLOWED_ORIGINS=http://localhost:3000,http://example.com
SERVER_PORT=8080

# Comma-separated. Leave empty to allow no cross-origin browser requests.
ALLOWED_ORIGINS=http://localhost:3000

# --- Security -----------------------------------------------------------------
# REQUIRED. Minimum 32 characters — HS256 signatures are only as strong as this
# key, so a short one lets an attacker mint valid tokens for any user.
# Generate with: openssl rand -base64 48
JWT_SECRET=

# How long an access token stays valid. Refresh tokens last 7 days.
ACCESS_TOKEN_TTL=1h

DB_DRIVER=mysql
# --- Database -----------------------------------------------------------------
# PostgreSQL is the supported database. mysql and sqlite drivers still load but
# the shipped migrations target Postgres.
DB_DRIVER=postgres
DB_HOST=localhost
DB_PORT=3306
DB_PORT=5432
DB_NAME=tusk
DB_USER=root
DB_PASS=toor
DB_USER=postgres
DB_PASS=

# disable | require | verify-ca | verify-full
# Defaults to "require" when APP_MODE=production, "disable" otherwise.
# DB_SSLMODE=require

# Connection pool
DB_MAX_IDLE_CONNS=10
DB_MAX_OPEN_CONNS=100
DB_CONN_MAX_LIFETIME=60

# --- API documentation --------------------------------------------------------
# Serves /docs and /openapi.json. Defaults to false when APP_MODE=production,
# true otherwise. API endpoints are unaffected either way — this only controls
# whether the route map and schemas are published.
# DOCS_ENABLED=false

# --- HTTP server limits -------------------------------------------------------
# Raise READ_TIMEOUT if clients legitimately send large bodies over slow links.
READ_TIMEOUT=30s
WRITE_TIMEOUT=60s
IDLE_TIMEOUT=120s
SHUTDOWN_TIMEOUT=30s
# Maximum request body, in bytes. Default 10 MiB.
MAX_REQUEST_BODY_BYTES=10485760

ACCESS_TOKEN_TTL= "3600s"
# --- Rate limiting (per client IP) --------------------------------------------
# Token bucket: BURST is the capacity, RPS the sustained refill rate.
RATE_LIMIT_BURST=30
RATE_LIMIT_RPS=10

# --- Logging ------------------------------------------------------------------
LOG_LEVEL=debug

# --- Mailer Configuration ---
MAIL_HOST=smtp.mailtrap.io # Example: smtp.gmail.com, smtp.mailtrap.io
MAIL_PORT=2525 # Example: 587 (TLS), 465 (SSL), 2525 (Mailtrap)
MAIL_USERNAME=your_username # Your SMTP username
MAIL_PASSWORD=your_password # Your SMTP password
MAIL_SENDER=no-reply@tusk.com # The "From" email address
# --- Mailer -------------------------------------------------------------------
MAIL_HOST=smtp.mailtrap.io
MAIL_PORT=2525
MAIL_USERNAME=
MAIL_PASSWORD=
MAIL_SENDER=no-reply@tusk.com
35 changes: 34 additions & 1 deletion .github/workflows/go.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,38 @@ permissions:
jobs:
build:
runs-on: ubuntu-latest

services:
# Integration tests run against a real PostgreSQL. SQLite is not used as a
# stand-in: it diverges on row-level security, partial indexes and ON
# CONFLICT semantics — precisely the behaviour worth testing.
postgres:
image: postgres:15
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: tusk_test
ports:
- 5432:5432
# Without a health check the test step can start before Postgres accepts
# connections, producing an intermittent failure that looks like a bug in
# the code rather than a race in the workflow.
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5

steps:
- name: Checkout code
uses: actions/checkout@v4

# Reading the version from go.mod keeps CI and the module in lockstep.
# A hardcoded version silently diverges the moment go.mod is bumped.
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.24.x'
go-version-file: 'go.mod'
check-latest: true

- name: Verify dependencies
Expand All @@ -28,5 +52,14 @@ jobs:
- name: Build packages
run: go build -v ./...

- name: Vet
run: go vet ./...

- name: Run tests
env:
TEST_DATABASE_URL: "host=127.0.0.1 port=5432 user=postgres password=postgres dbname=tusk_test sslmode=disable TimeZone=UTC"
# Integration tests skip when no database is reachable, which is correct
# locally but disastrous here — a green build would mean nothing was
# tested. This turns a skip into a failure.
REQUIRE_DB_TESTS: "1"
run: go test -v -race ./...
118 changes: 118 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
# Tusk

Opinionated Go backend framework for REST APIs. Huma v2 (OpenAPI 3.1) + chi + GORM + PostgreSQL.
Module path: `github.com/codetheuri/tusk`

Full guides live in [`docs/`](docs/). This file covers what you cannot infer from reading the code.

---

## Commands

```bash
make dev # hot-reload server (Air)
make run # run without hot reload
make build # production binary → ./bin/api
make test # all tests; integration tests skip without a database
make test-integration # same, but a missing database is a failure
make test-db-setup # create the local tusk_test database (one-off)
make vet # static analysis
make migrate-up # apply migrations
make migrate-down # roll back one
make auth-sync # push code-declared permissions into the DB
```

`make auth-sync` is **required after adding or renaming any permission**. Permissions are declared in code and only reach the database through this command — a new permission simply won't authorize anything until it is run.

---

## Architecture — one direction, no exceptions

```
Handler → Service → Repository
```

- **Handlers** bind and validate input, call a service, return a DTO. No business rules. No SQL. No direct DB access.
- **Services** hold business rules. They must never import `net/http` or know about status codes — they return domain errors. This is what makes them testable without a request.
- **Repositories** touch storage and nothing else. Always accept `context.Context`. No business rules.

Every dependency arrives through a constructor (`NewService(repo, cfg)`). The only global is the `authz` permission registry, and that is populated at `init()` and read-only afterwards.

If you find yourself wanting to call a repository from a handler, or check a permission inside a service, the design has drifted — say so rather than working around it.

---

## Design philosophy

Tusk should feel like **Go**, not Laravel, Spring Boot, or ASP.NET.

- No framework magic. No hidden behaviour. No reflection unless genuinely unavoidable.
- *"A little copying is better than a little dependency."* Prefer duplication over premature abstraction.
- Before introducing any abstraction, ask: **does this reduce complexity, or merely hide it?**
- Never build an abstraction for a future possibility. Build it when the second real case arrives.
- Every package has one clear responsibility. Keep exported APIs small and intentional.
- If a design becomes hard to explain, it is probably too complicated.

Prefer the standard library. Return errors, don't panic — except for unrecoverable startup failures. Never use global mutable state.

---

## Adding a module

Domains live in `internal/<domain>/`:

```
model.go GORM entities
dto.go Huma request/response shapes — kept separate from models on purpose
permissions.go permission constants + []authz.Permission + init() registration
repository_*.go storage
service_*.go business rules
handler_*.go HTTP boundary
router.go huma.Register calls, wiring, guards
```

`internal/auth` is the reference implementation — read it before writing a new module. Register the module's routes from `cmd/api/main.go`, against `application.API()` — `pkg/app` deliberately registers none itself. Then run `make auth-sync`.

Protect a route with `guard.Protected(op, PermSomething)`. Public routes simply omit it.

---

## Conventions worth stating

**Responses** are uniform: `{success, message, data}` on success, `{success, message, errors}` on failure, built through `pkg/response`. Never expose internal error text to clients — log the detail, return something a user can act on.

**Documentation** — every exported type, function, and package carries a doc comment explaining *why it exists*, not just what it does.

**Security** — validate all input, never trust client data, use prepared queries, never log secrets, bcrypt or Argon2 for passwords.

**Performance** — prefer clarity. Avoid premature optimization, unnecessary allocation, reflection, and goroutine leaks. Benchmark before optimizing.

---

## Current state

- **PostgreSQL only.** `LoadConfig` rejects any other driver at startup. Do not add dialect branching.
- **Primary keys are UUIDv7**, generated by `pkg/id`, never by the database. New models take `uuid.UUID` with `gorm:"type:uuid;primaryKey"` and a `BeforeCreate` hook that assigns **only when the ID is zero** — a client that minted its own ID offline must keep it.
- **Multi-tenancy is opt-in per model.** A model joins by implementing `tenant.TenantColumn() string`; everything else is untouched, and an application that opts nothing in behaves as though `pkg/tenant` were not installed. The scoping callbacks are registered for every connection by `database.Connect`, so a tenanted model cannot be added later and silently go unscoped. A tenanted query with no tenant in context **errors** rather than returning every row — use `tenant.Unscoped(db)` to mean it. See `docs/roadmap.md` §3.2.
- **Server-rendered pages** use `pkg/view` (layout inheritance, buffered rendering, parse-at-startup) and `pkg/session` (database-backed cookie sessions, hashed tokens, rotation). Never render straight to a `ResponseWriter`, and never store a session token unhashed. See `docs/roadmap.md` §3.3.
- **One PostgreSQL driver: pgx.** `gorm.io/driver/postgres` uses it, so config, the migrate CLI and the test harness all use it too. Do not reintroduce `lib/pq` — the two disagree on placeholder handling, and a schema built by one driver and used by another is how that surfaced.
- Production hardening work is tracked in [`docs/roadmap.md`](docs/roadmap.md). Phases 1–4 are complete; §3 lists what is planned next.
- **Integration tests need PostgreSQL.** They connect via `internal/testdb`, defaulting to `127.0.0.1:5434/tusk_test` and overridable with `TEST_DATABASE_URL`. They skip when no database is reachable, so `go test ./...` always works — but CI sets `REQUIRE_DB_TESTS=1` to turn a skip into a failure. The harness serialises database tests across packages with an advisory lock, since `go test ./...` runs packages in parallel and each one truncates the schema. Anything touching a query belongs in an integration test: GORM builds SQL at runtime, so a broken join compiles and vets cleanly.

---

## Working with me on this project

I am using Tusk to learn advanced Go architecture. When you improve my code, **do not just rewrite it**. Explain:

- why the original was less idiomatic
- why the new design is preferred, and what Go principle it follows
- whether it improves readability, simplicity, testability, or maintainability

Teaching me matters more than producing code quickly.

---

## Other agent tooling

`.agents/rules/` and `.agents/workflows/` hold the same philosophy in Windsurf's format. They are kept deliberately — this file is authoritative for Claude Code, `.agents/` for Windsurf. They overlap by design rather than through a generator, per the copying-over-dependency rule above. If you change a rule here that contradicts `.agents/`, update both.
25 changes: 22 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: dev run build test coverage vet migrate-up migrate-down migrate-status clean help
.PHONY: dev run build test test-unit test-integration test-db-setup coverage vet migrate-up migrate-down migrate-reset migrate-status auth-sync auth-sync-prune clean help

# ==============================================================================
# Development commands
Expand All @@ -18,9 +18,24 @@ build:
go build -o ./bin/api ./cmd/api/main.go
@echo "Built ./bin/api successfully!"

## test: run unit tests across all packages
## test: run all tests (integration tests skip if no database is reachable)
test:
go test -v -race ./...
go test -race ./...

## test-unit: run only tests that need no database
test-unit:
go test -race -short ./config/... ./pkg/... ./internal/middleware/...

## test-integration: run all tests and FAIL if the test database is unreachable
## Requires PostgreSQL. Override the target with TEST_DATABASE_URL.
test-integration:
REQUIRE_DB_TESTS=1 go test -race -count=1 ./...

## test-db-setup: create the local test database (one-off)
test-db-setup:
@psql "$${TEST_ADMIN_URL:-postgres://root:root@127.0.0.1:5434/postgres}" \
-c "CREATE DATABASE tusk_test" 2>/dev/null && echo "Created tusk_test" \
|| echo "tusk_test already exists (or psql is unavailable)"

## coverage: run tests and generate coverage report
coverage:
Expand All @@ -43,6 +58,10 @@ migrate-up:
migrate-down:
go run ./cmd/migrate/main.go down

## migrate-reset: revert every migration, dropping the schema
migrate-reset:
go run ./cmd/migrate/main.go reset

## migrate-status: check the status of database migrations
migrate-status:
go run ./cmd/migrate/main.go status
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ Explore the full documentation guides in the [`docs/`](docs/) directory:
- 🗄️ **[Database & Migrations](docs/database-and-migrations.md)** - GORM connectivity, seeder tools, and schema migration CLI (`cmd/migrate`).
- 🔍 **[Querying, Filtering & Pagination](docs/querying-and-pagination.md)** - Dynamic searching, sorting, field filtering, and metadata envelopes (`pkg/query`).
- 📬 **[Standardized Responses & Error Handling](docs/responses-and-errors.md)** - Uniform JSON response structure (`pkg/response`) and status code conventions.
- 🗺️ **[Roadmap & Known Gaps](docs/roadmap.md)** - Production hardening backlog, planned UUIDv7 keys, optional multi-tenancy, and server-rendered page support.

---

Expand Down
55 changes: 46 additions & 9 deletions cmd/api/main.go
Original file line number Diff line number Diff line change
@@ -1,30 +1,67 @@
// Command api is the Tusk HTTP server entrypoint.
package main

import (
"os"
"github.com/danielgtaylor/huma/v2"

"github.com/codetheuri/tusk/config"
"github.com/codetheuri/tusk/internal/app"
"github.com/codetheuri/tusk/pkg/logger"
"github.com/codetheuri/tusk/v2/config"
"github.com/codetheuri/tusk/v2/internal/auth"
"github.com/codetheuri/tusk/v2/pkg/app"
"github.com/codetheuri/tusk/v2/pkg/logger"
)

func main() {
log := logger.NewConsoleLogger()
// A bootstrap logger, because configuration must be loaded before we know
// which logger the environment wants — and a configuration failure still
// needs somewhere to be reported.
log := logger.NewTextLogger("info")

cfg, err := config.LoadConfig()
if err != nil {
log.Fatal("Failed to load configuration", err)
os.Exit(1)
}

application, err := app.New(cfg, log)
// Now that the environment is known: JSON in production, readable text
// locally.
log = logger.New(cfg.IsProduction(), cfg.LOG_LEVEL)

application, err := app.New(app.Options{
Config: cfg,
Logger: log,
Title: "Tusk Backend API",
Version: "1.0.0",
Description: "## Official Tusk Enterprise Backend API Documentation\n\nWelcome to the developer documentation for Tusk. Explore Identity, Authentication, and RBAC endpoints below.",
Contact: &huma.Contact{
Name: "API Support",
Email: "theurij113@gmail.com",
},
Tags: []*huma.Tag{
{Name: "Authentication", Description: "User registration, login, token refresh, logout, and self profile operations"},
{Name: "Users", Description: "User account management and listing"},
{Name: "Roles", Description: "Security role management (CRUD)"},
{Name: "Role Permissions", Description: "Attaching and detaching permission strings to/from roles"},
{Name: "User Roles", Description: "Assigning and revoking security roles to/from users"},
{Name: "Permissions", Description: "System permission catalog listing"},
},
TagGroups: []app.TagGroup{{
Name: "IAM",
Tags: []string{
"Authentication", "Users", "Roles",
"Role Permissions", "User Roles", "Permissions",
},
}},
})
if err != nil {
log.Fatal("Application setup failed", err)
os.Exit(1)
}

// Routes are registered here rather than inside app.New. Tusk's own auth
// module is just the first consumer of the framework, not a privileged part
// of it — a service that wants different authentication registers its own
// module in exactly this place.
auth.RegisterRoutes(application.API(), application.DB(), cfg, log)

if err := application.Run(); err != nil {
log.Fatal("Server exited with error", err)
os.Exit(1)
}
}
Loading
Loading