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 | 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_idslist on post and page responses with a nestedtagsobject 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:
- Add a
Featuresstruct tointernal/config/config.gowith aboolfield and a doc comment explaining what it enables and its env var name (EXPERIMENTAL_<NAME>). - Add an
Enabled() []stringmethod toFeaturesthat appends each flag name when its field istrue. Use this to log active flags at server startup so operators know what is enabled. - Add a
Features config.Featuresfield toConfigand populate it inLoad()usinggetEnvBool("EXPERIMENTAL_<NAME>", false). - Add a
features config.Featuresfield toRouter(and itsNew()signature) and user.features.<FieldName>to conditionally gate routes or roles. - Document the env var in
configs/.env.exampleand 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:
- Model - add a struct to
internal/models/ - Migration - add a numbered
.sqlfile tointernal/database/migrations/ - Repository - implement CRUD in
internal/repository/, with a corresponding_test.go - 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 ininternal/service/and keep the handler as a thin HTTP adapter: decode → call service → map error to status → respond - Router - register the new routes in
internal/router/router.go - OpenAPI - document the new endpoints in
api/openapi.yaml - Hurl scenarios - any new, modified, or removed endpoint requires a matching change in
test/. Add or update the relevanttest/scenario_*.hurlfile. 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. Seetest/README.mdfor conventions. - Handler unit tests - add exhaustive coverage in
internal/handlers/<resource>_test.gofor all success and error paths.
Development Workflow
- Run
make setupto confirm all required tools are present - Open an issue describing the feature or fix, or comment on an existing one to claim it
- Fork the repository or create a branch from
trunkusing the naming convention below - Implement your changes
- Run
make fmtandmake test FAST=1before opening an MR.FAST=1skips the API integration tests for faster local iteration. CI runs the fullmake test(noFAST=1). If your changes touch concurrent code, also runmake test-fulllocally to include the race detector before pushing. - Use conventional commits - the
.gitmessagetemplate at the repository root documents the expected format and issue footer conventions. Apply it withgit config commit.template .gitmessage - 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-cliffinstalled locally (see Prerequisites)- Write access to
trunk - At least one GitLab runner tagged
trusted(required for the release pipeline)
Steps
- 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
- 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.