Files
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

9.8 KiB
Raw Permalink Blame History

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 512 (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 HOMEsudo 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).