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.
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.
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.
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 —
/supportbehind 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.
| PRs merged | 21 |
| api tests | 2,178 |
| ui tests | 243 |
| routes declared | 98 ui + 179 api |
| route DSL | 5 concepts → 1 |
- 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
- https://github.com/vrpsinc/api/blob/main/docs/systems/routing.md — the map
- https://github.com/vrpsinc/ui/blob/main/routes.config.ts — every address
- https://github.com/vrpsinc/api/blob/main/src/lib/permission_map.ts — promise → enforcement
- https://app.parkagent.com/routes.json · https://a.parkagent.com/docs — live, per deployment