Skip to content

Architecture Manual

This document describes the architecture of snackbox: its component structure and deployment topology. The product requirements that drive these decisions are defined in PRD.md.

Snackbox aims to be fully compliant with the 12-factor app methodology. See 12FACTOR.md for the current compliance evaluation.

Components

Component View

graph TD
    Client([Client])

    subgraph server["server · :8080 (Go net/http)"]
        MW["Middleware\nSecurity Headers → CORS → Logging → Max Body"]
        Router["Router\n(Auth · Rate Limit applied per route)"]
        AuthH["Auth Handler"]
        HealthH["Health Handler"]
        ResourceH["Resource Handlers"]
        VersionH["Version Handler"]
        AuthSvc["Auth Service"]
        Repos["Repository\nTagRepo · PageRepo · PostRepo · UserRepo · MediaRepo\nSettingsRepo · NavigationRepo · SocialAccountRepo · RefreshTokenRepo"]
    end

    subgraph metricsMux["metricsMux · :9091 (Go net/http)"]
        MetricsH["Metrics Handler\n(Prometheus registry)"]
    end

    subgraph storage["Storage"]
        SQLite[("SQLite (file)")]
        Disk[("Storage (disk)")]
    end

    Client --> MW
    MW --> Router

    Router --> AuthH
    AuthH --> AuthSvc
    AuthSvc --> Repos

    Router --> ResourceH
    ResourceH --> Repos
    Repos --> SQLite
    ResourceH --> Disk

    Router --> HealthH
    Router --> VersionH
    HealthH --> SQLite

The binary is structured in strict layers. Each layer only calls the layer directly below it. This enforces the architectural constraint that business logic must not appear in the router or middleware:

Layer Packages Responsibility
Entry point cmd/server Wires all components, starts HTTP servers, handles OS signals
Middleware internal/middleware Security headers, CORS, auth (JWT - applied per-route), logging, rate-limit, max-body
Router internal/router Registers all routes, applies middleware chain
Handlers internal/handlers Parses requests, calls services and repositories, writes responses
Service internal/service Domain business logic: auth orchestration, token issuance, password management
Repositories internal/repository SQL queries against SQLite
Bootstrap internal/bootstrap Applies pending migrations, creates initial admin, seeds settings on first run
Build metadata internal/buildinfo VCS revision, release tag, and schema version; served by the version endpoint
Storage SQLite file + filesystem Persistent state

A second HTTP server runs on METRICS_ADDR (default :9091) and exposes only GET /metrics. It is wired directly in cmd/server/app.go and shares no middleware with the main API. This satisfies the requirement that the metrics endpoint must not be exposed on the public API port.

Bootstrap

On every startup internal/bootstrap applies pending SQL migrations embedded in the binary via //go:embed (no external migration path required), creates the initial admin user from the ADMIN_NAME / ADMIN_EMAIL / ADMIN_PASSWORD environment variables if none exists, and seeds the site settings row with language defaulting to "en" if it has not yet been created. When SEED_CONTENT=true, bootstrap also creates example posts, pages, tags, and navigation items on first run so a fresh instance has browsable demo content immediately - disabled by default so production deployments do not receive demo content without an explicit opt-in. This satisfies the requirement that migrations are applied automatically on startup.

Configuration

All runtime configuration is loaded by internal/config from environment variables, with an optional .env file as a fallback. See the Administrator Manual for the full reference.

Authentication and Session Model

Snackbox uses a stateful two-token model. This is a conscious design decision that balances statelessness (12-factor VI) with real-world usability.

Tokens

Token Format Lifetime Storage
Access token Signed JWT (HS256) JWT_EXPIRY (default 15 m) Client only - never stored server-side
Refresh token Opaque random hex (32 bytes) JWT_REFRESH_EXPIRY (default 7 days) SHA-256 hash stored in refresh_tokens table

The raw refresh token is generated with crypto/rand, hex-encoded, and returned to the client once. Only its SHA-256 digest is persisted - a database breach exposes no usable tokens.

Flow

  1. Login (POST /api/v1/auth/login) - validates credentials, issues both tokens, inserts a refresh_tokens row.
  2. Authenticated requests - client sends Authorization: Bearer <access_token>. The middleware validates the JWT signature and expiry; no database lookup is needed.
  3. Refresh (POST /api/v1/auth/refresh) - client submits the refresh token. The server hashes it, looks up the row, checks expiry, deletes the old row, and issues a new token pair (rotation). The old refresh token is immediately invalidated.
  4. Logout (POST /api/v1/auth/logout) - client submits the refresh token. The server deletes the row. No Authorization header is required.
  5. Logout-all (POST /api/v1/auth/logout-all) - requires a valid access token. The server deletes all refresh_tokens rows for the authenticated user, terminating every active session across all devices.

Why logout does not require the access token

Possession of the refresh token is the proof of identity for logout. An anonymous attacker cannot log out arbitrary users because they would need to guess a 32-byte random value (2²⁵⁶ search space). If an attacker already holds the refresh token they have full session access - being able to also revoke it is not a meaningful escalation. This pattern is codified in RFC 7009 (OAuth 2.0 Token Revocation).

Logout is idempotent: submitting an already-invalidated or unknown token returns 204 No Content without error, so clients do not need to handle "already logged out" edge cases.

Stateful state belongs in the database (12-factor VI)

Storing refresh tokens in SQLite is fully 12-factor compliant. Factor VI ("stateless processes") requires that state not be held in process memory between requests - it does not prohibit stateful backing services. The refresh_tokens table is the backing service for session state, exactly as intended by the 12-factor model.

Deployment

Deployment View

flowchart TD
    Client([Client])
    Prometheus([Prometheus])
    RP["Reverse Proxy\nnginx · Caddy · …"]

    subgraph host["Container / Host"]
        API["snackbox · :8080 · API\n/usr/local/bin/snackbox"]
        Metrics["snackbox · :9091 · Metrics\n/usr/local/bin/snackbox"]
        DB[("Database\n$DATABASE_PATH")]
        Media[("Media Storage\n$STORAGE_PATH")]
    end

    Client -->|"80/TCP"| RP
    RP -->|"8080/TCP"| API
    Prometheus -->|"9091/TCP"| Metrics
    API --> DB
    API --> Media

Snackbox is deployed as a single process behind a reverse proxy. The reverse proxy (nginx, Caddy, or similar) handles TLS termination and forwards requests to the API port. The metrics port is kept on loopback or an internal monitoring network. See Deployment Constraints for platform and resource requirements.

Port Default Exposure
LISTEN_ADDR :8080 Via reverse proxy (public)
METRICS_ADDR :9091 Internal / loopback only

Persistent state lives in two locations:

Path Content
DATABASE_PATH SQLite database file
STORAGE_PATH Uploaded media files

Both paths should reside on the same volume so that a single backup covers all state. See the Administrator Manual for deployment guides (systemd and Docker).

Tools and Frameworks

Key runtime dependencies

These are the libraries that shape architectural decisions. Versions are tracked in the SBOM below.

Library Rationale
net/http HTTP server - standard library, no framework; keeps the dependency surface minimal
mattn/go-sqlite3 SQLite driver via CGO; chosen for maturity and broad platform support
golang-jwt/jwt/v5 JWT signing and validation for access tokens
spf13/cobra CLI framework for subcommands and flag parsing
golang.org/x/crypto bcrypt password hashing
golang.org/x/time/rate Token-bucket rate limiter applied to the login endpoint
prometheus/client_golang Prometheus metrics instrumentation

Development and CI tools

These tools are not Go module dependencies and do not appear in the SBOM.

Tool Purpose
golangci-lint Static analysis - config in .golangci.yml
vacuum OpenAPI spec linting - config in .vacuum.conf.yaml
hurl End-to-end API integration tests - scenarios in test/
git-cliff Changelog and release notes generation

Dependencies and Licensing

Snackbox is released under the BSD-3-Clause License. All direct and transitive Go module dependencies use MIT, BSD-3-Clause, or Apache-2.0 - all of which are compatible with BSD-3-Clause outbound licensing and impose no copyleft or viral obligations.

Notable: mattn/go-sqlite3 bundles the SQLite C amalgamation, which is public domain and carries no license obligations.

Software Bill of Materials (SBOM)

The full dependency table, license identifiers, and instructions for regenerating the report have been extracted to docs/SBOM.md.

Architecture Decision Records

Architecture Decision Records (ADRs) document significant design choices, the context that prompted them, and the reasoning behind the decision taken. Each record is immutable once accepted - if a decision is reversed, a new ADR supersedes it rather than replacing the old one.

ADR-001 - Schema version representation: filename over PRAGMA user_version

Status: Accepted Issue: #29

Context

GET /version exposes a schema_version field populated by querying MAX(name) from the schema_migrations tracking table (database.LatestMigration()). SQLite also provides a built-in PRAGMA user_version - a 4-byte integer stored in the database file header that is readable on any SQLite file before any tables exist. Issue #29 asked whether PRAGMA user_version should be adopted alongside or instead of the filename-based approach.

Decision

Keep the filename-based approach. Do not adopt PRAGMA user_version.

Reasoning

  1. The pre-migration read problem does not arise in practice. In buildApp(), db.LatestMigration() is always called after bootstrap.Run(), which itself calls RunMigrations(). By the time the schema version is read, schema_migrations is guaranteed to exist. No code path in the server or any subcommand reads the schema version before migrations have run.

  2. The filename is more informative than a bare integer. The name 002_fix_title_image_nulls.sql encodes both the sequence number and the semantic purpose of the migration. PRAGMA user_version = 2 conveys only the number, offering no context to operators or tooling.

  3. Two sources of truth with no enforcement. Adopting PRAGMA user_version would require every migration file to end with PRAGMA user_version = N. There is no mechanism to validate that this was done correctly: a migration that omits the statement silently leaves the header value stale while the filename-based version remains accurate. Divergence between the two values would be harder to diagnose than a simple missing statement.

  4. No current or planned 1.x use case requires pre-migration inspection. The scenario where PRAGMA user_version adds value - an external tool that inspects a .db file before it is attached to a running instance - has no implementation in Snackbox and none is planned for the 1.x line. If such tooling is ever built, an ADR at that point can revisit this decision with a concrete use case to justify the added maintenance cost.

ADR-002 - Docker multi-arch strategy: two independent builds with QEMU emulation

Status: Accepted Issues: #38, #43

Context

The project produces two kinds of delivery artifacts:

  • Release binaries - built by the go_build CI job on a Debian/glibc toolchain, statically linked, published to the package registry for linux/amd64 and linux/arm64.
  • Docker images - built by the docker_build CI job using the Dockerfile's own Alpine/musl builder stage.

Issue #43 noted that these two paths produce binaries linked against different C runtimes (glibc vs musl), with different checksums, from the same source. Three consolidation strategies were evaluated:

  • Option A - Docker owns the build; release binaries are extracted from built images.
  • Option B - CI owns the build; the Dockerfile has no builder stage and packages pre-built binaries.
  • Option C - Accept two independent builds; document the split as policy.

Issue #38 noted that the docker_build job carried no --platform flag, meaning the image architecture silently followed whichever runner picked up the job. Two strategies were evaluated for adding multi-arch support:

  • C1 - Docker Buildx with QEMU emulation; no cross-compilers required.
  • C2 - Docker Buildx with --platform=$BUILDPLATFORM and musl cross-compilers; faster builds, more complex Dockerfile.

Decision

Adopt Option C (two independent builds) combined with C1 (Buildx + QEMU emulation).

Reasoning

  1. Dockerfile stays self-contained. Developers can run docker build . on any supported platform without pre-building binaries. Removing the builder stage (Option B) would break this workflow.

  2. The musl/glibc split is invisible to users. Both binaries are statically linked; the C runtime is not present in the final executable. Behavior is identical. The difference only matters for binary hash comparison, which is not a user-facing concern.

  3. QEMU emulation is acceptable at release cadence. The arm64 build runs under QEMU on the amd64 CI runner, adding roughly 5–10× overhead to the Go compilation step. For a project with infrequent releases this is a worthwhile trade-off for keeping the Dockerfile and CI configuration simple.

  4. macOS developers benefit without any local changes. Docker Desktop on Apple Silicon (arm64) pulls the linux/arm64 layer from the multi-arch manifest natively. No local Buildx setup is required.

  5. C2 is a drop-in upgrade path. If build time becomes a concern, adding --platform=$BUILDPLATFORM and the appropriate musl cross-compiler to the Dockerfile replaces QEMU emulation without changing the overall strategy or the CI job structure.

Consequences

  • The docker_build CI job registers QEMU via tonistiigi/binfmt and uses docker buildx build --platform linux/amd64,linux/arm64 --push.
  • The publish_docker CI job uses docker buildx imagetools create to retag the multi-arch manifest. The previous pull/tag/push pattern is replaced because it collapses a multi-arch manifest into a single-arch image.
  • Release binaries (go_build) are unchanged.
  • The Dockerfile is unchanged; its Alpine/musl builder stage continues to be the source of truth for the Docker image binary.