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
+9 -3
View File
@@ -29,14 +29,20 @@ lint:
echo "gofmt needed on:"; echo "$$unformatted"; exit 1; fi echo "gofmt needed on:"; echo "$$unformatted"; exit 1; fi
$(GO) vet ./... $(GO) vet ./...
# Requires Mongo published on loopback (test overlay) or MONGO_SEED_URI.
seed: seed:
@echo "seed script lands in phase 12 (scripts/seed.go)"; exit 1 $(GO) run ./scripts
smoke: smoke:
./scripts/smoke.sh ./scripts/smoke.sh
# mongodump/mongorestore through the mongo container into ./backups (§11.23)
backup: backup:
@echo "backup target lands in phase 12"; exit 1 @mkdir -p backups
docker compose exec -T mongo mongodump --archive --db=bountyboard > backups/bountyboard-$$(date +%Y%m%d-%H%M%S).archive
@ls -lh backups/ | tail -1
# usage: make restore FILE=backups/bountyboard-20260612-120000.archive
restore: restore:
@echo "restore target lands in phase 12"; exit 1 @test -n "$(FILE)" || (echo "usage: make restore FILE=backups/<archive>"; exit 1)
docker compose exec -T mongo mongorestore --archive --drop < $(FILE)
+1
View File
@@ -12,3 +12,4 @@
- Phase 9 (messaging): conversations (dm dedupe/group/project), sanitized rich-text messages, file/image attachments (generic POST /api/v1/files w/ rate limit), per-conversation unread counts + mark-read, chat-file access limited to participants, mentions→notifications, typing indicator over WS inbound relay, two-pane messages UI (contenteditable composer, formatting toolbar, drag&drop upload, inline previews + lightbox, live delivery, polling fallback), user directory search — integration tests green. - Phase 9 (messaging): conversations (dm dedupe/group/project), sanitized rich-text messages, file/image attachments (generic POST /api/v1/files w/ rate limit), per-conversation unread counts + mark-read, chat-file access limited to participants, mentions→notifications, typing indicator over WS inbound relay, two-pane messages UI (contenteditable composer, formatting toolbar, drag&drop upload, inline previews + lightbox, live delivery, polling fallback), user directory search — integration tests green.
- Phase 10 (metrics): aggregation pipelines over bountyAwards + timelines (totals, weekly $dateTrunc buckets, per-developer/per-customer, approval rate, time logged, assign→approve lead time, atomization lead time, open board depth), CSV export, leaderboard w/ opt-out (profile setting), dependency-free SVG line+bar charts, developer/consultant/admin dashboards — integration tests green. - Phase 10 (metrics): aggregation pipelines over bountyAwards + timelines (totals, weekly $dateTrunc buckets, per-developer/per-customer, approval rate, time logged, assign→approve lead time, atomization lead time, open board depth), CSV export, leaderboard w/ opt-out (profile setting), dependency-free SVG line+bar charts, developer/consultant/admin dashboards — integration tests green.
- Phase 11 (mock services): services/atomizer (own Go module, ports 8090, bearer auth, OpenAI-compatible chat completions + native /v1/messages via LLM_API_STYLE, strict-JSON prompt, fence-stripping defensive parse, one retry, deterministic equal-split fallback, exact sum normalization); services/work-performer (Node 20, zero deps, single-concurrency queue, TASK.md + attachment download + optional git clone, claude -p --output-format json --dangerously-skip-permissions, simulated result when CLI unavailable, artifact serving, HMAC-signed callbacks with retry); compose profile mocks w/ healthchecks + ${HOME}/.claude mounts; contract tests pass live (WP ran real Claude Code via mounted host creds). UFW rule added for container→host callbacks. - Phase 11 (mock services): services/atomizer (own Go module, ports 8090, bearer auth, OpenAI-compatible chat completions + native /v1/messages via LLM_API_STYLE, strict-JSON prompt, fence-stripping defensive parse, one retry, deterministic equal-split fallback, exact sum normalization); services/work-performer (Node 20, zero deps, single-concurrency queue, TASK.md + attachment download + optional git clone, claude -p --output-format json --dangerously-skip-permissions, simulated result when CLI unavailable, artifact serving, HMAC-signed callbacks with retry); compose profile mocks w/ healthchecks + ${HOME}/.claude mounts; contract tests pass live (WP ran real Claude Code via mounted host creds). UFW rule added for container→host callbacks.
- Phase 12 (polish): seed script (make seed: demo customer + 1 admin + 2 consultants + 6 devs + pools + conversations, idempotent), forgot/reset password (SMTP-gated, one-shot TTL tokens, anti-enumeration), profile hover cards, keyboard shortcuts (g b / g m / g t / g h / focus search), bulk archive endpoint, hand-written api/openapi.yaml served at /api/docs, make backup/restore via mongodump, README w/ runbook + Caddy & nginx samples (WS + client_max_body_size), extended register→login→board smoke script — all tests green.
+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.
+7
View File
@@ -0,0 +1,7 @@
// Package api embeds the hand-written OpenAPI document (§6).
package api
import _ "embed"
//go:embed openapi.yaml
var OpenAPI []byte
+289
View File
@@ -0,0 +1,289 @@
openapi: "3.1.0"
info:
title: Bounty Board API
version: "1.0"
description: |
Consulting work-management platform API (spec §6). All endpoints are
session-authenticated (httpOnly cookie `bb_session`); mutations also
require the CSRF double-submit header `X-CSRF-Token` mirroring the
`bb_csrf` cookie. Errors use the envelope
`{"error":{"code":"…","message":"…"}}`. Pagination: `?limit=&cursor=<ulid>`.
servers:
- url: /api/v1
tags:
- {name: auth}
- {name: admin}
- {name: consultant}
- {name: developer}
- {name: tasks}
- {name: messaging}
- {name: shared}
- {name: internal}
paths:
/auth/register:
post:
tags: [auth]
summary: Self-register a developer account
requestBody: {$ref: "#/components/requestBodies/Register"}
responses:
"201": {description: Account created and session started}
"409": {description: Email already registered}
/auth/login:
post:
tags: [auth]
summary: Local login
requestBody: {$ref: "#/components/requestBodies/Login"}
responses:
"200": {description: "Session cookies set; body: user + mustChangePassword"}
"401": {description: Invalid credentials}
"429": {description: Rate limited (10 attempts / 15 min / IP+email)}
/auth/logout:
post: {tags: [auth], summary: Revoke the current session, responses: {"204": {description: Logged out}}}
/auth/logout-all:
post: {tags: [auth], summary: Revoke every session of the current user, responses: {"200": {description: Count of revoked sessions}}}
/auth/change-password:
post:
tags: [auth]
summary: Change the local password (revokes other sessions)
responses: {"204": {description: Changed}, "401": {description: Wrong current password}}
/auth/forgot:
post:
tags: [auth]
summary: Request a password-reset email (SMTP required)
responses: {"200": {description: Uniform response}, "503": {description: SMTP not configured}}
/auth/reset:
post:
tags: [auth]
summary: Set a new password with a one-shot token
responses: {"204": {description: Password set}, "400": {description: Invalid or expired token}}
/auth/me:
get: {tags: [auth], summary: Current user, responses: {"200": {description: User profile + flags}}}
/auth/oidc/login:
get: {tags: [auth], summary: Start the OIDC code flow with PKCE (302 to the IdP), responses: {"302": {description: Redirect}}}
/auth/oidc/callback:
get: {tags: [auth], summary: OIDC redirect URI (links by verified email or creates a developer), responses: {"302": {description: Redirect home or to /login?error=…}}}
/admin/users:
get:
tags: [admin]
summary: List/search users
parameters:
- {name: q, in: query, schema: {type: string}}
- {name: role, in: query, schema: {type: string, enum: [admin, consultant, developer]}}
responses: {"200": {description: Users + nextCursor}}
/admin/users/{id}:
patch:
tags: [admin]
summary: "Update roles / disabled / name / force password reset"
responses: {"200": {description: Updated user}, "400": {description: Self-lockout guard}, "409": {description: Version conflict}}
delete: {tags: [admin], summary: Delete a user and their sessions, responses: {"204": {description: Deleted}}}
/admin/customers:
get: {tags: [admin], summary: List customers (includeArchived=true optional), responses: {"200": {description: Customers (credentials never returned)}}}
post:
tags: [admin]
summary: Create a customer with per-system credentials (encrypted at rest)
requestBody: {$ref: "#/components/requestBodies/Customer"}
responses: {"201": {description: Created}, "409": {description: Name taken}}
/admin/customers/{id}:
get: {tags: [admin], summary: Get one customer, responses: {"200": {description: Customer}}}
patch: {tags: [admin], summary: Partial update (credentials rotate when supplied), responses: {"200": {description: Updated}, "409": {description: Version conflict}}}
delete: {tags: [admin], summary: Delete (blocked while tasks exist), responses: {"204": {description: Deleted}, "409": {description: Has tasks — archive instead}}}
/admin/customers/test-connection:
post: {tags: [admin], summary: Test unsaved credentials (wizard), responses: {"200": {description: "{ok, latencyMs, error?}"}}}
/admin/customers/{id}/test-connection:
post: {tags: [admin], summary: Test the stored connection, responses: {"200": {description: "{ok, latencyMs, error?}"}}}
/admin/customers/{id}/sync-now:
post: {tags: [admin], summary: Trigger an immediate sync poll, responses: {"202": {description: Queued}}}
/admin/settings:
get: {tags: [admin], summary: Runtime settings, responses: {"200": {description: Settings}}}
patch: {tags: [admin], summary: Update runtime settings (atomizer URL override, branding, …), responses: {"200": {description: Updated settings}}}
/admin/audit-log:
get:
tags: [admin]
summary: Audit log of privileged mutations
parameters:
- {name: actorId, in: query, schema: {type: string}}
- {name: entityId, in: query, schema: {type: string}}
- {name: cursor, in: query, schema: {type: string}}
responses: {"200": {description: Entries + nextCursor}}
/admin/service-status:
get: {tags: [admin], summary: "Health/latency/breaker for atomizer + work performer, sync workers, job queue (§11.17)", responses: {"200": {description: Status payload}}}
/consultant/board:
get:
tags: [consultant]
summary: Atomization board (imported…claim_requested tasks of own customers)
parameters:
- {name: customerId, in: query, schema: {type: string}}
- {name: status, in: query, schema: {type: string}}
responses: {"200": {description: Customers + tasks}}
/consultant/reviews:
get: {tags: [consultant], summary: Tasks in review for own customers, responses: {"200": {description: Tasks}}}
/consultant/pool:
get: {tags: [consultant], summary: Global developer pool with membership flags, responses: {"200": {description: Developers}}}
post: {tags: [consultant], summary: Add a developer to my pool, responses: {"201": {description: Added}, "409": {description: Already in pool}}}
/consultant/pool/{developerId}:
delete: {tags: [consultant], summary: Remove a developer from my pool, responses: {"204": {description: Removed}}}
/consultant/metrics:
get:
tags: [consultant]
summary: "Aggregations per developer/customer + lead time + board depth (§6.2); format=csv exports the ledger"
parameters:
- {name: customerId, in: query, schema: {type: string}}
- {name: developerId, in: query, schema: {type: string}}
- {name: from, in: query, schema: {type: string, format: date}}
- {name: to, in: query, schema: {type: string, format: date}}
- {name: format, in: query, schema: {type: string, enum: [csv]}}
responses: {"200": {description: Metrics JSON or CSV}}
/board:
get:
tags: [developer]
summary: Bounty board (published tasks of consultants who pooled me)
parameters:
- {name: customerId, in: query, schema: {type: string}}
- {name: q, in: query, schema: {type: string}, description: Mongo text search}
- {name: minBounty, in: query, schema: {type: number}}
- {name: sort, in: query, schema: {type: string, enum: ["", bounty]}}
responses: {"200": {description: Tasks + customers}}
/my-tasks:
get: {tags: [developer], summary: My kanban (assigned…approved), responses: {"200": {description: Tasks}}}
/developer/metrics:
get:
tags: [developer]
summary: "Own §6.2 metrics; format=csv exports the ledger"
parameters:
- {name: from, in: query, schema: {type: string, format: date}}
- {name: to, in: query, schema: {type: string, format: date}}
- {name: format, in: query, schema: {type: string, enum: [csv]}}
responses: {"200": {description: Metrics JSON or CSV}}
/leaderboard:
get: {tags: [shared], summary: Top developers by bounty (opt-out honored), responses: {"200": {description: Leaderboard}}}
/tasks/{id}:
get: {tags: [tasks], summary: Task detail incl. timeline (role-scoped), responses: {"200": {description: Task}, "403": {description: No access}}}
patch: {tags: [consultant], summary: "Edit title/description/AC/coefficient/budget (version-checked, bounty recomputed; cascadeBudget optional)", responses: {"200": {description: Updated task}, "409": {description: Conflict or immutable}}}
/tasks/{id}/subdivide:
post: {tags: [consultant], summary: "Queue AI subdivision (§5.1); re-run needs confirmReplace", responses: {"202": {description: "{jobId}"}, "409": {description: Bad status / confirm required}}}
/tasks/{id}/extend:
post: {tags: [consultant], summary: Queue AI extension (note required), responses: {"202": {description: "{jobId}"}}}
/tasks/{id}/publish:
post: {tags: [consultant], summary: Publish an atomized task to the board, responses: {"200": {description: Task}, "409": {description: Illegal transition}}}
/tasks/publish:
post: {tags: [consultant], summary: "Bulk publish {ids:[…]}", responses: {"200": {description: "{published, failed}"}}}
/tasks/archive:
post: {tags: [consultant], summary: "Bulk archive {ids:[…]}", responses: {"200": {description: "{archived, failed}"}}}
/tasks/{id}/archive:
post: {tags: [tasks], summary: Archive (consultant/admin), responses: {"204": {description: Archived}}}
/tasks/{id}/claim:
post: {tags: [developer], summary: Request assignment (optional pitch note), responses: {"204": {description: Requested}, "409": {description: Already requested / not open}}}
/tasks/{id}/claim/withdraw:
post: {tags: [developer], summary: Withdraw my claim (back to published when none remain), responses: {"204": {description: Withdrawn}}}
/tasks/{id}/approve-claim:
post: {tags: [consultant], summary: "Approve one developer {developerId}; others are declined + notified", responses: {"204": {description: Assigned}}}
/tasks/{id}/decline-claim:
post: {tags: [consultant], summary: Decline one claim, responses: {"204": {description: Declined}}}
/tasks/{id}/assign-ai:
post: {tags: [consultant], summary: "Create a Work Performer job (§5.2) {context:{repositoryUrl,branch,instructions}}", responses: {"202": {description: "{jobId}"}, "502": {description: Performer rejected the job}}}
/tasks/{id}/unassign:
post: {tags: [consultant], summary: Return an assigned/in-progress task to the board, responses: {"204": {description: Unassigned}}}
/tasks/{id}/start:
post: {tags: [developer], summary: Start work (assigned or changes_requested), responses: {"204": {description: In progress}}}
/tasks/{id}/submit-review:
post: {tags: [developer], summary: Submit for review, responses: {"204": {description: In review}}}
/tasks/{id}/abandon:
post: {tags: [developer], summary: Abandon back to the board, responses: {"204": {description: Published}}}
/tasks/{id}/comments:
post: {tags: [tasks], summary: Add a sanitized comment (@mentions notify), responses: {"201": {description: Comment}}}
/tasks/{id}/time:
post: {tags: [developer], summary: "Log time {minutes, note}", responses: {"201": {description: Entry}}}
/tasks/{id}/review:
post: {tags: [consultant], summary: "Review {decision: approve|request_changes, note, checklist[]}; approve freezes the bounty into bountyAwards", responses: {"204": {description: Reviewed}}}
/conversations:
get: {tags: [messaging], summary: My conversations with unread counts, responses: {"200": {description: Conversations}}}
post: {tags: [messaging], summary: "Create dm (deduplicated) / group / project conversation", responses: {"200": {description: Existing dm}, "201": {description: Created}}}
/conversations/{id}/messages:
get:
tags: [messaging]
summary: Message history (newest page; cursor pages backwards)
parameters: [{name: cursor, in: query, schema: {type: string}}]
responses: {"200": {description: Messages chronological}}
post: {tags: [messaging], summary: "Send {body (sanitized rich text), attachments:[fileIds]}", responses: {"201": {description: Message}}}
/conversations/{id}/read:
post: {tags: [messaging], summary: Mark all messages read, responses: {"200": {description: "{marked}"}}}
/files:
post: {tags: [shared], summary: "Upload (multipart field `file`; scope=chat default, task for consultants)", responses: {"201": {description: "{fileId, name, mimeType, size, isImage}"}, "413": {description: Exceeds MAX_UPLOAD_MB}}}
/profile:
get: {tags: [shared], summary: Own profile, responses: {"200": {description: User}}}
patch: {tags: [shared], summary: "Partial profile update (name, bio, contact, extra, settings.theme/leaderboardOptOut); optional version → 409 on conflict", responses: {"200": {description: Updated user}}}
/profile/avatar:
post: {tags: [shared], summary: Upload avatar (must sniff as an image), responses: {"200": {description: "{fileId}"}}}
/users:
get: {tags: [shared], summary: User directory search (name/email) for messaging, responses: {"200": {description: Users}}}
/users/{id}/card:
get: {tags: [shared], summary: Public profile card (hover cards), responses: {"200": {description: Card}}}
/notifications:
get: {tags: [shared], summary: My notifications + unread count, responses: {"200": {description: Notifications}}}
/notifications/read:
post: {tags: [shared], summary: "Mark read {ids:[]} (empty = all)", responses: {"200": {description: "{marked}"}}}
/service-health:
get: {tags: [shared], summary: Atomizer + work-performer health/breaker for UI gating, responses: {"200": {description: Health}}}
/internal/work-results:
post:
tags: [internal]
summary: "Work Performer callback (§5.2): HMAC `X-Signature` over the raw body with WORK_PERFORMER_TOKEN; idempotent by jobId; artifacts ingested into GridFS"
responses:
"200": {description: Accepted or duplicate_ignored}
"401": {description: Bad signature}
components:
requestBodies:
Register:
content:
application/json:
schema:
type: object
required: [email, name, password]
properties:
email: {type: string, format: email}
name: {type: string}
password: {type: string, minLength: 8}
Login:
content:
application/json:
schema:
type: object
required: [email, password]
properties:
email: {type: string}
password: {type: string}
Customer:
content:
application/json:
schema:
type: object
required: [name, type]
properties:
name: {type: string}
type: {type: string, enum: [jira, azure_devops, youtrack, demo]}
baseUrl: {type: string}
projectKey: {type: string}
pollIntervalSec: {type: integer, minimum: 10}
defaultBudget: {type: number}
consultantIds: {type: array, items: {type: string}}
credentials:
type: object
description: "jira: {email, apiToken} · azure_devops: {organization, project, pat} · youtrack: {permanentToken} · demo: {}"
schemas:
Error:
type: object
properties:
error:
type: object
properties:
code: {type: string}
message: {type: string}
+43
View File
@@ -0,0 +1,43 @@
package auth
import (
"fmt"
"net/smtp"
"strings"
"bountyboard/internal/config"
)
// Mailer sends plain-text email via SMTP (net/smtp, STARTTLS when offered).
// All features depending on it stay disabled until SMTP_HOST is set (§7).
type Mailer struct {
cfg config.SMTP
}
func NewMailer(cfg config.SMTP) *Mailer { return &Mailer{cfg: cfg} }
func (m *Mailer) Enabled() bool { return m.cfg.Enabled() }
func (m *Mailer) Send(to, subject, body string) error {
if !m.Enabled() {
return fmt.Errorf("smtp is not configured")
}
addr := fmt.Sprintf("%s:%d", m.cfg.Host, m.cfg.Port)
msg := strings.Join([]string{
"From: " + m.cfg.From,
"To: " + to,
"Subject: " + subject,
"MIME-Version: 1.0",
"Content-Type: text/plain; charset=utf-8",
"",
body,
}, "\r\n")
var a smtp.Auth
if m.cfg.User != "" {
a = smtp.PlainAuth("", m.cfg.User, m.cfg.Password, m.cfg.Host)
}
if err := smtp.SendMail(addr, a, m.cfg.From, []string{to}, []byte(msg)); err != nil {
return fmt.Errorf("smtp send: %w", err)
}
return nil
}
+73
View File
@@ -24,6 +24,79 @@ func (s *Server) routesAuth(mux *http.ServeMux) {
mux.Handle("GET /api/v1/auth/me", s.requireAuth(http.HandlerFunc(s.handleMe))) mux.Handle("GET /api/v1/auth/me", s.requireAuth(http.HandlerFunc(s.handleMe)))
mux.HandleFunc("GET /api/v1/auth/oidc/login", s.handleOIDCLogin) mux.HandleFunc("GET /api/v1/auth/oidc/login", s.handleOIDCLogin)
mux.HandleFunc("GET /api/v1/auth/oidc/callback", s.handleOIDCCallback) mux.HandleFunc("GET /api/v1/auth/oidc/callback", s.handleOIDCCallback)
mux.HandleFunc("POST /api/v1/auth/forgot", s.handleForgotPassword)
mux.HandleFunc("POST /api/v1/auth/reset", s.handleResetPassword)
}
// handleForgotPassword issues a one-shot reset token by email (§11.22).
// Active only when SMTP is configured; never reveals account existence.
func (s *Server) handleForgotPassword(w http.ResponseWriter, r *http.Request) {
var req struct {
Email string `json:"email"`
}
if !decodeJSON(w, r, &req) {
return
}
if s.mailer == nil || !s.mailer.Enabled() {
writeError(w, http.StatusServiceUnavailable, "smtp_disabled",
"password reset by email is not configured on this server")
return
}
if !s.loginLimiter.Allow("forgot|" + ClientIP(r.Context()).String()) {
writeError(w, http.StatusTooManyRequests, "rate_limited", "too many attempts")
return
}
u, err := s.store.UserByEmail(r.Context(), req.Email)
if err == nil && u.Auth.Local != nil && !u.Disabled {
token := auth.NewToken()
if err := s.store.CreatePasswordReset(r.Context(), token, u.ID, time.Hour); err != nil {
s.internalError(w, r, "create reset", err)
return
}
link := s.cfg.AppBaseURL + "/reset-password?token=" + token
go func() {
if err := s.mailer.Send(u.Email, "Reset your Bounty Board password",
"Use this link within one hour to set a new password:\n\n"+link+
"\n\nIf you did not request this, ignore this email."); err != nil {
s.log.Error("send reset mail", "err", err)
}
}()
}
// uniform response regardless of account existence
writeJSON(w, http.StatusOK, map[string]string{"status": "sent_if_account_exists"})
}
func (s *Server) handleResetPassword(w http.ResponseWriter, r *http.Request) {
var req struct {
Token string `json:"token"`
NewPassword string `json:"newPassword"`
}
if !decodeJSON(w, r, &req) {
return
}
if len(req.NewPassword) < auth.MinPasswordLen {
writeError(w, http.StatusBadRequest, "weak_password",
fmt.Sprintf("password must be at least %d characters", auth.MinPasswordLen))
return
}
userID, err := s.store.ConsumePasswordReset(r.Context(), req.Token)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_token", "this reset link is invalid or expired")
return
}
hash, err := auth.HashPassword(req.NewPassword)
if err != nil {
s.internalError(w, r, "hash password", err)
return
}
if err := s.store.SetPassword(r.Context(), userID, hash, false); err != nil {
s.internalError(w, r, "set password", err)
return
}
if _, err := s.store.DeleteUserSessions(r.Context(), userID); err != nil {
s.log.Warn("revoke sessions after reset", "err", err)
}
w.WriteHeader(http.StatusNoContent)
} }
// decodeJSON reads a bounded JSON body into dst, rejecting unknown fields. // decodeJSON reads a bounded JSON body into dst, rejecting unknown fields.
+3
View File
@@ -28,6 +28,7 @@ type Server struct {
files *files.Store files *files.Store
templates map[string]*template.Template templates map[string]*template.Template
oidc *auth.OIDCClient oidc *auth.OIDCClient
mailer *auth.Mailer
loginLimiter *auth.RateLimiter loginLimiter *auth.RateLimiter
checks []ReadinessCheck checks []ReadinessCheck
startedAt time.Time startedAt time.Time
@@ -72,6 +73,7 @@ func New(cfg *config.Config, log *slog.Logger, reg *metrics.Registry, st *store.
files: fs, files: fs,
templates: templates, templates: templates,
oidc: auth.NewOIDCClient(cfg, log), oidc: auth.NewOIDCClient(cfg, log),
mailer: auth.NewMailer(cfg.SMTP),
loginLimiter: auth.NewRateLimiter(10, 15*time.Minute), loginLimiter: auth.NewRateLimiter(10, 15*time.Minute),
startedAt: time.Now(), startedAt: time.Now(),
} }
@@ -89,6 +91,7 @@ func New(cfg *config.Config, log *slog.Logger, reg *metrics.Registry, st *store.
s.routesWorkResults(mux) s.routesWorkResults(mux)
s.routesMessages(mux) s.routesMessages(mux)
s.routesMetrics(mux) s.routesMetrics(mux)
s.routesDocs(mux)
mux.HandleFunc("GET /ws", s.handleWS) mux.HandleFunc("GET /ws", s.handleWS)
s.routesWeb(mux) s.routesWeb(mux)
+39
View File
@@ -22,6 +22,7 @@ func (s *Server) routesTasks(mux *http.ServeMux) {
mux.Handle("POST /api/v1/tasks/{id}/publish", s.authedRole("consultant", s.handlePublishOne)) mux.Handle("POST /api/v1/tasks/{id}/publish", s.authedRole("consultant", s.handlePublishOne))
mux.Handle("POST /api/v1/tasks/publish", s.authedRole("consultant", s.handlePublishBulk)) mux.Handle("POST /api/v1/tasks/publish", s.authedRole("consultant", s.handlePublishBulk))
mux.Handle("POST /api/v1/tasks/{id}/archive", s.authed(s.handleArchiveTask)) mux.Handle("POST /api/v1/tasks/{id}/archive", s.authed(s.handleArchiveTask))
mux.Handle("POST /api/v1/tasks/archive", s.authedRole("consultant", s.handleArchiveBulk))
mux.Handle("GET /api/v1/tasks/{id}", s.requireAuth(http.HandlerFunc(s.handleTaskDetail))) mux.Handle("GET /api/v1/tasks/{id}", s.requireAuth(http.HandlerFunc(s.handleTaskDetail)))
mux.Handle("GET /api/v1/service-health", s.requireAuth(http.HandlerFunc(s.handleServiceHealth))) mux.Handle("GET /api/v1/service-health", s.requireAuth(http.HandlerFunc(s.handleServiceHealth)))
} }
@@ -405,6 +406,44 @@ func (s *Server) handleArchiveTask(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
// handleArchiveBulk implements §11.9 bulk archive.
func (s *Server) handleArchiveBulk(w http.ResponseWriter, r *http.Request) {
var req struct {
IDs []string `json:"ids"`
}
if !decodeJSON(w, r, &req) {
return
}
if len(req.IDs) == 0 || len(req.IDs) > 100 {
writeError(w, http.StatusBadRequest, "bad_request", "ids must contain 1..100 entries")
return
}
u := CurrentUser(r.Context())
archived := []string{}
failed := map[string]string{}
for _, id := range req.IDs {
t, err := s.store.TaskByID(r.Context(), id)
if err != nil {
failed[id] = "not found"
continue
}
c, err := s.store.CustomerByID(r.Context(), t.CustomerID)
if err != nil || (!u.Roles.Admin && !c.HasConsultant(u.ID)) {
failed[id] = "not your customer"
continue
}
if err := s.store.TransitionTask(r.Context(), t, domain.StatusArchived, u.ID, "archived", nil, nil); err != nil {
failed[id] = err.Error()
continue
}
archived = append(archived, id)
}
if len(archived) > 0 {
s.Publish("board", "task.archived_bulk", map[string]any{"taskIds": archived})
}
writeJSON(w, http.StatusOK, map[string]any{"archived": archived, "failed": failed})
}
// handleTaskDetail is role-scoped: admins and customer consultants always; // handleTaskDetail is role-scoped: admins and customer consultants always;
// developers when they are the assignee, have a claim, or the task is on the // developers when they are the assignee, have a claim, or the task is on the
// public board (published/claim_requested). // public board (published/claim_requested).
+31
View File
@@ -2,12 +2,14 @@ package httpx
import ( import (
"fmt" "fmt"
"html"
"html/template" "html/template"
"io/fs" "io/fs"
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
"bountyboard/api"
"bountyboard/internal/domain" "bountyboard/internal/domain"
"bountyboard/web" "bountyboard/web"
) )
@@ -155,6 +157,17 @@ func (s *Server) routesWeb(mux *http.ServeMux) {
}) })
}) })
for _, p := range []struct{ path, tmpl, title, script string }{
{"/forgot-password", "forgot-password.html", "Forgot password", "/static/js/forgot-password.js"},
{"/reset-password", "reset-password.html", "Reset password", "/static/js/reset-password.js"},
} {
mux.HandleFunc("GET "+p.path, func(w http.ResponseWriter, r *http.Request) {
s.render(w, r, p.tmpl, &pageData{
Title: p.title, Narrow: true, Scripts: []string{p.script},
})
})
}
mux.HandleFunc("GET /change-password", s.page(func(w http.ResponseWriter, r *http.Request, u *domain.User) { mux.HandleFunc("GET /change-password", s.page(func(w http.ResponseWriter, r *http.Request, u *domain.User) {
s.render(w, r, "change-password.html", &pageData{ s.render(w, r, "change-password.html", &pageData{
Title: "Change password", User: u, Narrow: true, Title: "Change password", User: u, Narrow: true,
@@ -223,6 +236,24 @@ func (s *Server) routesWeb(mux *http.ServeMux) {
rolePage("/metrics", "metrics.html", "Metrics", "metrics", "/static/js/metrics.js", nil) rolePage("/metrics", "metrics.html", "Metrics", "metrics", "/static/js/metrics.js", nil)
} }
// routesDocs serves the OpenAPI document and a minimal viewer (§6).
func (s *Server) routesDocs(mux *http.ServeMux) {
mux.HandleFunc("GET /api/openapi.yaml", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/yaml")
w.Write(api.OpenAPI)
})
mux.HandleFunc("GET /api/docs", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(`<!doctype html><html lang="en"><head><meta charset="utf-8">
<title>Bounty Board API</title><link rel="stylesheet" href="/static/css/app.css">
<script src="/static/js/theme.js"></script></head>
<body><main class="container"><h1>Bounty Board API</h1>
<p><a class="btn" href="/api/openapi.yaml" download>Download openapi.yaml</a></p>
<pre style="white-space:pre-wrap;background:var(--surface);border:1px solid var(--border);padding:16px;border-radius:var(--radius)">` +
html.EscapeString(string(api.OpenAPI)) + `</pre></main></body></html>`))
})
}
func loginErrorMessage(code string) string { func loginErrorMessage(code string) string {
switch code { switch code {
case "": case "":
+8 -4
View File
@@ -51,10 +51,6 @@ func EnsureIndexes(ctx context.Context, db *mongo.Database) error {
{Keys: bson.D{{Key: "participantIds", Value: 1}, {Key: "lastMessageAt", Value: -1}}}, {Keys: bson.D{{Key: "participantIds", Value: 1}, {Key: "lastMessageAt", Value: -1}}},
{Keys: bson.D{{Key: "kind", Value: 1}, {Key: "customerId", Value: 1}}}, {Keys: bson.D{{Key: "kind", Value: 1}, {Key: "customerId", Value: 1}}},
}, },
"messages": {
// ULID _id is time-ordered, so this serves history pagination.
{Keys: bson.D{{Key: "conversationId", Value: 1}, {Key: "_id", Value: 1}}},
},
"notifications": { "notifications": {
{Keys: bson.D{{Key: "userId", Value: 1}, {Key: "readAt", Value: 1}, {Key: "createdAt", Value: -1}}}, {Keys: bson.D{{Key: "userId", Value: 1}, {Key: "readAt", Value: 1}, {Key: "createdAt", Value: -1}}},
}, },
@@ -62,6 +58,14 @@ func EnsureIndexes(ctx context.Context, db *mongo.Database) error {
{Keys: bson.D{{Key: "expiresAt", Value: 1}}, Options: options.Index().SetExpireAfterSeconds(0)}, {Keys: bson.D{{Key: "expiresAt", Value: 1}}, Options: options.Index().SetExpireAfterSeconds(0)},
{Keys: bson.D{{Key: "userId", Value: 1}}}, {Keys: bson.D{{Key: "userId", Value: 1}}},
}, },
"passwordResets": {
{Keys: bson.D{{Key: "expiresAt", Value: 1}}, Options: options.Index().SetExpireAfterSeconds(0)},
},
"messages": {
// ULID _id is time-ordered, so this serves history pagination.
{Keys: bson.D{{Key: "conversationId", Value: 1}, {Key: "_id", Value: 1}}},
{Keys: bson.D{{Key: "attachments.fileId", Value: 1}}, Options: options.Index().SetSparse(true)},
},
"auditLog": { "auditLog": {
{Keys: bson.D{{Key: "at", Value: -1}}}, {Keys: bson.D{{Key: "at", Value: -1}}},
{Keys: bson.D{{Key: "entityId", Value: 1}, {Key: "at", Value: -1}}}, {Keys: bson.D{{Key: "entityId", Value: 1}, {Key: "at", Value: -1}}},
+45
View File
@@ -0,0 +1,45 @@
package store
import (
"context"
"errors"
"fmt"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
)
// PasswordReset is a one-shot token (§11.22), reaped by TTL index.
type PasswordReset struct {
Token string `bson:"_id"`
UserID string `bson:"userId"`
ExpiresAt time.Time `bson:"expiresAt"`
}
func (s *Store) CreatePasswordReset(ctx context.Context, token, userID string, ttl time.Duration) error {
_, err := s.DB.Collection("passwordResets").InsertOne(ctx, PasswordReset{
Token: token, UserID: userID, ExpiresAt: time.Now().UTC().Add(ttl),
})
if err != nil {
return fmt.Errorf("create password reset: %w", err)
}
return nil
}
// ConsumePasswordReset returns the userId for a live token and deletes it
// atomically (single use).
func (s *Store) ConsumePasswordReset(ctx context.Context, token string) (string, error) {
var pr PasswordReset
err := s.DB.Collection("passwordResets").FindOneAndDelete(ctx, bson.M{
"_id": token,
"expiresAt": bson.M{"$gt": time.Now().UTC()},
}).Decode(&pr)
if errors.Is(err, mongo.ErrNoDocuments) {
return "", ErrNotFound
}
if err != nil {
return "", fmt.Errorf("consume password reset: %w", err)
}
return pr.UserID, nil
}
+187
View File
@@ -0,0 +1,187 @@
// Command seed populates a demo environment (§11.11): one demo customer
// (offline ticketing type), 1 admin, 2 consultants, 6 developers, pool
// memberships, and sample conversations. Idempotent: existing users (by
// email) and customers (by name) are reused, not duplicated.
//
// MONGO_SEED_URI=mongodb://127.0.0.1:27017 go run ./scripts
package main
import (
"context"
"fmt"
"log"
"os"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
"bountyboard/internal/auth"
"bountyboard/internal/config"
"bountyboard/internal/domain"
"bountyboard/internal/store"
"bountyboard/internal/ulid"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
const demoPassword = "demo-pass-123"
func main() {
_ = config.LoadDotEnv(".env")
uri := os.Getenv("MONGO_SEED_URI")
if uri == "" {
uri = "mongodb://127.0.0.1:27017"
}
dbName := os.Getenv("MONGO_DB")
if dbName == "" {
dbName = "bountyboard"
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
client, err := mongo.Connect(options.Client().ApplyURI(uri).
SetServerSelectionTimeout(5 * time.Second))
if err != nil {
log.Fatalf("connect: %v", err)
}
defer client.Disconnect(context.Background())
st := &store.Store{Client: client, DB: client.Database(dbName)}
if err := store.EnsureIndexes(ctx, st.DB); err != nil {
log.Fatalf("indexes: %v", err)
}
user := func(email, name string, roles domain.Roles) *domain.User {
if u, err := st.UserByEmail(ctx, email); err == nil {
fmt.Printf(" exists: %s\n", email)
return u
}
hash, err := auth.HashPassword(demoPassword)
if err != nil {
log.Fatal(err)
}
u := &domain.User{
ID: ulid.New(), Email: email, Name: name, Roles: roles,
Auth: domain.Auth{Local: &domain.LocalAuth{PasswordHash: hash}},
Settings: domain.UserSettings{Theme: "light",
Notifications: domain.NotificationPrefs{Email: true, InApp: true}},
Bio: "Seeded demo account.",
}
if err := st.CreateUser(ctx, u); err != nil {
log.Fatalf("create %s: %v", email, err)
}
fmt.Printf(" created: %s (password %q)\n", email, demoPassword)
return u
}
fmt.Println("Seeding users…")
user("seed-admin@example.com", "Seed Admin", domain.Roles{Admin: true})
clara := user("clara@example.com", "Clara Consultant", domain.Roles{Consultant: true})
carlos := user("carlos@example.com", "Carlos Consultant", domain.Roles{Consultant: true})
devs := []*domain.User{}
devNames := []string{"Dana", "Devon", "Dimitri", "Daria", "Dylan", "Drew"}
for i, n := range devNames {
devs = append(devs, user(fmt.Sprintf("dev%d@example.com", i+1),
n+" Developer", domain.Roles{Developer: true}))
}
fmt.Println("Seeding pools…")
addPool := func(c, d *domain.User) {
if err := st.AddToPool(ctx, c.ID, d.ID); err != nil && err != store.ErrDuplicate {
log.Fatalf("pool: %v", err)
}
}
for _, d := range devs[:4] {
addPool(clara, d)
}
for _, d := range devs[2:] {
addPool(carlos, d)
}
fmt.Println("Seeding demo customer…")
customers, err := st.ListCustomers(ctx, "", true)
if err != nil {
log.Fatal(err)
}
var demoCustomer *domain.Customer
for i := range customers {
if customers[i].Name == "ACME Demo" {
demoCustomer = &customers[i]
}
}
if demoCustomer == nil {
demoCustomer = &domain.Customer{
Name: "ACME Demo",
Ticketing: domain.Ticketing{
Type: domain.TicketingDemo, ProjectKey: "ACME", PollIntervalSec: 30,
},
ConsultantIDs: []string{clara.ID, carlos.ID},
DefaultBudget: 1500,
}
if err := st.CreateCustomer(ctx, demoCustomer); err != nil {
log.Fatal(err)
}
fmt.Println(" created: ACME Demo (demo ticketing — tickets appear within one poll)")
} else {
fmt.Println(" exists: ACME Demo")
}
fmt.Println("Seeding conversations…")
dm, err := st.FindOrCreateDM(ctx, clara.ID, devs[0].ID)
if err != nil {
log.Fatal(err)
}
n, err := st.DB.Collection("messages").CountDocuments(ctx, bson.M{"conversationId": dm.ID})
if err != nil {
log.Fatal(err)
}
if n == 0 {
msgs := []struct {
from *domain.User
body string
}{
{clara, "<p>Hi Dana — I just added you to my pool. The ACME board fills up shortly.</p>"},
{devs[0], "<p>Great, I will grab the <b>CSV importer</b> once it is published.</p>"},
{clara, "<p>Perfect. Ping me here if any acceptance criteria are unclear.</p>"},
}
for _, m := range msgs {
if err := st.InsertMessage(ctx, &store.Message{
ConversationID: dm.ID, SenderID: m.from.ID, Body: m.body,
}); err != nil {
log.Fatal(err)
}
}
fmt.Println(" created: DM Clara ↔ Dana with 3 messages")
}
groups, _ := st.ListConversations(ctx, clara.ID)
hasGroup := false
for _, g := range groups {
if g.Kind == "group" && g.Title == "ACME standup" {
hasGroup = true
}
}
if !hasGroup {
grp := &store.Conversation{
Kind: "group", Title: "ACME standup",
ParticipantIDs: []string{clara.ID, carlos.ID, devs[0].ID, devs[1].ID, devs[2].ID},
}
if err := st.CreateConversation(ctx, grp); err != nil {
log.Fatal(err)
}
if err := st.InsertMessage(ctx, &store.Message{
ConversationID: grp.ID, SenderID: carlos.ID,
Body: "<p>Welcome to the ACME standup channel. Daily updates here, please.</p>",
}); err != nil {
log.Fatal(err)
}
fmt.Println(" created: group 'ACME standup'")
}
fmt.Println("\nSeed complete.")
fmt.Printf("Demo accounts (password %q):\n", demoPassword)
fmt.Println(" seed-admin@example.com (admin)")
fmt.Println(" clara@example.com, carlos@example.com (consultants)")
for i := range devNames {
fmt.Printf(" dev%d@example.com (developer)\n", i+1)
}
}
+39 -6
View File
@@ -1,28 +1,61 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Curl-based smoke test against a running stack (docker compose up -d). # Curl-based smoke test (§13): register → login → healthz → board against a
# Grows with the system; later phases add register→login→board coverage. # running stack (docker compose up -d).
set -euo pipefail set -euo pipefail
BASE_URL="${BASE_URL:-http://localhost:8787}" BASE_URL="${BASE_URL:-http://localhost:8787}"
JAR=$(mktemp)
trap 'rm -f "$JAR" /tmp/smoke-*.json' EXIT
fail() { echo "SMOKE FAIL: $*" >&2; exit 1; } fail() { echo "SMOKE FAIL: $*" >&2; exit 1; }
echo "smoke: $BASE_URL" echo "smoke: $BASE_URL"
# healthz must always be 200 while the process is up # 1. health endpoints
code=$(curl -fsS -o /tmp/smoke-healthz.json -w '%{http_code}' "$BASE_URL/healthz") \ code=$(curl -fsS -o /tmp/smoke-healthz.json -w '%{http_code}' "$BASE_URL/healthz") \
|| fail "healthz unreachable" || fail "healthz unreachable"
[ "$code" = "200" ] || fail "healthz returned $code" [ "$code" = "200" ] || fail "healthz returned $code"
grep -q '"ok"' /tmp/smoke-healthz.json || fail "healthz body unexpected: $(cat /tmp/smoke-healthz.json)" grep -q '"ok"' /tmp/smoke-healthz.json || fail "healthz body unexpected"
echo " healthz ok" echo " healthz ok"
# readyz reflects dependency state; required deps must be up for the smoke run
code=$(curl -sS -o /tmp/smoke-readyz.json -w '%{http_code}' "$BASE_URL/readyz") \ code=$(curl -sS -o /tmp/smoke-readyz.json -w '%{http_code}' "$BASE_URL/readyz") \
|| fail "readyz unreachable" || fail "readyz unreachable"
[ "$code" = "200" ] || fail "readyz returned $code: $(cat /tmp/smoke-readyz.json)" [ "$code" = "200" ] || fail "readyz returned $code: $(cat /tmp/smoke-readyz.json)"
echo " readyz ok" echo " readyz ok"
# metricsz exposes counters
curl -fsS "$BASE_URL/metricsz" | grep -q '"counters"' || fail "metricsz body unexpected" curl -fsS "$BASE_URL/metricsz" | grep -q '"counters"' || fail "metricsz body unexpected"
echo " metricsz ok" echo " metricsz ok"
# 2. register a throwaway developer
EMAIL="smoke-$(date +%s)-$RANDOM@example.com"
code=$(curl -sS -c "$JAR" -o /tmp/smoke-reg.json -w '%{http_code}' \
-H 'Content-Type: application/json' \
-d "{\"email\":\"$EMAIL\",\"name\":\"Smoke Test\",\"password\":\"smoke-pass-123\"}" \
"$BASE_URL/api/v1/auth/register")
[ "$code" = "201" ] || fail "register returned $code: $(cat /tmp/smoke-reg.json)"
echo " register ok ($EMAIL)"
# 3. logout, then login again
CSRF=$(awk '$6=="bb_csrf" {print $7}' "$JAR")
curl -fsS -b "$JAR" -X POST -H "X-CSRF-Token: $CSRF" \
"$BASE_URL/api/v1/auth/logout" -o /dev/null || fail "logout failed"
code=$(curl -sS -c "$JAR" -o /tmp/smoke-login.json -w '%{http_code}' \
-H 'Content-Type: application/json' \
-d "{\"email\":\"$EMAIL\",\"password\":\"smoke-pass-123\"}" \
"$BASE_URL/api/v1/auth/login")
[ "$code" = "200" ] || fail "login returned $code"
echo " login ok"
# 4. authenticated me + developer board
curl -fsS -b "$JAR" "$BASE_URL/api/v1/auth/me" | grep -q "$EMAIL" || fail "me missing email"
echo " me ok"
code=$(curl -sS -b "$JAR" -o /tmp/smoke-board.json -w '%{http_code}' "$BASE_URL/api/v1/board")
[ "$code" = "200" ] || fail "board returned $code: $(cat /tmp/smoke-board.json)"
grep -q '"tasks"' /tmp/smoke-board.json || fail "board body unexpected"
echo " board ok"
# 5. pages render
curl -fsS "$BASE_URL/login" | grep -q 'Log in · Bounty Board' || fail "login page broken"
curl -fsS "$BASE_URL/api/docs" | grep -q 'Bounty Board API' || fail "api docs broken"
echo " pages ok"
echo "SMOKE PASS" echo "SMOKE PASS"
+5
View File
@@ -155,6 +155,11 @@ table.list th { color: var(--muted); font-size: 0.875rem; }
.toast.err { border-left-color: var(--err); } .toast.err { border-left-color: var(--err); }
.toast.ok { border-left-color: var(--ok); } .toast.ok { border-left-color: var(--ok); }
.hovercard {
position: absolute; z-index: 90; width: 300px;
box-shadow: 0 4px 16px rgba(0,0,0,0.25);
}
/* chat */ /* chat */
.chat-layout { display: grid; grid-template-columns: 280px 1fr; gap: 16px; min-height: 70vh; } .chat-layout { display: grid; grid-template-columns: 280px 1fr; gap: 16px; min-height: 70vh; }
.chat-sidebar { overflow: auto; } .chat-sidebar { overflow: auto; }
+19
View File
@@ -0,0 +1,19 @@
import { api } from '/static/js/api.js';
document.getElementById('forgot-form').addEventListener('submit', async (e) => {
e.preventDefault();
const errorBox = document.getElementById('error');
const okBox = document.getElementById('ok');
errorBox.textContent = '';
okBox.textContent = '';
try {
await api('POST', '/api/v1/auth/forgot', {
email: document.getElementById('email').value.trim(),
});
okBox.textContent = 'If an account with that email exists, a reset link is on its way.';
} catch (err) {
errorBox.textContent = err.code === 'smtp_disabled'
? 'Email is not configured on this server — ask an administrator to reset your password.'
: err.message;
}
});
+19
View File
@@ -0,0 +1,19 @@
import { api } from '/static/js/api.js';
document.getElementById('reset-form').addEventListener('submit', async (e) => {
e.preventDefault();
const errorBox = document.getElementById('error');
errorBox.textContent = '';
const pw = document.getElementById('new').value;
if (pw !== document.getElementById('confirm').value) {
errorBox.textContent = 'Passwords do not match.';
return;
}
try {
await api('POST', '/api/v1/auth/reset', {
token: new URLSearchParams(window.location.search).get('token') || '',
newPassword: pw,
});
window.location.href = '/login';
} catch (err) { errorBox.textContent = err.message; }
});
+83
View File
@@ -0,0 +1,83 @@
// Global keyboard shortcuts (§10): g b → board, g m → messages, / → search.
// Plus profile hover cards (§11.13) for any [data-user-card] element.
import { api } from '/static/js/api.js';
let pendingG = false;
let gTimer = null;
function typingTarget(e) {
const t = e.target;
return t.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(t.tagName);
}
document.addEventListener('keydown', (e) => {
if (typingTarget(e) || e.ctrlKey || e.metaKey || e.altKey) return;
if (e.key === '/') {
const search = document.querySelector('input[type=search]');
if (search) { e.preventDefault(); search.focus(); }
return;
}
if (pendingG) {
pendingG = false;
clearTimeout(gTimer);
const map = { b: '/board', m: '/messages', h: '/', t: '/my-tasks', a: '/consultant/board' };
if (map[e.key]) { e.preventDefault(); window.location.href = map[e.key]; }
return;
}
if (e.key === 'g') {
pendingG = true;
gTimer = setTimeout(() => { pendingG = false; }, 1200);
}
});
// ---- hover cards ----
let cardEl = null;
let hideTimer = null;
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
}[c]));
function hideCard() {
if (cardEl) { cardEl.remove(); cardEl = null; }
}
async function showCard(target, userId) {
try {
const res = await api('GET', `/api/v1/users/${userId}/card`);
const c = res.card;
hideCard();
cardEl = document.createElement('div');
cardEl.className = 'hovercard card';
const roles = ['admin', 'consultant', 'developer'].filter((r) => c.roles[r]);
cardEl.innerHTML = `
<div class="spread">
${c.avatarFileId ? `<img class="avatar large" src="/files/${c.avatarFileId}" alt="">`
: `<span class="avatar large">${esc(c.name.slice(0, 1).toUpperCase())}</span>`}
<div>
<strong>${esc(c.name)}</strong><br>
${roles.map((r) => `<span class="badge">${r}</span>`).join(' ')}
</div>
</div>
${c.bio ? `<p class="muted">${esc(c.bio.slice(0, 160))}</p>` : ''}
${c.contact && c.contact.location ? `<p class="muted">📍 ${esc(c.contact.location)}</p>` : ''}`;
document.body.appendChild(cardEl);
const rect = target.getBoundingClientRect();
cardEl.style.left = Math.min(rect.left, window.innerWidth - 320) + 'px';
cardEl.style.top = (rect.bottom + window.scrollY + 6) + 'px';
cardEl.addEventListener('mouseenter', () => clearTimeout(hideTimer));
cardEl.addEventListener('mouseleave', () => { hideTimer = setTimeout(hideCard, 200); });
} catch (e) { /* card is best-effort */ }
}
document.addEventListener('mouseover', (e) => {
const target = e.target.closest('[data-user-card]');
if (!target) return;
clearTimeout(hideTimer);
hideTimer = setTimeout(() => showCard(target, target.dataset.userCard), 350);
});
document.addEventListener('mouseout', (e) => {
if (e.target.closest('[data-user-card]')) {
clearTimeout(hideTimer);
hideTimer = setTimeout(hideCard, 250);
}
});
+17
View File
@@ -0,0 +1,17 @@
{{define "content"}}
<div class="card">
<h1>Forgot password</h1>
<div class="error-box" id="error" role="alert"></div>
<div class="ok-box" id="ok" role="status"></div>
<form id="forgot-form">
<div class="field">
<label for="email">Email</label>
<input type="email" id="email" autocomplete="email" required>
</div>
<div class="spread">
<button class="btn primary" type="submit">Send reset link</button>
<a href="/login">Back to log in</a>
</div>
</form>
</div>
{{end}}
+2 -1
View File
@@ -57,7 +57,8 @@
</main> </main>
<div id="toasts" aria-live="polite"></div> <div id="toasts" aria-live="polite"></div>
<script type="module" src="/static/js/nav.js"></script> <script type="module" src="/static/js/nav.js"></script>
{{if .User}}<script type="module" src="/static/js/notifications.js"></script>{{end}} {{if .User}}<script type="module" src="/static/js/notifications.js"></script>
<script type="module" src="/static/js/shortcuts.js"></script>{{end}}
{{range .Scripts}}<script type="module" src="{{.}}"></script> {{range .Scripts}}<script type="module" src="{{.}}"></script>
{{end}} {{end}}
</body> </body>
+1 -1
View File
@@ -13,7 +13,7 @@
</div> </div>
<div class="spread"> <div class="spread">
<button class="btn primary" type="submit">Log in</button> <button class="btn primary" type="submit">Log in</button>
<a href="/register">Create an account</a> <span><a href="/forgot-password">Forgot password?</a> · <a href="/register">Create an account</a></span>
</div> </div>
</form> </form>
{{if .OIDCEnabled}} {{if .OIDCEnabled}}
+17
View File
@@ -0,0 +1,17 @@
{{define "content"}}
<div class="card">
<h1>Set a new password</h1>
<div class="error-box" id="error" role="alert"></div>
<form id="reset-form">
<div class="field">
<label for="new">New password</label>
<input type="password" id="new" autocomplete="new-password" minlength="8" required>
</div>
<div class="field">
<label for="confirm">Confirm new password</label>
<input type="password" id="confirm" autocomplete="new-password" minlength="8" required>
</div>
<button class="btn primary" type="submit">Set password</button>
</form>
</div>
{{end}}