feat: per-stop route overrides on the /stops listing - #107
Merged
Conversation
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>
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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
offline.lad.lviv.uaandpdf.lad.lviv.uaboth take?add=/?remove=now, but nothing produced those links — the query had to be written by hand.What changes
The
Маршрутиcolumn onGET /stopsapplies 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=1turns 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/:codesits behind Cloudflare Access. The Worker verifies the Access JWT itself — signature against the team's published keys, plusaud,iss,exp— rather than trusting that the request arrived through the proxy. Covered by tests for a forged signature, a wrongaud, a foreigniss, an expired token,alg: none, and an unknownkid.Deploy steps (not automated)
npx wrangler kv namespace create STOP_OVERRIDES, put the id inworker/stop-overrides/wrangler.tomlapi.lad.lviv.uapathstop-overrides/admin; copy its AUD tag and team domain into the same filenpx wrangler deploy(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 newPUTpayloads/62?add=%D0%A203&remove=%D0%9057renders3a 5a 55 60 80 t3—А57gone,Т03added — and the PDF returns 200Known limits
/stops.jsonstill reportssignandsign_pdfwithout overrides — it is server-rendered and cached, so applying them there would reintroduce the purge problem🤖 Generated with Claude Code