Fix 15 audit findings: browser concurrency, silent verification failures, auth gaps
All checks were successful
Deploy / deploy (push) Successful in 30s

Full read-through of the project. Grouped by what actually breaks:

Browser concurrency (the invariant the design rests on)
- Every Playwright session now goes through scheduler.submit() onto the
  single worker queue. "Refresh Rules", cookie import and the boot sync
  were calling automation.py directly from Flask request threads, so
  with gunicorn's threads=4 a refresh during a scheduled run opened a
  second Chromium and both wrote /data/auth_state.json — which
  Playwright writes itself, non-atomically. A torn write there reads
  back as "session expired" and costs a pointless cookie re-import.
- Request handlers that need a result use run_browser_task(), which
  waits REQUEST_WAIT_SECONDS (90) then raises BrowserBusy -> 503
  without cancelling the work. Previously a slow in-request scrape could
  exceed gunicorn's 120s timeout; killing that worker takes the
  APScheduler jobstore and run queue with it, silently dropping every
  pending verification poll.

Verification correctness (the feature that exists to catch silent failures)
- run_rule() no longer starts a poll loop when the watermark capture
  itself failed. watermark_id was None both for "capture failed" and
  "rule has no prior activity", and check_completion() skips its
  comparison on None — so a failed capture matched the *previous* run's
  Complete entry and reported a false "verified", the exact class of bug
  the verification step was added to catch.
- A refresh now reconciles schedules.json against the live rule list.
  Rule ids are stable, names are not, and the cached name is what the
  Activity-page match keys on: a renamed rule kept running while
  reporting "Not Confirmed" forever, training you to ignore the signal.
  A deleted rule was worse — its card disappears while its cron jobs
  keep firing, with no UI left to disable them. Skipped on an empty
  scrape so a MassPrice redesign can't delete every saved schedule.
- The dashboard's session banner reads a session_state.json flag set by
  _ensure_authenticated() instead of guessing from run_history.json.
  list_rules() writes no history entry, so a failed refresh — often the
  first symptom — never raised the banner, despite the docs saying it
  covered "run/refresh".

Scheduler robustness
- rebuild_jobs() parses and adds each entry under its own guard. It runs
  *after* clearing existing jobs, so one malformed entry escaping as an
  exception left the scheduler holding no jobs at all — every schedule
  silently dead until the next restart. _parse_entry() also accepts
  HH:MM:SS (some browsers emit seconds, which the old two-value unpack
  crashed on) and range-checks hour/minute.
- The API validates entries at save time rather than storing something
  every rebuild skips, and the frontend surfaces save failures instead
  of discarding them.
- An unknown TZ logs and falls back to UTC instead of crash-looping the
  container with a traceback that doesn't obviously point at .env.

Auth and web hygiene
- Empty-but-set LOGIN_PASSWORD/SECRET_KEY now fall back to their
  defaults. docker-compose always sets these, so a blank .env value
  arrived as "" — and compare_digest("", "") is True, i.e. no login at
  all. An empty submitted password is rejected outright too.
- Password compared as bytes: compare_digest raises TypeError on
  non-ASCII str, turning any accented character into a 500.
- CSRF token per session, in a meta tag, echoed as X-CSRF-Token on all
  four POST endpoints; explicit SameSite=Lax/HttpOnly.
- /api/* answers 401 instead of redirecting. fetch() follows a 302 to
  /login, gets 200 HTML, and reads it as success — "Run Now" reported
  "Queued" for runs that were never queued.
- ?next= is restricted to local paths (open redirect).

Smaller correctness fixes
- Timestamps render in TZ via a localtime filter; they were shown as raw
  UTC while schedules run in America/Phoenix.
- Activity's 24h filter excludes unparseable timestamps rather than
  including them, and treats naive values as UTC instead of raising.
- SameSite=None cookies are forced Secure — Chromium drops them
  otherwise, silently, possibly losing the one cookie carrying the login.
- The Activity selector escapes the rule name, so a name containing a
  quote no longer produces an unparseable selector.
- pytz pinned explicitly; it only ever resolved as an apscheduler 3.x
  transitive dep, which 4.x drops.

Docs corrected where they disagreed with the code: auth_state.json is
written by Playwright, not through storage.py's atomic helper; the
banner's real trigger; and the deploy skill's claim that a rename causes
"rule id not found" (it can't — schedules are keyed by the stable id).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H6TgffxTvYhqxykAbVQg2v
This commit is contained in:
Barely Removable 2026-09-01 00:28:17 -07:00
parent 213837ff97
commit e2eaabca06
14 changed files with 544 additions and 129 deletions

View file

@ -30,11 +30,25 @@ tool is confirmed working, so there's exactly one thing triggering runs.
- **`scheduler.py`** — an in-process APScheduler `BackgroundScheduler`
holding one `CronTrigger` job per `(rule, schedule entry)` pair,
rebuilt from `schedules.json` on startup and whenever the webui saves
a change. Triggered jobs (and manual "Run Now" clicks) are pushed onto
one `queue.Queue`, drained by a single dedicated worker thread — this
guarantees only one Playwright browser session is ever active at a
time, matching how a human would actually operate the site and
avoiding concurrent-session weirdness.
a change. **Every** Playwright session goes through `submit()` onto
one `queue.Queue`, drained by a single dedicated worker thread —
scheduled runs, "Run Now", "Refresh Rules" and cookie imports alike.
That guarantees only one browser is ever active at a time, matching
how a human would actually operate the site and avoiding
concurrent-session weirdness. Nothing may call `automation.py`
directly from a Flask request thread: with `threads = 4` that opens a
second Chromium alongside a scheduled run, and both write
`auth_state.json` — which Playwright writes itself, non-atomically —
so a torn write reads back as "session expired" and costs a pointless
cookie re-import.
- Request handlers that need an answer (refresh, import) use
`run_browser_task()`, which waits `REQUEST_WAIT_SECONDS` (90) and
then raises `BrowserBusy` → HTTP 503, *without* cancelling the work.
The bound is the point: gunicorn kills any worker whose request
exceeds its `timeout` (120s), and since the jobstore and queue live
in that worker's memory, being killed drops every pending
verification poll. Waiting behind a slow rule run must never be able
to take the scheduler down.
- **Must run in exactly one process.** `gunicorn.conf.py` pins
`workers = 1` for this reason — a second worker would instantiate a
second scheduler and duplicate every run. Fine for a low-traffic
@ -58,7 +72,15 @@ tool is confirmed working, so there's exactly one thing triggering runs.
- **`automation.py`** — all Playwright logic. `list_rules()` scrapes
`https://massprice.tcgplayer.com/Rule/List` for the live rule
id/name pairs (cached to `rules_cache.json`, refreshed via the
Dashboard's "Refresh Rules" button or on first boot). `run_rule(id)`
Dashboard's "Refresh Rules" button or on first boot). A refresh also
runs `_reconcile_schedules()` in `app.py`, which re-syncs
`schedules.json` against that live list: rule **ids** are stable but
names aren't, and a stale cached name silently breaks run
verification (it's what the Activity-page match keys on), while a
rule deleted upstream would otherwise keep firing cron jobs with no
card left in the UI to disable them. Deliberately skipped when the
scrape returns an empty list, so a MassPrice redesign that breaks
the scrape can't delete every saved schedule. `run_rule(id)`
finds that rule's `li.rule.widget-rule` row, clicks its
`.dropdown-ellipsis` three-dot toggle, clicks the row's
`a.run-rule[data-rule-id=...]` item — mirroring exactly what a human
@ -118,10 +140,16 @@ tool is confirmed working, so there's exactly one thing triggering runs.
against), the run fails with a clear "session expired or missing"
error rather than attempting to log in — see below for why.
- **`storage.py`** — tiny JSON read/write helpers (temp-file +
`os.replace`, so a crash mid-write can't corrupt state) for
everything under `/data`: `schedules.json`, `auth_state.json`,
`run_history.json` (bounded to the last 200 entries), `rules_cache.json`,
`os.replace`, so a crash mid-write can't corrupt state) for the state
files under `/data`: `schedules.json`, `run_history.json` (bounded to
the last 200 entries), `rules_cache.json`, `session_state.json` (the
"is the saved session known-bad" flag behind the dashboard banner) and
`notify_state.json` (the session-expiry alert's send-once dedupe flag).
`auth_state.json` lives in the same directory but is **not** written
through here — Playwright writes it itself via
`context.storage_state(path=...)` in `automation._save_state()`, so it
gets none of that atomic-write protection. Worth knowing: it's also
the one file two concurrent Playwright sessions can both write.
- **`templates/` / `static/`** — a small, plain dark dashboard. This is
a deliberately different, simpler visual identity than Arr Summary's
teletext system — that design language is specific to that app, not a
@ -175,9 +203,16 @@ automated run re-saves it (`_save_state()`), which tends to slide a
expire (inactivity, a password change, TCGplayer forcing re-auth) and
need re-importing.
The Dashboard shows a red banner (`_session_warning()` in `app.py`) when
the most recent run/refresh failed specifically because of a missing/
expired session, so this doesn't just look like "no rules found."
The Dashboard shows a red banner (`_session_warning()` in `app.py`)
whenever the saved session is known to be missing/expired, so this
doesn't just look like "no rules found." The flag behind it lives in
`session_state.json` and is set by `automation._ensure_authenticated()`
on *any* failed page load — a scheduled run, a "Refresh Rules", or a
verification poll — and cleared the moment one authenticates again (or a
fresh export is imported). It's deliberately not inferred from
`run_history.json`: `list_rules()` writes no history entry, so a failed
refresh — often the first symptom the user sees — would never raise the
banner.
## Session-expiry email alert
@ -210,9 +245,10 @@ python3 app.py
Runs on port 5000 (`PORT` env to change). Without Chromium installed and
without a saved `auth_state.json`, the Dashboard still boots —
`_bootstrap()` in `app.py` best-effort-catches the initial rule sync, so
local UI/schedule-editing work doesn't require Docker or a real MassPrice
session at all. `DATA_DIR` env var overrides where JSON state is
`_bootstrap()` in `app.py` queues the initial rule sync instead of
running it inline, so the failure lands in the worker thread's log and
local UI/schedule-editing work doesn't require Docker or a real
MassPrice session at all. `DATA_DIR` env var overrides where JSON state is
read/written (defaults to `/data`, which won't exist locally — point it
at a scratch directory).