CHANGELOG.md 17 KB

Changelog

All notable changes to this project are documented here. The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

Unreleased

Nothing scheduled.

0.23.0 — 2026-05-07

Quality-of-life release on top of v0.22.0: explicit OIDC kill-switch for dev / testing, a production-bootstrap guard against shipping an unreachable instance, and a refreshed admin-only Runtime panel that surfaces app version + creator instead of the PHP version.

Added

  • OIDC_ENABLED kill-switch for dev / testing. New env var (default true) that, when set to false / 0 / no / off (case-insensitive, trimmed), forces OidcClient::isConfigured() to return false even when every ENTRA_* var is populated. /auth/login and /auth/callback both short-circuit to the same operator-facing 503 config page, with copy that distinguishes "disabled by flag" from "not configured". Lets dev / on-prem deployments route everyone through LOCAL_ADMIN_* without unsetting the Entra creds in .env. New OidcClient::isExplicitlyDisabled() helper and 6 lock-in tests in tests/Auth/OidcClientTest.php.
  • App\Meta — single source of truth for app version + creator. New class exposes Meta::VERSION (0.23.0) and Meta::CREATOR (Alessandro Chiapparini); bump alongside the release commit so the CHANGELOG heading, the git tag, and the in-app Runtime panel stay aligned.

Changed

  • Production bootstrap refuses to start with no sign-in method. public/index.php now aborts with a 503 + Retry-After: 30 and an error_log line when APP_ENV=production and neither OIDC nor LOCAL_ADMIN_* is enabled. Stops a fully unreachable instance from shipping silently after a misconfigured deploy.
  • Admin-only Runtime panel on / swaps contents. Drops the PHP row; adds App version and Creator (sourced from App\Meta). The OIDC row's value vocabulary changes from configured / not configured to enabled / disabled, matching the Local admin row, so OIDC_ENABLED=false reads naturally. No leak-surface change — the gate (currentUser is not null and currentUser.isAdmin from R01-N02) is unchanged, and TwigViewTest::testHomeForAnonymousUserHidesRuntimePanel was updated to assert that neither the new appVersion nor appCreator strings render for anonymous visitors.

0.22.0 — 2026-05-07

First tagged release. The minor version mirrors the latest build phase shipped (Phase 22). Section grouping below reflects the build-phase history captured in SPEC.md §9 plus the security-review hardening pass tracked in doc/REVIEW_01.md (R01-N01..R01-N34).

Added

  • Phase 1 — Skeleton. Front controller, router, request/response primitives, view layer.
  • Phase 2 — Auth. Microsoft Entra ID via OpenID Connect (Authorization Code + PKCE), session + CSRF, first-user-is-admin bootstrap, optional local-admin fallback for dev / on-prem deployments.
  • Phase 3 — Workers, sprints, audit. Worker CRUD, sprint create, per-cell audit log.
  • Phase 4 — Sprint settings. Sprint metadata edit, weeks resize, worker membership add/remove/reorder, per-row reserve-time-buffer (RTB).
  • Phase 5 — Arbeitstage grid. Editable working-days matrix, capacity calculation, per-cell persistence with audit.
  • Phase 6 — Task list. CRUD, assignments grid, sort/filter/search, drag-reorder.
  • Phase 7 — Audit viewer + polish. /audit admin page with filters, pagination, collapsible diffs; security headers + strict-ish CSP; CSRF audit (18/18 mutations); PHPUnit harness with 59 tests.
  • Phase 8 — Cascade audit integrity. Three FK cascade paths (sprint_worker → sprint_worker_days, sprint_worker → task_assignments, sprint_week → sprint_worker_days on shrink) snapshot-audit before the parent delete fires.
  • Phase 9 — Users management. GET /users + POST /users/{id} with self-demote and last-admin guardrails.
  • Phase 10 — Task list polish. Multi-select owner filter and column visibility toggle, both pure client-side with localStorage persistence.
  • Phase 11 — CSP hardening. Vendored Tailwind via a Node css-builder Docker stage; inline onclick replaced by data-href + app.js; CSP dropped unsafe-inline and the Tailwind CDN host.
  • Phase 12 — Per-week weekday selection (Mo–Fr) drives Arbeitstage. sprint_weeks.active_days_mask becomes the source of truth; max_working_days is a cached popcount(mask) projection. Five checkboxes per week in Sprint Settings; row of five dots per week in the sprint view (green = active, gray = off). Migration 002 backfills legacy rows.
  • Phase 13 — Focus filter + Reset in the task list. New [data-focus-select] picks one sprint worker; applyFocusColumnVisibility hides all-zero columns; [data-reset-filters] wipes search, prio, ownerFilterSet, focusWorker, and hiddenCols in one click. State persists in localStorage.
  • Phase 14 — Hamburger menu. Workers / Users / Audit log / Sign out collapsed into a dropdown behind a data-menu-trigger button with an inline-SVG icon; vanilla-JS toggle (~30 lines) honours outside-click, Escape, and focus return.
  • Phase 15 — Big-screen (beamer) task viewer. New signed-in route /sprints/{id}/present renders a stripped-down view for projection; shared partial with /sprints/{id} via a loadSprintPage helper. Auto vertical column headers when overflow detected.
  • Phase 16 — Dark-mode toggle + light-mode contrast cleanup. Manual toggle (no prefers-color-scheme auto-detect); theme-init.js synchronously sets <html class="dark"> from localStorage to prevent FOUC; comprehensive dark: class sweep across every view.
  • Phase 17 — Number-stepper popover (later replaced). Hidden native number-input spinners (@layer base reset); per-cell click-to-open vertical-slider popover on day, RTB, and task-assignment inputs. After seven iterations the implementation was removed in favour of plain typed entry; ArrowUp/ArrowDown stepping retained via browser default.
  • Phase 18 — Per-cell task-status colours + filter + global toggle. task_assignments.status ∈ {zugewiesen, gestartet, abgeschlossen, abgebrochen}; new app_settings KV table with opt-in task_status_enabled flag; admin /settings page; Status multi-select filter; first non-admin write surface (PATCH /tasks/{id}/assignments/status). Migration 003.
  • Phase 19 — Twig 3 + Tailwind 3 + Alpine CSP + htmx + SortableJS; jQuery removed. Stack-shift of the entire UI layer with zero changes to controllers, repositories, schema, capacity math, or audit semantics. All 11 views/*.php rewritten as *.twig. Strict CSP: script-src 'self' and style-src 'self' only — no third-party hosts.
  • Phase 20 — XLSX import wizard. Two-step admin-only wizard at /sprints/import; multipart upload (≤ 5 MB, ZIP magic-byte check), preview screen with target picker (Create new / Merge into empty existing), per-sheet skip toggle, transactional commit; per-sprint IMPORTED_FROM_XLSX audit row. New service trio: XlsxColorClassifier, XlsxSprintImporter, SprintImporter. Composer dep phpoffice/phpspreadsheet ^3.4; runtime stage adds zip + gd extensions.
  • Phase 21 — Auto-derived week count in sprint settings. PATCH /sprints/{id} resyncs sprint_weeks whenever start_date or end_date changes (target = floor((end − start)/7) + 1, capped at 26). Existing rows realign; appended rows default to MASK_ALL; trailing rows shrink with the same audit-cascaded-days flow as the legacy replaceWeeks.
  • Phase 22 — Per-task hamburger menu: move / copy / edit / reorder. Per-row admin button gathers Edit details / Move to sprint / Copy to sprint / Move (pick up) / Delete. Tasks gain description (≤ 8000 chars), url (http(s)://, ≤ 2048 chars), and linked_task_id for bidirectional cross-sprint copy chips. Two new admin JSON endpoints (POST /tasks/{id}/move, POST /tasks/{id}/copy). Migration 004.
  • Sprint settings: secured Delete sprint action. Danger-zone POST /sprints/{id}/delete form gated by requireAdmin + CSRF + typed-confirmation match; full-cascade audit (every descendant snapshotted) plus an UPDATE audit for cross-sprint linked rows whose linked_task_id was nulled out.

Changed

  • Header cleanup: Import moved into the admin dropdown. The /sprints/import link moved from inline in the header to the top of the admin section in the hamburger menu.
  • New sprint form: drop weeks input. /sprints/new no longer collects n_weeks; the count is derived from start_date / end_date (capped at 26; above that redirects with ?error=dates_too_long).
  • Task table polish. Sortable headers gain whitespace-nowrap; per-row assignment cells gain whitespace-nowrap; .assign-status-* tint moved off the <td> onto the inner field.
  • Filter dropdown close polish. Owners / Status / Columns dropdowns no longer get cropped on a single-row task table; close on mouseleave with a 250 ms grace timer.
  • Sprint view tabs + smart Close on present. /sprints/{id} is split into "Arbeitstage and capacity" + "Capacity and tasks" tabs, persisted in localStorage. The present view's Close button calls history.back() when possible, falling back to window.close() then to navigation.
  • Cell popover replaces per-cell status select. Single body-attached .cell-popover panel anchored 8 px right of the cell; left column is a slider whose max comes from assignment_slider_max (1..100, default 10), right column is four status pills.

Removed

  • Number-stepper slider popover (Phase 17). Removed after seven iterations failed to land reliable behaviour; the team prefers plain typed entry. The @layer base rule that hides native number-spinner arrows app-wide stays.

Security (R01-N* — doc/REVIEW_01.md)

  • R01-N01 — Hash-only local-admin password. LOCAL_ADMIN_PASSWORDLOCAL_ADMIN_PASSWORD_HASH; password_verify() against a bcrypt hash; no plaintext fallback.
  • R01-N02 / R01-N31 — Runtime panel on / is admin-only. views/home.twig's "Runtime" <details> block was leaking PHP version, env, SQLite path, schema version, and OIDC flags to anonymous visitors; tightened to currentUser is not null and currentUser.isAdmin. In-page /healthz hint also removed (the route stays public for liveness probes).
  • R01-N03 — Explicit env-bootstrap for the first OIDC admin. OIDC no longer auto-promotes the first user to land on /auth/callback; BOOTSTRAP_ADMIN_OID / BOOTSTRAP_ADMIN_EMAIL name the principal up front. Without one of them set, OIDC never auto-promotes anyone.
  • R01-N04 — SESSION_SECRET removed. The env var was documented but unused; deployments that rotated it got a false sense of security.
  • R01-N05 + R01-N07 — Trusted-proxy aware HTTPS detection & client IP. TrustedProxies honours a comma-separated TRUSTED_PROXIES= env var and walks the XFF chain right-to-left; with the env blank, forwarded headers are ignored. Session cookie marked Secure when either APP_BASE_URL is HTTPS or the live request is effectively HTTPS via a trusted proxy. One-shot HTTP→HTTPS redirect (308) when the proxy explicitly reports X-Forwarded-Proto: http.
  • R01-N06 — Throttle local-admin login by (ip, email). Migration 005 adds auth_throttle. Policy in computeLockout(attempts, now): 1–4 → no lock; 5–9 → +5 min; 10–19 → +30 min; 20+ → +1 hour. Counter rolls over after a 15-min idle window; success deletes the row. LOGIN_FAILED audit row distinguishes throttled hits from credential mismatches.
  • R01-N08 — Idle session timeout + CSRF rotation on login. 30-min idle window (IDLE_TIMEOUT_SECONDS = 1800); last_active stamp drops auth keys + regenerates the session id past the threshold. Login now unset()s csrf_token so a pre-login token can't be replayed against the authenticated session.
  • R01-N10 — Bind sprint_id with placeholders in MAX(sort_order) lookups. Three repo-level read paths previously interpolated the integer parameter directly into SQL (not exploitable today; route layer int-casts) — switched to prepared statements.
  • R01-N11 — Whitelist column in AuditRepository::distinctColumn. Private helper interpolated its $col argument; added explicit in_array($col, ['action', 'entity_type'], true) guard.
  • R01-N12 — Validate /audit date filters server-side. Strict Y-m-d parse via DateTimeImmutable::createFromFormat round-trip equality; bad input drops the filter and surfaces a per-field error.
  • R01-N13 — Fatal-error safety net. New FatalErrorHandler registers both set_exception_handler and a fatal-mask register_shutdown_function. On fire, every output buffer is drained and a minimal 500 page is written with the same security headers a normal response carries (CSP / HSTS / X-Frame-Options / X-Content-Type-Options / Referrer-Policy). public/index.php now sources its security headers from FatalErrorHandler::securityHeaders() — single source of truth.
  • R01-N14 — XLSX session payload cap. The full parsed workbook was JSON-stashed in the session file with no upper bound on parsed expansion; capped at 2 MiB via MAX_SESSION_PAYLOAD_BYTES. Pruning paths emit an IMPORT_PREVIEW_ABANDONED audit row.
  • R01-N15 — noreferrer on external task URL link. The user-controlled t.url link in views/sprints/_task_list.twig now carries rel="noopener noreferrer"; sequential sprint IDs no longer leak via Referer.
  • R01-N16 — bin/audit.sh composer-audit helper. Wraps composer audit --locked --no-interaction inside the runtime image so the audit reflects the live container's dependency tree (host PHP often lacks the right ext-* set). Honours SPRINT_PLANER_IMAGE for non-default tags. Admin manual §5.5 documents a recommended cadence.
  • R01-N18 — OIDC email trust path. OidcClaims::resolveEmail() is the single decision point: drops the preferred_username fallback; honours claims.email only when email_verified !== false; falls back to entra:<oid> when no trusted email is available.
  • R01-N19 — CSP report-uri /csp-report + audit endpoint. Strict CSP gains a report URI; new public POST /csp-report route (CspReportController) writes one audit row per browser-fired report. Bodies that fail to decode or exceed the 16 KiB cap return 204 with no row written.
  • R01-N20 — Response::redirect rejects non-path locations. Refuses anything that is not a single / followed by a non-/, plus CR/LF/NUL guard (header injection). The HTTP→HTTPS canonical redirect moves to a new Response::external($url, $status) helper that scheme-restricts to http/https.
  • R01-N21 — Twig auto-escape pinned by tests. Two regression tests in tests/Http/TwigAutoescapeTest.php: a behaviour pin renders a known XSS payload through the wired-up View env and asserts HTML escaping; a static guard walks every .twig file under views/ and fails on any |raw, |safe, or {% autoescape directive.
  • R01-N22 — Migrations move to deploy time. bin/docker-entrypoint.shphp bin/migrate.php runs BEFORE Apache binds the port; a failed migration aborts container start. The web request path only checks schema_version and returns 503 when something is pending.
  • R01-N23 — users.tombstoned_at for soft erasure. Privacy-request path that overwrites email / OID with placeholder values, retains the row for FK integrity, and stops the user from signing back in.
  • R01-N24 — 1 MiB body cap + 5000-item batch cap on JSON endpoints. Defends against memory-exhaustion via oversized POST bodies.
  • R01-N25 — X-Permitted-Cross-Domain-Policies: none header. Emit alongside the other security headers.
  • R01-N26 — One-shot session flash for post-delete chip. Replaces the query-string-driven flash so the message survives a cookie path but doesn't bookmark.
  • R01-N27 — Backgrounded session-file GC loop in entrypoint. Long running cleanup decoupled from the request path.
  • R01-N28 — Drop dead $changed[] in SettingsController::update. Code-quality cleanup; no behaviour change.

Notes on accepted-by-design findings

The following review findings were closed without code change after analysis; see doc/REVIEW_01.md for the rationale on each: R01-N09 (SameSite=Lax retained — Strict would block the OIDC callback), R01-N17 (concurrent-tab OIDC clobber is correct RFC behaviour), R01-N29, R01-N30, R01-N32, R01-N33, R01-N34.