All notable changes to this project are documented here. The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
title input | URL + ref icons | info icon) so the info icon ends up
at the same horizontal offset across every row, regardless of how
many linked_task_id references the row has or whether a task URL is
set. The bidirectional "Copied from / Copied to: X (Sprint Y)" chips
introduced in Phase 22 are now rendered inline as small ←/→ arrow
icons that anchor to the linked sprint; the full chip text is kept
on the hover title= (and aria-label). A new gray/white circled-i
trigger replaces the old book-shaped description marker — clicking
it opens a body-attached popover positioned to the right of the icon
and vertically centred on it, listing the task title, full
description, the URL as a clickable link, and the linked-task list.
The popover closes on outside-pointerdown, Escape, scroll/resize,
focus loss, and a 250 ms mouseleave grace — same model as the
Phase 18 cell popover. Touchpoints: views/sprints/_task_list.twig,
public/assets/js/sprint-planner.js (renderTaskRefs,
buildInfoPopover / openInfoPopover / closeInfoPopover —
replaces the descPopover block; buildTaskRow mirrors the new
DOM shape so the JS-built "+ Add task" path stays in sync), and
assets/css/input.css (.task-title-grid, .task-title-mid,
.task-info-popover, retires the unused .task-desc-popover).
No PHP / migration / endpoint changes — the existing data-description,
data-url, and linkedMap data still drive the row; the new
data-task-title and data-links row attributes feed the popover
for newly added rows without an extra round-trip.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.
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.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./ 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.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).
/audit admin page with filters,
pagination, collapsible diffs; security headers + strict-ish CSP; CSRF
audit (18/18 mutations); PHPUnit harness with 59 tests.GET /users + POST /users/{id} with
self-demote and last-admin guardrails.onclick replaced by data-href + app.js; CSP
dropped unsafe-inline and the Tailwind CDN host.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.[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.data-menu-trigger button with an
inline-SVG icon; vanilla-JS toggle (~30 lines) honours outside-click,
Escape, and focus return./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.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.@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.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.views/*.php rewritten as *.twig. Strict CSP:
script-src 'self' and style-src 'self' only — no third-party
hosts./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.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.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.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./sprints/import link moved from inline in the header to the top of
the admin section in the hamburger menu./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).whitespace-nowrap;
per-row assignment cells gain whitespace-nowrap; .assign-status-*
tint moved off the <td> onto the inner field.mouseleave with a 250 ms grace timer./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 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.@layer base rule that hides native number-spinner
arrows app-wide stays.doc/REVIEW_01.md)LOCAL_ADMIN_PASSWORD
→ LOCAL_ADMIN_PASSWORD_HASH; password_verify() against a bcrypt
hash; no plaintext fallback./ 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)./auth/callback;
BOOTSTRAP_ADMIN_OID / BOOTSTRAP_ADMIN_EMAIL name the principal up
front. Without one of them set, OIDC never auto-promotes anyone.SESSION_SECRET removed. The env var was documented but
unused; deployments that rotated it got a false sense of security.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.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.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.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.AuditRepository::distinctColumn. Private helper interpolated its
$col argument; added explicit in_array($col, ['action',
'entity_type'], true) guard./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.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.MAX_SESSION_PAYLOAD_BYTES. Pruning
paths emit an IMPORT_PREVIEW_ABANDONED audit row.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.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.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.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.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.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.bin/docker-entrypoint.sh
→ php 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.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.X-Permitted-Cross-Domain-Policies: none header. Emit
alongside the other security headers.$changed[] in SettingsController::update.
Code-quality cleanup; no behaviour change.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.