Skip to content

feat: per-stop route overrides on the /stops listing - #107

Merged
vbhjckfd merged 4 commits into
masterfrom
feat/stop-route-overrides
Aug 7, 2026
Merged

feat: per-stop route overrides on the /stops listing#107
vbhjckfd merged 4 commits into
masterfrom
feat/stop-route-overrides

Conversation

@vbhjckfd

@vbhjckfd vbhjckfd commented Aug 7, 2026

Copy link
Copy Markdown
Owner

offline.lad.lviv.ua and pdf.lad.lviv.ua both take ?add= / ?remove= now, but nothing produced those links — the query had to be written by hand.

What changes

The Маршрути column on GET /stops applies a stored per-stop override: removed routes red and struck through, added ones green. That row's SVG and PDF links get the matching query hung off them.

?edit=1 turns the column into controls — click a route to drop or restore it, type one into the + box to add it. Each change saves immediately.

Where the overrides live

Workers KV, under a single key holding the whole map:

{ "62": { "add": ["Т03"], "remove": ["А57"] } }

The listing renders ~1000 rows and reads this once per page load; a key per stop would be a thousand reads for a few kilobytes.

Fetched by the browser, not rendered into the page. The listing is cached at Cloudflare for 30 days — baking overrides in would mean purging it on every edit. This way the HTML stays the plain upstream listing and an edit is live at once.

Auth

PUT /stop-overrides/admin/:code sits behind Cloudflare Access. The Worker verifies the Access JWT itself — signature against the team's published keys, plus aud, iss, exp — rather than trusting that the request arrived through the proxy. Covered by tests for a forged signature, a wrong aud, a foreign iss, an expired token, alg: none, and an unknown kid.

Deploy steps (not automated)

  1. npx wrangler kv namespace create STOP_OVERRIDES, put the id in worker/stop-overrides/wrangler.toml
  2. Create the Access application on api.lad.lviv.ua path stop-overrides/admin; copy its AUD tag and team domain into the same file
  3. npx wrangler deploy
  4. Add a last-position cache rule: (http.host eq "api.lad.lviv.ua" and starts_with(http.request.uri.path, "/stop-overrides")) → Bypass cache. Cache rules do not stop at the first match, and the zone's "Cache everything" rule would otherwise serve a stale map.

Details in worker/stop-overrides/README.md.

Verified

  • make test — 197 passing, 65 of them new
  • Read and edit paths driven in a real browser against a stub endpoint: chip colours, link rewriting, and the PUT payloads
  • The links it generates, against production: /62?add=%D0%A203&remove=%D0%9057 renders 3a 5a 55 60 80 t3А57 gone, Т03 added — and the PDF returns 200

Known limits

  • KV is eventually consistent; a write can take ~60s to reach every edge
  • Writes are read-modify-write on one key, so two people saving different stops in the same instant would lose one. One editor today
  • /stops.json still reports sign and sign_pdf without overrides — it is server-rendered and cached, so applying them there would reintroduce the purge problem

🤖 Generated with Claude Code

The upstream route list for a stop is sometimes behind reality, and
offline.lad.lviv.ua and pdf.lad.lviv.ua both learned ?add=/?remove= for
saying so. Nothing produced those links, so the query had to be written
by hand.

The Маршрути column now applies a stored override — removed routes red
and struck through, added ones green — and hangs the matching query on
that row's SVG and PDF links. ?edit=1 turns the column into controls:
click a route to drop or restore it, type one into the + box to add it.

The overrides are fetched by the browser rather than rendered into the
page. The listing is cached at Cloudflare for 30 days, so baking them in
would mean purging it on every edit; this way an edit is live at once
and the HTML is still the plain upstream listing.

They live in Workers KV under a single key, read once per page load —
the listing is ~1000 rows, and a key per stop would be a thousand reads
for a few kilobytes. Writes go to a Worker behind Cloudflare Access,
which verifies the Access JWT against the team's published keys rather
than trusting that the request came through the proxy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment thread index.js Fixed
CodeQL flagged /stop-overrides.js: a route handler doing a file read with
no rate limit in front of it. Cloudflare caches the file for a day, so an
origin hit is rare, but a client that bypasses cache could otherwise turn
the read under sendFile into an amplifier.

Pulled the in-memory limiter already used for /mcp out into
utils/rateLimiter.js so it is reusable and unit-testable on its own, and
put a second instance in front of this route.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment thread index.js Fixed
vbhjckfd and others added 2 commits August 7, 2026 14:30
The hand-rolled limiter still left CodeQL's missing-rate-limiting query
unsatisfied — it flagged the route again after the first fix, so its
model does not credit an ad-hoc middleware, only a rate limiter it
recognizes. Swapped to express-rate-limit, same 120 req/min shape, for
that one route.

utils/rateLimiter.js stays: /mcp still uses it, untouched and unflagged,
and it has its own tests now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
KV + a Worker behind Cloudflare Access meant a namespace to create, an
Access application to configure, and ACCESS_TEAM_DOMAIN/ACCESS_AUD to
fill in before any of it worked — all before the first override could
be saved.

localStorage needs none of that: no account to edit through, no cache
to purge, an edit applies at once. The trade is scope — an override is
now visible only in the browser that made it, not to everyone who opens
/stops. That is an acceptable trade for a single-editor tool.

loadOverrides/saveOverrides fail closed: a throw on access (storage
disabled), on a full quota, or a stored value that is not valid JSON all
fall back to no overrides rather than breaking the listing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vbhjckfd
vbhjckfd merged commit d4e672f into master Aug 7, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants