feat: seed script, forgot-password, hover cards, shortcuts, OpenAPI docs, runbook (phase 12)

- scripts/seed.go: idempotent demo data per §11.11 (make seed)
- forgot/reset password: SMTP-gated, one-shot TTL tokens, uniform responses
  against enumeration, sessions revoked on reset; login page link + pages
- profile hover cards on [data-user-card] elements (§11.13)
- keyboard shortcuts: g b/m/t/h navigation, / focuses search (§10)
- bulk archive endpoint (§11.9)
- hand-written OpenAPI 3.1 covering §6, served at /api/docs + yaml download
- make backup / make restore (mongodump archive via the mongo container)
- README: quick start, demo data, runbook, breaker/job operations, working
  Caddy + nginx reverse-proxy samples (WS block, client_max_body_size),
  documented later-stubs (§11.24)
- smoke.sh now exercises register → logout → login → me → board → pages

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
etalon
2026-06-12 20:39:38 +02:00
parent d698f70c4f
commit c69c028028
22 changed files with 1125 additions and 15 deletions
+188
View File
@@ -0,0 +1,188 @@
# 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=<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
```
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"}`.
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.