From 2ed9eecd2ab0e4fa4b9aadd0cf64579a6017330f Mon Sep 17 00:00:00 2001 From: Myroslav Date: Fri, 7 Aug 2026 14:19:55 +0300 Subject: [PATCH 1/4] feat: per-stop route overrides on the /stops listing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 17 ++ actions/getAllStopsAction.js | 17 +- index.js | 9 + public/stopOverrides.js | 235 +++++++++++++++ tests/actions/getAllStopsAction.test.js | 34 +++ tests/public/stopOverrides.test.js | 166 +++++++++++ tests/worker/stopOverridesWorker.test.js | 356 +++++++++++++++++++++++ worker/stop-overrides/README.md | 67 +++++ worker/stop-overrides/src/index.js | 165 +++++++++++ worker/stop-overrides/wrangler.toml | 21 ++ 10 files changed, 1083 insertions(+), 4 deletions(-) create mode 100644 public/stopOverrides.js create mode 100644 tests/public/stopOverrides.test.js create mode 100644 tests/worker/stopOverridesWorker.test.js create mode 100644 worker/stop-overrides/README.md create mode 100644 worker/stop-overrides/src/index.js create mode 100644 worker/stop-overrides/wrangler.toml diff --git a/README.md b/README.md index f7d0ee1..bf7307b 100644 --- a/README.md +++ b/README.md @@ -516,6 +516,23 @@ All stops as a JSON array, sorted by code. (`GET /stops` returns an HTML table instead.) +#### Per-stop route overrides + +The upstream route list for a stop is sometimes behind reality. `GET /stops` +applies a stored override to its `Маршрути` column — removed routes shown red and +struck through, added ones green — and hangs the matching `?add=`/`?remove=` on +that row's SVG and PDF links, which `offline.lad.lviv.ua` and `pdf.lad.lviv.ua` +both understand. + +Add `?edit=1` to the listing to change them: click a route to drop or restore it, +type one into the `+` box to add it. Saving goes through Cloudflare Access. + +The overrides are fetched by the browser rather than rendered into the page, so +the listing stays cacheable for 30 days and an edit needs no purge. They live in +Workers KV behind a small Worker — see [`worker/stop-overrides`](worker/stop-overrides). + +`/stops.json` reports `sign` and `sign_pdf` without overrides applied. + #### `GET /stops/:code` Single stop with live realtime timetable. Short-cached (5–10 s). diff --git a/actions/getAllStopsAction.js b/actions/getAllStopsAction.js index 10d1971..ea40855 100644 --- a/actions/getAllStopsAction.js +++ b/actions/getAllStopsAction.js @@ -58,8 +58,12 @@ export default async (req, res, next) => { + ${contactBannerHtml("stops")} @@ -83,16 +87,21 @@ ${contactBannerHtml("stops")} }) .sort(); - result += ` + // data-code and data-routes are what /stop-overrides.js rewrites the row + // from: the served HTML stays the plain upstream listing, cacheable for + // 30 days, and the overrides are applied in the browser. + result += ` ${s.code} (${s.microgiz_id}) - SVG + SVG   - PDF + PDF ${escapeHtml(s.name)} ${loc[0]}, ${loc[1]} - ${transfers.map(escapeHtml).join(" ")} + ${transfers + .map((r) => `${escapeHtml(r)}`) + .join(" ")} `; } result += "\n\n"; diff --git a/index.js b/index.js index 56484ca..a266dd3 100644 --- a/index.js +++ b/index.js @@ -340,6 +340,15 @@ app.get("/favicon.ico", (req, res, next) => { res.sendFile(path.join(__dirname, "favicon.ico")); }); +// Applies the per-stop route overrides to the /stops listing in the browser. +// Tagged "long" like the other baked-in assets: it ships with the image, so a +// GTFS refresh leaves it alone and a code push purges it. +app.get("/stop-overrides.js", (req, res) => { + setStaticAssetCache(res); + res.type("text/javascript"); + res.sendFile(path.join(__dirname, "public", "stopOverrides.js")); +}); + app.get("/smithery.json", (req, res) => { setStaticAssetCache(res, 3600 * 24 * 7); res.sendFile(path.join(__dirname, "smithery.json")); diff --git a/public/stopOverrides.js b/public/stopOverrides.js new file mode 100644 index 0000000..44aa5ac --- /dev/null +++ b/public/stopOverrides.js @@ -0,0 +1,235 @@ +/** + * Per-stop route overrides for the /stops listing. + * + * The listing itself is cached at Cloudflare for 30 days, so the overrides are + * never rendered into it — the browser fetches them separately and rewrites the + * route column and the SVG/PDF links in place. An edit goes live without + * purging anything. + * + * This file is served to the browser as an ES module and imported directly by + * the test suite, so the DOM half only runs when init() is called. + */ + +export const OVERRIDES_URL = "/stop-overrides"; +export const ADMIN_URL = "/stop-overrides/admin"; + +export const MAX_ROUTES_PER_LIST = 40; + +// Route names are alphanumeric in both alphabets: А03, Т25, 32A, Аеропорт. +const ROUTE_NAME = /^[\p{L}\p{N}]{1,16}$/u; + +export function isValidRouteName(name) { + return typeof name === "string" && ROUTE_NAME.test(name); +} + +/** + * Trims anything that is not a usable route name, drops duplicates, and caps + * the length. A name in both lists means remove, since that is the safer read. + */ +export function normalizeOverride(entry) { + const clean = (list) => + Array.from(new Set(Array.isArray(list) ? list : [])) + .filter(isValidRouteName) + .slice(0, MAX_ROUTES_PER_LIST); + + const remove = clean(entry?.remove); + const add = clean(entry?.add).filter((name) => !remove.includes(name)); + + return { add, remove }; +} + +export function isEmptyOverride(entry) { + const { add, remove } = normalizeOverride(entry); + return add.length === 0 && remove.length === 0; +} + +/** + * The query string timetable-offline and timetable-pdf both understand. + * Comma-separated, each name percent-encoded: the services split on the comma + * after decoding, and route names are Cyrillic. + */ +export function overrideQuery(entry) { + const { add, remove } = normalizeOverride(entry); + const parts = []; + + if (add.length) parts.push(`add=${add.map(encodeURIComponent).join(",")}`); + if (remove.length) + parts.push(`remove=${remove.map(encodeURIComponent).join(",")}`); + + return parts.join("&"); +} + +export function signLinks(code, entry) { + const query = overrideQuery(entry); + const suffix = query ? `?${query}` : ""; + + return { + svg: `https://offline.lad.lviv.ua/${code}${suffix}`, + pdf: `https://pdf.lad.lviv.ua/${code}.pdf${suffix}`, + }; +} + +/** + * The route column as it should read: upstream routes in their own order, the + * removed ones still shown but struck through, the added ones after them. + */ +export function applyOverride(routes, entry) { + const { add, remove } = normalizeOverride(entry); + const upstream = Array.isArray(routes) ? routes : []; + + const kept = upstream.map((name) => ({ + name, + state: remove.includes(name) ? "removed" : "kept", + })); + + const added = add + .filter((name) => !upstream.includes(name)) + .map((name) => ({ name, state: "added" })); + + return [...kept, ...added]; +} + +/** + * Toggles a name that the API does list for the stop: kept becomes removed and + * back. Toggling one the API does not list drops it from the add list instead, + * so a chip added by mistake can be clicked away. + */ +export function toggleRoute(entry, routes, name) { + const { add, remove } = normalizeOverride(entry); + const upstream = Array.isArray(routes) ? routes : []; + + if (!upstream.includes(name)) { + return normalizeOverride({ add: add.filter((r) => r !== name), remove }); + } + + return normalizeOverride( + remove.includes(name) + ? { add, remove: remove.filter((r) => r !== name) } + : { add, remove: [...remove, name] }, + ); +} + +/** + * A name the API already lists for the stop is not added — it is un-removed, + * which is what asking for it back means. + */ +export function addRoute(entry, routes, name) { + if (!isValidRouteName(name)) return normalizeOverride(entry); + + const { add, remove } = normalizeOverride(entry); + const upstream = Array.isArray(routes) ? routes : []; + + if (upstream.includes(name)) { + return normalizeOverride({ add, remove: remove.filter((r) => r !== name) }); + } + + return normalizeOverride({ add: [...add, name], remove }); +} + +// ── DOM ───────────────────────────────────────────────────────────────────── + +function renderRow(row, overrides, editing) { + const code = row.dataset.code; + const cell = row.querySelector("[data-routes]"); + const routes = cell.dataset.routes ? cell.dataset.routes.split(" ") : []; + const entry = normalizeOverride(overrides[code]); + + cell.textContent = ""; + for (const { name, state } of applyOverride(routes, entry)) { + const chip = document.createElement("span"); + chip.className = `route ${state}`; + chip.dataset.route = name; + chip.textContent = name; + if (editing) chip.title = "Клацніть, щоб прибрати або повернути"; + cell.append(chip, document.createTextNode(" ")); + } + + if (editing) { + const input = document.createElement("input"); + input.className = "route-add"; + input.size = 6; + input.placeholder = "+"; + cell.append(input); + } + + const links = signLinks(code, entry); + for (const link of row.querySelectorAll("[data-kind]")) { + link.href = links[link.dataset.kind]; + } +} + +async function save(code, entry) { + const response = await fetch(`${ADMIN_URL}/${code}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(normalizeOverride(entry)), + }); + + if (!response.ok) { + throw new Error(`save failed: ${response.status}`); + } +} + +export async function init() { + const rows = Array.from(document.querySelectorAll("tr[data-code]")); + if (!rows.length) return; + + const editing = new URLSearchParams(location.search).has("edit"); + if (editing) document.body.dataset.edit = ""; + let overrides = {}; + + try { + const response = await fetch(OVERRIDES_URL, { headers: { Accept: "application/json" } }); + if (response.ok) overrides = await response.json(); + } catch { + // An unreachable override store leaves the upstream listing as it is, + // which is still the truth the API knows. + } + + for (const row of rows) renderRow(row, overrides, editing); + + if (!editing) return; + + const routesOf = (row) => { + const cell = row.querySelector("[data-routes]"); + return cell.dataset.routes ? cell.dataset.routes.split(" ") : []; + }; + + const persist = async (row, code) => { + renderRow(row, overrides, editing); + try { + await save(code, overrides[code]); + } catch (error) { + row.querySelector("[data-routes]").append(" ⚠️"); + console.error(error); + } + }; + + document.addEventListener("click", (event) => { + const chip = event.target.closest(".route"); + if (!chip) return; + + const row = chip.closest("tr[data-code]"); + const code = row.dataset.code; + overrides[code] = toggleRoute(overrides[code], routesOf(row), chip.dataset.route); + persist(row, code); + }); + + document.addEventListener("keydown", (event) => { + if (event.key !== "Enter") return; + const input = event.target.closest(".route-add"); + if (!input) return; + + const row = input.closest("tr[data-code]"); + const code = row.dataset.code; + const name = input.value.trim(); + if (!isValidRouteName(name)) return; + + overrides[code] = addRoute(overrides[code], routesOf(row), name); + persist(row, code); + }); +} + +if (typeof document !== "undefined") { + init(); +} diff --git a/tests/actions/getAllStopsAction.test.js b/tests/actions/getAllStopsAction.test.js index 6da3094..a857fe6 100644 --- a/tests/actions/getAllStopsAction.test.js +++ b/tests/actions/getAllStopsAction.test.js @@ -50,6 +50,40 @@ describe("getAllStopsAction", () => { ); }); + // /stop-overrides.js rewrites the row from these; without them the overrides + // have nothing to attach to. + it("marks up each row for the override script", async () => { + const { req, res, next } = makeReqRes({ path: "/stops" }); + await getAllStopsAction(req, res, next); + + const html = res.send.mock.calls[0][0]; + expect(html).toContain(''); + expect(html).toContain('data-routes="А01 Т1"'); + expect(html).toContain('А01'); + expect(html).toContain('data-kind="svg"'); + expect(html).toContain('data-kind="pdf"'); + expect(html).toContain('src="/stop-overrides.js"'); + }); + + it("styles removed routes red and struck through, added ones green", async () => { + const { req, res, next } = makeReqRes({ path: "/stops" }); + await getAllStopsAction(req, res, next); + + const html = res.send.mock.calls[0][0]; + expect(html).toContain(".route.removed { color: red; text-decoration: line-through; }"); + expect(html).toContain(".route.added { color: green; }"); + }); + + it("serves the bare upstream route list, so the page stays cacheable", async () => { + const { req, res, next } = makeReqRes({ path: "/stops" }); + await getAllStopsAction(req, res, next); + + const html = res.send.mock.calls[0][0]; + expect(html).not.toContain("stop-overrides/admin"); + expect(html).not.toContain('class="route removed"'); + expect(html).not.toContain('class="route added"'); + }); + it("sets cache headers for Cloudflare", async () => { const { req, res, next } = makeReqRes({ path: "/stops.json" }); await getAllStopsAction(req, res, next); diff --git a/tests/public/stopOverrides.test.js b/tests/public/stopOverrides.test.js new file mode 100644 index 0000000..34bd8de --- /dev/null +++ b/tests/public/stopOverrides.test.js @@ -0,0 +1,166 @@ +import { describe, it, expect } from "vitest"; +import { + addRoute, + applyOverride, + isEmptyOverride, + isValidRouteName, + normalizeOverride, + overrideQuery, + signLinks, + toggleRoute, + MAX_ROUTES_PER_LIST, +} from "../../public/stopOverrides.js"; + +const ROUTES = ["А03", "А05", "А55"]; + +describe("isValidRouteName", () => { + it.each(["А03", "Т25", "32A", "Аеропорт", "5"])("accepts %s", (name) => { + expect(isValidRouteName(name)).toBe(true); + }); + + it.each(["", "../etc", "A 47", "A,47", "a".repeat(17), 47, null])( + "rejects %s", + (name) => { + expect(isValidRouteName(name)).toBe(false); + }, + ); +}); + +describe("normalizeOverride", () => { + it("returns empty lists for a missing entry", () => { + expect(normalizeOverride(undefined)).toEqual({ add: [], remove: [] }); + }); + + it("drops names that are not route names", () => { + expect(normalizeOverride({ add: ["Т03", "../etc"], remove: ["А57", ""] })).toEqual({ + add: ["Т03"], + remove: ["А57"], + }); + }); + + it("dedupes", () => { + expect(normalizeOverride({ add: ["Т03", "Т03"], remove: [] }).add).toEqual(["Т03"]); + }); + + it("lets remove win when a name is in both lists", () => { + expect(normalizeOverride({ add: ["Т03"], remove: ["Т03"] })).toEqual({ + add: [], + remove: ["Т03"], + }); + }); + + it("caps each list", () => { + const many = Array.from({ length: MAX_ROUTES_PER_LIST + 5 }, (_, i) => `A${i}`); + expect(normalizeOverride({ add: many }).add).toHaveLength(MAX_ROUTES_PER_LIST); + }); + + it("survives a non-array", () => { + expect(normalizeOverride({ add: "Т03", remove: 7 })).toEqual({ add: [], remove: [] }); + }); +}); + +describe("isEmptyOverride", () => { + it("is true for an entry with nothing usable in it", () => { + expect(isEmptyOverride({ add: ["../etc"], remove: [] })).toBe(true); + }); + + it("is false once a name survives", () => { + expect(isEmptyOverride({ add: ["Т03"], remove: [] })).toBe(false); + }); +}); + +describe("overrideQuery", () => { + it("is empty for no override", () => { + expect(overrideQuery({ add: [], remove: [] })).toBe(""); + }); + + it("emits add and remove", () => { + expect(overrideQuery({ add: ["T02"], remove: ["T03"] })).toBe("add=T02&remove=T03"); + }); + + it("percent-encodes Cyrillic names and keeps the comma literal", () => { + expect(overrideQuery({ add: ["Т03", "А47"], remove: [] })).toBe( + "add=%D0%A203,%D0%9047", + ); + }); + + it("omits the side that is empty", () => { + expect(overrideQuery({ add: [], remove: ["T03"] })).toBe("remove=T03"); + }); +}); + +describe("signLinks", () => { + it("leaves the links bare when there is no override", () => { + expect(signLinks(62, {})).toEqual({ + svg: "https://offline.lad.lviv.ua/62", + pdf: "https://pdf.lad.lviv.ua/62.pdf", + }); + }); + + it("hangs the query off both links", () => { + expect(signLinks(62, { add: ["T02"], remove: ["T03"] })).toEqual({ + svg: "https://offline.lad.lviv.ua/62?add=T02&remove=T03", + pdf: "https://pdf.lad.lviv.ua/62.pdf?add=T02&remove=T03", + }); + }); +}); + +describe("applyOverride", () => { + it("marks every upstream route kept when there is no override", () => { + expect(applyOverride(ROUTES, {})).toEqual([ + { name: "А03", state: "kept" }, + { name: "А05", state: "kept" }, + { name: "А55", state: "kept" }, + ]); + }); + + it("marks a removed route rather than dropping it", () => { + expect(applyOverride(ROUTES, { remove: ["А05"] })).toContainEqual({ + name: "А05", + state: "removed", + }); + }); + + it("appends added routes after the upstream ones", () => { + const result = applyOverride(ROUTES, { add: ["Т03"] }); + expect(result.at(-1)).toEqual({ name: "Т03", state: "added" }); + }); + + it("does not append a route the stop already has", () => { + const result = applyOverride(ROUTES, { add: ["А03"] }); + expect(result.filter((r) => r.name === "А03")).toHaveLength(1); + }); +}); + +describe("toggleRoute", () => { + it("removes an upstream route", () => { + expect(toggleRoute({}, ROUTES, "А05").remove).toEqual(["А05"]); + }); + + it("puts a removed route back", () => { + expect(toggleRoute({ remove: ["А05"] }, ROUTES, "А05").remove).toEqual([]); + }); + + it("drops an added route the stop does not serve", () => { + expect(toggleRoute({ add: ["Т03"] }, ROUTES, "Т03").add).toEqual([]); + }); +}); + +describe("addRoute", () => { + it("adds a route the stop does not serve", () => { + expect(addRoute({}, ROUTES, "Т03").add).toEqual(["Т03"]); + }); + + it("un-removes rather than adds a route the stop already serves", () => { + const result = addRoute({ remove: ["А05"] }, ROUTES, "А05"); + expect(result).toEqual({ add: [], remove: [] }); + }); + + it("ignores a name that is not a route name", () => { + expect(addRoute({}, ROUTES, "../etc")).toEqual({ add: [], remove: [] }); + }); + + it("does not add the same route twice", () => { + expect(addRoute({ add: ["Т03"] }, ROUTES, "Т03").add).toEqual(["Т03"]); + }); +}); diff --git a/tests/worker/stopOverridesWorker.test.js b/tests/worker/stopOverridesWorker.test.js new file mode 100644 index 0000000..76dd075 --- /dev/null +++ b/tests/worker/stopOverridesWorker.test.js @@ -0,0 +1,356 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import worker, { + OVERRIDES_KEY, + normalizeOverride, + verifyAccessJwt, +} from "../../worker/stop-overrides/src/index.js"; + +const TEAM_DOMAIN = "example.cloudflareaccess.com"; +const AUD = "aud-tag"; +const KID = "test-kid"; + +function makeKv(initial = null) { + let value = initial === null ? null : JSON.stringify(initial); + return { + get: vi.fn(async (key, type) => { + if (key !== OVERRIDES_KEY || value === null) return null; + return type === "json" ? JSON.parse(value) : value; + }), + put: vi.fn(async (key, next) => { + value = next; + }), + stored: () => (value === null ? null : JSON.parse(value)), + }; +} + +const base64Url = (bytes) => + btoa(String.fromCharCode(...new Uint8Array(bytes))) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); + +const encodeJson = (value) => + base64Url(new TextEncoder().encode(JSON.stringify(value))); + +let keyPair; +let jwks; + +beforeEach(async () => { + keyPair = await crypto.subtle.generateKey( + { + name: "RSASSA-PKCS1-v1_5", + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: "SHA-256", + }, + true, + ["sign", "verify"], + ); + + const jwk = await crypto.subtle.exportKey("jwk", keyPair.publicKey); + jwks = { keys: [{ ...jwk, kid: KID, alg: "RS256" }] }; +}); + +async function makeToken(overrides = {}, signingKey) { + const header = encodeJson({ alg: "RS256", kid: KID, ...overrides.header }); + const payload = encodeJson({ + aud: AUD, + iss: `https://${TEAM_DOMAIN}`, + exp: 4102444800, // 2100-01-01 + email: "editor@example.com", + ...overrides.payload, + }); + + const signature = await crypto.subtle.sign( + "RSASSA-PKCS1-v1_5", + signingKey ?? keyPair.privateKey, + new TextEncoder().encode(`${header}.${payload}`), + ); + + return `${header}.${payload}.${base64Url(signature)}`; +} + +const env = (kv) => ({ + STOP_OVERRIDES: kv, + ACCESS_TEAM_DOMAIN: TEAM_DOMAIN, + ACCESS_AUD: AUD, +}); + +const deps = () => ({ + fetch: vi.fn(async () => new Response(JSON.stringify(jwks), { status: 200 })), +}); + +const put = (code, body, token) => + new Request(`https://api.lad.lviv.ua/stop-overrides/admin/${code}`, { + method: "PUT", + headers: token + ? { "Cf-Access-Jwt-Assertion": token, "Content-Type": "application/json" } + : { "Content-Type": "application/json" }, + body: typeof body === "string" ? body : JSON.stringify(body), + }); + +describe("normalizeOverride", () => { + it("keeps only usable route names", () => { + expect(normalizeOverride({ add: ["Т03", "a b"], remove: ["А57"] })).toEqual({ + add: ["Т03"], + remove: ["А57"], + }); + }); + + it("lets remove win over add", () => { + expect(normalizeOverride({ add: ["Т03"], remove: ["Т03"] }).add).toEqual([]); + }); +}); + +describe("GET /stop-overrides", () => { + it("returns an empty map when nothing is stored", async () => { + const kv = makeKv(null); + const response = await worker.fetch( + new Request("https://api.lad.lviv.ua/stop-overrides"), + env(kv), + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({}); + }); + + it("returns the stored map", async () => { + const kv = makeKv({ 62: { add: ["Т03"], remove: [] } }); + const response = await worker.fetch( + new Request("https://api.lad.lviv.ua/stop-overrides"), + env(kv), + ); + + await expect(response.json()).resolves.toEqual({ 62: { add: ["Т03"], remove: [] } }); + }); + + // The listing it feeds is cached for 30 days; this is what makes an edit + // show up straight away. + it("is never cached", async () => { + const response = await worker.fetch( + new Request("https://api.lad.lviv.ua/stop-overrides"), + env(makeKv(null)), + ); + + expect(response.headers.get("Cache-Control")).toBe("no-store"); + }); + + it("rejects a write to the public path", async () => { + const response = await worker.fetch( + new Request("https://api.lad.lviv.ua/stop-overrides", { method: "PUT" }), + env(makeKv(null)), + ); + + expect(response.status).toBe(405); + }); +}); + +describe("PUT /stop-overrides/admin/:code", () => { + it("stores an entry for a valid token", async () => { + const kv = makeKv(null); + const token = await makeToken(); + + const response = await worker.fetch( + put(62, { add: ["Т03"], remove: ["А57"] }, token), + env(kv), + {}, + deps(), + ); + + expect(response.status).toBe(200); + expect(kv.stored()).toEqual({ 62: { add: ["Т03"], remove: ["А57"] } }); + }); + + it("drops names that are not route names before storing", async () => { + const kv = makeKv(null); + const token = await makeToken(); + + await worker.fetch( + put(62, { add: ["Т03", "../etc/passwd"], remove: [] }, token), + env(kv), + {}, + deps(), + ); + + expect(kv.stored()).toEqual({ 62: { add: ["Т03"], remove: [] } }); + }); + + it("leaves other stops alone", async () => { + const kv = makeKv({ 80: { add: ["Т02"], remove: [] } }); + const token = await makeToken(); + + await worker.fetch(put(62, { add: ["Т03"], remove: [] }, token), env(kv), {}, deps()); + + expect(kv.stored()).toEqual({ + 80: { add: ["Т02"], remove: [] }, + 62: { add: ["Т03"], remove: [] }, + }); + }); + + it("deletes the entry when both lists come back empty", async () => { + const kv = makeKv({ 62: { add: ["Т03"], remove: [] }, 80: { add: [], remove: ["А01"] } }); + const token = await makeToken(); + + await worker.fetch(put(62, { add: [], remove: [] }, token), env(kv), {}, deps()); + + expect(kv.stored()).toEqual({ 80: { add: [], remove: ["А01"] } }); + }); + + it("rejects a stop code that is not a number", async () => { + const token = await makeToken(); + const response = await worker.fetch( + put("..%2Fadmin", { add: [], remove: [] }, token), + env(makeKv(null)), + {}, + deps(), + ); + + expect(response.status).toBe(400); + }); + + it("rejects a body that is not JSON", async () => { + const token = await makeToken(); + const response = await worker.fetch( + put(62, "not json", token), + env(makeKv(null)), + {}, + deps(), + ); + + expect(response.status).toBe(400); + }); + + it("rejects GET", async () => { + const response = await worker.fetch( + new Request("https://api.lad.lviv.ua/stop-overrides/admin/62"), + env(makeKv(null)), + {}, + deps(), + ); + + expect(response.status).toBe(405); + }); + + it("returns 404 for an unknown path", async () => { + const response = await worker.fetch( + new Request("https://api.lad.lviv.ua/whatever"), + env(makeKv(null)), + ); + + expect(response.status).toBe(404); + }); +}); + +// Access sits in front of this path, but the Worker is reachable by anything +// that can route to it, so it verifies rather than trusts. +describe("Access enforcement", () => { + it("refuses a request with no token", async () => { + const kv = makeKv(null); + const response = await worker.fetch( + put(62, { add: ["Т03"], remove: [] }), + env(kv), + {}, + deps(), + ); + + expect(response.status).toBe(403); + expect(kv.put).not.toHaveBeenCalled(); + }); + + it("refuses a token signed by another key", async () => { + const attacker = await crypto.subtle.generateKey( + { + name: "RSASSA-PKCS1-v1_5", + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: "SHA-256", + }, + true, + ["sign", "verify"], + ); + const token = await makeToken({}, attacker.privateKey); + const kv = makeKv(null); + + const response = await worker.fetch( + put(62, { add: ["Т03"], remove: [] }, token), + env(kv), + {}, + deps(), + ); + + expect(response.status).toBe(403); + expect(kv.put).not.toHaveBeenCalled(); + }); + + it("refuses a token for another Access application", async () => { + const token = await makeToken({ payload: { aud: "someone-else" } }); + const response = await worker.fetch( + put(62, { add: ["Т03"], remove: [] }, token), + env(makeKv(null)), + {}, + deps(), + ); + + expect(response.status).toBe(403); + }); + + it("refuses a token from another team domain", async () => { + const token = await makeToken({ payload: { iss: "https://evil.cloudflareaccess.com" } }); + const response = await worker.fetch( + put(62, { add: ["Т03"], remove: [] }, token), + env(makeKv(null)), + {}, + deps(), + ); + + expect(response.status).toBe(403); + }); + + it("refuses an expired token", async () => { + const token = await makeToken({ payload: { exp: 1 } }); + const response = await worker.fetch( + put(62, { add: ["Т03"], remove: [] }, token), + env(makeKv(null)), + {}, + deps(), + ); + + expect(response.status).toBe(403); + }); + + // "alg": "none" is the classic way to hand a verifier a token it will + // happily believe. + it("refuses a token that asks for an algorithm we do not verify", async () => { + const token = await makeToken({ header: { alg: "none" } }); + const response = await worker.fetch( + put(62, { add: ["Т03"], remove: [] }, token), + env(makeKv(null)), + {}, + deps(), + ); + + expect(response.status).toBe(403); + }); + + it("refuses when no published key matches the token's kid", async () => { + const token = await makeToken({ header: { kid: "other-kid" } }); + const response = await worker.fetch( + put(62, { add: ["Т03"], remove: [] }, token), + env(makeKv(null)), + {}, + deps(), + ); + + expect(response.status).toBe(403); + }); + + it("accepts a valid token", async () => { + const token = await makeToken(); + const payload = await verifyAccessJwt(token, { + ACCESS_TEAM_DOMAIN: TEAM_DOMAIN, + ACCESS_AUD: AUD, + }, deps()); + + expect(payload.email).toBe("editor@example.com"); + }); +}); diff --git a/worker/stop-overrides/README.md b/worker/stop-overrides/README.md new file mode 100644 index 0000000..5a43cd5 --- /dev/null +++ b/worker/stop-overrides/README.md @@ -0,0 +1,67 @@ +# stop-overrides Worker + +Stores the per-stop route overrides that the `/stops` listing applies to its +route column and to its SVG/PDF links. + +| Method | Path | Auth | Purpose | +|---|---|---|---| +| `GET` | `/stop-overrides` | public | the whole map, `Cache-Control: no-store` | +| `PUT` | `/stop-overrides/admin/:code` | Cloudflare Access | replace one stop's entry | + +Body and stored shape: + +```json +{ "62": { "add": ["Т03"], "remove": ["А57"] } } +``` + +An entry whose `add` and `remove` are both empty is deleted rather than stored, +so a stop back on its upstream route list leaves no trace. + +## Why one KV key + +The whole map lives under a single key, `overrides`. The listing renders ~1000 +rows and reads this once per page load — a key per stop would be a thousand +reads for the same few kilobytes. KV is eventually consistent, so a write can +take up to ~60s to reach every edge; for an override list edited by hand that +is not worth trading for D1. + +Writes are read-modify-write on that one key, which would lose an update if two +people saved different stops in the same instant. One editor, so it has not been +worth a lock. + +## Setup + +```bash +npx wrangler kv namespace create STOP_OVERRIDES +``` + +Put the returned id into `wrangler.toml`. + +Then create the Access application: + +- **Application domain**: `api.lad.lviv.ua`, path `stop-overrides/admin` +- Policy: whoever should be able to edit +- Copy the application's **Audience (AUD) tag** into `ACCESS_AUD`, and your team + domain (`.cloudflareaccess.com`) into `ACCESS_TEAM_DOMAIN` + +The Worker verifies the Access JWT itself against +`https://.cloudflareaccess.com/cdn-cgi/access/certs` — signature, `aud`, +`iss` and `exp` — rather than trusting that the request came through the proxy. + +```bash +npx wrangler deploy +``` + +## Cache rule + +`api.lad.lviv.ua` is covered by a "Cache everything" rule, and cache rules do +not stop at the first match — the **last** matching rule wins. Add one and place +it **last**: + +``` +Expression: (http.host eq "api.lad.lviv.ua" and starts_with(http.request.uri.path, "/stop-overrides")) +Action: Bypass cache +``` + +Without it the override map is served from cache and edits appear to do nothing +for up to the zone's edge TTL. diff --git a/worker/stop-overrides/src/index.js b/worker/stop-overrides/src/index.js new file mode 100644 index 0000000..80ed27d --- /dev/null +++ b/worker/stop-overrides/src/index.js @@ -0,0 +1,165 @@ +/** + * Per-stop route override store. + * + * GET /stop-overrides → the whole map, public, never cached + * PUT /stop-overrides/admin/:code → replace one stop's entry, behind Access + * + * The whole map lives under a single KV key. The /stops listing renders ~1000 + * rows and reads this once per page load; a key per stop would be a thousand + * reads for the same few kilobytes. + */ + +export const OVERRIDES_KEY = "overrides"; +export const MAX_ROUTES_PER_LIST = 40; + +const ROUTE_NAME = /^[\p{L}\p{N}]{1,16}$/u; +const STOP_CODE = /^\d{1,10}$/; + +const json = (body, status = 200, extraHeaders = {}) => + new Response(JSON.stringify(body), { + status, + headers: { + "Content-Type": "application/json; charset=utf-8", + // The point of fetching this separately from the cached listing is that + // an edit shows up immediately. + "Cache-Control": "no-store", + ...extraHeaders, + }, + }); + +/** Drops anything that is not a usable route name, dedupes, caps the length. */ +export function normalizeOverride(entry) { + const clean = (list) => + Array.from(new Set(Array.isArray(list) ? list : [])) + .filter((name) => typeof name === "string" && ROUTE_NAME.test(name)) + .slice(0, MAX_ROUTES_PER_LIST); + + const remove = clean(entry?.remove); + const add = clean(entry?.add).filter((name) => !remove.includes(name)); + + return { add, remove }; +} + +function base64UrlToBytes(value) { + const padded = value.replace(/-/g, "+").replace(/_/g, "/"); + const binary = atob(padded.padEnd(Math.ceil(padded.length / 4) * 4, "=")); + return Uint8Array.from(binary, (char) => char.charCodeAt(0)); +} + +function base64UrlToJson(value) { + return JSON.parse(new TextDecoder().decode(base64UrlToBytes(value))); +} + +/** + * Verifies a Cloudflare Access JWT against the team's public keys. + * + * Access sits in front of this route, so an unverified request should not + * arrive — but the Worker is reachable by anything that can route to it, and + * "the proxy checked it" is only true while the route config says so. + */ +export async function verifyAccessJwt(token, env, deps = {}) { + const fetchImpl = deps.fetch ?? fetch; + const now = deps.now ?? (() => Math.floor(Date.now() / 1000)); + + if (typeof token !== "string") throw new Error("missing Access token"); + + const [rawHeader, rawPayload, rawSignature] = token.split("."); + if (!rawHeader || !rawPayload || !rawSignature) { + throw new Error("malformed Access token"); + } + + const header = base64UrlToJson(rawHeader); + if (header.alg !== "RS256") throw new Error(`unexpected alg ${header.alg}`); + + const issuer = `https://${env.ACCESS_TEAM_DOMAIN}`; + const certs = await fetchImpl(`${issuer}/cdn-cgi/access/certs`); + if (!certs.ok) throw new Error("could not read Access certs"); + + const { keys } = await certs.json(); + const jwk = (keys ?? []).find((key) => key.kid === header.kid); + if (!jwk) throw new Error("no Access key for kid"); + + const key = await crypto.subtle.importKey( + "jwk", + { ...jwk, alg: "RS256", key_ops: ["verify"], ext: true }, + { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, + false, + ["verify"], + ); + + const signed = new TextEncoder().encode(`${rawHeader}.${rawPayload}`); + const valid = await crypto.subtle.verify( + "RSASSA-PKCS1-v1_5", + key, + base64UrlToBytes(rawSignature), + signed, + ); + if (!valid) throw new Error("bad Access signature"); + + const payload = base64UrlToJson(rawPayload); + const audiences = Array.isArray(payload.aud) ? payload.aud : [payload.aud]; + + if (!audiences.includes(env.ACCESS_AUD)) throw new Error("wrong Access aud"); + if (payload.iss !== issuer) throw new Error("wrong Access iss"); + if (!payload.exp || payload.exp <= now()) throw new Error("expired Access token"); + + return payload; +} + +async function readOverrides(env) { + return (await env.STOP_OVERRIDES.get(OVERRIDES_KEY, "json")) ?? {}; +} + +export default { + async fetch(request, env, ctx, deps = {}) { + const url = new URL(request.url); + const path = url.pathname.replace(/\/+$/, "") || "/"; + + if (path === "/stop-overrides") { + if (request.method !== "GET") return json({ error: "Method not allowed" }, 405); + return json(await readOverrides(env)); + } + + const admin = path.match(/^\/stop-overrides\/admin\/([^/]+)$/); + if (admin) { + if (request.method !== "PUT") return json({ error: "Method not allowed" }, 405); + + const code = decodeURIComponent(admin[1]); + if (!STOP_CODE.test(code)) return json({ error: "Bad stop code" }, 400); + + try { + await verifyAccessJwt( + request.headers.get("Cf-Access-Jwt-Assertion"), + env, + deps, + ); + } catch (error) { + return json({ error: "Forbidden", detail: error.message }, 403); + } + + let body; + try { + body = await request.json(); + } catch { + return json({ error: "Bad JSON" }, 400); + } + + const entry = normalizeOverride(body); + const overrides = await readOverrides(env); + + // An empty entry is a deletion, so a stop returned to its upstream route + // list stops taking up room in the map. + if (entry.add.length === 0 && entry.remove.length === 0) { + delete overrides[code]; + } else { + overrides[code] = entry; + } + + await env.STOP_OVERRIDES.put(OVERRIDES_KEY, JSON.stringify(overrides)); + + return json({ code, override: overrides[code] ?? null }); + } + + return json({ error: "Not found" }, 404); + }, +}; diff --git a/worker/stop-overrides/wrangler.toml b/worker/stop-overrides/wrangler.toml new file mode 100644 index 0000000..ae1ab32 --- /dev/null +++ b/worker/stop-overrides/wrangler.toml @@ -0,0 +1,21 @@ +name = "stop-overrides" +main = "src/index.js" +compatibility_date = "2026-08-07" + +# Same origin as the /stops listing that reads it: no CORS, no preflight, and +# the Access cookie is already scoped to this hostname. +[[routes]] +pattern = "api.lad.lviv.ua/stop-overrides*" +zone_name = "lad.lviv.ua" + +# The Worker is only meant to be reachable through the zone, where Access sits +# in front of the admin path. +workers_dev = false + +[[kv_namespaces]] +binding = "STOP_OVERRIDES" +id = "REPLACE_WITH_NAMESPACE_ID" + +[vars] +ACCESS_TEAM_DOMAIN = "REPLACE.cloudflareaccess.com" +ACCESS_AUD = "REPLACE_WITH_ACCESS_APPLICATION_AUD" From ba7a1b202e8c110e507a651d6062a9b39445033d Mon Sep 17 00:00:00 2001 From: Myroslav Date: Fri, 7 Aug 2026 14:26:45 +0300 Subject: [PATCH 2/4] fix: rate-limit the stop-overrides static route 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 --- index.js | 42 ++++++++-------- tests/utils/rateLimiter.test.js | 75 +++++++++++++++++++++++++++++ utils/rateLimiter.js | 24 +++++++++ worker/stop-overrides/wrangler.toml | 2 +- 4 files changed, 119 insertions(+), 24 deletions(-) create mode 100644 tests/utils/rateLimiter.test.js create mode 100644 utils/rateLimiter.js diff --git a/index.js b/index.js index a266dd3..31f3e83 100644 --- a/index.js +++ b/index.js @@ -16,6 +16,7 @@ import localDb from "./connections/timetableSqliteDb.js"; import notFoundAction from "./actions/notFoundAction.js"; import validateStopCode from "./utils/stopCodeMiddleware.js"; +import createRateLimiter from "./utils/rateLimiter.js"; import getClosestStopsAction from "./actions/getClosestStopsAction.js"; import getSingleStopAction from "./actions/getSingleStopAction.js"; @@ -235,30 +236,16 @@ app.get("/ping", (req, res) => { app.get("/health", healthAction); -// Simple in-memory rate limiter: 60 requests/min per IP -const _mcpRateLimitMap = new Map(); -const MCP_RATE_LIMIT = 60; -const MCP_RATE_WINDOW_MS = 60_000; - -function mcpRateLimiter(req, res, next) { - const ip = req.ip ?? "unknown"; - const now = Date.now(); - const entry = _mcpRateLimitMap.get(ip) ?? { count: 0, windowStart: now }; - if (now - entry.windowStart > MCP_RATE_WINDOW_MS) { - entry.count = 0; - entry.windowStart = now; - } - entry.count++; - _mcpRateLimitMap.set(ip, entry); - if (entry.count > MCP_RATE_LIMIT) { - return res.status(429).json({ +const mcpRateLimiter = createRateLimiter({ + limit: 60, + windowMs: 60_000, + onLimit: (res) => + res.status(429).json({ jsonrpc: "2.0", error: { code: -32000, message: "Rate limit exceeded. Try again later." }, id: null, - }); - } - next(); -} + }), +}); app.post("/mcp", mcpRateLimiter, async (req, res) => { try { @@ -342,8 +329,17 @@ app.get("/favicon.ico", (req, res, next) => { // Applies the per-stop route overrides to the /stops listing in the browser. // Tagged "long" like the other baked-in assets: it ships with the image, so a -// GTFS refresh leaves it alone and a code push purges it. -app.get("/stop-overrides.js", (req, res) => { +// GTFS refresh leaves it alone and a code push purges it. Cloudflare caches it +// for a day on that tag, so an uncached request reaching this far is rare — +// the limiter is here so a cache-bypassing client can't turn the file read +// underneath sendFile into an amplifier. +const staticFileRateLimiter = createRateLimiter({ + limit: 120, + windowMs: 60_000, + onLimit: (res) => res.status(429).type("text/plain").send("Rate limit exceeded"), +}); + +app.get("/stop-overrides.js", staticFileRateLimiter, (req, res) => { setStaticAssetCache(res); res.type("text/javascript"); res.sendFile(path.join(__dirname, "public", "stopOverrides.js")); diff --git a/tests/utils/rateLimiter.test.js b/tests/utils/rateLimiter.test.js new file mode 100644 index 0000000..3c1ef8c --- /dev/null +++ b/tests/utils/rateLimiter.test.js @@ -0,0 +1,75 @@ +import { describe, it, expect, vi } from "vitest"; +import createRateLimiter from "../../utils/rateLimiter.js"; + +function makeRes() { + return { + status: vi.fn().mockReturnThis(), + type: vi.fn().mockReturnThis(), + send: vi.fn().mockReturnThis(), + json: vi.fn().mockReturnThis(), + }; +} + +describe("createRateLimiter", () => { + it("calls next() under the limit", () => { + const limiter = createRateLimiter({ limit: 2, windowMs: 60_000, onLimit: vi.fn() }); + const next = vi.fn(); + + limiter({ ip: "1.2.3.4" }, makeRes(), next); + limiter({ ip: "1.2.3.4" }, makeRes(), next); + + expect(next).toHaveBeenCalledTimes(2); + }); + + it("calls onLimit instead of next() once the limit is exceeded", () => { + const onLimit = vi.fn(); + const limiter = createRateLimiter({ limit: 1, windowMs: 60_000, onLimit }); + const next = vi.fn(); + + limiter({ ip: "1.2.3.4" }, makeRes(), next); + const res = makeRes(); + limiter({ ip: "1.2.3.4" }, res, next); + + expect(next).toHaveBeenCalledTimes(1); + expect(onLimit).toHaveBeenCalledWith(res); + }); + + it("tracks each IP separately", () => { + const onLimit = vi.fn(); + const limiter = createRateLimiter({ limit: 1, windowMs: 60_000, onLimit }); + const next = vi.fn(); + + limiter({ ip: "1.1.1.1" }, makeRes(), next); + limiter({ ip: "2.2.2.2" }, makeRes(), next); + + expect(next).toHaveBeenCalledTimes(2); + expect(onLimit).not.toHaveBeenCalled(); + }); + + it("resets the count once the window has passed", () => { + const onLimit = vi.fn(); + let time = 0; + const limiter = createRateLimiter({ + limit: 1, + windowMs: 1000, + onLimit, + now: () => time, + }); + const next = vi.fn(); + + limiter({ ip: "1.2.3.4" }, makeRes(), next); + time = 2000; + limiter({ ip: "1.2.3.4" }, makeRes(), next); + + expect(next).toHaveBeenCalledTimes(2); + expect(onLimit).not.toHaveBeenCalled(); + }); + + it("does not crash on a request with no ip", () => { + const limiter = createRateLimiter({ limit: 2, windowMs: 60_000, onLimit: vi.fn() }); + const next = vi.fn(); + + expect(() => limiter({}, makeRes(), next)).not.toThrow(); + expect(next).toHaveBeenCalledOnce(); + }); +}); diff --git a/utils/rateLimiter.js b/utils/rateLimiter.js new file mode 100644 index 0000000..fc0d773 --- /dev/null +++ b/utils/rateLimiter.js @@ -0,0 +1,24 @@ +/** + * Simple in-memory rate limiter, one counter map per instance. Good enough for + * a single running instance; would need a shared store (e.g. Redis) behind + * more than one. + */ +export default function createRateLimiter({ limit, windowMs, onLimit, now = Date.now }) { + const counts = new Map(); + + return function rateLimiter(req, res, next) { + const ip = req.ip ?? "unknown"; + const time = now(); + const entry = counts.get(ip) ?? { count: 0, windowStart: time }; + if (time - entry.windowStart > windowMs) { + entry.count = 0; + entry.windowStart = time; + } + entry.count++; + counts.set(ip, entry); + if (entry.count > limit) { + return onLimit(res); + } + next(); + }; +} diff --git a/worker/stop-overrides/wrangler.toml b/worker/stop-overrides/wrangler.toml index ae1ab32..d3e1910 100644 --- a/worker/stop-overrides/wrangler.toml +++ b/worker/stop-overrides/wrangler.toml @@ -14,7 +14,7 @@ workers_dev = false [[kv_namespaces]] binding = "STOP_OVERRIDES" -id = "REPLACE_WITH_NAMESPACE_ID" +id = "46ab3cf327a642529ce22c895be007f8" [vars] ACCESS_TEAM_DOMAIN = "REPLACE.cloudflareaccess.com" From cbcdd240f6ba228595bce5db759be2d33610ee1f Mon Sep 17 00:00:00 2001 From: Myroslav Date: Fri, 7 Aug 2026 14:30:25 +0300 Subject: [PATCH 3/4] fix: use express-rate-limit for the stop-overrides route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- index.js | 8 +++++--- package-lock.json | 8 +++++--- package.json | 1 + 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/index.js b/index.js index 31f3e83..2ff4ca3 100644 --- a/index.js +++ b/index.js @@ -10,6 +10,7 @@ const PORT = process.env.PORT || 8080; import { openDb } from "gtfs"; import { readFile } from "fs/promises"; import cors from "cors"; +import rateLimit from "express-rate-limit"; import express from "express"; import bodyParser from "body-parser"; import localDb from "./connections/timetableSqliteDb.js"; @@ -333,10 +334,11 @@ app.get("/favicon.ico", (req, res, next) => { // for a day on that tag, so an uncached request reaching this far is rare — // the limiter is here so a cache-bypassing client can't turn the file read // underneath sendFile into an amplifier. -const staticFileRateLimiter = createRateLimiter({ - limit: 120, +const staticFileRateLimiter = rateLimit({ windowMs: 60_000, - onLimit: (res) => res.status(429).type("text/plain").send("Rate limit exceeded"), + limit: 120, + standardHeaders: true, + legacyHeaders: false, }); app.get("/stop-overrides.js", staticFileRateLimiter, (req, res) => { diff --git a/package-lock.json b/package-lock.json index dea6cc7..6f182ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "cors": "^2.8.5", "dotenv": "^17.4", "express": "^5.2.1", + "express-rate-limit": "^8.6.2", "gtfs": "^4.18.5", "gtfs-realtime-bindings": "^2.0.0", "lokijs": "^1.5.12", @@ -2529,11 +2530,12 @@ } }, "node_modules/express-rate-limit": { - "version": "8.5.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.1.tgz", - "integrity": "sha512-5O6KYmyJEpuPJV5hNTXKbAHWRqrzyu+OI3vUnSd2kXFubIVpG7ezpgxQy76Zo5GQZtrQBg86hF+CM/NX+cioiQ==", + "version": "8.6.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.2.tgz", + "integrity": "sha512-YH4ru+eOJxQABscKFfRCy9R7x9QFGdezclVMwwgFFndzS2Xnm0uo6B0ABZsLhcpeptGv2qvuJVWlQr9gQZoC3A==", "license": "MIT", "dependencies": { + "debug": "^4.4.3", "ip-address": "^10.2.0" }, "engines": { diff --git a/package.json b/package.json index 988288f..5d26bce 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "cors": "^2.8.5", "dotenv": "^17.4", "express": "^5.2.1", + "express-rate-limit": "^8.6.2", "gtfs": "^4.18.5", "gtfs-realtime-bindings": "^2.0.0", "lokijs": "^1.5.12", From d1ddd6fc9f673e5134ad7aef6e0a3c28675469af Mon Sep 17 00:00:00 2001 From: Myroslav Date: Fri, 7 Aug 2026 15:02:32 +0300 Subject: [PATCH 4/4] refactor: store route overrides in localStorage, drop the Worker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 10 +- public/stopOverrides.js | 62 ++-- tests/actions/getAllStopsAction.test.js | 1 - tests/public/stopOverrides.test.js | 70 ++++- tests/worker/stopOverridesWorker.test.js | 356 ----------------------- worker/stop-overrides/README.md | 67 ----- worker/stop-overrides/src/index.js | 165 ----------- worker/stop-overrides/wrangler.toml | 21 -- 8 files changed, 108 insertions(+), 644 deletions(-) delete mode 100644 tests/worker/stopOverridesWorker.test.js delete mode 100644 worker/stop-overrides/README.md delete mode 100644 worker/stop-overrides/src/index.js delete mode 100644 worker/stop-overrides/wrangler.toml diff --git a/README.md b/README.md index bf7307b..8c04758 100644 --- a/README.md +++ b/README.md @@ -525,11 +525,13 @@ that row's SVG and PDF links, which `offline.lad.lviv.ua` and `pdf.lad.lviv.ua` both understand. Add `?edit=1` to the listing to change them: click a route to drop or restore it, -type one into the `+` box to add it. Saving goes through Cloudflare Access. +type one into the `+` box to add it. -The overrides are fetched by the browser rather than rendered into the page, so -the listing stays cacheable for 30 days and an edit needs no purge. They live in -Workers KV behind a small Worker — see [`worker/stop-overrides`](worker/stop-overrides). +Overrides live in the browser's own `localStorage` (see +[`public/stopOverrides.js`](public/stopOverrides.js)), not on a server — no +account to edit through, no cache to purge, an edit applies at once. The trade +is scope: an override is visible only in the browser that made it, not to +anyone else who opens `/stops`. `/stops.json` reports `sign` and `sign_pdf` without overrides applied. diff --git a/public/stopOverrides.js b/public/stopOverrides.js index 44aa5ac..c091708 100644 --- a/public/stopOverrides.js +++ b/public/stopOverrides.js @@ -1,17 +1,16 @@ /** * Per-stop route overrides for the /stops listing. * - * The listing itself is cached at Cloudflare for 30 days, so the overrides are - * never rendered into it — the browser fetches them separately and rewrites the - * route column and the SVG/PDF links in place. An edit goes live without - * purging anything. + * Stored in the browser's own localStorage, not on a server: no account to + * edit through, no round trip, no cache to purge. The trade is scope — an + * edit is visible only in the browser that made it, not to anyone else who + * opens /stops. * * This file is served to the browser as an ES module and imported directly by * the test suite, so the DOM half only runs when init() is called. */ -export const OVERRIDES_URL = "/stop-overrides"; -export const ADMIN_URL = "/stop-overrides/admin"; +export const STORAGE_KEY = "lad-route-overrides"; export const MAX_ROUTES_PER_LIST = 40; @@ -158,34 +157,39 @@ function renderRow(row, overrides, editing) { } } -async function save(code, entry) { - const response = await fetch(`${ADMIN_URL}/${code}`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(normalizeOverride(entry)), - }); +/** + * Reads the stored map. Private browsing / storage-disabled throws on access + * in some browsers rather than just returning null, and a hand-edited or + * previous-format value in there is not JSON worth trusting either — either + * way this falls back to no overrides rather than breaking the listing. + */ +export function loadOverrides() { + try { + return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? {}; + } catch { + return {}; + } +} - if (!response.ok) { - throw new Error(`save failed: ${response.status}`); +export function saveOverrides(overrides) { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(overrides)); + return true; + } catch { + // Storage disabled, full, or the quota was hit — the in-page state still + // reflects the edit, it just will not survive a reload. + return false; } } -export async function init() { +export function init() { const rows = Array.from(document.querySelectorAll("tr[data-code]")); if (!rows.length) return; const editing = new URLSearchParams(location.search).has("edit"); if (editing) document.body.dataset.edit = ""; - let overrides = {}; - - try { - const response = await fetch(OVERRIDES_URL, { headers: { Accept: "application/json" } }); - if (response.ok) overrides = await response.json(); - } catch { - // An unreachable override store leaves the upstream listing as it is, - // which is still the truth the API knows. - } + const overrides = loadOverrides(); for (const row of rows) renderRow(row, overrides, editing); if (!editing) return; @@ -195,13 +199,10 @@ export async function init() { return cell.dataset.routes ? cell.dataset.routes.split(" ") : []; }; - const persist = async (row, code) => { + const persist = (row, code) => { renderRow(row, overrides, editing); - try { - await save(code, overrides[code]); - } catch (error) { + if (!saveOverrides(overrides)) { row.querySelector("[data-routes]").append(" ⚠️"); - console.error(error); } }; @@ -212,6 +213,7 @@ export async function init() { const row = chip.closest("tr[data-code]"); const code = row.dataset.code; overrides[code] = toggleRoute(overrides[code], routesOf(row), chip.dataset.route); + if (isEmptyOverride(overrides[code])) delete overrides[code]; persist(row, code); }); @@ -226,6 +228,8 @@ export async function init() { if (!isValidRouteName(name)) return; overrides[code] = addRoute(overrides[code], routesOf(row), name); + if (isEmptyOverride(overrides[code])) delete overrides[code]; + input.value = ""; persist(row, code); }); } diff --git a/tests/actions/getAllStopsAction.test.js b/tests/actions/getAllStopsAction.test.js index a857fe6..5b7a64c 100644 --- a/tests/actions/getAllStopsAction.test.js +++ b/tests/actions/getAllStopsAction.test.js @@ -79,7 +79,6 @@ describe("getAllStopsAction", () => { await getAllStopsAction(req, res, next); const html = res.send.mock.calls[0][0]; - expect(html).not.toContain("stop-overrides/admin"); expect(html).not.toContain('class="route removed"'); expect(html).not.toContain('class="route added"'); }); diff --git a/tests/public/stopOverrides.test.js b/tests/public/stopOverrides.test.js index 34bd8de..5b46275 100644 --- a/tests/public/stopOverrides.test.js +++ b/tests/public/stopOverrides.test.js @@ -1,16 +1,28 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { addRoute, applyOverride, isEmptyOverride, isValidRouteName, + loadOverrides, normalizeOverride, overrideQuery, + saveOverrides, signLinks, toggleRoute, MAX_ROUTES_PER_LIST, + STORAGE_KEY, } from "../../public/stopOverrides.js"; +/** In-memory localStorage stand-in — no jsdom needed for two get/set calls. */ +function makeStorage() { + const data = new Map(); + return { + getItem: (key) => (data.has(key) ? data.get(key) : null), + setItem: (key, value) => data.set(key, String(value)), + }; +} + const ROUTES = ["А03", "А05", "А55"]; describe("isValidRouteName", () => { @@ -164,3 +176,59 @@ describe("addRoute", () => { expect(addRoute({ add: ["Т03"] }, ROUTES, "Т03").add).toEqual(["Т03"]); }); }); + +describe("loadOverrides / saveOverrides", () => { + let originalStorage; + + beforeEach(() => { + originalStorage = globalThis.localStorage; + globalThis.localStorage = makeStorage(); + }); + + afterEach(() => { + globalThis.localStorage = originalStorage; + }); + + it("returns an empty map when nothing is stored", () => { + expect(loadOverrides()).toEqual({}); + }); + + it("round-trips what was saved", () => { + saveOverrides({ 62: { add: ["Т03"], remove: ["А57"] } }); + expect(loadOverrides()).toEqual({ 62: { add: ["Т03"], remove: ["А57"] } }); + }); + + it("stores under the documented key, plain JSON", () => { + saveOverrides({ 62: { add: ["Т03"], remove: [] } }); + expect(JSON.parse(globalThis.localStorage.getItem(STORAGE_KEY))).toEqual({ + 62: { add: ["Т03"], remove: [] }, + }); + }); + + it("falls back to no overrides when the stored value is not JSON", () => { + globalThis.localStorage.setItem(STORAGE_KEY, "not json"); + expect(loadOverrides()).toEqual({}); + }); + + it("falls back to no overrides when storage access throws", () => { + globalThis.localStorage = { + getItem: () => { + throw new Error("storage disabled"); + }, + }; + expect(loadOverrides()).toEqual({}); + }); + + it("reports failure rather than throwing when the write is rejected", () => { + globalThis.localStorage = { + setItem: () => { + throw new Error("quota exceeded"); + }, + }; + expect(saveOverrides({ 62: { add: ["Т03"], remove: [] } })).toBe(false); + }); + + it("reports success on a normal write", () => { + expect(saveOverrides({})).toBe(true); + }); +}); diff --git a/tests/worker/stopOverridesWorker.test.js b/tests/worker/stopOverridesWorker.test.js deleted file mode 100644 index 76dd075..0000000 --- a/tests/worker/stopOverridesWorker.test.js +++ /dev/null @@ -1,356 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from "vitest"; -import worker, { - OVERRIDES_KEY, - normalizeOverride, - verifyAccessJwt, -} from "../../worker/stop-overrides/src/index.js"; - -const TEAM_DOMAIN = "example.cloudflareaccess.com"; -const AUD = "aud-tag"; -const KID = "test-kid"; - -function makeKv(initial = null) { - let value = initial === null ? null : JSON.stringify(initial); - return { - get: vi.fn(async (key, type) => { - if (key !== OVERRIDES_KEY || value === null) return null; - return type === "json" ? JSON.parse(value) : value; - }), - put: vi.fn(async (key, next) => { - value = next; - }), - stored: () => (value === null ? null : JSON.parse(value)), - }; -} - -const base64Url = (bytes) => - btoa(String.fromCharCode(...new Uint8Array(bytes))) - .replace(/\+/g, "-") - .replace(/\//g, "_") - .replace(/=+$/, ""); - -const encodeJson = (value) => - base64Url(new TextEncoder().encode(JSON.stringify(value))); - -let keyPair; -let jwks; - -beforeEach(async () => { - keyPair = await crypto.subtle.generateKey( - { - name: "RSASSA-PKCS1-v1_5", - modulusLength: 2048, - publicExponent: new Uint8Array([1, 0, 1]), - hash: "SHA-256", - }, - true, - ["sign", "verify"], - ); - - const jwk = await crypto.subtle.exportKey("jwk", keyPair.publicKey); - jwks = { keys: [{ ...jwk, kid: KID, alg: "RS256" }] }; -}); - -async function makeToken(overrides = {}, signingKey) { - const header = encodeJson({ alg: "RS256", kid: KID, ...overrides.header }); - const payload = encodeJson({ - aud: AUD, - iss: `https://${TEAM_DOMAIN}`, - exp: 4102444800, // 2100-01-01 - email: "editor@example.com", - ...overrides.payload, - }); - - const signature = await crypto.subtle.sign( - "RSASSA-PKCS1-v1_5", - signingKey ?? keyPair.privateKey, - new TextEncoder().encode(`${header}.${payload}`), - ); - - return `${header}.${payload}.${base64Url(signature)}`; -} - -const env = (kv) => ({ - STOP_OVERRIDES: kv, - ACCESS_TEAM_DOMAIN: TEAM_DOMAIN, - ACCESS_AUD: AUD, -}); - -const deps = () => ({ - fetch: vi.fn(async () => new Response(JSON.stringify(jwks), { status: 200 })), -}); - -const put = (code, body, token) => - new Request(`https://api.lad.lviv.ua/stop-overrides/admin/${code}`, { - method: "PUT", - headers: token - ? { "Cf-Access-Jwt-Assertion": token, "Content-Type": "application/json" } - : { "Content-Type": "application/json" }, - body: typeof body === "string" ? body : JSON.stringify(body), - }); - -describe("normalizeOverride", () => { - it("keeps only usable route names", () => { - expect(normalizeOverride({ add: ["Т03", "a b"], remove: ["А57"] })).toEqual({ - add: ["Т03"], - remove: ["А57"], - }); - }); - - it("lets remove win over add", () => { - expect(normalizeOverride({ add: ["Т03"], remove: ["Т03"] }).add).toEqual([]); - }); -}); - -describe("GET /stop-overrides", () => { - it("returns an empty map when nothing is stored", async () => { - const kv = makeKv(null); - const response = await worker.fetch( - new Request("https://api.lad.lviv.ua/stop-overrides"), - env(kv), - ); - - expect(response.status).toBe(200); - await expect(response.json()).resolves.toEqual({}); - }); - - it("returns the stored map", async () => { - const kv = makeKv({ 62: { add: ["Т03"], remove: [] } }); - const response = await worker.fetch( - new Request("https://api.lad.lviv.ua/stop-overrides"), - env(kv), - ); - - await expect(response.json()).resolves.toEqual({ 62: { add: ["Т03"], remove: [] } }); - }); - - // The listing it feeds is cached for 30 days; this is what makes an edit - // show up straight away. - it("is never cached", async () => { - const response = await worker.fetch( - new Request("https://api.lad.lviv.ua/stop-overrides"), - env(makeKv(null)), - ); - - expect(response.headers.get("Cache-Control")).toBe("no-store"); - }); - - it("rejects a write to the public path", async () => { - const response = await worker.fetch( - new Request("https://api.lad.lviv.ua/stop-overrides", { method: "PUT" }), - env(makeKv(null)), - ); - - expect(response.status).toBe(405); - }); -}); - -describe("PUT /stop-overrides/admin/:code", () => { - it("stores an entry for a valid token", async () => { - const kv = makeKv(null); - const token = await makeToken(); - - const response = await worker.fetch( - put(62, { add: ["Т03"], remove: ["А57"] }, token), - env(kv), - {}, - deps(), - ); - - expect(response.status).toBe(200); - expect(kv.stored()).toEqual({ 62: { add: ["Т03"], remove: ["А57"] } }); - }); - - it("drops names that are not route names before storing", async () => { - const kv = makeKv(null); - const token = await makeToken(); - - await worker.fetch( - put(62, { add: ["Т03", "../etc/passwd"], remove: [] }, token), - env(kv), - {}, - deps(), - ); - - expect(kv.stored()).toEqual({ 62: { add: ["Т03"], remove: [] } }); - }); - - it("leaves other stops alone", async () => { - const kv = makeKv({ 80: { add: ["Т02"], remove: [] } }); - const token = await makeToken(); - - await worker.fetch(put(62, { add: ["Т03"], remove: [] }, token), env(kv), {}, deps()); - - expect(kv.stored()).toEqual({ - 80: { add: ["Т02"], remove: [] }, - 62: { add: ["Т03"], remove: [] }, - }); - }); - - it("deletes the entry when both lists come back empty", async () => { - const kv = makeKv({ 62: { add: ["Т03"], remove: [] }, 80: { add: [], remove: ["А01"] } }); - const token = await makeToken(); - - await worker.fetch(put(62, { add: [], remove: [] }, token), env(kv), {}, deps()); - - expect(kv.stored()).toEqual({ 80: { add: [], remove: ["А01"] } }); - }); - - it("rejects a stop code that is not a number", async () => { - const token = await makeToken(); - const response = await worker.fetch( - put("..%2Fadmin", { add: [], remove: [] }, token), - env(makeKv(null)), - {}, - deps(), - ); - - expect(response.status).toBe(400); - }); - - it("rejects a body that is not JSON", async () => { - const token = await makeToken(); - const response = await worker.fetch( - put(62, "not json", token), - env(makeKv(null)), - {}, - deps(), - ); - - expect(response.status).toBe(400); - }); - - it("rejects GET", async () => { - const response = await worker.fetch( - new Request("https://api.lad.lviv.ua/stop-overrides/admin/62"), - env(makeKv(null)), - {}, - deps(), - ); - - expect(response.status).toBe(405); - }); - - it("returns 404 for an unknown path", async () => { - const response = await worker.fetch( - new Request("https://api.lad.lviv.ua/whatever"), - env(makeKv(null)), - ); - - expect(response.status).toBe(404); - }); -}); - -// Access sits in front of this path, but the Worker is reachable by anything -// that can route to it, so it verifies rather than trusts. -describe("Access enforcement", () => { - it("refuses a request with no token", async () => { - const kv = makeKv(null); - const response = await worker.fetch( - put(62, { add: ["Т03"], remove: [] }), - env(kv), - {}, - deps(), - ); - - expect(response.status).toBe(403); - expect(kv.put).not.toHaveBeenCalled(); - }); - - it("refuses a token signed by another key", async () => { - const attacker = await crypto.subtle.generateKey( - { - name: "RSASSA-PKCS1-v1_5", - modulusLength: 2048, - publicExponent: new Uint8Array([1, 0, 1]), - hash: "SHA-256", - }, - true, - ["sign", "verify"], - ); - const token = await makeToken({}, attacker.privateKey); - const kv = makeKv(null); - - const response = await worker.fetch( - put(62, { add: ["Т03"], remove: [] }, token), - env(kv), - {}, - deps(), - ); - - expect(response.status).toBe(403); - expect(kv.put).not.toHaveBeenCalled(); - }); - - it("refuses a token for another Access application", async () => { - const token = await makeToken({ payload: { aud: "someone-else" } }); - const response = await worker.fetch( - put(62, { add: ["Т03"], remove: [] }, token), - env(makeKv(null)), - {}, - deps(), - ); - - expect(response.status).toBe(403); - }); - - it("refuses a token from another team domain", async () => { - const token = await makeToken({ payload: { iss: "https://evil.cloudflareaccess.com" } }); - const response = await worker.fetch( - put(62, { add: ["Т03"], remove: [] }, token), - env(makeKv(null)), - {}, - deps(), - ); - - expect(response.status).toBe(403); - }); - - it("refuses an expired token", async () => { - const token = await makeToken({ payload: { exp: 1 } }); - const response = await worker.fetch( - put(62, { add: ["Т03"], remove: [] }, token), - env(makeKv(null)), - {}, - deps(), - ); - - expect(response.status).toBe(403); - }); - - // "alg": "none" is the classic way to hand a verifier a token it will - // happily believe. - it("refuses a token that asks for an algorithm we do not verify", async () => { - const token = await makeToken({ header: { alg: "none" } }); - const response = await worker.fetch( - put(62, { add: ["Т03"], remove: [] }, token), - env(makeKv(null)), - {}, - deps(), - ); - - expect(response.status).toBe(403); - }); - - it("refuses when no published key matches the token's kid", async () => { - const token = await makeToken({ header: { kid: "other-kid" } }); - const response = await worker.fetch( - put(62, { add: ["Т03"], remove: [] }, token), - env(makeKv(null)), - {}, - deps(), - ); - - expect(response.status).toBe(403); - }); - - it("accepts a valid token", async () => { - const token = await makeToken(); - const payload = await verifyAccessJwt(token, { - ACCESS_TEAM_DOMAIN: TEAM_DOMAIN, - ACCESS_AUD: AUD, - }, deps()); - - expect(payload.email).toBe("editor@example.com"); - }); -}); diff --git a/worker/stop-overrides/README.md b/worker/stop-overrides/README.md deleted file mode 100644 index 5a43cd5..0000000 --- a/worker/stop-overrides/README.md +++ /dev/null @@ -1,67 +0,0 @@ -# stop-overrides Worker - -Stores the per-stop route overrides that the `/stops` listing applies to its -route column and to its SVG/PDF links. - -| Method | Path | Auth | Purpose | -|---|---|---|---| -| `GET` | `/stop-overrides` | public | the whole map, `Cache-Control: no-store` | -| `PUT` | `/stop-overrides/admin/:code` | Cloudflare Access | replace one stop's entry | - -Body and stored shape: - -```json -{ "62": { "add": ["Т03"], "remove": ["А57"] } } -``` - -An entry whose `add` and `remove` are both empty is deleted rather than stored, -so a stop back on its upstream route list leaves no trace. - -## Why one KV key - -The whole map lives under a single key, `overrides`. The listing renders ~1000 -rows and reads this once per page load — a key per stop would be a thousand -reads for the same few kilobytes. KV is eventually consistent, so a write can -take up to ~60s to reach every edge; for an override list edited by hand that -is not worth trading for D1. - -Writes are read-modify-write on that one key, which would lose an update if two -people saved different stops in the same instant. One editor, so it has not been -worth a lock. - -## Setup - -```bash -npx wrangler kv namespace create STOP_OVERRIDES -``` - -Put the returned id into `wrangler.toml`. - -Then create the Access application: - -- **Application domain**: `api.lad.lviv.ua`, path `stop-overrides/admin` -- Policy: whoever should be able to edit -- Copy the application's **Audience (AUD) tag** into `ACCESS_AUD`, and your team - domain (`.cloudflareaccess.com`) into `ACCESS_TEAM_DOMAIN` - -The Worker verifies the Access JWT itself against -`https://.cloudflareaccess.com/cdn-cgi/access/certs` — signature, `aud`, -`iss` and `exp` — rather than trusting that the request came through the proxy. - -```bash -npx wrangler deploy -``` - -## Cache rule - -`api.lad.lviv.ua` is covered by a "Cache everything" rule, and cache rules do -not stop at the first match — the **last** matching rule wins. Add one and place -it **last**: - -``` -Expression: (http.host eq "api.lad.lviv.ua" and starts_with(http.request.uri.path, "/stop-overrides")) -Action: Bypass cache -``` - -Without it the override map is served from cache and edits appear to do nothing -for up to the zone's edge TTL. diff --git a/worker/stop-overrides/src/index.js b/worker/stop-overrides/src/index.js deleted file mode 100644 index 80ed27d..0000000 --- a/worker/stop-overrides/src/index.js +++ /dev/null @@ -1,165 +0,0 @@ -/** - * Per-stop route override store. - * - * GET /stop-overrides → the whole map, public, never cached - * PUT /stop-overrides/admin/:code → replace one stop's entry, behind Access - * - * The whole map lives under a single KV key. The /stops listing renders ~1000 - * rows and reads this once per page load; a key per stop would be a thousand - * reads for the same few kilobytes. - */ - -export const OVERRIDES_KEY = "overrides"; -export const MAX_ROUTES_PER_LIST = 40; - -const ROUTE_NAME = /^[\p{L}\p{N}]{1,16}$/u; -const STOP_CODE = /^\d{1,10}$/; - -const json = (body, status = 200, extraHeaders = {}) => - new Response(JSON.stringify(body), { - status, - headers: { - "Content-Type": "application/json; charset=utf-8", - // The point of fetching this separately from the cached listing is that - // an edit shows up immediately. - "Cache-Control": "no-store", - ...extraHeaders, - }, - }); - -/** Drops anything that is not a usable route name, dedupes, caps the length. */ -export function normalizeOverride(entry) { - const clean = (list) => - Array.from(new Set(Array.isArray(list) ? list : [])) - .filter((name) => typeof name === "string" && ROUTE_NAME.test(name)) - .slice(0, MAX_ROUTES_PER_LIST); - - const remove = clean(entry?.remove); - const add = clean(entry?.add).filter((name) => !remove.includes(name)); - - return { add, remove }; -} - -function base64UrlToBytes(value) { - const padded = value.replace(/-/g, "+").replace(/_/g, "/"); - const binary = atob(padded.padEnd(Math.ceil(padded.length / 4) * 4, "=")); - return Uint8Array.from(binary, (char) => char.charCodeAt(0)); -} - -function base64UrlToJson(value) { - return JSON.parse(new TextDecoder().decode(base64UrlToBytes(value))); -} - -/** - * Verifies a Cloudflare Access JWT against the team's public keys. - * - * Access sits in front of this route, so an unverified request should not - * arrive — but the Worker is reachable by anything that can route to it, and - * "the proxy checked it" is only true while the route config says so. - */ -export async function verifyAccessJwt(token, env, deps = {}) { - const fetchImpl = deps.fetch ?? fetch; - const now = deps.now ?? (() => Math.floor(Date.now() / 1000)); - - if (typeof token !== "string") throw new Error("missing Access token"); - - const [rawHeader, rawPayload, rawSignature] = token.split("."); - if (!rawHeader || !rawPayload || !rawSignature) { - throw new Error("malformed Access token"); - } - - const header = base64UrlToJson(rawHeader); - if (header.alg !== "RS256") throw new Error(`unexpected alg ${header.alg}`); - - const issuer = `https://${env.ACCESS_TEAM_DOMAIN}`; - const certs = await fetchImpl(`${issuer}/cdn-cgi/access/certs`); - if (!certs.ok) throw new Error("could not read Access certs"); - - const { keys } = await certs.json(); - const jwk = (keys ?? []).find((key) => key.kid === header.kid); - if (!jwk) throw new Error("no Access key for kid"); - - const key = await crypto.subtle.importKey( - "jwk", - { ...jwk, alg: "RS256", key_ops: ["verify"], ext: true }, - { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, - false, - ["verify"], - ); - - const signed = new TextEncoder().encode(`${rawHeader}.${rawPayload}`); - const valid = await crypto.subtle.verify( - "RSASSA-PKCS1-v1_5", - key, - base64UrlToBytes(rawSignature), - signed, - ); - if (!valid) throw new Error("bad Access signature"); - - const payload = base64UrlToJson(rawPayload); - const audiences = Array.isArray(payload.aud) ? payload.aud : [payload.aud]; - - if (!audiences.includes(env.ACCESS_AUD)) throw new Error("wrong Access aud"); - if (payload.iss !== issuer) throw new Error("wrong Access iss"); - if (!payload.exp || payload.exp <= now()) throw new Error("expired Access token"); - - return payload; -} - -async function readOverrides(env) { - return (await env.STOP_OVERRIDES.get(OVERRIDES_KEY, "json")) ?? {}; -} - -export default { - async fetch(request, env, ctx, deps = {}) { - const url = new URL(request.url); - const path = url.pathname.replace(/\/+$/, "") || "/"; - - if (path === "/stop-overrides") { - if (request.method !== "GET") return json({ error: "Method not allowed" }, 405); - return json(await readOverrides(env)); - } - - const admin = path.match(/^\/stop-overrides\/admin\/([^/]+)$/); - if (admin) { - if (request.method !== "PUT") return json({ error: "Method not allowed" }, 405); - - const code = decodeURIComponent(admin[1]); - if (!STOP_CODE.test(code)) return json({ error: "Bad stop code" }, 400); - - try { - await verifyAccessJwt( - request.headers.get("Cf-Access-Jwt-Assertion"), - env, - deps, - ); - } catch (error) { - return json({ error: "Forbidden", detail: error.message }, 403); - } - - let body; - try { - body = await request.json(); - } catch { - return json({ error: "Bad JSON" }, 400); - } - - const entry = normalizeOverride(body); - const overrides = await readOverrides(env); - - // An empty entry is a deletion, so a stop returned to its upstream route - // list stops taking up room in the map. - if (entry.add.length === 0 && entry.remove.length === 0) { - delete overrides[code]; - } else { - overrides[code] = entry; - } - - await env.STOP_OVERRIDES.put(OVERRIDES_KEY, JSON.stringify(overrides)); - - return json({ code, override: overrides[code] ?? null }); - } - - return json({ error: "Not found" }, 404); - }, -}; diff --git a/worker/stop-overrides/wrangler.toml b/worker/stop-overrides/wrangler.toml deleted file mode 100644 index d3e1910..0000000 --- a/worker/stop-overrides/wrangler.toml +++ /dev/null @@ -1,21 +0,0 @@ -name = "stop-overrides" -main = "src/index.js" -compatibility_date = "2026-08-07" - -# Same origin as the /stops listing that reads it: no CORS, no preflight, and -# the Access cookie is already scoped to this hostname. -[[routes]] -pattern = "api.lad.lviv.ua/stop-overrides*" -zone_name = "lad.lviv.ua" - -# The Worker is only meant to be reachable through the zone, where Access sits -# in front of the admin path. -workers_dev = false - -[[kv_namespaces]] -binding = "STOP_OVERRIDES" -id = "46ab3cf327a642529ce22c895be007f8" - -[vars] -ACCESS_TEAM_DOMAIN = "REPLACE.cloudflareaccess.com" -ACCESS_AUD = "REPLACE_WITH_ACCESS_APPLICATION_AUD"