commit 976f5238f83879b60fc0bddf0104e4e93b75b8eb Author: etalon Date: Fri Jun 12 18:10:30 2026 +0200 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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0776805 --- /dev/null +++ b/.env.example @@ -0,0 +1,56 @@ +# --- core --- +# Set APP_BASE_URL to the public https URL when behind the reverse proxy. +APP_BASE_URL=http://localhost:8787 +# Comma-separated CIDRs of trusted reverse proxies, e.g. 172.16.0.0/12,10.0.0.0/8. +# Empty = trust no proxy headers. +TRUSTED_PROXY_CIDRS= +APP_PORT=8787 +MONGO_URI=mongodb://mongo:27017 +MONGO_DB=bountyboard +SESSION_SECRET=change-me-32-bytes-random +# base64 of 32 random bytes; generate with: openssl rand -base64 32 +CREDENTIALS_ENC_KEY= +MAX_UPLOAD_MB=25 + +# --- bootstrap admin --- +ADMIN_EMAIL=admin@example.com +ADMIN_INITIAL_PASSWORD=change-me-now + +# --- external services (REQUIRED to be configurable here) --- +# Two independent services — separate URLs, ports, and tokens. +ATOMIZER_BASE_URL=http://atomizer-mock:8090 +ATOMIZER_TOKEN=change-me-atomizer +ATOMIZER_TIMEOUT_SEC=120 +WORK_PERFORMER_BASE_URL=http://work-performer:8091 +WORK_PERFORMER_TOKEN=change-me-performer +# Job submission/polling only; jobs themselves are async. +WORK_PERFORMER_HTTP_TIMEOUT_SEC=30 + +# --- SMTP (optional; enables forgot-password + email notifications when set) --- +SMTP_HOST= +SMTP_PORT=587 +SMTP_USER= +SMTP_PASSWORD= +SMTP_FROM=bountyboard@example.com + +# --- hardening / tuning --- +# auto: Secure cookie flag only when APP_BASE_URL is https. Or: true | false. +COOKIE_SECURE=auto +# Cap parallel LLM calls (cost + rate-limit control). +ATOMIZE_MAX_CONCURRENCY=3 +# Optional; enable Mongo auth for non-local deploys. +MONGO_USERNAME= +MONGO_PASSWORD= + +# --- OIDC (optional; SSO hidden if empty) --- +OIDC_ISSUER_URL= +OIDC_CLIENT_ID= +OIDC_CLIENT_SECRET= + +# --- mock atomizer / work performer --- +# Paste your key here; NEVER commit it. +ANTHROPIC_API_KEY= +ANTHROPIC_OPENAI_BASE_URL=https://api.anthropic.com/v1 +ANTHROPIC_MODEL=claude-sonnet-4-6 +# openai | anthropic (native /v1/messages fallback) +LLM_API_STYLE=openai diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a507813 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.env +bin/ +backups/ +*.test +coverage.out diff --git a/DECISIONS.md b/DECISIONS.md new file mode 100644 index 0000000..6c6654e --- /dev/null +++ b/DECISIONS.md @@ -0,0 +1,38 @@ +# Decisions + +Spec-silent choices, recorded as required by the build instructions. + +## Phase 1 + +- **Go toolchain 1.26.4** (latest stable; spec requires ≥ 1.22). Installed at + `/usr/local/go`. +- **Module name `bountyboard`** — no remote repository exists; a single-word + module keeps import paths short (`bountyboard/internal/...`). +- **Package in `internal/http` is named `httpx`** to avoid clashing with + stdlib `net/http` inside its own files. The directory stays `internal/http` + per spec §14; importers alias it (`httpx "bountyboard/internal/http"`). +- **`/readyz` uses a pluggable checker registry** (`AddReadinessCheck`): + required checks (Mongo, Phase 2) gate readiness with 503; optional checks + (atomizer / work performer, Phase 11) are reported as `degraded` but stay + 200, matching §6's "non-fatal, reported". +- **Runtime image is `alpine:3.21`, not distroless** — busybox `wget` enables + the compose healthcheck without adding tooling, and `ca-certificates` is + needed for outbound HTTPS to Jira/ADO/YouTrack. Non-root user. +- **Config validation is strict and fail-fast**: `CREDENTIALS_ENC_KEY` is + mandatory at startup (base64, exactly 32 bytes) and `SESSION_SECRET` must be + ≥ 16 chars, even before the features using them land — a misconfigured + deployment should die at boot, not at first use. All validation errors are + reported together via `errors.Join`. +- **Trusted-proxy resolution walks X-Forwarded-For right-to-left**, skipping + addresses inside `TRUSTED_PROXY_CIDRS`; the first untrusted address is the + client. A malformed entry stops the walk (everything left of it is + attacker-controllable). If the whole chain is trusted, the leftmost entry is + used. Headers are ignored entirely when the direct peer is untrusted. +- **ULID**: canonical 48-bit ms timestamp + 80-bit crypto randomness, no + intra-millisecond monotonicity (spec only needs sortability at ms + resolution; randomness makes collisions negligible). +- **`.env` loader semantics**: real environment variables win over `.env` + values; unquoted values support trailing ` # comment`; missing `.env` file + is not an error (containers receive env via compose `env_file`). +- **Makefile `seed`/`backup`/`restore` are failing stubs until Phase 12** so + the targets exist but cannot be mistaken for working. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..cc46d57 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +# --- build stage --- +FROM golang:1.26-alpine AS build +WORKDIR /src +COPY go.* ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/app ./cmd/app + +# --- runtime stage --- +FROM alpine:3.21 +RUN apk add --no-cache ca-certificates && \ + addgroup -S app && adduser -S -G app app +USER app +WORKDIR /srv +COPY --from=build /out/app /usr/local/bin/app +EXPOSE 8787 +# busybox wget ships with alpine; used by the compose healthcheck +ENTRYPOINT ["/usr/local/bin/app"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..5a8d27c --- /dev/null +++ b/Makefile @@ -0,0 +1,32 @@ +GO ?= go + +.PHONY: build run test test-integration lint seed smoke backup restore + +build: + $(GO) build ./... + +run: + $(GO) run ./cmd/app + +test: + $(GO) test ./... + +test-integration: + $(GO) test -tags=integration ./... + +lint: + @unformatted=$$(gofmt -l .); if [ -n "$$unformatted" ]; then \ + echo "gofmt needed on:"; echo "$$unformatted"; exit 1; fi + $(GO) vet ./... + +seed: + @echo "seed script lands in phase 12 (scripts/seed.go)"; exit 1 + +smoke: + ./scripts/smoke.sh + +backup: + @echo "backup target lands in phase 12"; exit 1 + +restore: + @echo "restore target lands in phase 12"; exit 1 diff --git a/PROGRESS.md b/PROGRESS.md new file mode 100644 index 0000000..04dc481 --- /dev/null +++ b/PROGRESS.md @@ -0,0 +1,4 @@ +# Progress + +- Phase 1 (scaffold): repo layout, Makefile, .env loader + validated config, slog JSON, ULID pkg, trusted-proxy middleware, /healthz /readyz /metricsz, Dockerfile + compose (mongo+app, healthchecks, localhost-bound 8787), graceful shutdown — build/vet/tests green, smoke PASS. + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/cmd/app/main.go b/cmd/app/main.go new file mode 100644 index 0000000..e9f805f --- /dev/null +++ b/cmd/app/main.go @@ -0,0 +1,42 @@ +// Command app is the Bounty Board main service. +package main + +import ( + "context" + "fmt" + "log/slog" + "os" + "os/signal" + "syscall" + + "bountyboard/internal/config" + httpx "bountyboard/internal/http" + "bountyboard/internal/metrics" +) + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, "fatal:", err) + os.Exit(1) + } +} + +func run() error { + if err := config.LoadDotEnv(".env"); err != nil { + return fmt.Errorf("load .env: %w", err) + } + cfg, err := config.Load() + if err != nil { + return err + } + + log := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) + slog.SetDefault(log) + + reg := metrics.NewRegistry() + srv := httpx.New(cfg, log, reg) + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + return srv.Run(ctx) +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b6b7ef2 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,35 @@ +services: + mongo: + image: mongo:7 + restart: unless-stopped + # Mongo is reachable only on the compose network; never published to the + # host (§12). + volumes: + - mongo-data:/data/db + healthcheck: + test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 15s + + app: + build: . + restart: unless-stopped + env_file: .env + environment: + MONGO_URI: mongodb://mongo:27017 + ports: + - "127.0.0.1:${APP_PORT:-8787}:8787" + depends_on: + mongo: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8787/healthz"] + interval: 10s + timeout: 5s + retries: 3 + start_period: 10s + +volumes: + mongo-data: diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..44b3861 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module bountyboard + +go 1.26 diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..8fb9331 --- /dev/null +++ b/internal/config/config.go @@ -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 +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..0de9300 --- /dev/null +++ b/internal/config/config_test.go @@ -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") + } +} diff --git a/internal/config/env.go b/internal/config/env.go new file mode 100644 index 0000000..08c0f54 --- /dev/null +++ b/internal/config/env.go @@ -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() +} diff --git a/internal/config/env_test.go b/internal/config/env_test.go new file mode 100644 index 0000000..2706cb0 --- /dev/null +++ b/internal/config/env_test.go @@ -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") + } +} diff --git a/internal/http/health.go b/internal/http/health.go new file mode 100644 index 0000000..51ef3df --- /dev/null +++ b/internal/http/health.go @@ -0,0 +1,67 @@ +package httpx + +import ( + "context" + "net/http" + "runtime" + "time" +) + +// ReadinessCheck is a named dependency probe run by /readyz. Required checks +// (Mongo) gate readiness; optional ones (atomizer, work performer) are +// reported but non-fatal, per §6. +type ReadinessCheck struct { + Name string + Required bool + Probe func(ctx context.Context) error +} + +// AddReadinessCheck registers a probe; later phases plug in Mongo and the +// external services here. +func (s *Server) AddReadinessCheck(c ReadinessCheck) { + s.checks = append(s.checks, c) +} + +func (s *Server) handleHealthz(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +type checkResult struct { + OK bool `json:"ok"` + Required bool `json:"required"` + Error string `json:"error,omitempty"` +} + +func (s *Server) handleReadyz(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + status := "ok" + httpStatus := http.StatusOK + results := make(map[string]checkResult, len(s.checks)) + for _, c := range s.checks { + res := checkResult{OK: true, Required: c.Required} + if err := c.Probe(ctx); err != nil { + res.OK = false + res.Error = err.Error() + if c.Required { + status = "unavailable" + httpStatus = http.StatusServiceUnavailable + } else if status == "ok" { + status = "degraded" + } + } + results[c.Name] = res + } + writeJSON(w, httpStatus, map[string]any{"status": status, "checks": results}) +} + +func (s *Server) handleMetricsz(w http.ResponseWriter, _ *http.Request) { + counters, gauges := s.metrics.Snapshot() + writeJSON(w, http.StatusOK, map[string]any{ + "uptimeSec": int64(time.Since(s.startedAt).Seconds()), + "goroutines": runtime.NumGoroutine(), + "counters": counters, + "gauges": gauges, + }) +} diff --git a/internal/http/middleware.go b/internal/http/middleware.go new file mode 100644 index 0000000..a3fd566 --- /dev/null +++ b/internal/http/middleware.go @@ -0,0 +1,122 @@ +package httpx + +import ( + "context" + "fmt" + "net/http" + "net/netip" + "runtime/debug" + "time" + + "bountyboard/internal/ulid" +) + +type ctxKey int + +const ( + ctxKeyRequestID ctxKey = iota + ctxKeyClient +) + +// RequestID returns the id assigned to this request by the middleware chain. +func RequestID(ctx context.Context) string { + id, _ := ctx.Value(ctxKeyRequestID).(string) + return id +} + +// Client returns the trusted-proxy-resolved client info for this request. +func Client(ctx context.Context) clientInfo { + c, _ := ctx.Value(ctxKeyClient).(clientInfo) + return c +} + +// ClientIP is a convenience accessor used by rate limiting and audit logging. +func ClientIP(ctx context.Context) netip.Addr { return Client(ctx).IP } + +// statusRecorder captures the response status for logging and metrics while +// staying compatible with http.ResponseController (Unwrap). +type statusRecorder struct { + http.ResponseWriter + status int +} + +func (r *statusRecorder) WriteHeader(code int) { + if r.status == 0 { + r.status = code + } + r.ResponseWriter.WriteHeader(code) +} + +func (r *statusRecorder) Write(b []byte) (int, error) { + if r.status == 0 { + r.status = http.StatusOK + } + return r.ResponseWriter.Write(b) +} + +func (r *statusRecorder) Unwrap() http.ResponseWriter { return r.ResponseWriter } + +func (s *Server) withMiddleware(next http.Handler) http.Handler { + return s.recoverPanic(s.assignRequestID(s.resolveClientInfo(s.accessLog(next)))) +} + +// recoverPanic guarantees no panic escapes a request path: it logs the stack +// and returns the standard JSON error envelope. +func (s *Server) recoverPanic(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if rec := recover(); rec != nil { + if rec == http.ErrAbortHandler { // client went away mid-write + panic(rec) + } + s.log.Error("panic in request handler", + "err", fmt.Sprint(rec), + "requestId", RequestID(r.Context()), + "path", r.URL.Path, + "stack", string(debug.Stack())) + s.metrics.Inc("http_panics_total", 1) + writeError(w, http.StatusInternalServerError, "internal", "internal server error") + } + }() + next.ServeHTTP(w, r) + }) +} + +func (s *Server) assignRequestID(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + id := ulid.New() + w.Header().Set("X-Request-Id", id) + next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxKeyRequestID, id))) + }) +} + +func (s *Server) resolveClientInfo(next http.Handler) http.Handler { + defaultProto := "http" + if r := s.cfg.AppBaseURL; len(r) >= 8 && r[:8] == "https://" { + defaultProto = "https" + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + info := resolveClient(r, s.cfg.TrustedProxyCIDRs, defaultProto) + next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxKeyClient, info))) + }) +} + +func (s *Server) accessLog(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + rec := &statusRecorder{ResponseWriter: w} + next.ServeHTTP(rec, r) + if rec.status == 0 { + rec.status = http.StatusOK + } + s.metrics.Inc("http_requests_total", 1) + s.metrics.Inc(fmt.Sprintf("http_responses_%dxx_total", rec.status/100), 1) + s.log.Info("request", + "method", r.Method, + "path", r.URL.Path, + "status", rec.status, + "durationMs", time.Since(start).Milliseconds(), + "clientIp", Client(r.Context()).IP.String(), + "requestId", RequestID(r.Context())) + }) +} diff --git a/internal/http/proxy.go b/internal/http/proxy.go new file mode 100644 index 0000000..03c2379 --- /dev/null +++ b/internal/http/proxy.go @@ -0,0 +1,84 @@ +package httpx + +import ( + "net/http" + "net/netip" + "strings" +) + +// clientInfo is the resolved view of the requester after applying the +// trusted-proxy policy. It feeds rate limiting, sessions, and the audit log. +type clientInfo struct { + IP netip.Addr + Proto string // effective scheme: "http" or "https" + Host string // effective host header +} + +func parsePeer(remoteAddr string) netip.Addr { + if ap, err := netip.ParseAddrPort(remoteAddr); err == nil { + return ap.Addr().Unmap() + } + if a, err := netip.ParseAddr(remoteAddr); err == nil { + return a.Unmap() + } + return netip.Addr{} +} + +func isTrusted(a netip.Addr, trusted []netip.Prefix) bool { + if !a.IsValid() { + return false + } + for _, p := range trusted { + if p.Contains(a) { + return true + } + } + return false +} + +// resolveClient applies §12: X-Forwarded-For / X-Forwarded-Proto / +// X-Forwarded-Host are honored only when the direct peer is inside +// TRUSTED_PROXY_CIDRS; otherwise the headers are ignored entirely. +// +// The client IP is found by walking the X-Forwarded-For chain from the +// rightmost entry (appended by our own proxy) towards the left, skipping +// addresses that are themselves trusted proxies; the first untrusted address +// is the real client. A malformed entry stops the walk (everything further +// left is attacker-controllable). +func resolveClient(r *http.Request, trusted []netip.Prefix, defaultProto string) clientInfo { + peer := parsePeer(r.RemoteAddr) + info := clientInfo{IP: peer, Proto: defaultProto, Host: r.Host} + if len(trusted) == 0 || !isTrusted(peer, trusted) { + return info + } + + var chain []string + for _, h := range r.Header.Values("X-Forwarded-For") { + for part := range strings.SplitSeq(h, ",") { + if part = strings.TrimSpace(part); part != "" { + chain = append(chain, part) + } + } + } + for i := len(chain) - 1; i >= 0; i-- { + a := parsePeer(chain[i]) + if !a.IsValid() { + break + } + info.IP = a + if !isTrusted(a, trusted) { + break + } + // a is itself a trusted proxy: keep walking left. If the whole chain + // is trusted, the leftmost entry stands as the best available answer. + } + + switch p := strings.ToLower(strings.TrimSpace(r.Header.Get("X-Forwarded-Proto"))); p { + case "http", "https": + info.Proto = p + } + if h := strings.TrimSpace(r.Header.Get("X-Forwarded-Host")); h != "" { + info.Host = h + } + return info +} diff --git a/internal/http/proxy_test.go b/internal/http/proxy_test.go new file mode 100644 index 0000000..43274c4 --- /dev/null +++ b/internal/http/proxy_test.go @@ -0,0 +1,154 @@ +package httpx + +import ( + "net/http" + "net/http/httptest" + "net/netip" + "testing" +) + +func prefixes(t *testing.T, cidrs ...string) []netip.Prefix { + t.Helper() + var out []netip.Prefix + for _, c := range cidrs { + p, err := netip.ParsePrefix(c) + if err != nil { + t.Fatal(err) + } + out = append(out, p) + } + return out +} + +func TestResolveClient(t *testing.T) { + trusted := "172.16.0.0/12,127.0.0.0/8" + tests := []struct { + name string + remoteAddr string + cidrs string + hdr map[string][]string + wantIP string + wantProto string + wantHost string // empty = request Host + }{ + { + name: "no proxies configured: headers ignored", + remoteAddr: "203.0.113.7:4444", + cidrs: "", + hdr: map[string][]string{"X-Forwarded-For": {"198.51.100.1"}, "X-Forwarded-Proto": {"https"}}, + wantIP: "203.0.113.7", + wantProto: "http", + }, + { + name: "untrusted peer spoofing headers: ignored", + remoteAddr: "203.0.113.7:4444", + cidrs: trusted, + hdr: map[string][]string{"X-Forwarded-For": {"198.51.100.1"}, "X-Forwarded-Proto": {"https"}, "X-Forwarded-Host": {"evil.example"}}, + wantIP: "203.0.113.7", + wantProto: "http", + }, + { + name: "trusted peer, single hop", + remoteAddr: "172.18.0.2:1234", + cidrs: trusted, + hdr: map[string][]string{"X-Forwarded-For": {"198.51.100.1"}, "X-Forwarded-Proto": {"https"}, "X-Forwarded-Host": {"bounty.example.com"}}, + wantIP: "198.51.100.1", + wantProto: "https", + wantHost: "bounty.example.com", + }, + { + name: "client-spoofed XFF prefix is skipped (rightmost untrusted wins)", + remoteAddr: "172.18.0.2:1234", + cidrs: trusted, + hdr: map[string][]string{"X-Forwarded-For": {"6.6.6.6, 198.51.100.1"}}, + wantIP: "198.51.100.1", + wantProto: "http", + }, + { + name: "chain of trusted proxies collapses to real client", + remoteAddr: "172.18.0.2:1234", + cidrs: trusted, + hdr: map[string][]string{"X-Forwarded-For": {"198.51.100.1, 172.20.0.5, 172.20.0.9"}}, + wantIP: "198.51.100.1", + wantProto: "http", + }, + { + name: "all-trusted chain falls back to leftmost", + remoteAddr: "127.0.0.1:9999", + cidrs: trusted, + hdr: map[string][]string{"X-Forwarded-For": {"172.20.0.5"}}, + wantIP: "172.20.0.5", + wantProto: "http", + }, + { + name: "trusted peer without forwarded headers", + remoteAddr: "172.18.0.2:1234", + cidrs: trusted, + hdr: nil, + wantIP: "172.18.0.2", + wantProto: "http", + }, + { + name: "malformed XFF entry stops the walk", + remoteAddr: "172.18.0.2:1234", + cidrs: trusted, + hdr: map[string][]string{"X-Forwarded-For": {"garbage, 198.51.100.1"}}, + wantIP: "198.51.100.1", + wantProto: "http", + }, + { + name: "multiple XFF headers are concatenated in order", + remoteAddr: "172.18.0.2:1234", + cidrs: trusted, + hdr: map[string][]string{"X-Forwarded-For": {"198.51.100.1", "172.20.0.5"}}, + wantIP: "198.51.100.1", + wantProto: "http", + }, + { + name: "invalid forwarded proto ignored", + remoteAddr: "172.18.0.2:1234", + cidrs: trusted, + hdr: map[string][]string{"X-Forwarded-For": {"198.51.100.1"}, "X-Forwarded-Proto": {"gopher"}}, + wantIP: "198.51.100.1", + wantProto: "http", + }, + { + name: "ipv6 client through trusted proxy", + remoteAddr: "172.18.0.2:1234", + cidrs: trusted, + hdr: map[string][]string{"X-Forwarded-For": {"2001:db8::1"}}, + wantIP: "2001:db8::1", + wantProto: "http", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "http://app.local/", nil) + r.RemoteAddr = tt.remoteAddr + for k, vs := range tt.hdr { + for _, v := range vs { + r.Header.Add(k, v) + } + } + var cidrs []netip.Prefix + if tt.cidrs != "" { + cidrs = prefixes(t, "172.16.0.0/12", "127.0.0.0/8") + } + got := resolveClient(r, cidrs, "http") + if got.IP.String() != tt.wantIP { + t.Errorf("IP = %s, want %s", got.IP, tt.wantIP) + } + if got.Proto != tt.wantProto { + t.Errorf("Proto = %s, want %s", got.Proto, tt.wantProto) + } + wantHost := tt.wantHost + if wantHost == "" { + wantHost = "app.local" + } + if got.Host != wantHost { + t.Errorf("Host = %s, want %s", got.Host, wantHost) + } + }) + } +} diff --git a/internal/http/respond.go b/internal/http/respond.go new file mode 100644 index 0000000..a688ad3 --- /dev/null +++ b/internal/http/respond.go @@ -0,0 +1,34 @@ +// Package httpx implements the application HTTP server: routing, middleware +// (recovery, request id, trusted-proxy client resolution, access logging), +// health endpoints, and shared JSON response helpers. Named httpx to avoid +// clashing with net/http; it lives in internal/http per the repository layout. +package httpx + +import ( + "encoding/json" + "log/slog" + "net/http" +) + +// errorEnvelope is the consistent error body for every JSON error response: +// {"error":{"code":"...","message":"..."}}. +type errorEnvelope struct { + Error errorBody `json:"error"` +} + +type errorBody struct { + Code string `json:"code"` + Message string `json:"message"` +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + if err := json.NewEncoder(w).Encode(v); err != nil { + slog.Error("write json response", "err", err) + } +} + +func writeError(w http.ResponseWriter, status int, code, message string) { + writeJSON(w, status, errorEnvelope{Error: errorBody{Code: code, Message: message}}) +} diff --git a/internal/http/server.go b/internal/http/server.go new file mode 100644 index 0000000..0006d7f --- /dev/null +++ b/internal/http/server.go @@ -0,0 +1,78 @@ +package httpx + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "time" + + "bountyboard/internal/config" + "bountyboard/internal/metrics" +) + +// shutdownBudget is the §12 graceful-shutdown drain window. +const shutdownBudget = 15 * time.Second + +type Server struct { + cfg *config.Config + log *slog.Logger + metrics *metrics.Registry + checks []ReadinessCheck + startedAt time.Time + httpSrv *http.Server +} + +func New(cfg *config.Config, log *slog.Logger, reg *metrics.Registry) *Server { + s := &Server{ + cfg: cfg, + log: log, + metrics: reg, + startedAt: time.Now(), + } + + mux := http.NewServeMux() + mux.HandleFunc("GET /healthz", s.handleHealthz) + mux.HandleFunc("GET /readyz", s.handleReadyz) + mux.HandleFunc("GET /metricsz", s.handleMetricsz) + + s.httpSrv = &http.Server{ + Addr: fmt.Sprintf(":%d", cfg.AppPort), + Handler: s.withMiddleware(mux), + ReadHeaderTimeout: 10 * time.Second, + ErrorLog: slog.NewLogLogger(log.Handler(), slog.LevelWarn), + } + return s +} + +// Handler exposes the full middleware-wrapped handler for tests. +func (s *Server) Handler() http.Handler { return s.httpSrv.Handler } + +// Run serves until ctx is canceled (SIGTERM/SIGINT), then drains in-flight +// requests within the shutdown budget. +func (s *Server) Run(ctx context.Context) error { + errCh := make(chan error, 1) + go func() { + s.log.Info("http server listening", "addr", s.httpSrv.Addr, "baseUrl", s.cfg.AppBaseURL) + if err := s.httpSrv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { + errCh <- err + return + } + errCh <- nil + }() + + select { + case err := <-errCh: + return fmt.Errorf("http server: %w", err) + case <-ctx.Done(): + } + + s.log.Info("shutting down", "budget", shutdownBudget.String()) + shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownBudget) + defer cancel() + if err := s.httpSrv.Shutdown(shutdownCtx); err != nil { + return fmt.Errorf("graceful shutdown: %w", err) + } + return <-errCh +} diff --git a/internal/http/server_test.go b/internal/http/server_test.go new file mode 100644 index 0000000..babe95b --- /dev/null +++ b/internal/http/server_test.go @@ -0,0 +1,172 @@ +package httpx + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + + "bountyboard/internal/config" + "bountyboard/internal/metrics" + "bountyboard/internal/ulid" +) + +func newTestServer(t *testing.T) *Server { + t.Helper() + t.Setenv("SESSION_SECRET", "0123456789abcdef0123456789abcdef") + t.Setenv("CREDENTIALS_ENC_KEY", base64.StdEncoding.EncodeToString(make([]byte, 32))) + cfg, err := config.Load() + if err != nil { + t.Fatalf("config: %v", err) + } + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + return New(cfg, log, metrics.NewRegistry()) +} + +func get(t *testing.T, h http.Handler, path string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodGet, path, nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec +} + +func TestHealthz(t *testing.T) { + s := newTestServer(t) + rec := get(t, s.Handler(), "/healthz") + if rec.Code != http.StatusOK { + t.Fatalf("status = %d", rec.Code) + } + var body map[string]string + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if body["status"] != "ok" { + t.Errorf("status field = %q", body["status"]) + } + if id := rec.Header().Get("X-Request-Id"); !ulid.IsValid(id) { + t.Errorf("X-Request-Id %q is not a ULID", id) + } +} + +func TestReadyzAggregation(t *testing.T) { + tests := []struct { + name string + checks []ReadinessCheck + wantStatus string + wantCode int + }{ + { + name: "no checks", + wantStatus: "ok", + wantCode: http.StatusOK, + }, + { + name: "all passing", + checks: []ReadinessCheck{ + {Name: "mongo", Required: true, Probe: func(context.Context) error { return nil }}, + {Name: "atomizer", Probe: func(context.Context) error { return nil }}, + }, + wantStatus: "ok", + wantCode: http.StatusOK, + }, + { + name: "optional failing is non-fatal", + checks: []ReadinessCheck{ + {Name: "mongo", Required: true, Probe: func(context.Context) error { return nil }}, + {Name: "atomizer", Probe: func(context.Context) error { return errors.New("down") }}, + }, + wantStatus: "degraded", + wantCode: http.StatusOK, + }, + { + name: "required failing gates readiness", + checks: []ReadinessCheck{ + {Name: "mongo", Required: true, Probe: func(context.Context) error { return errors.New("no reachable servers") }}, + }, + wantStatus: "unavailable", + wantCode: http.StatusServiceUnavailable, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := newTestServer(t) + for _, c := range tt.checks { + s.AddReadinessCheck(c) + } + rec := get(t, s.Handler(), "/readyz") + if rec.Code != tt.wantCode { + t.Fatalf("code = %d, want %d", rec.Code, tt.wantCode) + } + var body struct { + Status string `json:"status"` + Checks map[string]checkResult `json:"checks"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if body.Status != tt.wantStatus { + t.Errorf("status = %q, want %q", body.Status, tt.wantStatus) + } + if len(body.Checks) != len(tt.checks) { + t.Errorf("checks = %d, want %d", len(body.Checks), len(tt.checks)) + } + }) + } +} + +func TestMetricszCountsRequests(t *testing.T) { + s := newTestServer(t) + get(t, s.Handler(), "/healthz") + get(t, s.Handler(), "/healthz") + rec := get(t, s.Handler(), "/metricsz") + if rec.Code != http.StatusOK { + t.Fatalf("status = %d", rec.Code) + } + var body struct { + Counters map[string]int64 `json:"counters"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if body.Counters["http_requests_total"] != 2 { + t.Errorf("http_requests_total = %d, want 2", body.Counters["http_requests_total"]) + } +} + +func TestPanicRecoveryEnvelope(t *testing.T) { + s := newTestServer(t) + mux := http.NewServeMux() + mux.HandleFunc("GET /boom", func(http.ResponseWriter, *http.Request) { panic("kaboom") }) + h := s.withMiddleware(mux) + + rec := get(t, h, "/boom") + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d", rec.Code) + } + var env struct { + Error struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &env); err != nil { + t.Fatal(err) + } + if env.Error.Code != "internal" { + t.Errorf("error code = %q", env.Error.Code) + } +} + +func TestUnknownRouteNotFound(t *testing.T) { + s := newTestServer(t) + rec := get(t, s.Handler(), "/nope") + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404", rec.Code) + } +} diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go new file mode 100644 index 0000000..99ed1c0 --- /dev/null +++ b/internal/metrics/metrics.go @@ -0,0 +1,58 @@ +// Package metrics is a tiny in-process registry of named counters and gauges +// exposed as JSON at /metricsz. No external metrics system is required. +package metrics + +import ( + "sync" + "sync/atomic" +) + +type Registry struct { + mu sync.RWMutex + counters map[string]*atomic.Int64 + gauges map[string]*atomic.Int64 +} + +func NewRegistry() *Registry { + return &Registry{ + counters: make(map[string]*atomic.Int64), + gauges: make(map[string]*atomic.Int64), + } +} + +func (r *Registry) cell(m map[string]*atomic.Int64, name string) *atomic.Int64 { + r.mu.RLock() + c, ok := m[name] + r.mu.RUnlock() + if ok { + return c + } + r.mu.Lock() + defer r.mu.Unlock() + if c, ok = m[name]; !ok { + c = new(atomic.Int64) + m[name] = c + } + return c +} + +// Inc adds delta to a monotonic counter. +func (r *Registry) Inc(name string, delta int64) { r.cell(r.counters, name).Add(delta) } + +// Set assigns the current value of a gauge (e.g. queue depth). +func (r *Registry) Set(name string, value int64) { r.cell(r.gauges, name).Store(value) } + +// Snapshot returns a point-in-time copy of all values for /metricsz. +func (r *Registry) Snapshot() (counters, gauges map[string]int64) { + r.mu.RLock() + defer r.mu.RUnlock() + counters = make(map[string]int64, len(r.counters)) + for k, v := range r.counters { + counters[k] = v.Load() + } + gauges = make(map[string]int64, len(r.gauges)) + for k, v := range r.gauges { + gauges[k] = v.Load() + } + return counters, gauges +} diff --git a/internal/ulid/ulid.go b/internal/ulid/ulid.go new file mode 100644 index 0000000..23ed51c --- /dev/null +++ b/internal/ulid/ulid.go @@ -0,0 +1,94 @@ +// Package ulid generates ULIDs (https://github.com/ulid/spec): 26-character +// Crockford-base32 strings of a 48-bit millisecond timestamp followed by +// 80 bits of cryptographic randomness. Lexicographic order equals creation +// order at millisecond resolution, which the schema relies on for cursor +// pagination and time-ordered message ids. +package ulid + +import ( + "crypto/rand" + "fmt" + "time" +) + +const alphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" + +// Len is the length of the canonical string form. +const Len = 26 + +var decode [256]byte + +func init() { + for i := range decode { + decode[i] = 0xFF + } + for i := 0; i < len(alphabet); i++ { + decode[alphabet[i]] = byte(i) + // Crockford base32 is case-insensitive. + decode[alphabet[i]|0x20] = byte(i) + } +} + +// New returns a fresh ULID for the current time. +func New() string { return At(time.Now()) } + +// At returns a ULID whose timestamp component is t (randomness still fresh). +func At(t time.Time) string { + var b [16]byte + ms := uint64(t.UnixMilli()) + b[0] = byte(ms >> 40) + b[1] = byte(ms >> 32) + b[2] = byte(ms >> 24) + b[3] = byte(ms >> 16) + b[4] = byte(ms >> 8) + b[5] = byte(ms) + if _, err := rand.Read(b[6:]); err != nil { + // crypto/rand never fails on supported platforms; if it does, the + // process cannot safely generate ids at all. + panic(fmt.Sprintf("ulid: crypto/rand failed: %v", err)) + } + + // Base32-encode 128 bits into 26 chars (130-bit capacity, top 2 bits 0): + // consume bytes from the least-significant end, emit 5-bit groups + // right-to-left, which yields the canonical big-endian representation. + var out [Len]byte + acc, nbits, j := uint32(0), 0, Len-1 + for i := 15; i >= 0; i-- { + acc |= uint32(b[i]) << nbits + nbits += 8 + for nbits >= 5 && j > 0 { + out[j] = alphabet[acc&31] + acc >>= 5 + nbits -= 5 + j-- + } + } + out[0] = alphabet[acc&31] // remaining 3 timestamp bits + return string(out[:]) +} + +// IsValid reports whether s is a well-formed ULID. +func IsValid(s string) bool { + if len(s) != Len { + return false + } + for i := 0; i < Len; i++ { + if decode[s[i]] == 0xFF { + return false + } + } + // First char encodes the top 3 bits of a 48-bit value; > '7' overflows. + return decode[s[0]] <= 7 +} + +// Timestamp extracts the embedded creation time. +func Timestamp(s string) (time.Time, error) { + if !IsValid(s) { + return time.Time{}, fmt.Errorf("ulid: invalid id %q", s) + } + var ms uint64 + for i := 0; i < 10; i++ { // first 10 chars = 50 bits, low 48 are the timestamp + ms = ms<<5 | uint64(decode[s[i]]) + } + return time.UnixMilli(int64(ms)).UTC(), nil +} diff --git a/internal/ulid/ulid_test.go b/internal/ulid/ulid_test.go new file mode 100644 index 0000000..a104fff --- /dev/null +++ b/internal/ulid/ulid_test.go @@ -0,0 +1,92 @@ +package ulid + +import ( + "sort" + "strings" + "testing" + "time" +) + +func TestNewShape(t *testing.T) { + id := New() + if len(id) != Len { + t.Fatalf("len = %d, want %d", len(id), Len) + } + for i := 0; i < len(id); i++ { + if !strings.ContainsRune(alphabet, rune(id[i])) { + t.Fatalf("char %q at %d not in Crockford alphabet", id[i], i) + } + } + if !IsValid(id) { + t.Fatalf("IsValid(%q) = false", id) + } +} + +func TestUniqueness(t *testing.T) { + const n = 10000 + seen := make(map[string]struct{}, n) + for i := 0; i < n; i++ { + id := New() + if _, dup := seen[id]; dup { + t.Fatalf("duplicate ULID %q after %d ids", id, i) + } + seen[id] = struct{}{} + } +} + +func TestLexicographicTimeOrdering(t *testing.T) { + base := time.Now() + var ids []string + for i := 0; i < 50; i++ { + ids = append(ids, At(base.Add(time.Duration(i)*time.Millisecond))) + } + if !sort.StringsAreSorted(ids) { + t.Fatal("ULIDs with increasing timestamps are not lexicographically sorted") + } +} + +func TestTimestampRoundTrip(t *testing.T) { + tests := []time.Time{ + time.UnixMilli(0), + time.Date(2026, 6, 12, 12, 0, 0, 0, time.UTC), + time.Now(), + } + for _, want := range tests { + id := At(want) + got, err := Timestamp(id) + if err != nil { + t.Fatalf("Timestamp(%q): %v", id, err) + } + if got.UnixMilli() != want.UnixMilli() { + t.Errorf("timestamp = %v, want %v", got.UnixMilli(), want.UnixMilli()) + } + } +} + +func TestIsValid(t *testing.T) { + tests := []struct { + in string + want bool + }{ + {New(), true}, + {strings.ToLower(New()), true}, // Crockford decoding is case-insensitive + {"", false}, + {"short", false}, + {strings.Repeat("0", 25), false}, + {strings.Repeat("0", 27), false}, + {strings.Repeat("U", 26), false}, // U not in alphabet + {"8" + strings.Repeat("0", 25), false}, // timestamp overflow (>48 bits) + {"7" + strings.Repeat("Z", 25), true}, + } + for _, tt := range tests { + if got := IsValid(tt.in); got != tt.want { + t.Errorf("IsValid(%q) = %v, want %v", tt.in, got, tt.want) + } + } +} + +func TestTimestampRejectsInvalid(t *testing.T) { + if _, err := Timestamp("definitely-not-a-ulid!!!!!"); err == nil { + t.Fatal("want error") + } +} diff --git a/scripts/smoke.sh b/scripts/smoke.sh new file mode 100755 index 0000000..a70ace2 --- /dev/null +++ b/scripts/smoke.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Curl-based smoke test against a running stack (docker compose up -d). +# Grows with the system; later phases add register→login→board coverage. +set -euo pipefail + +BASE_URL="${BASE_URL:-http://localhost:8787}" +fail() { echo "SMOKE FAIL: $*" >&2; exit 1; } + +echo "smoke: $BASE_URL" + +# healthz must always be 200 while the process is up +code=$(curl -fsS -o /tmp/smoke-healthz.json -w '%{http_code}' "$BASE_URL/healthz") \ + || fail "healthz unreachable" +[ "$code" = "200" ] || fail "healthz returned $code" +grep -q '"ok"' /tmp/smoke-healthz.json || fail "healthz body unexpected: $(cat /tmp/smoke-healthz.json)" +echo " healthz ok" + +# readyz reflects dependency state; required deps must be up for the smoke run +code=$(curl -sS -o /tmp/smoke-readyz.json -w '%{http_code}' "$BASE_URL/readyz") \ + || fail "readyz unreachable" +[ "$code" = "200" ] || fail "readyz returned $code: $(cat /tmp/smoke-readyz.json)" +echo " readyz ok" + +# metricsz exposes counters +curl -fsS "$BASE_URL/metricsz" | grep -q '"counters"' || fail "metricsz body unexpected" +echo " metricsz ok" + +echo "SMOKE PASS" diff --git a/specification.md b/specification.md new file mode 100644 index 0000000..860ce79 --- /dev/null +++ b/specification.md @@ -0,0 +1,813 @@ +# Bounty Board — System Specification + +Version: 1.0 (draft) +Status: Ready for implementation + +--- + +## 1. Overview + +Bounty Board is a consulting work-management platform. It imports tickets from a customer's +existing ticketing system (Jira, Azure DevOps, or YouTrack), lets a **consultant** atomize +them into small, well-scoped developer tasks (with AI assistance via an external Atomization +Service), publishes those tasks on a **bounty board** where **developers** claim work, and +tracks review, approval, and bounty-based performance metrics. Tasks may alternatively be +assigned to an **AI work performer** (pluggable interface; Claude Code-based placeholder). + +### 1.1 Core flow + +``` +Customer ticketing system (Jira / Azure DevOps / YouTrack) + │ (ticket assigned to consultant's linked account) + ▼ + Sync worker (poll) ──► Imported task on Atomization Board (consultant view) + │ + ▼ + Consultant: Subdivide (optional note) ──► Atomization Service ──► N subtasks + Extend (required note) ──► Extension API ──► 1 sibling task + │ (consultant edits titles/descriptions/AC/effort coefficients, sets budget) + ▼ + Publish ──► Bounty Board (developer view, bounty = coefficient × budget) + │ + ├── Developer requests assignment ──► Consultant approves ──► In progress + │ ──► Developer marks "In review" ──► Consultant approves/rejects + │ ──► Approved: bounty awarded to developer, synced stats + │ + └── Consultant assigns to AI ──► Work Performer Service ──► result ──► review +``` + +### 1.2 Goals and non-goals + +Goals: reliability, speed (proper Mongo indexes), minimal dependencies, modern square-edged +UI with light (beige) and dark themes, OIDC SSO **and** local accounts, instant messaging +with files/images/rich text, health checks, full test coverage, Docker Compose deployment. + +Non-goals (v1): real AI work performer implementation (interface + Claude Code placeholder +container only), billing/payments (bounty is a performance metric, not money), mobile apps, +pushing changes back into the customer's ticketing system (read-only sync in v1; status +write-back is listed as a quality-of-life extension). + +--- + +## 2. Technology choices (minimal dependencies) + +### 2.1 Backend — Go (≥ 1.22) + +| Concern | Choice | Rationale | +|---|---|---| +| HTTP server & routing | `net/http` stdlib (Go 1.22 pattern routing: `GET /api/v1/tasks/{id}`) | zero deps, reliable | +| MongoDB | `go.mongodb.org/mongo-driver/v2` | official driver, required | +| OIDC | `github.com/coreos/go-oidc/v3` + `golang.org/x/oauth2` | de-facto standard, small | +| Passwords | `golang.org/x/crypto/argon2` | stdlib-adjacent | +| WebSockets (chat, live updates) | `github.com/coder/websocket` (single, maintained, no transitive deps) | gorilla is in maintenance mode; coder/websocket is minimal | +| JWT sessions | **none** — opaque session tokens stored in Mongo, httpOnly+Secure+SameSite=Lax cookie | fewer deps, instant revocation | +| Config | stdlib `os.Getenv` + tiny internal `.env` loader (~40 lines, no lib) | zero deps | +| Logging | `log/slog` stdlib, JSON handler | zero deps | +| Testing | stdlib `testing` + `httptest`; `testcontainers` is NOT used — integration tests run against the compose Mongo with a dedicated test database | zero extra deps | + +Everything else (Jira/Azure/YouTrack clients, rate limiting, CSRF, validation, ID +generation, AES-GCM encryption of customer credentials) is implemented with the standard +library. + +### 2.2 Frontend — no framework, no build step + +- Server-rendered Go `html/template` pages + small vanilla ES-module JavaScript per page. +- No npm, no bundler. Static assets served by the Go binary from `embed.FS`. +- CSS: hand-written, CSS custom properties for theming (`data-theme="light|dark"` on + ``, persisted in `localStorage` + user profile). Square edges: `--radius: 2px` + globally. Light theme = beige palette (see §10). +- Live updates: one WebSocket per logged-in session multiplexing channels + (`chat`, `board`, `notifications`). Graceful fallback to 15 s polling if WS fails. +- Rich text in chat: `contenteditable` with a whitelist sanitizer **on the server** + (allowed tags: `b i u s a code pre ul ol li br p blockquote`). No editor library. +- Drag & drop file/image upload in chat; images previewed inline. + +### 2.3 Services (Docker Compose) + +| Service | Image / build | Purpose | +|---|---|---| +| `app` | `./` (multi-stage Go build, distroless or alpine) | main Bounty Board app | +| `mongo` | `mongo:7` | database, named volume | +| `atomizer-mock` | `./services/atomizer` (Go, port 8090, compose profile `mocks`) | standalone placeholder Atomization Service (atomize + extend API) backed by Anthropic's OpenAI-compatible chat-completions endpoint | +| `work-performer` | `./services/work-performer` (Node base + `@anthropic-ai/claude-code`, port 8091, compose profile `mocks`) | standalone placeholder Work Performer Service; mounts local Claude Code config/secrets | + +--- + +## 3. Roles & permissions + +One account can hold multiple roles (boolean flags on the user document). + +| Capability | Admin | Consultant | Developer | +|---|---|---|---| +| Application settings (SMTP, OIDC, atomizer URL override, branding) | ✔ | | | +| Customer CRUD + ticketing-system connection setup & test | ✔ | | | +| User management (roles, disable, reset password, delete) | ✔ | | | +| Assign consultants to customers/projects | ✔ | | | +| Select developers from global pool into own pool | | ✔ | | +| Atomization board (subdivide / extend / edit / set budget / publish) | | ✔ (own customers) | | +| Approve developer assignment requests; assign to AI | | ✔ | | +| Review / approve / reject submitted work; award bounty | | ✔ | | +| Bounty board (view + request assignment for own consultants' tasks) | | | ✔ | +| Work tracking on own tasks (status, comments, time log, mark for review) | | | ✔ | +| Own metrics dashboard | ✔ (global) | ✔ (per project/dev) | ✔ (own) | +| Messaging | ✔ | ✔ | ✔ | +| Edit own profile (avatar, name, contacts, bio, arbitrary key/value fields) | ✔ | ✔ | ✔ | + +Registration: open self-registration creates a **developer** account in the global pool. +Admin and consultant flags are granted only by an admin. First-run bootstrap: if the users +collection is empty, credentials from `ADMIN_EMAIL` / `ADMIN_INITIAL_PASSWORD` env vars +create the initial admin (password change forced at first login). + +--- + +## 4. Data model (MongoDB, database `bountyboard`) + +Conventions: `_id` is a ULID string (sortable, generated in Go, no dep — ~80-line internal +package). All documents carry `createdAt`, `updatedAt` (UTC), and `version` (int, +incremented on update; used for optimistic concurrency — updates filter on `version` and +return 409 on mismatch). + +### 4.1 `users` + +```jsonc +{ + "_id": "01J...", + "email": "a@b.c", // unique, lowercase + "name": "Jane Doe", + "avatarFileId": "01J...|null", // ref files + "bio": "…", + "contact": { "phone": "…", "location": "…", "links": ["…"] }, + "extra": { "anyKey": "anyValue" }, // arbitrary admin/self-defined info + "roles": { "admin": false, "consultant": false, "developer": true }, + "auth": { + "local": { "passwordHash": "argon2id$…", "mustChange": false } | null, + "oidc": { "issuer": "…", "subject": "…" } | null // at least one of local/oidc + }, + "settings": { "theme": "light", "notifications": { "email": true, "inApp": true } }, + "disabled": false, + "lastSeenAt": ISODate +} +``` + +### 4.2 `customers` + +```jsonc +{ + "_id": "01J...", + "name": "ACME Corp", + "ticketing": { + "type": "jira" | "azure_devops" | "youtrack", + "baseUrl": "https://acme.atlassian.net", + // encrypted blob, AES-256-GCM with key from CREDENTIALS_ENC_KEY (32B base64): + "credentialsEnc": "base64(nonce|ciphertext)", + // plaintext shape before encryption, by type: + // jira: { "email": "...", "apiToken": "..." } + // azure_devops:{ "organization": "...", "project": "...", "pat": "..." } + // youtrack: { "permanentToken": "..." } + "projectKey": "ACME", // Jira project key / ADO project / YouTrack project + "pollIntervalSec": 60, + "lastSyncAt": ISODate, "lastSyncStatus": "ok|error", "lastSyncError": "…" + }, + "consultantIds": ["01J..."], // consultants assigned to this customer (≥1 to sync) + "archived": false +} +``` + +A "project" in the UI == a customer. Multiple consultants per customer and multiple +customers per consultant are both supported by this single array. + +### 4.3 `pools` — developer pool membership + +```jsonc +{ "_id": "01J...", "consultantId": "01J...", "developerId": "01J...", "addedAt": ISODate } +``` + +### 4.4 `tasks` + +```jsonc +{ + "_id": "01J...", + "customerId": "01J...", + "consultantId": "01J...", // owning consultant (the sync assignee); ALL + // consultants assigned to the customer can view and + // manage the task — consultantId records provenance + // and is the default reviewer/notification target + "origin": "imported" | "subdivided" | "extended", + "parentId": "01J...|null", // imported root for subdivided; sibling source for extended + "rootId": "01J...", // top imported ancestor (== _id for imported) + "external": { // only for origin=imported + "system": "jira|azure_devops|youtrack", + "key": "ACME-123", "url": "…", + "type": "epic" | "story" | "task", // shown as icon only; treated uniformly + "raw": { … } // original payload snapshot + } | null, + "title": "…", + "description": "…", // markdown-ish plain text + "acceptanceCriteria": ["…", "…"], + "attachments": [{ "name": "…", "url": "…", "mimeType": "…", "fileId": "01J...|null" }], + "links": ["https://…"], + "effortCoefficient": 0.25, // 0..1; siblings under one parent sum to 1.0 + "budget": 1000, // set by consultant on root; inherited, overridable + "bounty": 250, // computed = effortCoefficient × budget; denormalized + "status": "imported" | "atomizing" | "atomized" | "published" + | "claim_requested" | "assigned" | "in_progress" + | "in_review" | "changes_requested" | "approved" | "archived", + "assignee": { "kind": "human"|"ai", "userId": "01J...|null", "jobId": "…|null" } | null, + "claimRequests": [{ "developerId": "01J...", "note": "…", "at": ISODate }], + "atomizationNote": "…|null", // last subdivide note + "timeline": [{ "at": ISODate, "actorId": "01J...|system", "event": "…", "data": {…} }], + "comments": [{ "_id": "01J...", "authorId": "…", "body": "…(sanitized html)", "at": ISODate }], + "timeLog": [{ "developerId": "…", "minutes": 90, "note": "…", "at": ISODate }] +} +``` + +Status rules (enforced server-side, single source of truth in `internal/domain/status.go`): + +``` +imported ──subdivide──► atomizing ──service ok──► (children created: atomized) +imported/atomized ──extend──► sibling created (atomized) +atomized ──consultant publishes──► published +published ──dev requests──► claim_requested ──consultant approves──► assigned +published ──consultant assigns AI──► assigned (assignee.kind=ai) +assigned ──dev starts──► in_progress ──dev submits──► in_review +in_review ──consultant──► approved (bounty awarded) | changes_requested ──► in_progress +claim_requested ──developer withdraws / consultant declines──► published (declined devs notified) +assigned|in_progress ──consultant unassigns OR developer abandons──► published (timeline entry) +any ──consultant/admin──► archived +``` + +### 4.5 `bountyAwards` + +```jsonc +{ "_id": "01J...", "taskId": "…", "developerId": "…", "consultantId": "…", + "customerId": "…", "amount": 250, "coefficient": 0.25, "awardedAt": ISODate } +``` + +Immutable ledger; metrics aggregate from here (never recompute from mutable tasks). + +### 4.6 `conversations` and `messages` + +```jsonc +// conversations +{ "_id": "01J...", "kind": "dm" | "group" | "project", + "customerId": "01J...|null", // for kind=project + "title": "…|null", + "participantIds": ["…"], // for dm exactly 2; unique key for dm + "lastMessageAt": ISODate } + +// messages +{ "_id": "01J...", "conversationId": "…", "senderId": "…", + "body": "

sanitized rich text

", + "attachments": [{ "fileId": "…", "name": "…", "mimeType": "…", "size": 12345, + "isImage": true }], + "readBy": [{ "userId": "…", "at": ISODate }], + "editedAt": ISODate|null, "deletedAt": ISODate|null } +``` + +### 4.7 `files` (GridFS) + +Uploads (avatars, chat attachments, imported ticket attachments cached locally) stored in +GridFS buckets `fs`. Metadata: `ownerId`, `scope` (`avatar|chat|task`), `mimeType`, +`size`, `sha256`. Max upload size: `MAX_UPLOAD_MB` (default 25). Served via +`GET /files/{id}` with access control by scope. + +### 4.8 `sessions`, `auditLog`, `notifications`, `settings` + +- `sessions`: `{ _id: token(32B random, base64url), userId, createdAt, expiresAt, ip, ua }` + with TTL index on `expiresAt`. +- `auditLog`: every privileged mutation `{ at, actorId, action, entity, entityId, diff }`. +- `notifications`: `{ userId, kind, title, body, link, readAt|null, createdAt }`. +- `settings`: single document `{_id:"app"}` for admin-editable runtime settings + (env vars are the defaults; DB overrides where marked overridable). + +### 4.9 Indexes (created idempotently at startup, `internal/store/indexes.go`) + +``` +users: { email: 1 } unique + { "auth.oidc.issuer": 1, "auth.oidc.subject": 1 } unique sparse +customers: { name: 1 } unique; { consultantIds: 1 } +pools: { consultantId: 1, developerId: 1 } unique; { developerId: 1 } +tasks: { customerId: 1, status: 1, updatedAt: -1 } + { consultantId: 1, status: 1, updatedAt: -1 } + { "assignee.userId": 1, status: 1 } + { rootId: 1 } ; { parentId: 1 } + { "external.system": 1, "external.key": 1, customerId: 1 } unique sparse + { title: "text", description: "text" } // board search +bountyAwards: { developerId: 1, awardedAt: -1 } + { consultantId: 1, awardedAt: -1 } + { customerId: 1, awardedAt: -1 } +conversations: { participantIds: 1, lastMessageAt: -1 } + { kind: 1, customerId: 1 } +messages: { conversationId: 1, _id: 1 } // ULID ⇒ time-ordered +notifications: { userId: 1, readAt: 1, createdAt: -1 } +sessions: { expiresAt: 1 } expireAfterSeconds: 0; { userId: 1 } +auditLog: { at: -1 } ; { entityId: 1, at: -1 } +``` + +--- + +## 5. External service interfaces (REST) + +The Atomization Service and the Work Performer Service are **two fully independent +services**: separate codebases (`services/atomizer`, `services/work-performer`), +separate containers, separate base URLs, separate ports, **separate bearer tokens** +(`ATOMIZER_TOKEN`, `WORK_PERFORMER_TOKEN`), independent health checks, and independent +versioning. The app must never assume they are co-located, share state, or share +credentials; each is replaceable on its own by changing only its base URL + token in +`.env`. Both are **owned interfaces**: the Bounty Board app is the client; any +implementation honoring these contracts can be swapped in. Mock implementations ship in +this repo (§9). Common conventions only: JSON, UTF-8, `Authorization: Bearer `, +errors as `{ "error": { "code": "string", "message": "string" } }`. + +### 5.1 Atomization Service — base URL `ATOMIZER_BASE_URL` + +#### POST `/v1/atomize` + +Subdivides one task into smaller tasks. + +Request: + +```jsonc +{ + "taskId": "01J...", // for tracing/idempotency + "title": "Implement user import", + "description": "…full original description…", + "acceptanceCriteria": ["…"], // original AC (may be empty) + // attachment URLs carry a short-lived signed token (?st=hmac, 1 h TTL) so external + // services can fetch them WITHOUT a session cookie — /files/{id} accepts session OR valid st + "attachments": [{ "name": "spec.pdf", "url": "https://app/files/01J...?st=…", "mimeType": "application/pdf" }], + "links": ["https://acme.atlassian.net/browse/ACME-123"], + "subdivisionNote": "split backend/frontend, keep DB migration separate", // optional + "constraints": { "minTasks": 2, "maxTasks": 8 } // optional +} +``` + +Response `200`: + +```jsonc +{ + "tasks": [ + { + "title": "DB migration for user import", + "description": "…", + "acceptanceCriteria": ["migration is reversible", "…"], + "effortCoefficient": 0.2 // 0 < c ≤ 1 + }, + { "title": "…", "description": "…", "acceptanceCriteria": ["…"], "effortCoefficient": 0.5 }, + { "title": "…", "description": "…", "acceptanceCriteria": ["…"], "effortCoefficient": 0.3 } + ], + "model": "claude-sonnet-4-6", // optional metadata + "notes": "…" // optional, shown to consultant +} +``` + +Contract: `sum(effortCoefficient) == 1.0 ± 0.001`. The **client** (app) re-normalizes +coefficients defensively if the sum deviates ≤ 0.05, otherwise treats it as a 502-class +error. Timeout: `ATOMIZER_TIMEOUT_SEC` (default 120). The app sets the task to +`atomizing` and processes the call in a background job; UI updates over WebSocket. + +#### POST `/v1/extend` + +Creates exactly one parallel (sibling) task extending the source task's functionality. + +Request: same shape as `/v1/atomize` but with required `"extensionNote"` instead of +`subdivisionNote`, and no `constraints`. + +Response `200`: + +```jsonc +{ + "task": { + "title": "User import — CSV mapping presets", + "description": "…", + "acceptanceCriteria": ["…"], + "effortCoefficient": 0.4 // suggested effort RELATIVE TO THE SOURCE task (0..1+ allowed up to 2.0) + }, + "model": "…", "notes": "…" +} +``` + +The new sibling's bounty = its coefficient × the same `budget` as the source. Because an +extension adds scope, sibling coefficients under a parent are allowed to sum to > 1 once +extensions exist; the UI shows the per-parent sum and lets the consultant rebalance or +raise the budget. + +#### GET `/healthz` → `200 {"status":"ok"}` + +### 5.2 Work Performer Service — base URL `WORK_PERFORMER_BASE_URL` + +Asynchronous job API. v1 ships a placeholder implementation; the contract is final. + +- `POST /v1/jobs` — request body: + +```jsonc +{ + "taskId": "01J...", + "title": "…", "description": "…", "acceptanceCriteria": ["…"], + "attachments": [{ "name": "…", "url": "…", "mimeType": "…" }], + "links": ["…"], + "context": { "repositoryUrl": "…|null", "branch": "…|null", "instructions": "…|null" }, + "callbackUrl": "http://app:8787/api/v1/internal/work-results" // signed callback +} +``` + + → `202 { "jobId": "wp_…", "status": "queued" }` + +- `GET /v1/jobs/{jobId}` → `{ "jobId": "…", "status": "queued|running|succeeded|failed", + "startedAt": "…", "finishedAt": "…|null" }` +- `DELETE /v1/jobs/{jobId}` → cancel (best effort). +- Callback (service → app): `POST {callbackUrl}` with header + `X-Signature: hex(hmac-sha256(body, WORK_PERFORMER_TOKEN))`: + +```jsonc +{ "jobId": "wp_…", "taskId": "01J...", "status": "succeeded|failed", + "summary": "what was done", "artifacts": [{ "name": "patch.diff", "url": "…" }], + "log": "…tail of execution log…" } +``` + +On `succeeded`, the app verifies the signature, treats the callback as **idempotent by +`jobId`** (duplicates ignored), downloads all artifact URLs into GridFS (so review +survives the performer container), and moves the task to `in_review` with the AI as +submitter; the consultant reviews exactly as for a human. On `failed`, status returns to +`assigned` with a timeline entry and a consultant notification. + +- `GET /healthz` → `200`. + +### 5.3 Inbound ticketing sync (Jira / Azure DevOps / YouTrack) + +v1 uses **polling** (reliable, no inbound firewall requirements). A per-customer worker +runs every `pollIntervalSec`, querying for tickets **assigned to the consultant's linked +account** in that system, updated since `lastSyncAt − 5 min` (overlap window): + +| System | Query mechanism | +|---|---| +| Jira Cloud | `GET /rest/api/3/search?jql=assignee="" AND project= AND updated >= ""` (Basic auth: email+API token) | +| Azure DevOps | `POST …/_apis/wit/wiql?api-version=7.1` with WIQL `[System.AssignedTo] = '' AND [System.TeamProject] = '' AND [System.ChangedDate] >= ''`, then batch `GET workitems` (PAT auth) | +| YouTrack | `GET /api/issues?query=assignee: project: updated: .. *` (Bearer permanent token) | + +Mapping → task `external.type`: Jira Epic→epic, Story→story, else task; ADO Epic→epic, +User Story/PBI→story, else task; YouTrack by issue type name, default task. Each +consultant stores per-system identity in `users.extra.ticketingIdentities` +(`{ jira: "email", azure_devops: "email", youtrack: "login" }`). + +Upsert key: `(external.system, external.key, customerId)`. New tickets → status +`imported`, notification to consultant. Updated tickets → refresh `external.raw`, title, +description, attachments **only while status is `imported`** (after atomization begins, +changes only add a timeline entry "upstream ticket changed" + notification). Attachments +are downloaded into GridFS so the atomizer can fetch them from the app. If a previously +imported ticket is no longer returned for the consultant (reassigned or deleted +upstream), the task is **not** deleted: it gets `external.orphaned: true`, a timeline +entry, a consultant notification, and a visible badge; the consultant decides whether to +archive it. + +A "Test connection" button on the customer form calls a cheap authenticated endpoint +(`/myself`, `/_apis/projects`, `/api/users/me`) and reports success/failure inline. + +--- + +## 6. Application HTTP API (selected, `/api/v1`) + +All responses JSON. Auth: session cookie; CSRF: double-submit token header `X-CSRF-Token` +required on mutations. Pagination: `?limit=50&cursor=`. Full OpenAPI 3.1 document +is generated by hand into `api/openapi.yaml` and served at `/api/docs` (rendered with +embedded Swagger-UI-free minimal HTML viewer or plain YAML download). + +``` +Auth + POST /auth/register {email,name,password} → developer account + POST /auth/login {email,password} + POST /auth/logout + GET /auth/oidc/login → redirect to provider (PKCE) + GET /auth/oidc/callback + GET /auth/me + +Admin + GET/POST/PATCH /admin/users… roles, disable, force-reset + GET/POST/PATCH/DELETE /admin/customers… + POST /admin/customers/{id}/test-connection + POST /admin/customers/{id}/sync-now + GET/PATCH /admin/settings + GET /admin/audit-log + +Consultant + GET /consultant/board?customerId=&status= atomization board + POST /tasks/{id}/subdivide { note?: string, constraints? } → 202 (async) + POST /tasks/{id}/extend { note: string } → 202 (async) + PATCH /tasks/{id} edit title/description/AC/coefficient/budget (version-checked) + POST /tasks/{id}/publish single or POST /tasks/publish {ids:[…]} + POST /tasks/{id}/approve-claim { developerId } + POST /tasks/{id}/assign-ai { context?: {...} } + POST /tasks/{id}/review { decision: "approve"|"request_changes", note } + GET/POST/DELETE /consultant/pool… manage developer pool + GET /consultant/metrics?customerId=&developerId=&from=&to= + +Developer + GET /board bounty board (published tasks of dev's consultants) + filters: customer, search(q), minBounty, sort + POST /tasks/{id}/claim { note? } + POST /tasks/{id}/start | /submit-review + POST /tasks/{id}/comments { body } + POST /tasks/{id}/time { minutes, note } + GET /developer/metrics?from=&to= + +Shared + GET /tasks/{id} detail incl. timeline (role-scoped) + GET/POST /conversations… GET/POST /conversations/{id}/messages… + POST /files (multipart) GET /files/{id} + GET/PATCH /profile GET /users/{id}/card public profile card + GET /notifications POST /notifications/read + WS /ws multiplexed live channel + +Health & ops + GET /healthz liveness: always 200 if process up + GET /readyz readiness: Mongo ping + (non-fatal, reported) atomizer + and work-performer /healthz status + GET /metricsz JSON counters (requests, sync runs, queue depths) +``` + +### 6.1 Bounty calculation + +`bounty = round(effortCoefficient × budget, 2)`; recomputed and persisted whenever either +input changes and task is not yet `approved`. Once `approved`, the awarded amount in +`bountyAwards` is frozen forever. + +### 6.2 Metrics definitions + +- Developer: total bounty earned, tasks completed, approval rate + (approved / (approved + changes_requested submissions)), average time + assigned→approved, time logged, earnings-over-time series (weekly buckets), per-customer + breakdown. +- Consultant: same aggregations grouped per developer and per customer + atomization + throughput (imported→published lead time), open board depth. +- All computed via Mongo aggregation pipelines over `bountyAwards` + `tasks.timeline`; + rendered as SVG charts generated by ~150 lines of vanilla JS (no chart library). + +--- + +## 7. Authentication + +1. **Local**: email + password (argon2id, parameters: t=3, m=64MiB, p=2). Rate limit: + 10 attempts / 15 min / IP+email (in-memory token bucket). Optional email verification + when SMTP is configured (QoL, off by default). +2. **OIDC SSO**: standard code flow with PKCE against `OIDC_ISSUER_URL` / + `OIDC_CLIENT_ID` / `OIDC_CLIENT_SECRET`. On first OIDC login: if a local user with the + same verified email exists, link; otherwise create a developer account. Buttons appear + only when OIDC env vars are set. +3. Sessions: 32-byte random opaque token, httpOnly Secure SameSite=Lax cookie, 30-day + sliding expiry, server-side revocation (logout-all in profile). + +--- + +## 8. Configuration (`.env`, loaded by app and referenced in `docker-compose.yml`) + +`.env.example` (committed; real `.env` gitignored): + +```dotenv +# --- core --- +APP_BASE_URL=http://localhost:8787 # set to the public https URL when behind the proxy +TRUSTED_PROXY_CIDRS= # e.g. 172.16.0.0/12,10.0.0.0/8 — empty = trust no proxy headers +APP_PORT=8787 +MONGO_URI=mongodb://mongo:27017 +MONGO_DB=bountyboard +SESSION_SECRET=change-me-32-bytes-random +CREDENTIALS_ENC_KEY= # base64 32 bytes; `openssl rand -base64 32` +MAX_UPLOAD_MB=25 + +# --- bootstrap admin --- +ADMIN_EMAIL=admin@example.com +ADMIN_INITIAL_PASSWORD=change-me-now + +# --- external services (REQUIRED to be configurable here) --- +# two independent services — separate URLs, ports, and tokens +ATOMIZER_BASE_URL=http://atomizer-mock:8090 +ATOMIZER_TOKEN=change-me-atomizer +ATOMIZER_TIMEOUT_SEC=120 +WORK_PERFORMER_BASE_URL=http://work-performer:8091 +WORK_PERFORMER_TOKEN=change-me-performer +WORK_PERFORMER_HTTP_TIMEOUT_SEC=30 # job submission/polling only; jobs are async + +# --- SMTP (optional; enables forgot-password + email notifications when set) --- +SMTP_HOST= +SMTP_PORT=587 +SMTP_USER= +SMTP_PASSWORD= +SMTP_FROM=bountyboard@example.com + +# --- hardening / tuning --- +COOKIE_SECURE=auto # auto: Secure flag only when APP_BASE_URL is https +ATOMIZE_MAX_CONCURRENCY=3 # cap parallel LLM calls (cost + rate-limit control) +MONGO_USERNAME= # optional; enable Mongo auth for non-local deploys +MONGO_PASSWORD= + +# --- OIDC (optional; SSO hidden if empty) --- +OIDC_ISSUER_URL= +OIDC_CLIENT_ID= +OIDC_CLIENT_SECRET= + +# --- mock atomizer / work performer --- +ANTHROPIC_API_KEY= # paste your key here; NEVER commit +ANTHROPIC_OPENAI_BASE_URL=https://api.anthropic.com/v1 +ANTHROPIC_MODEL=claude-sonnet-4-6 +LLM_API_STYLE=openai # openai | anthropic (native /v1/messages fallback) +``` + +--- + +## 9. Placeholder service containers + +### 9.1 `atomizer-mock` (Go, same repo, `services/atomizer`) + +Implements §5.1 exactly. Internally calls Anthropic through its **OpenAI-compatible** +chat-completions endpoint (`POST {ANTHROPIC_OPENAI_BASE_URL}/chat/completions`, header +`Authorization: Bearer $ANTHROPIC_API_KEY`, model `$ANTHROPIC_MODEL`). Because the +compatibility layer covers core chat completions only, the service also implements the +native Anthropic Messages API (`/v1/messages`, headers `x-api-key`, +`anthropic-version: 2023-06-01`) selected by `LLM_API_STYLE=anthropic` — flip this if the +compatibility endpoint misbehaves. Plain `net/http`; no SDK. + +Prompting: system prompt instructs strict-JSON output matching the response schema; +response is parsed defensively (strip code fences), validated (coefficient sum +normalization), retried once on parse failure, and falls back to a deterministic +"split into N equal parts" stub when no API key is configured (so the full system remains +testable offline). + +### 9.2 `work-performer` (Node 20 + Claude Code, `services/work-performer`) + +Implements §5.2. `Dockerfile`: `FROM node:20-bookworm`, `npm install -g +@anthropic-ai/claude-code`, plus a ~200-line Express-free Node `http` server. The compose +file mounts the host's local Claude Code configuration/secrets read-only: + +```yaml +volumes: + - ${HOME}/.claude:/root/.claude + - ${HOME}/.claude.json:/root/.claude.json +``` + +For each job it prepares `/work/{jobId}/TASK.md` (title, description, AC, links, +downloaded attachments) and runs `claude -p "$(cat TASK.md)" --output-format json +--dangerously-skip-permissions` inside that directory, then posts the HMAC-signed +callback with the summary and any produced files as artifacts. This is explicitly a +placeholder: single-job concurrency, no repo cloning unless `context.repositoryUrl` is +given (then `git clone` first). The interface, not the implementation, is the deliverable. + +--- + +## 10. UI / UX + +Pages: Login/Register · Admin → Settings, Customers (list + wizard with system-specific +credential fields + Test connection), Users · Consultant → Atomization Board, Pool, +Reviews queue, Metrics · Developer → Bounty Board, My tasks (kanban: Assigned / In +progress / In review / Done), Metrics · Shared → Task detail, Messages, Profile, +Notifications. + +Atomization board: column or tree layout grouped by root ticket; each card shows source-type +icon (epic ◆ / story ▣ / task ▢), title, coefficient slider (0–1, step 0.01, live +per-parent sum indicator that turns red when ≠ 1.00 for non-extended sets), bounty +preview, and the two primary buttons **Subdivide** (modal with optional note + +min/max task count) and **Extend** (modal with required note). While atomizing, the card +shows a progress shimmer; results stream in via WebSocket. + +Bounty board: responsive card grid; bounty badge prominent; filters (customer, text +search via Mongo text index, min bounty, newest/highest sort); "Request assignment" +button with optional pitch note; "requested" state visible to other developers +(configurable: admins may hide competing requests). + +Design tokens (light, beige default): + +```css +:root[data-theme=light] { + --bg:#f3ead9; --surface:#faf5ea; --surface2:#efe5d0; --border:#d8cbb0; + --text:#2b2620; --muted:#6f6353; --accent:#8a5a2b; --accent-contrast:#fff; + --ok:#3c6e47; --warn:#a06a1f; --err:#9c3a2e; --radius:2px; +} +:root[data-theme=dark] { + --bg:#191714; --surface:#221f1b; --surface2:#2b2722; --border:#3a342c; + --text:#ece5d8; --muted:#a59a87; --accent:#caa15e; --accent-contrast:#1a160f; + --ok:#7fb78a; --warn:#d9a44a; --err:#d97b6c; --radius:2px; +} +``` + +System font stack; 8-px spacing grid; visible focus rings; keyboard shortcuts +(`g b` board, `g m` messages, `/` search); WCAG AA contrast in both themes. + +--- + +## 11. Quality-of-life features (in scope v1 unless marked later) + +1. In-app notification center + WS toasts (claim requests, approvals, review results, + sync errors, mentions in chat). +2. Audit log with admin UI (filter by user/entity). +3. Task comments + @mentions (mention → notification). +4. Time logging per task; surfaced in metrics. +5. Optimistic-concurrency conflict toasts ("reload — consultant edited this task"). +6. Coefficient rebalancer: "distribute remaining evenly" / lock-and-normalize controls. +7. Re-run subdivision (replaces previous unpublished children after confirm dialog). +8. Saved board filters per user; leaderboard (top developers by bounty, opt-out flag). +9. Bulk publish; bulk archive. +10. Search everywhere (tasks text index; users by name/email). +11. Seed script `make seed` — demo customer (mock ticketing type `demo` that fabricates + tickets locally, so the whole flow is demonstrable without real Jira), 1 admin, + 2 consultants, 6 developers, sample conversations. +12. Export metrics as CSV. +13. Profile cards on hover (avatar, roles, bio, contact). +14. Unread badges in messaging; typing indicator; image lightbox. +15. Per-user theme persistence (profile + localStorage). +16. Graceful degradation: if the Atomization Service is down, Subdivide/Extend buttons + are disabled with the last health-check status; a simple circuit breaker (open after + 3 consecutive failures, half-open probe every 60 s) stops hammering either external + service. Independent breaker per service. +17. Admin **service status panel**: live health, latency, breaker state, and queue depth + for Atomization Service, Work Performer Service, and each customer sync worker. +18. **Review checklist**: in the review dialog each acceptance criterion renders as a + checkbox; the consultant's per-AC verdict is stored on the timeline — this is the + quality-control audit trail. +19. **Stale-task aging**: bounty-board cards show subtle age badges (>7 d, >14 d + published without assignment) so the consultant spots unattractive tasks and can + raise the budget or re-atomize. +20. **Per-customer default budget**: set on the customer; pre-fills the root-task budget + so consultants don't retype it. +21. Compose **profile `mocks`** for the two placeholder services: `docker compose + --profile mocks up` runs everything; omitting the profile runs only app+mongo against + real external services configured in `.env`. +22. Self-service "Forgot password" (token email; active only when SMTP configured). +23. `make backup` / `make restore` — `mongodump`/`mongorestore` into ./backups via the + mongo container; README documents a cron example. +24. Later (documented stubs only): webhook ingestion instead of polling; status + write-back to customer systems; email digests; AI work performer production + implementation; localization. + +--- + +## 12. Reliability, security, operations + +- Health checks: `/healthz`, `/readyz` (§6) + Docker Compose `healthcheck` blocks for all + four services; `app` `depends_on` mongo `condition: service_healthy`. +- Graceful shutdown (SIGTERM): stop accepting, drain WS, flush sync workers, 15 s budget. +- Background jobs (sync, atomize calls, callbacks) run through a small internal worker + pool with per-job panic recovery and exponential-backoff retry (max 3) persisted in a + `jobs` collection so restarts don't lose work. +- All external HTTP calls: contexts with timeouts, retry on 5xx/network ×2 with jitter. +- Security: argon2id; AES-256-GCM for stored credentials; CSRF double-submit; strict + cookie flags; HTML sanitizer for all rich text; upload MIME sniffing + size limits; + per-IP rate limits on auth and file upload; security headers (CSP without + unsafe-inline for scripts, X-Frame-Options DENY); RBAC middleware asserting both role + and resource ownership (consultant ↔ customer ↔ task chains). +- Deployment posture: the app is designed to run **behind a TLS-terminating reverse + proxy** (Caddy/nginx). Reverse-proxy readiness checklist, all implemented in v1: + - app listens on `APP_PORT` (default **8787**), bound to localhost in compose; Mongo + port is never published to the host; + - `X-Forwarded-For` / `X-Forwarded-Proto` / `X-Forwarded-Host` are honored **only** + when the direct peer is within `TRUSTED_PROXY_CIDRS` (otherwise ignored) — the + resolved client IP feeds rate limiting, sessions, and the audit log; + - `APP_BASE_URL` set to the public https URL drives all absolute URLs (OIDC redirect + URI, signed file URLs, email links) and, with `COOKIE_SECURE=auto`, enables Secure + cookies behind https even though app↔proxy traffic is plain http; + - WebSocket endpoint works through proxies (Upgrade/Connection pass-through; 30 s + server ping/pong heartbeats so idle proxy timeouts don't kill connections); Origin + is checked against `APP_BASE_URL`; + - request body limits enforced in-app (don't rely on proxy limits); no HTTP redirects + to absolute http:// URLs anywhere; + - README must include working sample configs for **Caddy** and **nginx** (incl. WS + location block and `client_max_body_size` matching `MAX_UPLOAD_MB`). +- Backups: named volume + `make backup`/`make restore` (mongodump); restore drill is part + of the acceptance checklist for production use (not required for this test deploy). +- Logging: slog JSON to stdout; request ID middleware; sync/atomizer failures logged at + ERROR and surfaced as admin notifications. + +## 13. Testing & acceptance + +- Unit tests: status machine, bounty math, coefficient normalization, sanitizer, + crypto round-trip, ULID, RBAC matrix. +- Integration tests (`go test -tags=integration`, runs against compose Mongo with a + per-run database name): auth flows, full task lifecycle imported→approved with the + fallback (stubbed) atomizer, messaging, sync upsert idempotency, optimistic locking. +- Contract tests for §5.1/§5.2 run against the mock services. +- A Playwright-free smoke script (`scripts/smoke.sh`, curl-based) exercises + register→login→healthz→board after `docker compose up`. +- Target: `go vet`, `gofmt -l` clean; CI-able via `make test`. + +Acceptance checklist: admin can add a Jira customer and test the connection · ticket +assigned to consultant appears on atomization board within one poll interval · subdivide +returns N tasks whose coefficients sum to 1 and are editable · publish puts tasks on the +bounty board with bounty = coefficient × budget · developer claim → consultant approve → +submit → review approve → bountyAwards row + metrics update · extend creates one sibling · +AI assignment creates a work-performer job and the callback moves the task to in_review · +consultant can decline a claim and unassign an assigned task back to the board · +signed attachment URL fetch works without a session (and expires) · OIDC and local login both work · light/dark theme toggle persists · `/readyz` reflects a +stopped Mongo · all compose health checks green · app reachable through a sample reverse-proxy config +on 8787 with correct client IPs in the audit log · stopping the atomizer container opens +its breaker and disables Subdivide/Extend without affecting Work Performer assignments +(and vice versa — proving full service independence). + +## 14. Repository layout + +``` +bountyboard/ +├─ cmd/app/main.go +├─ internal/{config,store,domain,http,auth,sync,atomize,workperform,chat,ws,metrics,files,jobs,crypto,ulid} +├─ web/{templates,static/{css,js,icons}} +├─ services/atomizer/ services/work-performer/ +├─ api/openapi.yaml +├─ scripts/{seed.go,smoke.sh} +├─ docker-compose.yml Dockerfile Makefile .env.example specification.md README.md +```