A batch of changes to the public incidents board, plus one correction to
something I told you all earlier that was wrong.
Same gist, revised in place throughout:
https://gist.github.com/rjonesbsink/d92d24f3932ec3c8de62230885b9f06bI've been holding these rather than posting one message per change. The code
has been current the whole time — if you pull today you already have all of
this. Only the announcement is batched.
CORRECTION FIRST: the Generate button
In the original write-up I said:
"There's a Generate button next to the Feed API Key in Settings — Generate,
then Save."
That is wrong for the only people who need it, and I'm sorry for the wasted
looking if anyone went hunting.
The Generate button sits inside the orange "External feed is disabled" banner,
and that banner is hidden as soon as a key is configured. So it's an onboarding
control — "you have no key, make one" — not a rotation control. Anyone rotating
after a leak already has a key, and will find no button at all.
I only checked that the button existed, not when it renders. Same mistake as
checking a page returns HTTP 200 without checking anything on it loaded.
To actually rotate on stock TicketsCAD: paste a new key into the field and press
Save API Keys. Any long random string; 48 hex characters is what Generate would
have produced. From PowerShell:
-join ((1..48) | ForEach-Object { '0123456789abcdef'[(Get-Random -Max 16)] })
My settings patch now adds a shuffle button next to the field that does this
properly — generates from crypto, reveals the field so you can copy the key,
and refuses to generate at all if the browser has no secure random source
(a predictable key on something that reads every open incident is worse than
no button). It's in settings.php only; assets/js/config.js stays untouched.
The underlying gap is TicketsCAD's rather than mine — stock has no way to
rotate a stored feed key — so I've raised it as openises/TicketsCAD#49 and left
the decision with Eric.
TWO THAT CHANGE WHAT A RUNNING BOARD DISCLOSES
1. The feed was publishing internal ticket ids.
The payload carried the ticket table's primary key, which the page never used.
Those ids are sequential. Two snapshots a day apart, subtract, and you have the
agency's true call volume and rate — including every incident the never-publish
list suppressed and every medical call it redacted. It partially undid the
exclusion feature: you could count what you couldn't see.
Only the event number is published now.
The transferable lesson, which is why I'm leading with it: ask what a field
discloses IN AGGREGATE OVER TIME, not just what it says about one incident. I'd
only been asking the second question.
2. Assigned units are now a setting.
Address had five masking levels, medical had redaction, incident types had
suppression — assigned units had nothing at all, and were published in full with
live status. On a public board that's a staffing picture refreshed every few
seconds: fleet size, how many are committed, how many are left, how thin cover
is right now.
public_feed_unit_detail:
full Frank 11 · ENR (default — unchanged behaviour)
names Frank 11 (status stripped)
count "3 units assigned"
hidden nothing, not even the count
"names" is a real middle rather than padding, because the two halves leak
different things: unit names are frequently public already — they're painted on
the vehicle — while the live status is what reveals posture. "hidden" withholds
the count too, since "4 units assigned" still answers how committed you are.
Default is "full" so upgrading never silently removes something you were already
publishing. Worth deciding deliberately rather than inheriting.
THE REST
The basemap now follows your tile_provider setting instead of hardcoding
OpenStreetMap — EXCEPT for providers that need an API key, which are refused
with a fallback to OSM. Mapbox's shipped tile URL ends "?access_token={key}".
Publishing that on a page served to anonymous visitors would print a billable
credential into public HTML. Authenticated pages can use keyed providers because
the tile proxy substitutes server-side; a public page has no such path.
Tile privacy is now disclosed rather than fixed. Whatever provider is used, the
visitor's browser contacts it directly, so that provider learns their IP and
which area — which incident — they were looking at. TicketsCAD ships a tile
proxy that would prevent this, but it requires a session by design: an
unauthenticated tile relay is an open proxy billed to the provider and
attributed to your server. I considered building a narrowed public one and
decided against it. The page now says, in its own policy line, which host it
contacts.
A 429 from the rate limiter is no longer treated as an outage. It was going
through the generic failure path, so the board announced "could not reach the
incident feed" and kiosk mode threw its full-width red banner — a false alarm
during a busy incident with lots of viewers behind one carrier address, which is
exactly when a lobby screen is being watched. It now says "waiting to update",
keeps showing what it has, and honours Retry-After.
LIMIT 200 no longer truncates silently — the board says so above the list.
The page requires JavaScript and now admits it with a noscript block, because a
blank panel reads as "no incidents" rather than "couldn't load", and on a public
safety board those are very different statements.
A CAUTION WORTH MORE THAN THE FEATURES: the blank basemap
I broke the map and want to describe the symptom, because anyone pointing a
public page at a tile provider can hit this.
The provider URLs in TicketsCAD's tile-proxy config are written for SERVER-side
fetching, where CSP doesn't apply. I reused one browser-side and got
"
https://tile.openstreetmap.org/..." while the shipped CSP allows
"https://*.
tile.openstreetmap.org" — a wildcard label, which the bare host does
not match. Every tile was blocked.
The map still DREW. Correctly sized, incident pins in the right places, floating
on blank background. Nothing in the console I noticed, no error state.
What separated "CSP blocked it" from "no network" was checking
img.naturalWidth === 0 on the tile elements while curl fetched the same tile
fine from the host. 0 of 12 rendered. That's the check to reach for if a map
ever looks present but empty.
The board now checks the resolved tile host against the install's own img-src
before adopting a provider — which also catches google_* and bing_*, both in the
shipped provider list, neither in the CSP allowlist.
NEW: a test you can run against your own install
php tests/test_public_feed_redaction.php
https://your-host44 assertions over every masking level, the medical redaction, both exclusion
lists, unit detail, the truncation flag, and — the one you can't check by hand —
that tightening a setting never serves the payload cached under the looser one.
It builds its own incidents, restores every setting it touches, and cleans up
after itself including on failure. No credential needed, because the endpoint
doesn't need one.
I wrote it because every setting on this thing is a privacy control and the
failure mode is silent and in the wrong direction: a broken rule doesn't error,
it publishes more than intended and the board looks normal. That's happened
twice on my own code.
It has been checked to actually fail — deleting the masking level from the cache
key makes it report a real street address surviving a tightened setting. A test
nobody has watched fail is decoration.
On HTTPS, which is what most of you are running: pass your URL and it just
works, and certificate verification is ON. It is never silently disabled —
this project already has an advisory about TLS enforcement being bypassable
(GHSA-984v-rw78-3223), and a test that shipped with verification off would be
demonstrating that same pattern while reporting a pass.
If your install uses a self-signed or private-CA certificate, which plenty of
internal CAD boxes do, there is a --insecure flag. It has to be asked for, it
prints a banner, and the summary line marks the run TLS UNVERIFIED so a green
result can't be mistaken for a verified one. Trusting your CA on the machine
running the test is better if you can.
With no URL at all it derives one from your own `host` and `require_https`
settings rather than assuming anything about your deployment.
One gotcha, and it applies over HTTPS too: if it fails immediately after you
edit the feed file, wait a couple of seconds and re-run. It drives the
endpoint over the web server, so it executes the server's copy, and opcache
can lag a file you just saved. A failure that vanishes on a second run was
that, not a regression.
Usual caveats. Local addition, not upstream, not maintained by anyone but you,
and still only one pair of eyes on it — though there are now 44 assertions
watching the parts that matter most.
Ron