b5ebbfb748
- 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>
169 lines
9.8 KiB
Markdown
169 lines
9.8 KiB
Markdown
# Decisions
|
||
|
||
Spec-silent choices, recorded as required by the build instructions.
|
||
|
||
## Phase 1
|
||
|
||
- **Go toolchain 1.26.4** (latest stable; spec requires ≥ 1.22). Installed at
|
||
`/usr/local/go`.
|
||
- **Module name `bountyboard`** — no remote repository exists; a single-word
|
||
module keeps import paths short (`bountyboard/internal/...`).
|
||
- **Package in `internal/http` is named `httpx`** to avoid clashing with
|
||
stdlib `net/http` inside its own files. The directory stays `internal/http`
|
||
per spec §14; importers alias it (`httpx "bountyboard/internal/http"`).
|
||
- **`/readyz` uses a pluggable checker registry** (`AddReadinessCheck`):
|
||
required checks (Mongo, Phase 2) gate readiness with 503; optional checks
|
||
(atomizer / work performer, Phase 11) are reported as `degraded` but stay
|
||
200, matching §6's "non-fatal, reported".
|
||
- **Runtime image is `alpine:3.21`, not distroless** — busybox `wget` enables
|
||
the compose healthcheck without adding tooling, and `ca-certificates` is
|
||
needed for outbound HTTPS to Jira/ADO/YouTrack. Non-root user.
|
||
- **Config validation is strict and fail-fast**: `CREDENTIALS_ENC_KEY` is
|
||
mandatory at startup (base64, exactly 32 bytes) and `SESSION_SECRET` must be
|
||
≥ 16 chars, even before the features using them land — a misconfigured
|
||
deployment should die at boot, not at first use. All validation errors are
|
||
reported together via `errors.Join`.
|
||
- **Trusted-proxy resolution walks X-Forwarded-For right-to-left**, skipping
|
||
addresses inside `TRUSTED_PROXY_CIDRS`; the first untrusted address is the
|
||
client. A malformed entry stops the walk (everything left of it is
|
||
attacker-controllable). If the whole chain is trusted, the leftmost entry is
|
||
used. Headers are ignored entirely when the direct peer is untrusted.
|
||
- **ULID**: canonical 48-bit ms timestamp + 80-bit crypto randomness, no
|
||
intra-millisecond monotonicity (spec only needs sortability at ms
|
||
resolution; randomness makes collisions negligible).
|
||
- **`.env` loader semantics**: real environment variables win over `.env`
|
||
values; unquoted values support trailing ` # comment`; missing `.env` file
|
||
is not an error (containers receive env via compose `env_file`).
|
||
- **Makefile `seed`/`backup`/`restore` are failing stubs until Phase 12** so
|
||
the targets exist but cannot be mistaken for working.
|
||
|
||
## Phase 2
|
||
|
||
- **Integration tests reach Mongo via `docker-compose.test.yml`**, an explicit
|
||
test-only overlay publishing Mongo on `127.0.0.1:27017`. The normal compose
|
||
file still never publishes Mongo (§12); the spec's integration-test
|
||
requirement (§2.1) needs host access, and a loopback-only opt-in overlay is
|
||
the smallest hole. Each run uses a `bountyboard_test_<ulid>` database and
|
||
drops it on cleanup.
|
||
- **`UpdateVersioned` rejects updates that touch `version`** and merges
|
||
`updatedAt`/`$inc version` into the caller's operators; it distinguishes
|
||
`ErrNotFound` from `ErrVersionConflict` with a follow-up existence check.
|
||
- **File MIME type = sniffed (`http.DetectContentType`) unless inconclusive**
|
||
(`application/octet-stream`), in which case the client-declared type is
|
||
kept. A confident sniff overrides a lying declaration.
|
||
- **sha256 of uploads is recorded post-upload** via an update on `fs.files`
|
||
metadata (GridFS metadata must be supplied before streaming; the hash is
|
||
only known after).
|
||
- **Signed file tokens** are `base64url(exp || hmac-sha256(fileID|exp))` with
|
||
a key derived as `sha256("bountyboard/file-url/v1" + SESSION_SECRET)` so
|
||
file tokens can never collide with other uses of the session secret.
|
||
|
||
## Phase 3
|
||
|
||
- **OIDC linking requires `email_verified=true`.** An unverified IdP email
|
||
matching an existing local account is rejected (`oidc_email_unverified`)
|
||
instead of linking or creating a duplicate — linking on unverified email
|
||
would allow account takeover via a rogue IdP account.
|
||
- **OIDC provider discovery is lazy** (first login attempt, retried on
|
||
failure) so an unreachable IdP cannot block app startup.
|
||
- **CSRF protection applies to authenticated mutations only**; login and
|
||
register are pre-session and protected by rate limiting + SameSite=Lax.
|
||
The CSRF cookie is intentionally JS-readable (double-submit pattern).
|
||
- **Password change revokes all other sessions** of the user (recovery
|
||
semantics) but keeps the current one. `SetPassword` is deliberately not
|
||
version-checked: a password change must never lose an optimistic-locking
|
||
race against a profile edit.
|
||
- **Forced password change (`mustChange`)** is enforced in `requireAuth` via
|
||
a path allowlist (`me`, `logout`, `logout-all`, `change-password`).
|
||
- **Sessions slide at most once per hour** (refreshedAt watermark) instead of
|
||
updating expiry on every request.
|
||
- **Login rate-limit key is `clientIP|email`** (and `register|clientIP` for
|
||
registration), using the trusted-proxy-resolved IP.
|
||
- **Mongo runs with `--wiredTigerCacheSizeGB 0.5`** in compose — the target
|
||
host has 4 GB RAM; default cache (≈50% of RAM) caused a restart under the
|
||
integration-test load.
|
||
|
||
## Phase 4
|
||
|
||
- **CSP has no `unsafe-inline` for scripts from day one** (§12), so all JS is
|
||
external ES modules (`theme.js` loads synchronously in `<head>` to avoid a
|
||
theme flash; everything else is `type=module`). Inline event handlers are
|
||
never used.
|
||
- **`PATCH /api/v1/profile` is a partial update**: only fields present in the
|
||
body change. `version` is optional — when present the update is
|
||
version-checked (409 + reload toast on conflict); the nav theme toggle
|
||
omits it so flipping themes can't conflict with a profile edit.
|
||
- **Avatar uploads must sniff as `image/*`**; the rejected/replaced GridFS
|
||
files are deleted eagerly. Replacing an avatar removes the previous file.
|
||
- **File serving (`GET /files/{id}`)**: valid signed token (?st=) OR session.
|
||
With a session: avatars are visible to any logged-in user; other scopes
|
||
(chat/task) currently allow owner/admin/consultant and will be tightened as
|
||
those features land.
|
||
- **Pages that need login redirect to `/login?next=…`** (HTML UX), while API
|
||
routes return 401 JSON. Forced password change redirects all pages to
|
||
/change-password.
|
||
- **Pages for later phases render an "under construction" placeholder** so
|
||
role-based navigation is complete and clickable now.
|
||
|
||
## Phases 5–12 (selected)
|
||
|
||
- **`internal/extsvc`** (not in the §14 list) holds the circuit breaker and
|
||
retrying JSON client shared by the two service clients — sharing plumbing,
|
||
not state; each service keeps its own breaker, URL, and token.
|
||
- **Mock services are separate codebases**: `services/atomizer` has its own
|
||
`go.mod` (stdlib only); `services/work-performer` is plain Node with zero
|
||
npm dependencies (plus the globally installed claude CLI in its image).
|
||
- **AI-approved tasks earn no `bountyAwards` row** — §4.5 awards are a
|
||
developer performance ledger; the AI submitter has no developer identity.
|
||
- **Bulk archive/publish are partial-success APIs** returning
|
||
`{done[], failed{}}` instead of failing the whole batch.
|
||
- **Extension siblings record `parentId = source task`** (spec literal),
|
||
so the UI tree shows extensions beneath their source.
|
||
|
||
## Phase 13 (deployment)
|
||
|
||
- **`APP_INTERNAL_URL` (compose: `http://app:8787`)** is handed to the
|
||
external services for callback URLs and signed attachment URLs — the §5.2
|
||
example uses the in-network hostname; `APP_BASE_URL` stays browser-facing.
|
||
- **The work-performer container runs as the `node` user** (uid 1000) with
|
||
`${HOME}/.claude` mounted into `/home/node/` instead of `/root/` (§9.2
|
||
shows /root): the claude CLI refuses `--dangerously-skip-permissions` as
|
||
root, so the literal spec mount can never execute jobs.
|
||
- **Run compose with the real user's HOME** — `sudo docker compose` resolves
|
||
`${HOME}` to `/root` and silently mounts the wrong Claude credentials. Use
|
||
`sudo --preserve-env=HOME docker compose …` (or run docker unprivileged).
|
||
- **UFW**: a rule allowing `172.16.0.0/12` (docker networks) to reach the
|
||
host was added so container→host callbacks work in contract tests.
|
||
- **`scripts/acceptance.sh`** automates the §13 checklist live (import →
|
||
subdivide → extend → publish → claim/decline/approve → review → award →
|
||
AI job via real Claude Code → breaker independence) and is re-run-safe.
|
||
|
||
## Post-deploy additions
|
||
|
||
- **WeKan connector**: `projectKey` is the board id; "assigned to the
|
||
consultant" means the consultant's WeKan username (from
|
||
`ticketingIdentities.wekan`) appears in a card's `assignees` (falling back
|
||
to `members` when no assignee is set). Archived cards are skipped. WeKan
|
||
has no epic/story hierarchy → all cards map to type `task`; the API offers
|
||
no server-side updated-since filter, so the connector filters on
|
||
`modifiedAt` client-side (one details request per card — fine for board
|
||
sizes WeKan handles). Auth: username+password login per sync with a 10-min
|
||
token reuse window, or a pre-issued token. Card attachments are not
|
||
imported in v1.
|
||
|
||
## Remote access + UI design pass (user-requested)
|
||
|
||
- **App published on 0.0.0.0:8787** (spec default loopback-only kept as a
|
||
commented line in compose): this host's pattern exposes services directly
|
||
(gitea/outline/wekan) and Docker-published ports bypass UFW anyway. The
|
||
public entrypoint is Nginx Proxy Manager → http://bountyboard.anypreta.com
|
||
with `APP_BASE_URL` set accordingly and `TRUSTED_PROXY_CIDRS=172.16.0.0/12`
|
||
so audit-log client IPs resolve through the proxy.
|
||
- **"The Ledger" design pass** (user invoked the frontend-design skill):
|
||
§10 tokens, 2px radius, AA contrast and both themes unchanged; typography
|
||
deviates from the spec's system font stack by request — self-hosted
|
||
variable woff2 (Fraunces display, Schibsted Grotesk body, Spline Sans Mono
|
||
for ledger numerals), ~147 KB total, no build step, CSP font-src 'self'.
|
||
Adds paper grain, hairline double rules, letterpress buttons, stamp
|
||
badges, staggered page reveal (disabled under prefers-reduced-motion).
|