Routes 8 call sites through the cm6 controller alias:
- 5 reads (byte-count, save POST, dirty checks at 306, 481, 496, 511,
528) → getEditorValue() helper, falling back to
editorEls.contentBox.value if window.__filesEditor isn't mounted
(no-JS / pre-mount path).
- 3 writes (clear, "Loading…" placeholder, fetched body content at
362, 395, 402) → setEditorValue() helper with the same fallback.
The two helpers live inline next to editorEls so the rest of the
module's call sites stay close to existing style.
Known regressions (out of scope for v2, candidate follow-ups):
- Byte-count badge updates only on file-open / setContent calls, not
live on every keystroke. Needs a controller.onChange(cb) hook.
- Ctrl+S inside cm6 doesn't trigger the modal Save. cm6 owns the
keymap in its editing surface; users can still click the Save
button. Adding a custom cm6 keymap entry would restore the
shortcut.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New test_blueprint_config_form_post_round_trip — POSTs a multi-line
config, GETs the page, asserts each line re-renders inside the
textarea. Pins the round-trip the v2 editor's submit-time copy
handler must preserve before any template wiring lands.
Skipped a corresponding test_overlay_script_form_post_contract test
— the existing test_admin_creates_system_wide_script_overlay at
test_script_overlay_routes.py:~270 already asserts
overlay.script == "echo admin" after a POST /overlays/<id>/script,
which is the same form-contract pin. YAGNI; no need to duplicate.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five-line partial included on every page that mounts an editor.
Two <link> stylesheets (vendor + glue) and two nonce'd <script>
tags (bundle + glue). The `defer` attribute preserves document
order, so editor.bundle.js (which assigns window.__editor)
executes before editor.js (which reads it).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Un-bundled progressive-enhancement glue:
- DOMContentLoaded → mount cm6 on every textarea[data-editor-language].
- Each <form> gets one capture-phase submit handler that copies every
contained editor's getValue() into its textarea.value before the
browser serializes the form (submit-time copy bridge).
- The textarea with class files-editor-content (the files-modal
textarea) exposes its controller as window.__filesEditor for
files-overlay.js's getValue / setContent / setLanguage calls.
- 'auto' language resolves from the modal's filename input
([data-editor-filename]); a language [data-editor-language-select]
dropdown lets the user override.
- Vocab fetched lazily on the first srccfg mount; cached for the page.
Falls through silently if window.__editor isn't defined (bundle
failed to load), keeping the raw textarea visible — no-JS fallback.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
tokens.css gains:
- --syntax-{keyword,string,comment,number}: source-of-truth syntax
token colors, overridden in the prefers-color-scheme: dark block.
- --cm-{bg,fg,keyword,string,comment,number,selection}: bridge
variables the cm6 themes (themes.js) reference. --cm-bg / --cm-fg
route through the existing --color-surface / --color-text palette
so they pick up dark-mode automatically.
editor.css scopes the cm6 shell (.cm-editor) to match the app's
existing --line / --radius-s / --color-focus tokens. Token colors
themselves come from cm6 themes, not this stylesheet.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
build-editor.sh runs npm install + esbuild from editor-src/, produces:
- editor.bundle.js 324.6 KB minified IIFE, sets window.__editor.mount
- editor.bundle.css 0 B placeholder (cm6 injects styles at runtime
via StyleModule; future extensions that need real CSS can drop into
the same file without a template change)
- editor.bundle.sha256 integrity hashes
The script uses $TMPDIR/npm-cache (override via NPM_CACHE env var)
to work around root-owned files in the default ~/.npm cache from
older npm versions (the env's `npm ci` rejected the default cache).
vendor/README.md documents the rebuild command, the cache override,
and the integrity-record convention.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the Task 1 stub. Builds an EditorView with:
- history, line numbers, active-line highlight, bracket matching,
close brackets, indent-on-input
- default + custom HighlightStyle
- light/dark theme via matchMedia-driven Compartment with a
prefers-color-scheme change listener
- language via Compartment (swappable for the files-modal dropdown)
- autocomplete via Compartment (only if vocab is provided)
- keymap stack: closeBrackets, default, history, completion, indentWithTab
Mounts the EditorView immediately before the textarea, hides the
textarea. Exposes window.__editor.mount(textarea, opts) returning a
controller with getValue / setContent / setLanguage / destroy.
bash language comes via @codemirror/legacy-modes/mode/shell wrapped
in StreamLanguage.define — same mechanism as srccfg.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CompletionSource over the srccfg-vocab.json shape. Word fragment
matched via /[A-Za-z0-9_]{2,}/ at the caret; ranking is
prefix-match-first (shorter prefixes preferred) then substring;
cap 50 candidates, top 8 rendered. Each option carries the kind
('cvar'/'command') as cm6's autocomplete `type` so the popup
shows the appropriate icon.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
themes.js exports four extensions:
- editorLightTheme / editorDarkTheme: EditorView.theme() variants
keyed to the --cm-* CSS variables defined in tokens.css (light) and
its prefers-color-scheme: dark block.
- editorHighlightStyle: HighlightStyle bound to Lezer tags
(comment, string, number, keyword, variableName).
- editorHighlighting: syntaxHighlighting(editorHighlightStyle) ready
to drop into the EditorState extensions array.
@lezer/highlight comes in transitively via @codemirror/language;
no new package.json dependency needed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
~30 LOC StreamLanguage definition for Source-engine .cfg syntax.
Tokens: line comment (//…), string, number, keyword (exec/alias/bind/
unbindall/wait), identifier. Linewise, no nesting — matches the
shape we authored as a Prism regex grammar in the v1 attempt.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
build-vocab.py parses ./cvar_list (live L4D2 cvarlist dump, 2196 entries)
into static/data/srccfg-vocab.json — 1523 cvars + 671 commands.
Idempotent. Records the source-file SHA256 in the JSON header so
regenerations are auditable.
cvar_list is committed as a tracked data file so the generation is
reproducible from the repo alone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The contenteditable + CodeJar + Prism approach (Tasks 1-12 + 4 smoke
fixes shipped this session) hit too many contenteditable edge cases to
ship:
- Copy collapses multi-line selections to one line (Selection.toString()
doesn't reliably reconstruct newlines across Prism's tokenized <span>
topology).
- Enter sometimes requires two presses + cursor color shifts (caret
lands "between" sibling tokenized spans; first Enter shifts it into
a real text node, second actually inserts).
- Cascade of earlier bugs already fixed (cursor jumped to start, then
end; popup-accepted-quote duplicated; popup didn't accept at
end-of-line) were all symptoms of the same root cause: manual Range
API manipulation against tokenized contenteditable DOM is unreliable.
Exiting the sunk-cost path before more fixes accrue. The next attempt
will be a fresh brainstorming session weighing CodeMirror 6 (battle-
tested, accepts a one-time bundler step) vs textarea-overlay (real
<textarea> for editing, passive <pre> highlight, no contenteditable).
Kept (informs the next attempt):
- spec + plan documents in docs/superpowers/
- Playwright scaffolding (conftest + smoke test) + dev deps + e2e marker
- scripts/dev-server.py (independent of editor approach)
- AGENTS.md sandbox + Chromium Mach-port notes
Removed:
- editor JS (editor.js, srccfg-grammar.js)
- editor CSS (editor.css)
- vendored CodeJar + Prism + README
- srccfg vocab data
- editor partial (_editor_assets.html)
- template wiring (data-editor-language attributes, asset partial includes,
files-editor language <select>)
- files-overlay.js editor bridge (setEditorContent helper, dropdown
listener, filename-handler auto-redetect, dropdown reset)
- tokens.css syntax-color additions (dead without the editor)
- form-contract tests in test_blueprints.py + test_script_overlay_routes.py
- the editor-specific Playwright test (test_editor.py)
- create-blueprint modal trim that was tied to editor UX (Arguments +
Config textareas restored)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The new-blueprint modal had Name + Arguments + Config textareas, but
the modal lives on blueprints.html (the list page), not on
blueprint_detail.html, so neither textarea was wired to the srccfg
editor — mixing themed-editor and raw-textarea UX in the same flow.
Keep just Name; arguments/config are edited on the detail page where
the editor lives. Add autofocus to the name field for keyboard flow.
Server contract unchanged: create_blueprint (blueprint_routes.py:80)
already defaults arguments/config to [] when absent from the form.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Prism's stock theme has
code[class*=language-] { color:#000; background:transparent; }
at specificity (0,1,1), which beats our .editor-code (0,1,0). Result:
the editor's background was transparent and base text was #000, leaving
black-on-dark text in dark mode (unreadable).
We override every Prism token class we use (.token.comment / .string /
.keyword / .number / .operator / .identifier) via theme-aware
--color-* tokens defined in both :root and the
@media (prefers-color-scheme: dark) block of tokens.css, so prism.css
contributes nothing of value. Drop the <link> from _editor_assets.html.
Flip the form-contract tests to assert prism.css is NOT in the body so
a future accidental re-add is caught.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Four smoke-discovered fixes in acceptCompletion / CodeJar options:
- Backward selection walk via Selection.modify replaces the buggy
range.setStart(endContainer, endOffset - fragment.length). The old
code assumed the fragment lived in endContainer; at end-of-line the
caret often sits in a post-Prism-<span> text node, so the subtraction
went negative → IndexSizeError → caught silently → popup dismissed
with no insert.
- Save/restore caret around updateCode because codejar.js:469-474 does
editor.textContent = code; highlight(editor) with no caret preservation,
which dropped the caret to the start of the editor.
- Set the selection inside the inserted text node before save() so
CodeJar's save() doesn't trip its anchorNode === editor special case
at codejar.js:122-127, which collapses to end-of-all-text.
- addClosing: false on both CodeJar constructors so closing quotes
don't duplicate — CodeJar's addClosing: true default inserts a paired
closing character without skipping past an existing one, producing
e.g. "rcon_password"" when you finish a string literal.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Addresses Important #2 from the final code review.
The asset partial was previously included unconditionally for any
overlay detail page. Workshop overlays and read-only files-type
overlays (when the viewer isn't the owner or admin) have no
data-editor-language textarea, so the ~30 KB of Prism + CodeJar + JS
+ CSS shipped pointlessly. Gate the include on the two cases that
actually mount an editor: script-type overlays (bash editor) and
files-type overlays where the current user has edit rights (the
files-editor modal).
I-1 from the review (race window during "Loading…") was confirmed
moot — editorDialog.showModal() only fires after the fetch resolves
(files-overlay.js:409), so the dialog is invisible during the
fetch-and-placeholder window and the user can't type into it.
I-3 (Playwright coverage of the language dropdown override) is real
follow-up work that needs a new files-type overlay seed in the
live_server fixture. Deferred as a v2 ticket.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Logs in as the seed user, navigates to the blueprint detail page,
types sv_che into the editor, asserts the autocomplete popup appears
with sv_cheats, accepts via Tab, and asserts the hidden textarea
(form field) now contains the inserted cvar.
This exercises the full chain end-to-end: editor mount on
DOMContentLoaded, srccfg-vocab.json fetch, popup positioning,
capture-phase keydown handling (Task 9 fix), Range-API completion
insertion, and textarea-mirroring on every input.
Two follow-ups from the Task 11 code review.
Important — without SESSION_COOKIE_SECURE=0, Task 12's Playwright
login would silently fail. app.py:57 sets SESSION_COOKIE_SECURE = not
TESTING, so with our TESTING=False conftest the cookie is marked
Secure; the browser drops it over http://127.0.0.1 and the
session never establishes. The env-var override (app.py:53-55) is the
least invasive fix and preserves the SECRET_KEY guard.
Minor — the second init_db() looked redundant but is actually load-
bearing: create_app's init_db runs inside the app context (binds to
the in-app engine), while the seed work uses session_scope() outside
the app context (binds to an env-derived engine). The second
init_db() creates tables on THAT engine. Added a clarifying comment
so a future reader doesn't drop the line and silently break the seed.
Addresses Important #1 + Minor #1 from the Task 11 code review.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
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.
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>
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.
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.
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.
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>
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).
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>
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>
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>
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>
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>
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>
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>
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>
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.
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>
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>
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>
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>
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>
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>
Preserves the existing '-' placeholder for nullable started_at /
finished_at columns.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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).
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>
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)
- 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>
- _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>
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>
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>
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>
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>
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>
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'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>
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>
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>
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>
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>
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>