# Bounty Board

A consulting work-management platform: imports tickets from Jira / Azure
DevOps / YouTrack / WeKan (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=<something strong>
#   ATOMIZER_TOKEN / WORK_PERFORMER_TOKEN = random strings
#   ANTHROPIC_API_KEY=<your key>          # optional; offline fallback without it

docker compose --profile mocks up -d --build
open http://localhost:8787
```

> **Note:** if you run compose via `sudo`, use
> `sudo --preserve-env=HOME docker compose …` — otherwise `${HOME}` resolves
> to `/root` and the work performer mounts the wrong `~/.claude` credentials.

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-<ts>.archive
make restore FILE=backups/<archive>  # 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", "wekan": "wekan-username"}`.
For WeKan the customer's project key is the **board id** and cards assigned
to the consultant (assignees, falling back to members) are imported.
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.
