Skip to content

Developer Manual

This guide covers the project structure and workflow for contributors and developers working on Snackbox. Before diving in, it is worth reading the Product Requirements and Architecture Manual to understand the design constraints and component structure.

Prerequisites

The following tools are required for local development:

Tool Version Purpose
Go 1.26+ Build and test
cc system C compiler required for CGO (mattn/go-sqlite3); install gcc on Linux or Xcode CLI tools on macOS (xcode-select --install)
golangci-lint latest Go linting (make lint-go)
vacuum latest OpenAPI spec linting (make lint-openapi)
hurl 7.1.0 API integration tests (make test-api)
govulncheck latest Go vulnerability scanner (make govulncheck); install with make install-tools
gosec latest Go security scanner (make gosec); install with make install-tools
git-cliff latest Changelog generation (make changelog) - only needed when cutting a release

Go

Install Go 1.26 or later from go.dev/dl. make vet runs go mod tidy and go vet ./... automatically, so no separate tidy step is needed during normal development.

golangci-lint

Install via the official installer. The linter config is embedded in the project; run make lint to execute it.

vacuum

Install via go install:

go install github.com/daveshanley/vacuum@latest

hurl

Install version 7.1.0 from hurl.dev. Only required if you want to run the API integration tests locally (make test-api or make test). The Makefile pins HURL_VERSION ?= 7.1.0; make setup will warn if your installed version differs.

git-cliff

Install via the git-cliff installation guide. On macOS: brew install git-cliff. Only required when cutting a release.

Verifying your setup

After installing all tools, run:

make setup

This checks that go, cc, golangci-lint, hurl, vacuum, govulncheck, and gosec are all present and prints their installed versions. It exits non-zero with install guidance if any required tool is missing. git-cliff is checked but does not cause a hard failure - it is only needed when cutting a release.

Project Structure

For a full description of the component layout and design decisions, see the Architecture Manual.

Building and Testing

# list all available targets with descriptions
make help

# verify required tools are installed
make setup

# format source files
make fmt

# build the binary
make build          # output: ./build/snackbox

# run the server locally (uses built-in smoke-test credentials)
make run

# run unit tests
make test-unit      # go test -v ./...

# run unit tests with race detector
make test-race      # go test -race ./...

# run unit tests and write coverage report to build/coverage/
make test-coverage

# individual checks
make vet            # go mod tidy && go vet ./...
make lint           # all linters (lint-go + lint-openapi)
make lint-go        # golangci-lint run
make lint-openapi   # vacuum lint api/openapi.yaml

# full CI equivalent (vet + lint + test-coverage + test-api)
make test

# skip the API integration tests when iterating locally
make test FAST=1

# API integration tests only (builds binary, starts server, runs hurl, tears down)
make test-api

# generate CHANGELOG.md from conventional commits (needed when cutting a release)
make changelog

All environment variables used by make run and make test-api are centralized in a DEV_* variable block at the top of the Makefile and can be overridden on the command line, e.g.:

make run DEV_LISTEN_ADDR=:9090

For a persistent local setup, copy configs/.env.example to .env and adjust values as needed.

Docker Workflow

# build the image locally (single-arch for your current platform)
make docker-build

# start the container stack in the background (requires .env)
make docker-run

# follow container logs
make docker-logs

# stop and remove the container stack
make docker-stop

make docker-build produces a single-arch image for your host platform, which is all you need for local development and smoke-testing. Multi-arch images (linux/amd64 + linux/arm64) are built exclusively in CI via Docker Buildx + QEMU emulation and pushed to the registry on every relevant commit. On Apple Silicon, Docker Desktop pulls the linux/arm64 layer from the registry image natively - no local Buildx setup required.

Exploring the API

The full API is documented in api/openapi.yaml. Browse it interactively with any OpenAPI-compatible tool - no installation required beyond npx:

# Interactive rendered docs (Redocly)
npx @redocly/cli preview-docs api/openapi.yaml

# Mock server  -  replay example responses for frontend development
npx @stoplight/prism-cli mock api/openapi.yaml

Both commands start a local server; follow the URL printed to the terminal. For a quick static render you can also upload api/openapi.yaml to https://editor.swagger.io.

Two-Tier Test Strategy

Snackbox uses two complementary test layers. Each tier has a distinct purpose and the two should not be confused.

Tier 1 - Handler unit tests (internal/handlers/*_test.go)

These are the primary exhaustive tests. They run in-process using net/http/httptest against a temporary SQLite database created by testutil.SetupDB(t). Every endpoint, status code, validation error, and permission boundary must be covered here. They run as part of make test-unit and make test-coverage.

Use this tier for:

  • All success and error paths (400, 401, 403, 404, 409, 422, …)
  • Pagination, filtering, and field validation
  • Role-based access control boundaries
  • Content size limits

Tier 2 - API scenario smoke tests (test/scenario_*.hurl)

These are narrative, human-readable scenario smoke tests that exercise the running binary end-to-end over a real HTTP connection. They are intentionally few - eight files - and deliberately avoid duplicating the exhaustive coverage in Tier 1. They exist to confirm the binary starts correctly, the router wires up, and the most important multi-step workflows work across the full stack.

The eight scenarios run in order (earlier scenarios may leave state that later ones depend on):

File What it demonstrates
scenario_admin_setup.hurl Admin configures site metadata, social accounts, navigation, and static pages on day one; tears down created content at the end
scenario_content_lifecycle.hurl Admin creates a tag, publishes a post, uploads media; content is left in place for the browsing scenario
scenario_public_browsing.hurl Anonymous visitor reads public content created in the lifecycle scenario
scenario_author_workflow.hurl Admin creates an author user; author creates content and is blocked from modifying admin content
scenario_auth_session.hurl Login → refresh with token rotation → logout → token invalidated
scenario_scheduled_publishing.hurl Content with a future publish_at is hidden from public endpoints; publish-due transitions it to visible
scenario_account_self_service.hurl Author uses GET /me, PUT /me, and POST /auth/change-password; verifies role cannot be self-escalated
scenario_editor_workflow.hurl Editor creates, updates, and deletes any content regardless of authorship; blocked from user management and site settings

Run them with make test-api. They are also run in CI by the go_test_api job.

Test Data Conventions

All API integration tests (test/*.hurl) and Go unit tests follow these conventions for test data to make intent obvious and avoid ambiguity with real data.

Reserved domains - RFC 2606

RFC 2606 reserves certain domain names specifically for use in documentation, examples, and testing. They will never resolve to a real host:

Domain Use
example.com Primary test domain - use for all test email addresses
example.net Secondary test domain
example.org Tertiary test domain

Always use @example.com addresses in tests. Never invent fictional domains like @smoke-domain.io that could accidentally resolve.

Test personas - Alice and Bob

Use the canonical cryptographic persona names for test users. They are universally recognized, role-neutral, and carry no implied meaning beyond "test participant":

Name Email Suggested role
Alice alice@example.com author
Bob bob@example.com member
Carol carol@example.com additional user if needed
Chad chad@example.com the bad guy: unauthorized access and permission boundary tests

Content identifiers

Use real-world, recognizable names for test content rather than prefixing everything with smoke- or test-:

  • Tags: Technology, General, Golang
  • Posts: Hello World
  • Pages: About

Seed Fixtures

The seed dataset is a self-contained bakery demo called Birkenweg Bakery, a fictional Berlin sourdough bakery. It is loaded either by setting SEED_CONTENT=true at startup or by running snackbox seed-fixtures. The dataset is intentionally complete enough to give a theme or frontend developer realistic content to work against from day one.

What gets created

Resource Count Details
Site settings 1 Title "Birkenweg Bakery", slogan, description, language en
Tags 11 sourdough, pastry, baking-science, fermentation, home-baking, behind-the-scenes, equipment, recipes, seasonal, team, sustainability
Posts 7 Why Sourdough Takes 48 Hours, The Science Behind a Perfect Croissant, Tools We Actually Use, A Week in the Bakery, Our Seasonal Menu Changes, Brown Butter and Patience, Our Top 3 Customer Favorite Recipes
Pages 3 About Us, Our Menu, Contact
Navigation items 4 Blog (main), About (main), Menu (main), Contact (secondary)
Social accounts 3 Instagram, Facebook, GitLab
Media 30 Logo, icon, per-post and per-page cover images, per-tag images, inline post images

Content characteristics

All posts and pages are published immediately. Several posts include multiple tags. Three posts contain an inline image embedded in the body text to demonstrate that use case. One post (why-sourdough-takes-48-hours) includes footnotes to demonstrate the Plate footnote extension.

British English

The bakery dataset is written in British English throughout. The misspell linter and typos checker are configured to skip internal/bootstrap/seed.go and internal/bootstrap/seed_content/ so the spell checkers do not flag these as errors.

Idempotency

The seed command checks for the existence of the post with slug why-sourdough-takes-48-hours before doing any work. If that post already exists the command exits without touching the database. Running it twice is safe.

Embedded files

All seed content (markdown files and images) lives in internal/bootstrap/seed_content/ and is compiled into the binary via //go:embed. No external files are needed at runtime.

Feature Flags

Most features should be implemented and shipped directly - no flag needed. A feature flag is only justified when at least one of the following is true:

  • The change breaks an existing API contract. Response shapes, field renames, or removed endpoints that would silently break API consumers if they landed without warning. Example: replacing the flat tag_ids list on post and page responses with a nested tags object that includes additional metadata fields.
  • The change expands access control. Introducing a new role or widening permissions on existing endpoints needs a deliberate rollout window so operators can audit and opt in rather than having access silently broadened. Example: introducing a new role with write access to content endpoints.
  • The feature is large enough to merge in parts. Trunk-based development sometimes requires landing an incomplete feature without it being visible in production. In this case the flag acts as a build-time shutter, not a long-term toggle.

For anything else - new optional fields, new endpoints, new CLI subcommands, documentation improvements - just implement and ship. Do not add a flag.

There are currently no active feature flags. The flag infrastructure (Features struct, Enabled() startup log, router features field) was removed when the editor role graduated to stable. When you need to introduce the first new flag, re-establish the pattern from scratch:

  1. Add a Features struct to internal/config/config.go with a bool field and a doc comment explaining what it enables and its env var name (EXPERIMENTAL_<NAME>).
  2. Add an Enabled() []string method to Features that appends each flag name when its field is true. Use this to log active flags at server startup so operators know what is enabled.
  3. Add a Features config.Features field to Config and populate it in Load() using getEnvBool("EXPERIMENTAL_<NAME>", false).
  4. Add a features config.Features field to Router (and its New() signature) and use r.features.<FieldName> to conditionally gate routes or roles.
  5. Document the env var in configs/.env.example and in the Configuration Reference.

Operators opt in via EXPERIMENTAL_<NAME>=true. Promoting a flag to stable means removing the flag entirely - drop the struct field, its getEnvBool line, the router guard, and all flag-specific tests. The feature becomes unconditional behavior.

Adding a New Resource

The pattern is consistent across all resources:

  1. Model - add a struct to internal/models/
  2. Migration - add a numbered .sql file to internal/database/migrations/
  3. Repository - implement CRUD in internal/repository/, with a corresponding _test.go
  4. Handler - implement HTTP handlers in internal/handlers/, with a corresponding _test.go. If the handler orchestrates non-trivial business logic (multiple repositories, token management, password operations), extract it to a service in internal/service/ and keep the handler as a thin HTTP adapter: decode → call service → map error to status → respond
  5. Router - register the new routes in internal/router/router.go
  6. OpenAPI - document the new endpoints in api/openapi.yaml
  7. Hurl scenarios - any new, modified, or removed endpoint requires a matching change in test/. Add or update the relevant test/scenario_*.hurl file. If the new resource introduces a workflow that spans multiple actors or steps (e.g. ownership boundaries, role checks), consider adding a dedicated scenario file rather than appending to an existing one. See test/README.md for conventions.
  8. Handler unit tests - add exhaustive coverage in internal/handlers/<resource>_test.go for all success and error paths.

Development Workflow

  1. Run make setup to confirm all required tools are present
  2. Open an issue describing the feature or fix, or comment on an existing one to claim it
  3. Fork the repository or create a branch from trunk using the naming convention below
  4. Implement your changes
  5. Run make fmt and make test FAST=1 before opening an MR. FAST=1 skips the API integration tests for faster local iteration. CI runs the full make test (no FAST=1). If your changes touch concurrent code, also run make test-full locally to include the race detector before pushing.
  6. Use conventional commits - the .gitmessage template at the repository root documents the expected format and issue footer conventions. Apply it with git config commit.template .gitmessage
  7. Open a merge request and check that the pipeline passes

Branch naming

Use the pattern <type>/<issue-number>-<short-description>, where <type> matches the conventional commit type for the primary change:

Type When to use
feat/ New feature or behavior
fix/ Bug fix
docs/ Documentation only
test/ Tests only
refactor/ Code restructuring without behavior change
chore/ Tooling, CI, dependency updates

Examples: feat/42-media-alt-text, fix/17-token-rotation-race, docs/99-administrator-guide.

For any questions, ping via the issue.

Race detector

The CI race detector (go test -race) runs only on protected branches and release tags to avoid the ~3× overhead on every MR commit. Run it locally before opening an MR that touches goroutines, shared state, or the middleware chain:

make test-race   # race detector only
make test-full   # full suite + race detector

Security Scanning

The CI pipeline runs four automated security scans:

Scan Template When it runs
SAST Jobs/SAST.gitlab-ci.yml Every MR, protected branch, and tag
SAST-IaC Jobs/SAST-IaC.gitlab-ci.yml Every MR, protected branch, and tag
Secret Detection Jobs/Secret-Detection.gitlab-ci.yml Every MR, protected branch, and tag
Container Scanning Jobs/Container-Scanning.gitlab-ci.yml When docker_build runs (Dockerfile or Go source changed)

Container Scanning depends on docker_build. On MRs, docker_build only runs when the Dockerfile, Go source files, go.mod, go.sum, or migration files change - so container scanning is skipped on doc-only or YAML-only MRs.

Reviewing results

Scan reports are available as pipeline artifacts (gl-sast-report.json, gl-secret-detection-report.json, gl-container-scanning-report.json). On GitLab Ultimate the Security Dashboard aggregates findings across pipelines; on lower tiers download the JSON artifact from the pipeline view directly.

Severity threshold

Container Scanning is configured with CS_SEVERITY_THRESHOLD: HIGH. The job fails the pipeline on HIGH or CRITICAL findings. MEDIUM and lower are reported but do not block the pipeline.

Suppressing false positives

To suppress a known false positive in the container scan, add a .trivyignore file at the repository root listing CVE IDs to ignore, one per line:

# Known false positive  -  not reachable in our build
CVE-2024-12345

Trivy picks up .trivyignore automatically. Document the reason in a comment so future contributors understand why the suppression exists.

Cutting a Release

Releases are triggered by pushing a version tag. The CI pipeline then builds binaries, publishes the Docker image, generates release notes, and creates the GitLab release entry automatically.

Versioning

Snackbox follows Semantic Versioning (MAJOR.MINOR.PATCH). Tags use the bare version number without a v prefix - 1.0.0, not v1.0.0.

Increment When
PATCH Backwards-compatible bug fixes
MINOR New backwards-compatible functionality
MAJOR Breaking API or behavioral changes

Trusted runners

Several CI jobs interact with the container registry, the package registry, or GitLab Pages and must run on a runner tagged trusted. These jobs are: docker_build, container_scanning, publish_binaries, publish_docker, generate_changelog, release, and pages.

If none of your registered runners carry the trusted tag these jobs will queue indefinitely. Tag at least one runner as trusted in your GitLab runner configuration.

Prerequisites

  • git-cliff installed locally (see Prerequisites)
  • Write access to trunk
  • At least one GitLab runner tagged trusted (required for the release pipeline)

Steps

  1. Update the changelog locally, commit it with your signing key, then tag:

bash make changelog git add CHANGELOG.md git commit -S -m "chore(changelog): update CHANGELOG.md for X.Y.Z" git tag -a X.Y.Z -m "Short human-readable summary of this release" git push origin trunk X.Y.Z

The tag message is a one-line summary of what this release is about - not just the version number. It appears as the release headline in CHANGELOG.md and the GitLab release page. Examples from prior releases: "Settings included and lots of improved testing and docs", "Initial release for development purposes and polishing".

The CI release pipeline triggers automatically on the tag. It will: - Build Linux amd64 and arm64 binaries and publish them to the package registry - Retag the multi-arch Docker image (linux/amd64 + linux/arm64) as :<version> and :latest - Generate release notes from the tag's commits (in-pipeline, not committed) - Create the GitLab release entry with those release notes

  1. Verify the release at gitlab.com/cozybadgerde/applications/snackbox/-/releases.

Documentation

Snackbox documentation is written in Markdown and lives in the docs/ directory. It is published as a static site via MkDocs Material on GitLab Pages whenever a new release tag is pushed.

Structure

File Purpose
docs/README.md Home page of the docs site
docs/PRD.md Product requirements (functional, quality, constraints)
docs/ARCHITECTURE.md Component structure and deployment topology
docs/12FACTOR.md 12-factor compliance evaluation
docs/ADMINISTRATOR.md Installation and operations guide
docs/USER.md REST API usage guide
docs/DEVELOPER.md This file
docs/TAGS.md Tag index (auto-populated by the tags plugin)
docs/TODO.md Known issues and planned improvements
docs/OWASP.md OWASP security review notes and compliance status
docs/SBOM.md Software Bill of Materials (dependency table with license identifiers)
docs/assets/ Images and diagrams referenced by docs pages
mkdocs.yml MkDocs site configuration

Writing Docs

Each Markdown file can optionally include a YAML frontmatter block with tags:

---
tags:
  - "development"
  - "administration"
---

Tags are picked up automatically by the MkDocs tags plugin and rendered on the Tags page - no manual maintenance required.

Testing Locally

Use the same Docker image as CI to preview the site with live reload:

docker run --rm -it \
  -p 8000:8000 \
  -v "$PWD":/docs \
  squidfunk/mkdocs-material serve --dev-addr 0.0.0.0:8000

Open http://localhost:8000 in your browser. The page reloads automatically on every file save.

To run the same strict build that CI executes (fails on warnings):

docker run --rm \
  -v "$PWD":/docs \
  squidfunk/mkdocs-material build --strict --site-dir public

A non-zero exit code means CI will also fail - verify this locally before pushing.