etalon b5ebbfb748 feat: public access via reverse proxy + 'The Ledger' UI design pass
- 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>
2026-06-12 21:38:20 +02:00
2026-06-12 21:15:34 +02:00
2026-06-12 21:15:34 +02:00

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, 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

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.comdev6@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.

HealthGET /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 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.
S
Description
No description provided
Readme 5.8 MiB
Languages
Go 72%
JavaScript 16.9%
CSS 4.7%
HTML 4.5%
Shell 1.5%
Other 0.4%