feat: scaffold app skeleton with config, ULID, health endpoints, compose stack (phase 1)

- internal .env loader (real env wins) + strictly validated config struct
- ULID package: 48-bit ms timestamp + 80-bit crypto randomness, sortable
- HTTP server with recovery/request-id/trusted-proxy/access-log middleware
- /healthz, /readyz (pluggable checkers), /metricsz (counter registry)
- multi-stage Dockerfile (alpine, non-root) + compose with healthchecks,
  app bound to 127.0.0.1:8787, mongo unpublished, graceful 15s shutdown

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
etalon
2026-06-12 18:10:30 +02:00
commit 976f5238f8
26 changed files with 2478 additions and 0 deletions
+196
View File
@@ -0,0 +1,196 @@
// Package config loads and validates all application configuration from the
// environment (optionally seeded from a .env file). The app fails fast at
// startup on invalid configuration rather than misbehaving later.
package config
import (
"encoding/base64"
"errors"
"fmt"
"net/netip"
"net/url"
"os"
"strconv"
"strings"
"time"
)
type SMTP struct {
Host string
Port int
User string
Password string
From string
}
func (s SMTP) Enabled() bool { return s.Host != "" }
type OIDC struct {
IssuerURL string
ClientID string
ClientSecret string
}
func (o OIDC) Enabled() bool {
return o.IssuerURL != "" && o.ClientID != "" && o.ClientSecret != ""
}
type Config struct {
AppBaseURL string
TrustedProxyCIDRs []netip.Prefix
AppPort int
MongoURI string
MongoDB string
MongoUsername string
MongoPassword string
SessionSecret string
CredentialsEncKey []byte // 32 bytes, decoded
MaxUploadMB int
AdminEmail string
AdminInitialPassword string
AtomizerBaseURL string
AtomizerToken string
AtomizerTimeout time.Duration
WorkPerformerBaseURL string
WorkPerformerToken string
WorkPerformerHTTPTimeout time.Duration
SMTP SMTP
OIDC OIDC
CookieSecureMode string // auto | true | false
AtomizeMaxConcurrency int
}
// CookieSecure resolves the Secure cookie flag: explicit true/false, or in
// auto mode derived from the APP_BASE_URL scheme.
func (c *Config) CookieSecure() bool {
switch c.CookieSecureMode {
case "true":
return true
case "false":
return false
default:
return strings.HasPrefix(c.AppBaseURL, "https://")
}
}
func getenv(key, def string) string {
if v, ok := os.LookupEnv(key); ok {
return v
}
return def
}
// Load reads configuration from the environment and validates it. All
// problems are reported together so a broken deployment is fixed in one pass.
func Load() (*Config, error) {
var errs []error
fail := func(format string, a ...any) { errs = append(errs, fmt.Errorf(format, a...)) }
atoi := func(key, def string) int {
raw := getenv(key, def)
n, err := strconv.Atoi(raw)
if err != nil {
fail("%s: %q is not an integer", key, raw)
}
return n
}
c := &Config{
AppBaseURL: strings.TrimRight(getenv("APP_BASE_URL", "http://localhost:8787"), "/"),
AppPort: atoi("APP_PORT", "8787"),
MongoURI: getenv("MONGO_URI", "mongodb://localhost:27017"),
MongoDB: getenv("MONGO_DB", "bountyboard"),
MongoUsername: os.Getenv("MONGO_USERNAME"),
MongoPassword: os.Getenv("MONGO_PASSWORD"),
SessionSecret: os.Getenv("SESSION_SECRET"),
MaxUploadMB: atoi("MAX_UPLOAD_MB", "25"),
AdminEmail: strings.ToLower(strings.TrimSpace(getenv("ADMIN_EMAIL", ""))),
AdminInitialPassword: os.Getenv("ADMIN_INITIAL_PASSWORD"),
AtomizerBaseURL: strings.TrimRight(getenv("ATOMIZER_BASE_URL", "http://atomizer-mock:8090"), "/"),
AtomizerToken: os.Getenv("ATOMIZER_TOKEN"),
WorkPerformerBaseURL: strings.TrimRight(getenv("WORK_PERFORMER_BASE_URL", "http://work-performer:8091"), "/"),
WorkPerformerToken: os.Getenv("WORK_PERFORMER_TOKEN"),
SMTP: SMTP{
Host: os.Getenv("SMTP_HOST"),
Port: atoi("SMTP_PORT", "587"),
User: os.Getenv("SMTP_USER"),
Password: os.Getenv("SMTP_PASSWORD"),
From: getenv("SMTP_FROM", "bountyboard@example.com"),
},
OIDC: OIDC{
IssuerURL: os.Getenv("OIDC_ISSUER_URL"),
ClientID: os.Getenv("OIDC_CLIENT_ID"),
ClientSecret: os.Getenv("OIDC_CLIENT_SECRET"),
},
CookieSecureMode: getenv("COOKIE_SECURE", "auto"),
AtomizeMaxConcurrency: atoi("ATOMIZE_MAX_CONCURRENCY", "3"),
}
c.AtomizerTimeout = time.Duration(atoi("ATOMIZER_TIMEOUT_SEC", "120")) * time.Second
c.WorkPerformerHTTPTimeout = time.Duration(atoi("WORK_PERFORMER_HTTP_TIMEOUT_SEC", "30")) * time.Second
if c.AppPort < 1 || c.AppPort > 65535 {
fail("APP_PORT: %d out of range", c.AppPort)
}
if u, err := url.Parse(c.AppBaseURL); err != nil || u.Scheme == "" || u.Host == "" {
fail("APP_BASE_URL: %q is not an absolute URL", c.AppBaseURL)
}
for _, name := range []string{"ATOMIZER_BASE_URL", "WORK_PERFORMER_BASE_URL"} {
v := c.AtomizerBaseURL
if name == "WORK_PERFORMER_BASE_URL" {
v = c.WorkPerformerBaseURL
}
if u, err := url.Parse(v); err != nil || u.Scheme == "" || u.Host == "" {
fail("%s: %q is not an absolute URL", name, v)
}
}
if len(c.SessionSecret) < 16 {
fail("SESSION_SECRET: must be at least 16 characters of random data")
}
switch raw := os.Getenv("CREDENTIALS_ENC_KEY"); raw {
case "":
fail("CREDENTIALS_ENC_KEY: required; generate with `openssl rand -base64 32`")
default:
key, err := base64.StdEncoding.DecodeString(raw)
if err != nil || len(key) != 32 {
fail("CREDENTIALS_ENC_KEY: must be base64 of exactly 32 bytes")
}
c.CredentialsEncKey = key
}
if c.MaxUploadMB < 1 {
fail("MAX_UPLOAD_MB: must be >= 1")
}
if c.AtomizeMaxConcurrency < 1 {
fail("ATOMIZE_MAX_CONCURRENCY: must be >= 1")
}
switch c.CookieSecureMode {
case "auto", "true", "false":
default:
fail("COOKIE_SECURE: %q must be auto, true or false", c.CookieSecureMode)
}
if raw := strings.TrimSpace(os.Getenv("TRUSTED_PROXY_CIDRS")); raw != "" {
for cidr := range strings.SplitSeq(raw, ",") {
cidr = strings.TrimSpace(cidr)
if cidr == "" {
continue
}
p, err := netip.ParsePrefix(cidr)
if err != nil {
fail("TRUSTED_PROXY_CIDRS: %q is not a CIDR", cidr)
continue
}
c.TrustedProxyCIDRs = append(c.TrustedProxyCIDRs, p.Masked())
}
}
if len(errs) > 0 {
return nil, fmt.Errorf("invalid configuration:\n%w", errors.Join(errs...))
}
return c, nil
}
+137
View File
@@ -0,0 +1,137 @@
package config
import (
"encoding/base64"
"strings"
"testing"
)
// validEnv sets the minimum required variables for a passing Load.
func validEnv(t *testing.T) {
t.Helper()
t.Setenv("SESSION_SECRET", "0123456789abcdef0123456789abcdef")
t.Setenv("CREDENTIALS_ENC_KEY", base64.StdEncoding.EncodeToString(make([]byte, 32)))
}
func TestLoadDefaults(t *testing.T) {
validEnv(t)
c, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if c.AppPort != 8787 {
t.Errorf("AppPort = %d, want 8787", c.AppPort)
}
if c.MongoDB != "bountyboard" {
t.Errorf("MongoDB = %q", c.MongoDB)
}
if c.AtomizerTimeout.Seconds() != 120 {
t.Errorf("AtomizerTimeout = %v", c.AtomizerTimeout)
}
if c.CookieSecure() {
t.Error("CookieSecure should be false for http base URL in auto mode")
}
if len(c.CredentialsEncKey) != 32 {
t.Errorf("CredentialsEncKey len = %d", len(c.CredentialsEncKey))
}
}
func TestLoadValidationErrors(t *testing.T) {
tests := []struct {
name string
key string
value string
wantSub string
}{
{"bad port", "APP_PORT", "99999", "APP_PORT"},
{"non-numeric port", "APP_PORT", "abc", "APP_PORT"},
{"short session secret", "SESSION_SECRET", "short", "SESSION_SECRET"},
{"bad enc key", "CREDENTIALS_ENC_KEY", "not-base64!!", "CREDENTIALS_ENC_KEY"},
{"wrong size enc key", "CREDENTIALS_ENC_KEY", base64.StdEncoding.EncodeToString(make([]byte, 16)), "CREDENTIALS_ENC_KEY"},
{"bad base url", "APP_BASE_URL", "not a url", "APP_BASE_URL"},
{"bad atomizer url", "ATOMIZER_BASE_URL", "::::", "ATOMIZER_BASE_URL"},
{"bad cidr", "TRUSTED_PROXY_CIDRS", "10.0.0.0/8,banana", "TRUSTED_PROXY_CIDRS"},
{"bad cookie mode", "COOKIE_SECURE", "maybe", "COOKIE_SECURE"},
{"bad upload size", "MAX_UPLOAD_MB", "0", "MAX_UPLOAD_MB"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
validEnv(t)
t.Setenv(tt.key, tt.value)
_, err := Load()
if err == nil {
t.Fatal("want error, got nil")
}
if !strings.Contains(err.Error(), tt.wantSub) {
t.Errorf("error %q does not mention %q", err, tt.wantSub)
}
})
}
}
func TestLoadMissingEncKey(t *testing.T) {
t.Setenv("SESSION_SECRET", "0123456789abcdef0123456789abcdef")
t.Setenv("CREDENTIALS_ENC_KEY", "")
if _, err := Load(); err == nil {
t.Fatal("want error for missing CREDENTIALS_ENC_KEY")
}
}
func TestTrustedProxyCIDRs(t *testing.T) {
validEnv(t)
t.Setenv("TRUSTED_PROXY_CIDRS", "172.16.0.0/12, 10.0.0.0/8")
c, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if len(c.TrustedProxyCIDRs) != 2 {
t.Fatalf("got %d prefixes, want 2", len(c.TrustedProxyCIDRs))
}
}
func TestCookieSecureModes(t *testing.T) {
tests := []struct {
mode, baseURL string
want bool
}{
{"auto", "http://localhost:8787", false},
{"auto", "https://bounty.example.com", true},
{"true", "http://localhost:8787", true},
{"false", "https://bounty.example.com", false},
}
for _, tt := range tests {
validEnv(t)
t.Setenv("COOKIE_SECURE", tt.mode)
t.Setenv("APP_BASE_URL", tt.baseURL)
c, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if got := c.CookieSecure(); got != tt.want {
t.Errorf("mode=%s url=%s: CookieSecure()=%v, want %v", tt.mode, tt.baseURL, got, tt.want)
}
}
}
func TestOIDCAndSMTPEnabled(t *testing.T) {
validEnv(t)
c, err := Load()
if err != nil {
t.Fatal(err)
}
if c.OIDC.Enabled() || c.SMTP.Enabled() {
t.Error("OIDC and SMTP must be disabled when unset")
}
t.Setenv("OIDC_ISSUER_URL", "https://issuer.example.com")
t.Setenv("OIDC_CLIENT_ID", "id")
t.Setenv("OIDC_CLIENT_SECRET", "secret")
t.Setenv("SMTP_HOST", "smtp.example.com")
c, err = Load()
if err != nil {
t.Fatal(err)
}
if !c.OIDC.Enabled() || !c.SMTP.Enabled() {
t.Error("OIDC and SMTP must be enabled when set")
}
}
+50
View File
@@ -0,0 +1,50 @@
package config
import (
"bufio"
"fmt"
"os"
"strings"
)
// LoadDotEnv reads a dotenv file and sets each variable into the process
// environment unless it is already set (real environment wins). Supported
// syntax: KEY=VALUE, blank lines, full-line and unquoted inline comments,
// optional `export ` prefix, single- or double-quoted values.
func LoadDotEnv(path string) error {
f, err := os.Open(path)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return fmt.Errorf("open %s: %w", path, err)
}
defer f.Close()
sc := bufio.NewScanner(f)
for n := 1; sc.Scan(); n++ {
line := strings.TrimSpace(sc.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
line = strings.TrimPrefix(line, "export ")
key, val, ok := strings.Cut(line, "=")
if !ok || strings.TrimSpace(key) == "" {
return fmt.Errorf("%s:%d: not a KEY=VALUE line", path, n)
}
key = strings.TrimSpace(key)
val = strings.TrimSpace(val)
switch {
case len(val) >= 2 && (val[0] == '"' || val[0] == '\'') && val[len(val)-1] == val[0]:
val = val[1 : len(val)-1]
default: // unquoted: strip trailing inline comment
if i := strings.Index(val, " #"); i >= 0 {
val = strings.TrimSpace(val[:i])
}
}
if _, exists := os.LookupEnv(key); !exists {
os.Setenv(key, val)
}
}
return sc.Err()
}
+66
View File
@@ -0,0 +1,66 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestLoadDotEnv(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, ".env")
content := `# full-line comment
PLAIN=hello
INLINE=value # trailing comment
QUOTED="quoted # not a comment"
SINGLE='single quoted'
export EXPORTED=yes
EMPTY=
SPACED = padded value
`
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("PLAIN", "preexisting") // real env must win
for _, k := range []string{"INLINE", "QUOTED", "SINGLE", "EXPORTED", "EMPTY", "SPACED"} {
os.Unsetenv(k)
t.Cleanup(func() { os.Unsetenv(k) })
}
if err := LoadDotEnv(path); err != nil {
t.Fatalf("LoadDotEnv: %v", err)
}
want := map[string]string{
"PLAIN": "preexisting",
"INLINE": "value",
"QUOTED": "quoted # not a comment",
"SINGLE": "single quoted",
"EXPORTED": "yes",
"EMPTY": "",
"SPACED": "padded value",
}
for k, v := range want {
if got := os.Getenv(k); got != v {
t.Errorf("%s = %q, want %q", k, got, v)
}
}
}
func TestLoadDotEnvMissingFileIsOK(t *testing.T) {
if err := LoadDotEnv(filepath.Join(t.TempDir(), "absent")); err != nil {
t.Fatalf("missing file should not error, got %v", err)
}
}
func TestLoadDotEnvMalformed(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, ".env")
if err := os.WriteFile(path, []byte("NOT A KV LINE\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := LoadDotEnv(path); err == nil {
t.Fatal("want error for malformed line")
}
}