Skip to content

Instantly share code, notes, and snippets.

@ahoward
Created August 22, 2026 04:23
Show Gist options
  • Select an option

  • Save ahoward/b71d998ad53dfea3f77ee44370b52bb8 to your computer and use it in GitHub Desktop.

Select an option

Save ahoward/b71d998ad53dfea3f77ee44370b52bb8 to your computer and use it in GitHub Desktop.
URLs and permissions refactor — rationale and impact

URLs and permissions: what changed, and why

TL;DR — a URL used to be a string anyone could assemble, and a permission was described in two vocabularies that nothing compared. Both are now declared once, checked by the compiler and the test suite, and verified against production.


Why

Three production bugs, one shape: a link that passed every check we had and was still dead for the person who received it.

  • api#766 — PA-338 moved every route under a portal prefix, nobody told the api, and every link we texted 404'd. Two destinations had never existed at all.
  • ui#380 — /go/* sat behind the auth gate, so every texted link bounced its recipient — signed out by definition — to /login.
  • product#391 — search emitted /r/violation/<id>, which dead-ended. Findable, not openable.

Each was found by a human, after shipping. The common cause: a URL was a string, assembled at a call site and checked by nobody.

The same was true of permissions. The ui gated a page on property.configure; the api enforced update. Two vocabularies for one authority, each checked in isolation, so a disagreement was invisible.

What changed

One table declares every address. routes.config.ts — 98 routes, one concept (path), nesting is the URL. Generates routes.json, which the api vendors, so the api cannot mint an address the ui does not serve.

Every URL goes through a function that throws. Url.for('/operator/passes/:pass') refuses an unknown route, a missing param, an empty param (which silently collapses a detail page into its index), and a param carrying / or ... The ui's route() and portalPath() used to be casts that validated nothing; they now resolve against the same table.

Every route declares the permission it needs — the ui's 98 pages and the api's 179 endpoints, in the same vocabulary, taken from the product spec.

And the two halves are checked against each other. A route's declared gate must match the capability its handler actually enforces.

Impact

Twenty broken destinations found and fixed, all live in production:

  • 10 dead links — including the parker's own "My Passes" tile
  • 10 pages that existed, were linked, and 404'd — /support behind six links, marketing's "Edit landing", and a bulk-ops tab bar where every tab was dead
  • 6 endpoints whose declared permission was weaker than what they enforce

A 26-route over-grant, deleted. The ui's role table was a hand-written list of URL prefixes that had drifted wider than the spec — Managers reaching enforcement surfaces and Property configuration. It is gone; roles are now derived from the spec.

The security claim is now tested, not asserted. "A ui mistake exposes a surface, never a row" — 30 tests prove it across every row-returning endpoint, including the case a capability check cannot catch: a real admin of a real property reaching sideways.

Production verifies itself. pa smoke runs the whole parker journey against production — buys a pass, gets scanned, gets a violation refused for being paid — and walks all 98 routes. It caught a regression within minutes of a deploy, which is the first time our monitoring found something before a person did.

Numbers

PRs merged 21
api tests 2,178
ui tests 243
routes declared 98 ui + 179 api
route DSL 5 concepts → 1

What is NOT done

  • The permission spec has no "view staff" permission, so the staff roster borrows the invite one — https://github.com/vrpsinc/api/issues/904
  • Production checks run on a schedule but the parker journey is opt-in per environment
  • The gate cross-check traces at file level, not per function: it proves the two vocabularies are consistent, not that every endpoint is individually correct
  • A lint that fails CI on a hardcoded link is designed, not built — https://github.com/vrpsinc/api/issues/898

Where to look

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment