Files
BountyBoard/internal/config/config.go
etalon 6265ffa894 feat: live deployment with automated acceptance checklist (phase 13)
- APP_INTERNAL_URL: in-network base URL for §5.2 callbacks and signed
  attachment URLs handed to the external services (compose: http://app:8787)
- work-performer image runs as the node user with ~/.claude mounted into
  /home/node — the claude CLI refuses --dangerously-skip-permissions as root
- scripts/acceptance.sh: re-run-safe live verification of the §13 checklist
  (demo import within one poll, subdivide sum=1 + editable, extend sibling,
  publish/bounty math, decline/claim/approve, changes-requested loop,
  approval award in metrics, unassign, AI job through real Claude Code with
  signed idempotent callback, breaker independence between the two services)
- README/DECISIONS: sudo HOME gotcha, internal URL, non-root performer

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 21:06:23 +02:00

202 lines
5.9 KiB
Go

// 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
// AppInternalURL is how the external services reach the app from inside
// the compose network (callback URLs, signed attachment URLs). Defaults
// to AppBaseURL.
AppInternalURL 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.AppInternalURL = strings.TrimRight(getenv("APP_INTERNAL_URL", c.AppBaseURL), "/")
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
}