Templates can now call {{ ts | timeago }} directly without route-side
precomputation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wrap humanize_delta in an HTML <time> element with datetime= and
title= attributes carrying the precise UTC value, so hovering surfaces
the exact timestamp regardless of the relative label.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Rewrite humanize_delta as a symmetric past/future ladder with
sub-minute precision. Replace the bare ISO date fallback after 7 days
with a day-month form (year suppressed when same as now). Refs spec
docs/superpowers/specs/2026-05-16-timeago-shared-display-design.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Migrate from pip-install-e + setuptools to a uv workspace with a
committed uv.lock for deterministic deps. Switch both members to
hatchling, and move package sources into nested standard layout
(l4d2host/l4d2host/, l4d2web/l4d2web/) so builds work from a
read-only source tree — setuptools wrote egg-info to source under
the old layout, which broke uv sync on the root-owned /opt/left4me/src.
Local dev install: `pip install -e ./l4d2host -e ./l4d2web` -> `uv sync`.
.envrc switches from `layout python python3.13` to `use uv`. Python
pinned to 3.13 via .python-version.
l4d2web now declares its cross-dep on l4d2host explicitly via
[tool.uv.sources] (workspace = true). l4d2web/alembic.ini and
l4d2web/alembic/ stay at the project root (standard alembic layout).
Test fixes:
- tests/__init__.py added to both test dirs so pytest doesn't shadow
l4d2host as a namespace package via outer-dir walk.
- 3 CWD-relative paths in tests (l4d2web/static/css/{tokens,layout}.css
and js/sse.js) anchored to Path(__file__) so they survive layout
changes.
- Two test_install.py tests now monkeypatch HOME to tmp_path so they
stop silently mutating ~/.steam/sdk32 on every run.
628 tests pass under sandboxed `uv run pytest`.
Per docs/superpowers/plans/2026-05-15-uv-workspace-execution.md;
prereq for the ckn-bw bundle's uv-sync action (queued).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The live-state grid renders player avatars as <img src="https://avatars.steamstatic.com/...">,
but the CSP img-src directive was `'self' data:` — so the browser
silently blocked every avatar load, leaving placeholder circles in
place. The DB cache and Steam API path were both healthy; only the
browser-side load was blocked.
Use the wildcard *.steamstatic.com host-source rather than pinning a
single hostname: Steam rotates avatars across steamcdn-a.akamaihd.net,
avatars.akamai/cloudflare/fastly.steamstatic.com over time, and a
single-hostname allowlist would re-break on the next shuffle.
Test now pins img-src explicitly — the previous assertions only
checked default-src/frame-ancestors/form-action, so a regression of
this exact line would have silently passed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A 20-attempts-per-60s budget keyed by IP doesn't slow a distributed brute force that rotates source IPs. Add a parallel per-username bucket with the same threshold so a single account can't burn through more than 20 failed logins/min regardless of where they come from. Empty usernames aren't bucketed (would DoS the anonymous 401 path). Successful login clears both buckets.
_load_files_overlay docs already promised "owner or admin" for mutations, but the check only filtered by overlay.type — system overlays (user_id IS NULL) were writable by any logged-in user. Add the explicit 403 for non-admins; read-only routes remain open across all overlay types.
Mirror the delete-route last-admin guard on /admin/users/<id>/deactivate so a future auth-model change (service accounts bypassing require_admin, etc.) can't accidentally lock out the system.
- login_user clears any pre-login session state before stamping user_id/pw_changed_at/admin so a fixated cookie value cannot smuggle data past the login boundary
- logout_user now session.clear()s instead of only popping user_id, removing leftover pw_changed_at/admin markers
- CSRF token comparison uses hmac.compare_digest
- load_current_user rejects sessions where the stamped admin flag no longer matches the user row, preventing a demoted admin from retaining elevated access until next password change (backward-compatible: sessions issued pre-upgrade lack the marker and pass through until next login)
- _console_line.html: command + reply, error variant, "(no reply)" placeholder.
- server_detail.html: console section between Live State and Files, replays
last 50 history rows server-side; HTMX form appends new lines via hx-swap.
- console-history.js: ArrowUp/Down recall against /console/history JSON;
scroll-to-bottom on load and after each new line.
- CSS: fixed-height scrolling transcript, terminal-ish styling, spinner via
HTMX in-flight class.
- test_console_routes.py: update 4 assertions from legacy [ERROR] literal
to console-error CSS class (matches new semantic markup).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- ?limit clamp test now actually verifies the clamp instead of just
passing through 5 rows.
- Single is_error assignment per branch, single db.add path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- POST /servers/<id>/console runs a command via rcon.execute_command and
persists every outcome (success / empty / error) to command_history.
- GET /servers/<id>/console/history returns paginated newest-first JSON
for client-side up-arrow recall.
- server_detail() now passes the last 50 history rows as console_history
for server-side replay on page load.
- 404 on ownership mismatch — no admin override.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extracts _connect_and_auth helper from query_status, adds execute_command
using the trailing-marker pattern for multi-packet reassembly, and covers
all paths (happy path, multi-packet, empty reply, auth failure, timeout,
input validation, marker drain) with 10 new tests.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
64x64 avatarmedium looked soft on high-DPI screens. Switching the
GetPlayerSummaries field to avatarfull (184x184) and constraining
display size to 64px via .live-state .avatar gives sharp rendering on
retina/4k panels at the cost of a slightly larger CDN fetch (still
hot-linked, so no proxying cost).
Also adds the previously-missing CSS for the live-state player grid:
avatar+name+meta arranged in a tight 2-column grid per card, link
spans the avatar+name so the meta stays non-interactive.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Wraps avatar + persona name in an a[href=steamcommunity.com/profiles/<id>]
in both the Current and Recent blocks. Steam auto-redirects to the user's
vanity URL on follow, so we don't need to store profileurl separately.
target=_blank + rel=noopener noreferrer to keep the dashboard page in
place when a link is followed.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
HTMX-refreshed /servers/<id>/live-state fragment renders snapshot
summary, current players with avatars/ping, and recent-player history;
server_detail.html bootstraps it via hx-trigger="load".
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Closes the negative-assertion gap from the Task 10 review: without this
check, a regression that drops the freshness guard would still pass the
positive 2/4 + c1m2_streets assertions.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Source cvar semantics are last-wins; appending the rcon_password after
all overlay exec lines and blueprint config ensures no overlay or user
config line can silently override it.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- _parse_duration wraps int() in try/except so malformed connected
durations raise RconError (not ValueError leaking past the poller's
except RconError).
- fake_rcon_server captures handler exceptions and re-raises at context
exit, so a buggy test handler surfaces as a real failure instead of
silently degrading into a client-side timeout.
- Two new parser tests: HH:MM:SS duration parsing and malformed input
coverage.
- Fix Steam ID formula typo in the spec doc (Z*2 + Y, not Y*2 + Z; Y is
the low bit). Code was already correct.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Catch only requests.RequestException in refresh_overlay so that
server-side data errors (e.g., ValueError) bubble up as 500 rather
than being disguised as a 502 "steam api error". Update the 502 test to
use a real requests exception, add a sibling test that verifies
non-requests exceptions propagate, and explicitly assert that refresh
enqueues a build_overlay job even when Steam returns no entries.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
POST /overlays/{id}/refresh lets the overlay owner (or any admin)
re-fetch fresh Steam metadata for all items and enqueue a rebuild.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Commit 16adc5c silently dropped two defensive guards from the
symlink-creation loop in WorkshopBuilder.build. Restore them:
- refuse to overwrite a non-symlink file that collides with a workshop
name (logs a message, skips creation)
- refuse to overwrite a foreign symlink (target outside the cache root)
Also: change `skipped` from list to set (O(1) membership test, no
duplicates possible), and add a brief comment above WorkshopMetadata
construction explaining which fields download_to_cache actually uses.
Two regression tests added to pin the guard behaviour.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace the old skip-uncached-with-warning logic in WorkshopBuilder.build
with an inline download phase that calls _download_with_retry for each item
whose cache file is absent or stale (mtime/size mismatch). Stamps
last_downloaded_at / last_error after each download, and skips items with
no file_url. Update test fixture to utime cache files so mtime matches
time_updated, delete the now-superseded skip-warning test, and add six
new builder-level behavior tests covering the new download path.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Same validate_new_password used by the web change-password flow,
so the policy is enforced uniformly across CLI and HTTP entry
points. Existing CLI tests bumped to passwords that satisfy the
new floor.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Verifies that on a successful change the digest rotates, the
password_changed_at advances, this session keeps working with the
re-stamped marker, and a parallel session forged from the
pre-change marker is rejected by load_current_user.
profile_password_change now writes a naive password_changed_at so
the in-memory marker matches what SQLite returns on next read.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements the change-password endpoint:
- Per-IP rate limit reusing services/rate_limit
- Required fields, mismatched-confirm, policy, wrong-current
branches each redirect with a specific ?error= key
- Rotates digest + password_changed_at, then re-stamps the
current session marker so this browser stays logged in
while other sessions get rejected by load_current_user
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the page reachable from the username link in the header.
Renders the form skeleton; the POST handler lands in the next
commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
load_current_user now treats a session whose pw_changed_at marker
is missing, malformed, or older than the user's current
password_changed_at as logged-out. Same shape as the existing
user.active check.
Forced fan-out updates to every test fixture that forges a session
via session_transaction(): each now stamps a current pw_changed_at
marker. test_deactivated_user_existing_session_invalidated keeps
its meaning — the deactivation still flips the user to inactive,
and load_current_user rejects the session via the user.active
branch before reaching the freshness branch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
login_user now records the user's current password_changed_at on the
session. The next commit will use this marker to invalidate sessions
whose password has been rotated under them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pulled the per-IP sliding-window check out of auth_routes so the
upcoming /profile/password endpoint can share it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>