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
+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}