# Bounty Board A consulting work-management platform: imports tickets from Jira / Azure DevOps / YouTrack (or an offline `demo` source), lets **consultants** atomize them into small developer tasks with AI assistance, publishes them on a **bounty board** where **developers** claim work (or an **AI work performer** does it), and tracks review, approval, and bounty-based metrics. Built per [specification.md](specification.md): Go ≥ 1.22 + stdlib `net/http`, MongoDB, server-rendered templates + vanilla ES modules (no build step), Docker Compose deployment. Architectural decisions are logged in [DECISIONS.md](DECISIONS.md); build history in [PROGRESS.md](PROGRESS.md). ## Quick start ```bash cp .env.example .env # then edit .env: # SESSION_SECRET=$(openssl rand -base64 32) # CREDENTIALS_ENC_KEY=$(openssl rand -base64 32) # ADMIN_INITIAL_PASSWORD= # ATOMIZER_TOKEN / WORK_PERFORMER_TOKEN = random strings # ANTHROPIC_API_KEY= # optional; offline fallback without it docker compose --profile mocks up -d --build open http://localhost:8787 ``` First login: `ADMIN_EMAIL` / `ADMIN_INITIAL_PASSWORD` from `.env` — a password change is forced immediately. Without `--profile mocks` only `app` + `mongo` start and the external service URLs in `.env` must point at real implementations of the §5 contracts. ### Demo data ```bash docker compose -f docker-compose.yml -f docker-compose.test.yml up -d # publishes mongo on loopback make seed ``` Seeds a demo customer (offline `demo` ticketing — tickets appear within one poll), consultants `clara@example.com` / `carlos@example.com`, developers `dev1@example.com` … `dev6@example.com` (password `demo-pass-123` for all), pools, and sample conversations. ## Services | Service | Port (loopback) | What | |---|---|---| | `app` | 8787 | Bounty Board (UI + API + WS) | | `mongo` | — (never published; test overlay adds 27017) | database | | `atomizer-mock` | 8090 (profile `mocks`) | §5.1 Atomization Service — Anthropic chat completions (or native `/v1/messages` via `LLM_API_STYLE=anthropic`), deterministic fallback without an API key | | `work-performer` | 8091 (profile `mocks`) | §5.2 Work Performer — runs Claude Code with the host's `~/.claude` mounted read-only; simulated result when the CLI is unavailable | The two external services are intentionally independent: separate codebases (`services/atomizer`, `services/work-performer`), containers, ports, and bearer tokens. Swap either by changing its base URL + token in `.env`. ## Development ```bash make build # app + atomizer mock make test # unit tests (no Mongo needed) make test-integration # needs mongo on loopback (test overlay) make test-contract # needs the mocks profile running make lint # gofmt + go vet make run # run the app locally (expects Mongo + .env) make smoke # curl smoke test against a running stack ``` API reference: `GET /api/docs` (rendered) or [api/openapi.yaml](api/openapi.yaml). ## Operations runbook **Logs** — JSON to stdout: `docker compose logs -f app` (request ids, sync runs, job retries). `docker compose logs -f atomizer-mock work-performer` for the mocks. **Health** — `GET /healthz` (liveness), `GET /readyz` (Mongo required; atomizer/work-performer reported non-fatally), `GET /metricsz` (JSON counters: requests, sync runs, job queue depths). The admin UI → *Service status* shows live health, latency, circuit-breaker state, sync workers, and queue depths. **Backups** (§11.23): ```bash make backup # mongodump → ./backups/bountyboard-.archive make restore FILE=backups/ # mongorestore --drop ``` Cron example: `0 3 * * * cd /opt/bountyboard && make backup >/dev/null`. **Circuit breakers** — after 3 consecutive failures calls to an external service stop for 60 s (one half-open probe per minute afterwards). The consultant board disables Subdivide/Extend while the atomizer is down; AI assignment fails fast while the work performer is down. Each service has an independent breaker. **Stuck jobs** — background jobs (`jobs` collection) retry 3× with backoff; jobs stuck `running` after a crash are re-queued automatically within 5 minutes. ## Reverse proxy The app expects a TLS-terminating proxy (§12). Set in `.env`: ```dotenv APP_BASE_URL=https://bounty.example.com TRUSTED_PROXY_CIDRS=172.16.0.0/12 # the proxy's source range as seen by the app COOKIE_SECURE=auto ``` `X-Forwarded-*` headers are honored only from `TRUSTED_PROXY_CIDRS`; the resolved client IP feeds rate limiting and the audit log. WebSocket heartbeats (30 s) keep idle proxy timeouts from killing `/ws`. ### Caddy ```caddyfile bounty.example.com { encode gzip reverse_proxy 127.0.0.1:8787 # WebSockets are proxied automatically; raise the body limit to match MAX_UPLOAD_MB request_body { max_size 25MB } } ``` ### nginx ```nginx server { listen 443 ssl http2; server_name bounty.example.com; ssl_certificate /etc/letsencrypt/live/bounty.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/bounty.example.com/privkey.pem; client_max_body_size 25m; # match MAX_UPLOAD_MB location /ws { proxy_pass http://127.0.0.1:8787; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_read_timeout 120s; # > the 30s WS heartbeat } location / { proxy_pass http://127.0.0.1:8787; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Host $host; } } ``` ## Configuration All configuration comes from `.env` (see `.env.example` for every knob with comments): Mongo, session/credential secrets, bootstrap admin, the two external service URLs + tokens + timeouts, SMTP (enables forgot-password emails when set), OIDC SSO (buttons appear when set), upload limit, rate limits, and the mock services' Anthropic settings. Consultants map their ticketing identities in their profile via `users.extra.ticketingIdentities` (e.g. set by an admin): `{"jira": "consultant@corp.com", "azure_devops": "consultant@corp.com", "youtrack": "consultant.login"}`. The `demo` ticketing type needs no identity. ## Deliberately out of scope (documented stubs, §11.24) - **Webhook ingestion** instead of polling — the sync layer is keyed on an idempotent `(system, key, customerId)` upsert, so a webhook receiver can reuse `UpsertImportedTask` unchanged. - **Status write-back** to customer systems — v1 sync is read-only. - **Email digests** — notifications are in-app + WS; the mailer exists and is used for password resets. - **Production AI work performer** — the §5.2 HTTP contract is final; the shipped container is a placeholder. - **Localization** — UI strings are English-only.