Commit graph

219 commits

Author SHA1 Message Date
mwiegand
f30b9a6b0c
test(e2e): scaffold Playwright + live-server fixture
Adds playwright + pytest-playwright to workspace dev deps, an e2e
pytest marker, and a live_server fixture that boots the Flask app on
an ephemeral port with a temp SQLite DB. addopts default to -m 'not
e2e' so the regular fast suite excludes them; explicit
`pytest -m e2e` runs them. Smoke test confirms the live server is
reachable.

Workspace root pyproject.toml is the right place for the dev deps and
pytest config — l4d2web/pyproject.toml is minimal and has neither.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 21:00:45 +02:00
mwiegand
8e8a3aeb3e
fix(files-editor): reset language dropdown on every modal open
Without this, a user who picked a manual override (e.g. "Bash") on
one open would see the stale selection on the next open while the
editor itself silently re-derived from the filename via
setEditorContent's setLanguage("auto") call. The displayed dropdown
would lie about the active language.

Additionally, the existing filename-input handler's
"if (languageSelect.value === 'auto') re-derive" check was effectively
disabled whenever the user had previously picked an override —
renaming the file wouldn't re-derive even though the active language
was already auto.

Addresses Important #1 from the Task 10 code review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 20:57:00 +02:00
mwiegand
3c882e020c
feat(files-editor): mount auto-language editor + dropdown override
The modal textarea opts in with data-editor-language=auto; the editor
derives the language from the filename extension on each modal open.
A dropdown lets the user override (srccfg / bash / plain). The
existing fetch-based /files/save path is unchanged — files-overlay.js
keeps reading textarea.value, which the editor mirrors.
2026-05-16 20:51:35 +02:00
mwiegand
10cf0da3d2
fix(editor): capture-phase keydown + popup leak + cache warmup
Addresses Critical #1 + Important #2/#3/#4 from the Task 9 code review.

CRITICAL — Tab/Enter were stolen by CodeJar before the popup handler
saw them. CodeJar registers its keydown listener during construction
(line ~159), so it ran first in bubble order: Tab handler
preventDefaulted and inserted 2 spaces, Enter handler preventDefaulted
+ stopPropagation'd (with leading indent), so the popup-accept either
ran on corrupted state or never fired at all. Fix: register the popup
listener with {capture: true} and call stopPropagation on the keys we
own — that way capture phase fires before CodeJar's bubble listener
and the key is fully consumed by the popup while it's visible. Normal
typing (popup hidden) early-returns without stopPropagation, so
CodeJar's tab-indent + enter-preserve-indent still work when there's
no autocomplete to accept.

IMPORTANT — destroy() leaked the popup <ul> into document.body. Each
mount/destroy cycle (e.g. modal close/reopen) left an orphan popup.
Fix: pop.remove() in destroy().

IMPORTANT — async refreshPopup could race in stale renders if the
first keystroke fired the vocab fetch and the second keystroke
captured a different ctx before the fetch resolved. Fix: warm the
cache with a fire-and-forget loadVocab(language) at mount, so the
first user keystroke hits cache. Eliminates the only realistic window
for the race.

IMPORTANT — acceptCompletion's Range.setStart could throw
IndexSizeError on pathological state (caret inside a tokenized span
where the fragment isn't fully upstream). Fix: try/catch the entire
DOM mutation block, log + dismiss on failure. Plus an inline comment
documenting the single-text-node invariant the current grammars hold.

Plan source updated for the capture-phase fix (most important for
future regeneration); the other fixes are smaller and only mirrored
into the actual code.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 20:49:23 +02:00
mwiegand
3d3629f592
feat(editor): add identifier autocomplete popup
Vocab loaded lazily from /static/data/<lang>-vocab.json on first
mount, cached in memory. Popup appears when the word fragment before
the caret has >=2 word characters and matches the vocabulary. Prefix
matches rank ahead of substring matches; popup shows up to 8 with
scroll. Up/Down navigate, Tab/Enter accept, Esc dismisses.

acceptCompletion uses instance.jar (not the captured closure) so
runtime jar reassignment via setLanguage stays consistent.
2026-05-16 20:42:03 +02:00
mwiegand
e6fe701718
data(editor): seed L4D2 cvar/command vocabulary
Hand-curated set of high-traffic cvars and commands sourced from the
existing l4d2-server-cvar-reference.md and common SourceMod usage.
Regeneration procedure documented in the file header.

30 cvars + 8 commands.
2026-05-16 20:39:33 +02:00
mwiegand
482312c3d8
feat(overlay): mount bash editor on script overlay form
data-editor-language=bash opts the textarea in; the editor uses
Prism's stock bash grammar (no project-owned bash code).

Partial include sits outside all conditional blocks in the template
so the editor assets load for both script-type and files-type
overlays.
2026-05-16 20:37:28 +02:00
mwiegand
c6f10e632d
test(blueprint): also assert prism.css is referenced in editor assets
The plan template (and verbatim implementation) listed five of the six
editor asset URLs in the structural test — vendor/prism.css was
omitted. If a future change drops the Prism stylesheet from the
partial, syntax tokens lose their color rules silently and the test
still passes. Add the missing assertion and update the plan to match.

Addresses Minor #1 from the Task 6 code review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 20:35:44 +02:00
mwiegand
607970eb43
feat(blueprint): mount srccfg editor on the config textarea
The textarea is preserved as the form field; the editor renders a
contenteditable sibling and mirrors content back on every input. Form
POST contract is untouched (covered by new round-trip test).
2026-05-16 19:39:58 +02:00
mwiegand
b203a83f58
feat(editor): add Jinja partial for editor asset includes
Five script/link tags consolidated so call-site templates only need a
single {% include '_editor_assets.html' %} to enable the widget.
2026-05-16 19:36:53 +02:00
mwiegand
04f9a4d6a2
fix(editor): narrow findFilenameInput scope + dispatch input from setValue
Addresses two Minor follow-ups from the Task 4 code review:

- findFilenameInput previously included `body` in its closest() selector,
  meaning any "auto" textarea outside a modal would walk all the way up
  and pick up the files-editor modal's filename input from elsewhere in
  the document. Drop `body` so out-of-modal "auto" usage degrades
  cleanly to "plain".
- setValue now dispatches an `input` event on the textarea after
  writing, matching the onUpdate mirror. Task 10 wires the files-editor
  modal to call setValue when loading file content — without this fix,
  textarea-listening code (e.g. unsaved-changes indicators) wouldn't
  see programmatic loads. Now setValue and user typing produce the
  same observable side effects.

Plan source block updated to match.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 19:35:47 +02:00
mwiegand
e29eaf3254
feat(editor): widget core — mount, sync, language switch
Mounts on <textarea data-editor-language>, hides the textarea, renders
content in a contenteditable sibling with Prism highlighting via
CodeJar. Mirrors content back to textarea.value on every input so form
POST and existing JS readers keep working unchanged. Exposes
setValue/setLanguage/getValue on textarea._codeEditor for callers.

Language switch uses tear-down-and-remount because CodeJar captures
its highlighter by closure at construction time and has no API to
swap it on a live instance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 19:29:27 +02:00
mwiegand
cdcb7e4853
style(editor): visible light-mode popup active state + plan sync
Addresses Important #1 + Minor #2/#3 from the Task 3 re-review:

- --color-bg-popover-active light value: #f3f4f6 → #e5e7eb. The prior
  value was within ~1.05:1 luminance of the white surface — keyboard
  navigation through the autocomplete list had no visible focus
  indicator in light mode. e5e7eb (Tailwind gray-200) clears that.
- Drop dead fallback hexes on the four guaranteed tokens
  (--color-string/-keyword/-number/-bg-popover-active). They never
  fired post-fix and only produced a dark-mode-only palette if
  tokens.css somehow failed to load — i.e. they were misleading.
- Plan source block (Task 3 Step 2) replaced with the post-fix CSS
  verbatim + a new Step 2b that documents the tokens.css additions
  alongside the editor.css template, so a fresh regeneration
  produces the same file.

Deferred: cross-cutting --font-mono token (Minor #4 — would touch 7+
sites outside Task 3's scope).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 19:27:07 +02:00
mwiegand
f9c8518212
style(editor): theme-aware syntax tokens + match textarea metrics
Addresses Important #1 + #2 from the Task 3 code review:

- Adds --color-string, --color-keyword, --color-number,
  --color-bg-popover-active to tokens.css in both the :root and dark
  blocks. GitHub-style palette tuned for legibility on each theme's
  surface.
- Updates .editor-code to use the same padding tokens, font-family
  stack, font-size, and line-height as the existing textarea rule so
  the contenteditable doesn't visibly jump when the widget mounts.
- Drops the caret-color override (browser default adapts to system
  theme — no token needed).

Plan source block updated to match.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 19:22:27 +02:00
mwiegand
75a586a47b
style(editor): add stylesheet for editor shell + Prism tokens + popup
Defines .editor-shell, .editor-code, .editor-popup. Reuses tokens.css
variables where present so the editor matches the site palette.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 19:17:05 +02:00
mwiegand
db1a255223
fix(editor): drop dead -? from srccfg number regex
The `\b` word boundary anchor prevents the optional minus from ever
matching from positions where signed numbers naturally appear (` -1`,
`(-1`, `=-1` all word-boundary-from-non-word and the `-?` fires zero
chars). Negative numbers are tokenised via the operator class instead,
which is the consistent behaviour the grammar already exhibits.
Plan source block updated to match so a fresh regeneration produces the
same file.

Addresses Minor #1 from the Task 2 code review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 19:15:34 +02:00
mwiegand
7cfbedb929
feat(editor): add Prism grammar for Source-engine .cfg syntax
Five token classes (comment, string, keyword, number, identifier) plus
operators. Purely visual highlighting; no semantic validation of cvar
names or values.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 19:11:56 +02:00
mwiegand
02e9edd4ed
vendor(editor): add CodeJar attribution header + source-form rationale
Brings codejar.js to parity with prism.js, which already carries a
self-documenting header. The README also now records *why* the
unminified source form was chosen: CSP rules out runtime sourcemap
loading from a CDN, so debuggability lives in the vendored bytes.
SHA256 column updated to match the new file content.

Addresses Minor #1 + #2 from the Task 1 code review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 19:10:39 +02:00
mwiegand
6ade91b870
vendor(editor): pin Prism v1.29.0 + CodeJar v4.0.0
Self-host the editor dependencies under /static/vendor/ since the strict
CSP forbids CDN loading. README records source URLs, versions, and
SHA256s for each file.
2026-05-16 19:04:26 +02:00
mwiegand
18113637e9
refactor(datetime): introduce UtcDateTime, remove naive-strip workarounds
Adds a UtcDateTime TypeDecorator (models.py) that enforces aware-UTC on
write and stamps tzinfo=UTC on read. Replaces 26 DateTime column
declarations. Removes 5 production sites that defensively stripped tzinfo
to match SQLite's lossy round-trip. auth.py now coerces legacy session
cookies upward (stamp UTC on parsed naive marker) instead of stripping
live aware markers downward.

The change is Python-side only: UtcDateTime.impl = DateTime, so DDL and
emitted SQL are unchanged. No Alembic migration needed.

Adds 2 unit tests in test_models.py pinning the decorator's contract
independently of the column declarations.

The three deliberately-naive test_timeago.py fixtures (lines 67, 73, 113)
remain naive on purpose -- they exercise _ensure_utc's normalize-up path
at the public filter boundary, which stays as belt-and-braces defense.

See docs/superpowers/specs/2026-05-16-tz-aware-datetime-design.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 11:59:29 +02:00
mwiegand
a5436deaf0
test(datetime): pin tz-aware contract for fixtures (red until UtcDateTime lands)
Drops .replace(tzinfo=None) from 8 fixture sites that mirrored the
production-side strip convention. Two of these (test_live_state_poller.py
test_new_player_opens_session_with_backfilled_join, test_models.py
test_user_has_password_changed_at_default) now fail with TypeError when
comparing aware in-memory values against naive DB reads -- that failure
is intentional and describes the contract commit 2 must satisfy:
DB-sourced datetimes return aware UTC.

The remaining 6 sites were already cosmetic (fixture-seed only, no
aware-vs-DB comparison) but are flipped here so future authors write
aware fixtures.

The three deliberately-naive sites in test_timeago.py (lines 67, 73,
113) are LEFT untouched -- they exercise _ensure_utc's normalize-up
path and are feature tests, not workarounds.

See docs/superpowers/specs/2026-05-16-tz-aware-datetime-design.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 11:55:48 +02:00
mwiegand
6cef55f900
fix(csp): allow workshop preview thumbnails from steamusercontent.com
Steam serves workshop preview images from images.steamusercontent.com,
which the previous img-src whitelist did not cover, so the browser
silently blocked every <img> in _overlay_item_table.html.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 11:22:30 +02:00
mwiegand
55b2abfdc9
refactor(server_routes): drop unused 'now' kwarg from _live_state render
After the timeago migration, the live-state template no longer reads
'now' — it computes relative labels through the filter, which derives
its own reference time.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 11:15:14 +02:00
mwiegand
b6305f2aac
refactor(page_routes): pass datetime to templates for timeago filter
Drop the inline humanize_delta imports and string-precomputation; pass
the raw datetime as latest_job_at / latest_build_at and let the
template apply the timeago filter. One fewer code path computing
relative-time strings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 11:14:08 +02:00
mwiegand
99e477700a
refactor(templates): use timeago filter in _live_state.html
Replaces three bespoke (now - x).total_seconds() expressions with the
shared filter, unifying vocabulary (no more '0m ago' inside the first
minute) and adding the UTC tooltip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 11:12:26 +02:00
mwiegand
d9cee233ab
refactor(templates): use timeago filter for job timestamps
Preserves the existing '-' placeholder for nullable started_at /
finished_at columns.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 11:11:56 +02:00
mwiegand
4f6d9bcca6
refactor(templates): use timeago filter for admin/blueprint timestamps
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 11:11:23 +02:00
mwiegand
263a9a9f27
feat(app): register timeago Jinja filter
Templates can now call {{ ts | timeago }} directly without route-side
precomputation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 11:10:59 +02:00
mwiegand
1926fe895c
feat(timeago): add format_time_html returning a <time> element
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>
2026-05-16 11:09:23 +02:00
mwiegand
237f26e5cb
feat(timeago): symmetric ladder with second precision and date fallback
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>
2026-05-16 11:08:43 +02:00
mwiegand
49992b3a26
refactor(repo): uv workspace + hatchling + layout restructure
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>
2026-05-15 22:04:29 +02:00
mwiegand
e28d4fad8c
l4d2web/csp: allow Steam avatar CDN in img-src
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>
2026-05-15 20:23:29 +02:00
mwiegand
8971b23617
refactor(sandbox): collapse l4d2-sandbox user into left4me
The hardening refactor that just landed closes the same-uid attack
surface (FS view, ptrace, /proc visibility, signals) for the web +
gameserver units via systemd directives plus system-wide
kernel.yama.ptrace_scope=2. Keeping the script-sandbox on a separate
uid was the inconsistent half-step — defense-in-depth only, with
build-time-idmap complexity attached. One principle wins: harden
once, share the uid.

scripts/libexec/left4me-script-sandbox: drop the idmap block (uid
lookups, STAGING setup, cleanup_staging trap, mount --bind
--map-users), switch User=/Group= to left4me, point BindPaths at
\$OVERLAY_DIR directly. Header comment updated to reflect
hardening-not-uid as the same-uid defense. nsenter self-wrap kept —
it's about mount-namespace escape, not uid.

Tests + comments + companion docs updated. Build-time-idmap and
overlay-idmap plans marked SUPERSEDED; user-uid-split spec revised
to "1 user is correct"; one-line update notes on the hardening
specs and the build-overlay-unit-design.

Companion ckn-bw commit removes the l4d2-sandbox user + group and
tightens /var/lib/left4me from 0711 → 0755 (the traverse-only mode
was specifically for the sandbox uid).
2026-05-15 15:50:57 +02:00
mwiegand
8f30dd7754
docs: correct stale bubblewrap references in v1 spec + live docstring
Janitorial item 6 in 2026-05-15-janitorial-cleanup.md. The v1 sandbox
design (2026-05-08-l4d2-script-overlays-design.md) was approved
2026-05-08 and superseded the same day by the v2 systemd-only design
(2026-05-08-l4d2-script-sandbox-v2-systemd.md). The current
left4me-script-sandbox helper uses systemd-run in service-unit mode;
no bwrap binary is invoked. The v1 spec still described bubblewrap as
the engine.

- v1 spec gets a top-of-file banner pointing at v2 as the supersede.
  Body preserved; the rest of the v1 design (overlay-type unification,
  resource caps, helper auth) is still valid — only the sandbox engine
  changed.
- l4d2web/services/overlay_builders.py: ScriptBuilder docstring
  "bubblewrap + systemd-run" → "hardened systemd-run transient
  service" (the as-built reality).
- scripts/tests/test_script_sandbox.py: stray "/bwrap" in a comment
  cleaned up. Negative regression assertions (`assert "bwrap" not in
  text`) intentionally retained as the guard against accidental
  re-introduction.
- Plan docs left untouched (historical action snapshots).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 12:12:31 +02:00
mwiegand
bbb2b983bc
harden(l4d2web): per-username login rate limit alongside per-IP
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.
2026-05-14 22:26:20 +02:00
mwiegand
0e2a78e065
secure(l4d2web): block non-admin writes on system overlays; last-admin guard on deactivate
_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.
2026-05-14 22:24:19 +02:00
mwiegand
74b7f61437
harden(l4d2web): default security response headers and generic error handlers
- after_request hook sets X-Content-Type-Options=nosniff, X-Frame-Options=DENY, Referrer-Policy=strict-origin-when-cross-origin, and a strict CSP (default-src 'self', script-src self+nonce, frame-ancestors 'none', form-action 'self'); HSTS added on secure non-test responses
- per-request CSP nonce minted in g.csp_nonce; servers.html's inline showModal script picks it up
- 404 and 500 handlers return short plain-text responses so a misbehaving deployment can't leak tracebacks via Werkzeug's debug page
2026-05-14 22:21:36 +02:00
mwiegand
2902c9cc82
harden(l4d2web): auth/session — clear on login+logout, constant-time CSRF, role-change invalidation
- 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)
2026-05-14 22:18:46 +02:00
mwiegand
66d14feca5
refactor(l4d2-web): harden console-history.js against HTMX version drift and races
- pendingCommand captured in htmx:beforeRequest (not requestConfig).
- ensureLoaded shares a single inflight Promise across concurrent calls.
- Document why synthetic null-id entries are safe in the cache.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 21:42:05 +02:00
mwiegand
6f49efd44a
feat(l4d2-web): console panel UI on server detail page
- _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>
2026-05-14 21:39:21 +02:00
mwiegand
ecc4aa28c6
refactor(l4d2-web): tighten console route limit test and dedupe is_error
- ?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>
2026-05-14 21:35:22 +02:00
mwiegand
553b280e40
feat(l4d2-web): backend for RCON console with persisted transcript
- 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>
2026-05-14 21:32:13 +02:00
mwiegand
c4dffd471b
feat(l4d2-web): add command_history table for RCON console transcript
A row per RCON command execution: (user, server, command, reply, is_error,
created_at). Composite index on (user_id, server_id, id) supports the only
query shape — "latest N for this user+server", id DESC.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 21:26:56 +02:00
mwiegand
9ef9ffdbde
chore(l4d2-web): clarify rcon req_id constants and helper docstring
Add comment noting _EXEC_REQ_ID/_MARKER_REQ_ID are arbitrary client-chosen
values unrelated to SERVERDATA_* packet-type constants. Update _connect_and_auth
docstring to accurately reflect that OSError/socket.timeout propagate raw from
post-connect send/recv, while only connect failure is wrapped in RconError.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 21:24:41 +02:00
mwiegand
085fd714a5
feat(l4d2-web): add execute_command to rcon service with full test coverage
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>
2026-05-14 21:21:41 +02:00
mwiegand
6cc1736f17
feat(l4d2-web): add hostname edit form to server detail page 2026-05-13 15:42:46 +02:00
mwiegand
963851c0e1
feat(l4d2-web): emit hostname in spec config with ephemeral fallback 2026-05-13 15:31:12 +02:00
mwiegand
69d93dda4f
feat(l4d2-web): accept hostname on server update, default empty on create 2026-05-13 14:29:53 +02:00
mwiegand
0a7f48f174
feat(l4d2-web): add hostname column to Server model 2026-05-13 14:26:14 +02:00
mwiegand
fe43f67b51
feat: include password-reveal.js in base template 2026-05-13 11:37:47 +02:00
mwiegand
ab83f5fd2b
feat: add RCON password row to server detail page 2026-05-13 11:37:28 +02:00
mwiegand
d9aa6bd395
feat: add password reveal toggle JS 2026-05-13 11:36:40 +02:00
mwiegand
d113b7821c
fix(live-state): remove loading=lazy from avatars to fix Firefox/Safari flash
Firefox and Safari defer lazy images by one paint cycle even when cached,
causing a blank frame on each innerHTML swap. These avatars are always
in-viewport and cached after the first poll, so lazy loading has no benefit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 23:34:53 +02:00
mwiegand
175e4e653c
fix(live-state): eliminate flash on poll by switching to innerHTML swap
outerHTML removes and re-inserts the section on each tick, causing a
blank frame. Keeping the <section> as a stable DOM container and
swapping only innerHTML means avatars and text update in-place without
any teardown/reconstruct cycle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 23:26:54 +02:00
mwiegand
096d18ac64
feat(live-state): use Steam avatarfull (184x184), downscale in CSS
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>
2026-05-12 23:17:51 +02:00
mwiegand
6cbe7dc9f2
feat(live-state): link player cards to their Steam profile
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>
2026-05-12 22:51:50 +02:00
mwiegand
37a9ad68a2
fix(live-state): cast poll_seconds to int for HTMX hx-trigger
HTMX's hx-trigger="every Ns" syntax does not accept fractional seconds —
a config override like 7.5 would render every 7.5s and silently break
auto-refresh. Floor to int with a 1s minimum.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 22:23:15 +02:00
mwiegand
9aaa26d9a9
feat(servers): add live-state panel with current and recent players
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>
2026-05-12 22:20:01 +02:00
mwiegand
b00a3cceea
test(live-state): assert stale server's map is not rendered in the badge
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>
2026-05-12 22:17:02 +02:00
mwiegand
072d9f78e7
feat(servers): show live counts + map badge in server list
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 22:14:57 +02:00
mwiegand
0dc61d5de4
feat(live-state): start daemon poller, prune history, close stuck sessions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 22:10:55 +02:00
mwiegand
be476112ee
feat(live-state): enrich roster with cached Steam profiles
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 22:02:58 +02:00
mwiegand
33899f8c17
feat(live-state): reconcile player sessions on each poll
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 21:58:30 +02:00
mwiegand
c9cd2557fd
style(live-state): drop unused imports staged for later tasks
threading, time, Callable were imported in anticipation of Task 9's
daemon-thread startup. Task 9 will re-add them when actually needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 21:55:36 +02:00
mwiegand
f48d624dcc
feat(live-state): poller writes RLE snapshots to server_live_state
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 21:53:58 +02:00
mwiegand
f88d07a473
feat(steam): add GetPlayerSummaries client
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 21:48:02 +02:00
mwiegand
465a103c3a
feat(servers): generate rcon_password on server create
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 21:43:56 +02:00
mwiegand
2a440dae45
feat(facade): append rcon_password as final server.cfg line
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>
2026-05-12 21:40:56 +02:00
mwiegand
83d2a9932c
refactor(rcon): harden _parse_duration; surface fixture handler errors
- _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>
2026-05-12 21:39:32 +02:00
mwiegand
b95a82b8a4
feat(rcon): add Source RCON client + status parser
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 21:31:32 +02:00
mwiegand
e25e7098f6
refactor(live-state): drop redundant ix_sps_server_recent index
The two indexes ix_sps_server_open and ix_sps_server_recent were
byte-identical because SQLAlchemy's Index(name, *cols) form drops the
DESC ordering the spec intended. Rather than reach for text("left_at
DESC"), drop the second index entirely — SQLite scans the ASC index
backwards at no measurable cost. Spec and plan updated to match.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 21:27:01 +02:00
mwiegand
0f825686c6
feat(live-state): add schema for snapshots, sessions, steam profiles
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 21:18:24 +02:00
mwiegand
f614ac05f0
tests/cli: cover running+cancelling idempotency, tighten app-context scope 2026-05-11 23:18:54 +02:00
mwiegand
0ab54b4a7d
cli: add workshop-refresh subcommand for scheduled global refresh
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 23:15:05 +02:00
mwiegand
653e3212b9
tests: harden refresh-hidden-during-build with positive assertion 2026-05-11 23:13:39 +02:00
mwiegand
25b38e633d
overlay_detail: add 'Refresh from Steam' button for workshop overlays
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 23:10:27 +02:00
mwiegand
e1b189ad3c
workshop_routes: narrow refresh's steam exception handler
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>
2026-05-11 23:08:41 +02:00
mwiegand
f5094c2d9d
workshop_routes: add per-overlay refresh endpoint
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>
2026-05-11 23:03:48 +02:00
mwiegand
81c6863cca
overlay_builders: restore symlink overwrite guards + nits
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>
2026-05-11 23:01:38 +02:00
mwiegand
16adc5c1fe
overlay_builders: download missing/stale workshop items inline
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>
2026-05-11 22:56:09 +02:00
mwiegand
6fc7f87943
overlay_builders: address code-review nits on retry helpers
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 22:48:28 +02:00
mwiegand
13bd2e48f6
overlay_builders: add _download_with_retry + _sleep_with_cancel helpers
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 22:43:22 +02:00
mwiegand
cb52a69faf
tests/test_profile: hoist sqlalchemy import to module top
ruff E402: import was after non-import top-level code.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 22:01:29 +02:00
mwiegand
f643246a84
cli: apply min-length password policy in create-user
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>
2026-05-11 22:00:16 +02:00
mwiegand
224b023ca0
profile: rate-limit test for POST /profile/password
Exceeding the per-IP attempt cap within the window returns 429.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 21:58:46 +02:00
mwiegand
47722dbb19
profile: happy-path + cross-session invalidation tests
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>
2026-05-11 21:58:26 +02:00
mwiegand
d25fb57f30
profile: POST /profile/password validation branches
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>
2026-05-11 21:57:11 +02:00
mwiegand
eef85f36a9
profile: GET /profile page with change-password form
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>
2026-05-11 21:55:34 +02:00
mwiegand
e75f379dcb
auth: reject sessions older than user.password_changed_at
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>
2026-05-11 21:54:13 +02:00
mwiegand
84dc672180
auth: stamp password_changed_at marker in session on login
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>
2026-05-11 21:46:20 +02:00
mwiegand
26a6a9d7b0
rate-limit: extract generic helper, reuse from login
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>
2026-05-11 21:45:51 +02:00
mwiegand
a5982941df
auth: validate_new_password helper (min length 8)
Single source of truth for the password policy, to be reused by the
upcoming /profile/password endpoint and (optionally) the create-user
CLI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 21:45:03 +02:00
mwiegand
2353378b23
alembic: add users.password_changed_at column
Backfills existing rows from created_at, then enforces NOT NULL.
Existing sessions without a pw_changed_at marker will be rejected
on next request once the freshness check lands (one-time forced
re-login post-deploy).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 21:44:39 +02:00
mwiegand
eb1f2b82eb
models: add User.password_changed_at
First step of the self-service password-change feature: a timestamp
that backs the per-session freshness check used to invalidate other
sessions on password change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 21:43:25 +02:00
mwiegand
e1149704c8
job_worker: don't duplicate streamed stderr on HostCommandError
services/host_commands.run_command pumps each stderr line into JobLog
via on_stderr (job_worker.py:215) before it raises HostCommandError —
appending exc.stderr again as a single row produced a second copy of
the entire traceback truncated at JOB_LOG_LINE_MAX_CHARS (4096), which
was visible as the awkward duplicated/cut-off second block at the end
of failed install logs.

Split the existing `except subprocess.CalledProcessError` into two:

  except HostCommandError: stderr already streamed — just record exit
    code + last error summary on the job/server row. No log append.

  except subprocess.CalledProcessError: catches raw CalledProcessErrors
    raised outside host_commands (no pump ran), so still append stderr
    to the log. Preserves the path test_called_process_error_fails_job
    exercises.

New regression test asserts a HostCommandError with multi-line stderr
doesn't land as a single concatenated JobLog row.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:52:54 +02:00
mwiegand
c594d4b5e8
tests: admin user management
14 tests covering /admin/users/<id>/{deactivate,activate,delete}:

  - deactivate/activate flips and 404 on unknown user
  - deactivate-self refused (409)
  - deactivated user cannot log in (same 401 as wrong-password)
  - existing sessions stop working after deactivation (load_current_user
    returns None for inactive users → @require_login redirects to /login)
  - delete-self refused (409)
  - delete refuses when user owns Server, Blueprint, or custom Overlay
  - delete on orphan succeeds (302 → /admin/users)
  - delete nulls out Job.user_id (jobs survive as audit trail)
  - delete-other-admin succeeds when more than one admin exists

The "last admin" branch in the delete endpoint is defense-in-depth and
unreachable via normal flow (any path that triggers it is shadowed by
self-delete) — covered by a comment, not a test.
2026-05-10 21:19:03 +02:00
mwiegand
bcea450e98
admin: deactivate/activate/delete endpoints for /admin/users
Three new POST endpoints on the existing admin blueprint, all guarded
by @require_admin and CSRF (per the global before_request hook):

  /admin/users/<id>/deactivate  flips active=False (refuses self)
  /admin/users/<id>/activate    flips active=True
  /admin/users/<id>/delete      hard delete with safeties:
    - refuses self-delete
    - refuses delete-of-the-last-admin
    - refuses if the user owns Servers, Blueprints, or custom
      Overlays (operator deletes those first via existing UIs)
    - nulls out Job.user_id (jobs stay as audit trail; FK is nullable)

admin_users.html grows an Active column + an Actions column with the
appropriate button per row (none for self, Deactivate/Activate
toggle, Delete-with-confirmation modal). Modal pattern mirrors
blueprint_detail.html (same modal-close/modal-open data attrs,
csrf_token hidden field).

Refusal responses are 409 with a plain-text body (matches the
blueprint-in-use refusal at blueprint_routes.py:182). No flash
infrastructure introduced; consistent with the rest of the codebase.

All 367 existing tests still pass.
2026-05-10 21:15:52 +02:00
mwiegand
3490be5fb7
auth: reject inactive users at login + invalidate existing sessions
Two-pronged enforcement so deactivation has effect both for fresh
logins and already-issued sessions:

  - load_current_user(): treat User with active=False as logged-out
    (sets g.user=None). Existing sessions stop working immediately.
  - login(): include `not user.active` in the existing 401 condition,
    so deactivated accounts get the same "invalid credentials"
    response as wrong-password / unknown-user — no timing oracle for
    deactivation status.

Tests still green (12/12 in test_auth.py).
2026-05-10 21:13:31 +02:00
mwiegand
726acfa4ff
models: add User.active column for soft-delete (deactivation)
Default true; server_default '1'. Lets the admin UI deactivate a user
without losing the row or the user's content (servers, blueprints,
overlays). Reactivation flips it back. Migration 0008 adds the column
via op.add_column; downgrade uses batch_alter_table per SQLite ALTER
TABLE semantics, matching the 0007 pattern.
2026-05-10 21:12:27 +02:00
mwiegand
62d6d4cbcd
ui(files-overlay): label root row as "/" instead of "(overlay root)"
Tighter, more terminal-flavored. Mono font on the label echoes how
paths are rendered elsewhere in the tree. New-folder dialog title
also shows "/" when targeting the root.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 19:50:14 +02:00