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>
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>
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>
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>
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>
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>
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.
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.
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).
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.
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>
Three bugs surfaced in browser testing, plus one UX request:
1. The Uploads panel and the binary-mode editor sub-panels stayed
visible after `el.hidden = true` because their `display: flex/grid`
rules in components.css have the same specificity as the UA's
`[hidden]{display:none}` and come later in cascade. Add a targeted
`[hidden]!important` rule for the affected classes.
2. Clicking a folder toggle inside a `files` overlay did nothing.
`file-tree.js` looked for `.file-tree-children` via
`button.nextElementSibling`, but the files-overlay row template
inserts a per-row action span between the toggle and the children
div. Switch to `closest('.file-tree-row').querySelector(':scope >
.file-tree-children')` so both row variants resolve correctly.
3. Pressing Enter on the new-folder dialog did nothing — the keydown
handler was attached with `{once:true}` inside `openNewFolder`,
so the first letter the user typed consumed the listener and Enter
never fired. Move the listener to module init so it survives
subsequent keystrokes and dialog reopenings.
UX: render the overlay root as a row inside the tree (label
"(overlay root)") rather than as a separate toolbar. The root row
carries the same `+ new file · + new folder · ⬇ zip` hover-action
column as every other folder row, so drop-on-row, hover-reveal, and
data-target-path semantics are uniform across the tree.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
webkitGetAsEntry() only returns an Entry for real OS-originated drag-drops;
synthetic DragEvents (and some browsers without folder-drop support) get
null back. Per-item fallback to getAsFile() keeps single-file drops working
in those cases without sacrificing the whole-folder upload path on real
OS drops.
Caught while end-to-end testing on the deploy box: a programmatically-
dispatched drop fired the listener and reached preventDefault(), but no
upload row appeared because the file collection loop never enqueued.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds Overlay.type='files' whose source-of-truth IS the overlay directory
itself. Users can:
* upload arbitrary files / whole folders by dragging from the OS onto a
folder row in the file tree (one POST per file, queue with
concurrency 3, per-file progress in a floating Uploads panel)
* move via drag-and-drop inside the tree (same gesture, source
distinguishes; refuses cycles)
* create / edit / rename / replace through a single editor modal
(text flavor for editable files, binary flavor with replace-upload
for everything else; filename input is the rename surface)
* mkdir empty folders (slashes allowed for nested intermediates)
* stream a folder as a zip download
* delete files and empty folders
Backend is type-agnostic past the new files_routes endpoints, so the
existing mount / spec / overlayfs / expose_server_cfg pipeline is reused
unchanged. is_editable gates the row's edit affordance and the /save
content rules. Three new safe-resolve helpers (write/delete/move) cover
the new operations with the same anchor-and-resolve pattern as listing
and download. FilesBuilder is a no-op so the build subsystem can
dispatch uniformly.
Spec: docs/superpowers/specs/2026-05-09-files-overlay-design.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous wiring attached click listeners on DOMContentLoaded, so
any [data-modal-open] / [data-modal-close] / dialog.modal element
that came in via a later HTMX partial swap silently lost its
behaviour. The server-detail Actions partial reloads its reset/delete
triggers on every state change, so reset was unclickable after the
first state change post-load.
Switch to a single delegated click handler on document. Same logic,
but matches via Element.closest() so it works regardless of when an
element was added to the DOM. No re-bind needed after HTMX swaps.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A background thread spawned alongside the job workers polls every
server's status every STATE_POLLER_INTERVAL_SECONDS (default 30) and
writes the result via the existing refresh_server_actual_state path.
Servers with in-flight jobs (queued/running/cancelling) are skipped to
avoid racing the post-job refresh. Catches reboot drift, OOM kills,
manual systemctl operations, and any other out-of-band state change.
Spec: docs/superpowers/specs/2026-05-09-l4d2-server-lifecycle-reboot-and-drift-design.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a /servers/<id>/files/download route mirroring the overlay download
endpoint. Same safety rules: real-path must resolve under LEFT4ME_ROOT
(merged view threads through `installation/` and overlay layers, all
already inside the root). The server file-tree partial now renders
download links.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drops the inline Name input from the blueprint edit form. A Rename link
sits next to Delete in the page footer; clicking opens a one-line modal
that posts to a new POST /blueprints/<id>/rename route. The main edit
form keeps the current name as a hidden input so its full Save still
works unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a Files section at the bottom of the server detail page that lists
the kernel-overlayfs merged view at runtime/<server_id>/merged/. Reuses
the overlay file-tree partial via two new template variables:
- files_base_url: parent passes "/overlays/<id>" or "/servers/<id>"
- download_supported: false for servers (runtime holds large game
binaries; no download endpoint), true for overlays (existing behavior)
New service helper safe_resolve_for_server_listing() rejects path
traversal beyond the merged root and returns None when the overlayfs
mount doesn't exist (server never started or just reset).
New route GET /servers/<id>/files?path=<rel> returns the lazy-load
file-tree fragment, gated to the server owner. No download counterpart.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>