feat: scaffold app skeleton with config, ULID, health endpoints, compose stack (phase 1)
- internal .env loader (real env wins) + strictly validated config struct - ULID package: 48-bit ms timestamp + 80-bit crypto randomness, sortable - HTTP server with recovery/request-id/trusted-proxy/access-log middleware - /healthz, /readyz (pluggable checkers), /metricsz (counter registry) - multi-stage Dockerfile (alpine, non-root) + compose with healthchecks, app bound to 127.0.0.1:8787, mongo unpublished, graceful 15s shutdown Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
.env
|
||||||
|
bin/
|
||||||
|
backups/
|
||||||
|
*.test
|
||||||
|
coverage.out
|
||||||
@@ -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.
|
||||||
+18
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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:
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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()))
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+28
@@ -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"
|
||||||
@@ -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
|
||||||
|
`<html>`, 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": "<p>sanitized rich text</p>",
|
||||||
|
"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 <token>`,
|
||||||
|
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="<email>" AND project=<key> AND updated >= "<ts>"` (Basic auth: email+API token) |
|
||||||
|
| Azure DevOps | `POST …/_apis/wit/wiql?api-version=7.1` with WIQL `[System.AssignedTo] = '<email>' AND [System.TeamProject] = '<project>' AND [System.ChangedDate] >= '<ts>'`, then batch `GET workitems` (PAT auth) |
|
||||||
|
| YouTrack | `GET /api/issues?query=assignee: <login> project: <key> updated: <ts> .. *` (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=<ulid>`. 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
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user