All notable changes to this project are documented here. The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Nothing scheduled.
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.