From 976f5238f83879b60fc0bddf0104e4e93b75b8eb Mon Sep 17 00:00:00 2001 From: etalon Date: Fri, 12 Jun 2026 18:10:30 +0200 Subject: [PATCH] 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 --- .env.example | 56 +++ .gitignore | 5 + DECISIONS.md | 38 ++ Dockerfile | 18 + Makefile | 32 ++ PROGRESS.md | 4 + README.md | 0 cmd/app/main.go | 42 ++ docker-compose.yml | 35 ++ go.mod | 3 + internal/config/config.go | 196 ++++++++ internal/config/config_test.go | 137 ++++++ internal/config/env.go | 50 ++ internal/config/env_test.go | 66 +++ internal/http/health.go | 67 +++ internal/http/middleware.go | 122 +++++ internal/http/proxy.go | 84 ++++ internal/http/proxy_test.go | 154 +++++++ internal/http/respond.go | 34 ++ internal/http/server.go | 78 ++++ internal/http/server_test.go | 172 +++++++ internal/metrics/metrics.go | 58 +++ internal/ulid/ulid.go | 94 ++++ internal/ulid/ulid_test.go | 92 ++++ scripts/smoke.sh | 28 ++ specification.md | 813 +++++++++++++++++++++++++++++++++ 26 files changed, 2478 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 DECISIONS.md create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 PROGRESS.md create mode 100644 README.md create mode 100644 cmd/app/main.go create mode 100644 docker-compose.yml create mode 100644 go.mod create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go create mode 100644 internal/config/env.go create mode 100644 internal/config/env_test.go create mode 100644 internal/http/health.go create mode 100644 internal/http/middleware.go create mode 100644 internal/http/proxy.go create mode 100644 internal/http/proxy_test.go create mode 100644 internal/http/respond.go create mode 100644 internal/http/server.go create mode 100644 internal/http/server_test.go create mode 100644 internal/metrics/metrics.go create mode 100644 internal/ulid/ulid.go create mode 100644 internal/ulid/ulid_test.go create mode 100755 scripts/smoke.sh create mode 100644 specification.md 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 +```