- compose publishes 8787 on all interfaces (NPM fronts it at bountyboard.anypreta.com); APP_BASE_URL + TRUSTED_PROXY_CIDRS configured, client-IP resolution through the proxy verified live - design: self-hosted variable fonts (Fraunces display serif, Schibsted Grotesk UI, Spline Sans Mono ledger numerals), paper-grain overlay, hairline double rules, letterpress buttons, stamped badges, banknote bounty chips, ledger tables, staggered page reveal (reduced-motion safe) - §10 tokens, 2px radius, both themes, AA contrast preserved exactly; no build step, CSP-clean (fonts/img self/data) - login/register masthead; headless-chrome screenshots verified both themes Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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: 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; build history in PROGRESS.md.
Quick start
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, usesudo --preserve-env=HOME docker compose …— otherwise${HOME}resolves to/rootand the work performer mounts the wrong~/.claudecredentials.
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
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
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.
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):
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:
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
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
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 reuseUpsertImportedTaskunchanged. - 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.