City Portal & Magic-Link Auth
City compliance officers don't want another password. The Levy city portal at /city/{slug} uses email-based magic-link auth — enter your email, click a link, you're in. The session is HMAC-signed, lasts 24 hours, and is scoped to a single jurisdiction.
Who can log in
Only emails listed in city_contacts with portal_access = true can authenticate. Adding a city contact happens in the operator dashboard, not the portal itself — there is no self-service signup.
URL structure
| Page | URL | Purpose |
|---|---|---|
| Login | /city/{slug} | Enter email, request magic link |
| Auth callback | /api/city/{slug}/auth/callback?token=<tok> | Consume the magic link, set session cookie, redirect to dashboard |
| Dashboard | /city/{slug} (post-auth) | Compliance scoreboard + links to other views |
| Fleet map | /city/{slug}/fleet | Vehicles inside the jurisdiction's bbox |
| Trip heatmap | /city/{slug}/trips | Time-bucketed trip density |
| Parking corrals | /city/{slug}/parking-corrals | Utilization snapshots |
| Compliance report | /city/{slug}/compliance-report?period=monthly&date=2026-05 | Download CSV/PDF |
The slug matches mds_jurisdictions.slug. One slug = one jurisdiction = one bbox.
Magic-link flow
Contact enters their email
On /city/{slug}, the form posts to POST /api/city/{slug}/auth/magic-link with { email }. The route always returns HTTP 200 — we don't reveal whether the email exists in city_contacts (avoids enumeration).
Token minted and emailed
If the email matches a city_contacts row with portal_access = true for this slug, the route generates a one-time token, stores its sha256 hash + expires_at = now() + 15 minutes on the contact row, and emails the link.
Contact clicks the link
The link goes to /api/city/{slug}/auth/callback?token=<tok>. The route hashes the token, looks up the matching city_contacts row, checks expires_at, marks the token consumed, and issues a session cookie.
Session cookie set
Set-Cookie: levy_city_session=<hmac>; Path=/city/<slug>; HttpOnly; Secure; SameSite=Lax; Max-Age=86400. The cookie payload encodes city_contact_id, jurisdiction_id, issued_at. HMAC-signed with a server-side secret.
Redirect to /city/{slug}
Now showing the post-auth dashboard. Every subsequent request to /api/city/{slug}/* verifies the HMAC and bbox-clips the response.
What the contact sees
The dashboard shows:
- Compliance scoreboard — pass/fail per
permit_conditionsrow, 30-day rolling - Today's snapshot — deployed fleet count, trips, open complaints
- Active policies — the rules ingested from their own Policy feed (sanity check)
- Links — to fleet map, trip heatmap, parking corrals, monthly report
Everything below the snapshot is read-only. The portal does not let the city edit operator data — they edit their own policy feed, and we re-ingest it.
Bbox clipping
Every city-portal API route is clipped to the jurisdiction's bbox before returning data. The clipping is enforced server-side, not at the UI layer:
// Inside every /api/city/{slug}/* route
const session = await verifyCitySession(request);
const jurisdiction = await getJurisdiction(session.jurisdiction_id);
const bbox = jurisdiction.bbox; // [minLng, minLat, maxLng, maxLat]
const vehicles = await sb
.from('vehicles')
.select('id, last_lat, last_lng, status, vehicle_number')
.gte('last_lng', bbox[0])
.lte('last_lng', bbox[2])
.gte('last_lat', bbox[1])
.lte('last_lat', bbox[3]);
A city contact will never see a vehicle outside their boundary, even if it exists in the same subaccount.
For the trip heatmap, clipping is performed on the trip's start point — a trip that began inside the jurisdiction and ended outside still appears on the heatmap. This is what cities expect for trip-origination analytics.
Session expiry + refresh
Sessions last 24 hours. There is no refresh flow — the contact requests a new magic link when the session lapses. This is deliberate: a city analyst losing access for a few hours and going through the magic-link motion again is acceptable; long-lived city portal sessions are not.
The HMAC secret can be rotated by setting a new value in environment config and redeploying. Sessions signed with the old secret immediately fail HMAC verification and the contact gets a clean re-login prompt.
Multiple contacts per jurisdiction
A jurisdiction can have any number of city_contacts rows. Common pattern:
| Role | |
|---|---|
| Lead compliance officer | sarah@city.gov |
| Permit reviewer | pat@city.gov |
| Populus / Ride Report analyst (consultant) | analyst@populus.ai |
All three see the same data, all three receive digest emails on their own cadence. Sessions are independent — one contact logging out does not affect the others.
Revoking access
To revoke a contact's access:
- Open
/dashboard/compliance/{jurisdiction-id}-> City contacts. - Set
portal_access = falseon the row, or delete it outright. - Active session cookies on that contact will fail server-side verification at the next request — the cookie is still valid HMAC, but the contact row no longer permits portal access.
Magic links already minted to that contact are also invalidated — auth/callback rejects tokens for contacts where portal_access = false.
Localization
The portal is rendered in the contact's language preference (city_contacts.locale, defaults to en). Digest emails also use the same preference. Launch supports en and es; additional locales fall back to English.
Troubleshooting
- Contact says "didn't get the email" — check spam, then confirm the email matches
city_contacts.emailexactly (case-insensitive). Re-send by entering the email in the portal form again. - Link says "expired" — magic links live 15 minutes. The contact should request a new one.
- Link says "invalid" — the token was already consumed, the contact row was deleted, or
portal_accesswas set to false. Investigate in the operator dashboard. - Session cookie not setting — the contact is on a browser that blocks third-party cookies and
/city/{slug}is being loaded inside an iframe somewhere. Avoid iframe embeds.
What's next
- Digest Emails and Cadence — what the contact receives in their inbox.
- Permit-Condition Reports — what the dashboard scoreboard is checking.