Street Light Base is a gamified civic reporting web application developed to help the NYC Department of Transportation (DOT) proactively detect and document damaged streetlight bases. Instead of relying solely on 311 reports, this platform transforms data collection into an engaging experience through leaderboards, challenges, badges, and achievement tiers — rewarding citizens who report damaged streetlight bases across NYC boroughs.
- Features
- Tech Stack
- Architecture
- Getting Started
- Environment Variables
- Database Schema
- API Documentation
- User Manual
- Test Cases
- Validation Rules
- User registration with email verification
- Secure login and password reset via email
- Submit streetlight damage reports with GPS location, photos (up to 3), damage type, and condition rating
- View and manage personal reports
- Leaderboard rankings (daily, weekly, monthly, all-time; filterable by borough)
- Challenges and achievements system
- Progress page showing badges, tier level, and challenge completions
- Mission page explaining the platform's purpose
- Account settings (update profile, change password, delete account)
- Analytics dashboard with charts: damage breakdown, reports over time, heatmap, borough clusters
- View all submitted reports with filtering and pagination
- Download reports as GeoJSON (supports EPSG:4326 and EPSG:2263 spatial reference systems)
| Layer | Technology |
|---|---|
| Frontend | React 19, React Router 7, Vite 7, D3.js, Leaflet |
| Backend | Python 3, Flask 3, Flask-JWT-Extended |
| Database | PostgreSQL (hosted on Neon) |
| Image Storage | Cloudinary |
| Authentication | JWT tokens + bcrypt password hashing |
| Deployment | Gunicorn (backend), Vite build (frontend) |
┌──────────────────────────────────────────────────────┐
│ Frontend (React + Vite) │
│ Port 5173 (HTTPS for mobile/geolocation testing) │
│ Routes: /home, /reports, /leaderboard, /dashboard │
└─────────────────────┬────────────────────────────────┘
│ /api/* proxy
▼
┌──────────────────────────────────────────────────────┐
│ Backend (Flask + Python) │
│ Port 5000 │
│ 13 route blueprints (auth, reports, analytics, ...) │
│ JWT authentication · role-based access control │
└─────────────────────┬────────────────────────────────┘
│ psycopg2 (SSL)
▼
┌──────────────────────────────────────────────────────┐
│ PostgreSQL Database (Neon) │
│ 8 tables: users, reports, report_images, │
│ points_log, user_challenges, user_achievements, │
│ user_badges, leaderboard_snapshots │
└──────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────┐
│ External Services │
│ Cloudinary — image upload & CDN │
│ Gmail SMTP — email verification & password reset │
└──────────────────────────────────────────────────────┘
- User registers → email verification token sent (30 min expiry)
- User clicks verification link →
email_verifiedset totruein DB - User logs in → JWT access token issued, stored in browser cookie
- Protected routes check JWT via
@jwt_required()decorator - Role-based guards:
@citizen_requiredand@dot_admin_required
| Role value in DB | Frontend role | Access |
|---|---|---|
citizen |
citizen |
Citizen pages only |
dot_admin |
admin |
DOT admin pages only |
ppl |
admin |
Same as dot_admin |
- Node.js 18+
- Python 3.10+
- A PostgreSQL database (local or Neon)
- A Cloudinary account (for image uploads)
- A Gmail account with App Password enabled (for email sending)
1. Clone the repository
git clone <repo-url>
cd street-light-base2. Backend
cd Backend
python -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
pip install -r requirements.txt
cp .env.example .env # fill in your credentials
python app.py3. Frontend
cd Frontend
npm install
npm run devThe frontend dev server runs on https://localhost:5173 and proxies /api/* requests to http://localhost:5000.
4. Initialize the database
psql $DATABASE_URL -f Backend/database/schema.sql
psql $DATABASE_URL -f Backend/database/seed.sql # optional sample datacd Backend
pytest tests/ -vTests require a live PostgreSQL database. Configure TEST_DATABASE_URL in your .env file (or use DATABASE_URL).
Create a .env file inside Backend/ with the following variables:
# Database (Neon or local PostgreSQL)
DATABASE_URL=postgresql://user:password@host:5432/dbname
# Or individual parts:
DB_HOST=
DB_NAME=
DB_USER=
DB_PASSWORD=
DB_PORT=5432
# For tests (can be same as DATABASE_URL)
TEST_DATABASE_URL=
# JWT
JWT_SECRET_KEY=your-secret-key-min-32-chars
# Cloudinary
CLOUDINARY_CLOUD_NAME=
CLOUDINARY_API_KEY=
CLOUDINARY_API_SECRET=
# Email (Gmail with App Password)
MAIL=your-gmail@gmail.com
MAIL_PASSWORD=your-app-password
# Frontend URL (used in email links)
FRONTEND_URL=https://localhost:5173
users ──< reports ──< report_images
│ │
└──< points_log
└──< user_challenges
└──< user_achievements
└──< user_badges
└──< leaderboard_snapshots
Stores all registered accounts.
| Column | Type | Constraints | Description |
|---|---|---|---|
id |
SERIAL | PRIMARY KEY | Auto-increment user ID |
username |
VARCHAR(50) | NOT NULL, UNIQUE | Display name |
email |
VARCHAR(100) | NOT NULL, UNIQUE | Login email |
password_hash |
VARCHAR(255) | NOT NULL | bcrypt hash |
role |
VARCHAR(20) | NOT NULL, DEFAULT citizen |
citizen, dot_admin, or ppl |
first_name |
VARCHAR(50) | Optional first name | |
last_name |
VARCHAR(50) | Optional last name | |
email_verified |
BOOLEAN | DEFAULT false | Must be true before login |
created_at |
TIMESTAMP | DEFAULT NOW() | Account creation time |
One row per submitted streetlight damage report.
| Column | Type | Constraints | Description |
|---|---|---|---|
id |
SERIAL | PRIMARY KEY | Auto-increment report ID |
user_id |
INTEGER | FK → users(id) ON DELETE CASCADE | Report author |
latitude |
FLOAT | NOT NULL | GPS latitude |
longitude |
FLOAT | NOT NULL | GPS longitude |
borough |
VARCHAR(50) | NYC borough (auto-detected from coordinates) | |
rating |
VARCHAR(10) | NOT NULL, CHECK IN ('good','fair','poor') | Condition rating |
damage_types |
TEXT[] | PostgreSQL array of damage type strings | |
created_at |
TIMESTAMP | DEFAULT NOW() | Submission time |
Up to 3 Cloudinary photo URLs per report.
| Column | Type | Constraints | Description |
|---|---|---|---|
id |
SERIAL | PRIMARY KEY | |
report_id |
INTEGER | FK → reports(id) ON DELETE CASCADE | |
image_url |
VARCHAR(500) | NOT NULL | Cloudinary CDN URL |
created_at |
TIMESTAMP | DEFAULT NOW() |
Audit log of all points earned (from reports and challenge completions).
| Column | Type | Constraints | Description |
|---|---|---|---|
id |
SERIAL | PRIMARY KEY | |
user_id |
INTEGER | FK → users(id) ON DELETE CASCADE | |
report_id |
INTEGER | FK → reports(id) ON DELETE SET NULL | NULL for challenge bonus points |
points_earned |
INTEGER | NOT NULL | Points awarded in this event |
created_at |
TIMESTAMP | DEFAULT NOW() |
Records which challenges a user has completed (with period suffixes for daily/weekly challenges).
| Column | Type | Constraints | Description |
|---|---|---|---|
id |
SERIAL | PRIMARY KEY | |
user_id |
INTEGER | FK → users(id) ON DELETE CASCADE | |
challenge_key |
VARCHAR(100) | NOT NULL | e.g. daily_reporter:2026-05-08 or connector |
created_at |
TIMESTAMP | DEFAULT NOW() | |
| UNIQUE(user_id, challenge_key) | Prevents double-awarding |
Records which tier milestones a user has reached.
| Column | Type | Constraints | Description |
|---|---|---|---|
id |
SERIAL | PRIMARY KEY | |
user_id |
INTEGER | FK → users(id) ON DELETE CASCADE | |
achievement_key |
VARCHAR(100) | NOT NULL | beginner, explorer, elite, legendary |
created_at |
TIMESTAMP | DEFAULT NOW() | |
| UNIQUE(user_id, achievement_key) |
Records badges earned by each user.
| Column | Type | Constraints | Description |
|---|---|---|---|
id |
SERIAL | PRIMARY KEY | |
user_id |
INTEGER | FK → users(id) ON DELETE CASCADE | |
badge_key |
VARCHAR(100) | NOT NULL | e.g. rookie_reporter, community_guardian |
created_at |
TIMESTAMP | DEFAULT NOW() | |
| UNIQUE(user_id, badge_key) |
Cached leaderboard rankings for performance.
| Column | Type | Constraints | Description |
|---|---|---|---|
id |
SERIAL | PRIMARY KEY | |
user_id |
INTEGER | FK → users(id) ON DELETE CASCADE | |
period |
VARCHAR(20) | CHECK IN ('daily','weekly','monthly','all_time') | |
borough |
VARCHAR(50) | DEFAULT 'all' | Borough filter or 'all' |
total_points |
INTEGER | DEFAULT 0 | Cached point total |
rank |
INTEGER | NOT NULL | Rank position |
created_at |
TIMESTAMP | DEFAULT NOW() | Snapshot time |
| UNIQUE(user_id, period, borough) |
All endpoints are prefixed with /api. JSON responses include a "success": true/false field.
Most protected endpoints require a JWT bearer token:
Authorization: Bearer <access_token>
Register a new user account. Sends an email verification link.
Request body (JSON):
{
"username": "john_doe",
"email": "john@example.com",
"password": "SecurePass1!",
"first_name": "John",
"last_name": "Doe",
"role": "citizen"
}Response 201:
{
"success": true,
"user": { "id": 1, "username": "john_doe", "email": "john@example.com", "role": "citizen" }
}Errors: 400 missing/invalid fields · 409 duplicate email or username
Log in and receive a JWT access token.
Request body (JSON):
{
"email": "john@example.com",
"password": "SecurePass1!",
"role": "user"
}Use "role": "admin" to log in as a DOT admin.
Response 200:
{
"success": true,
"access_token": "<jwt>",
"user": { "id": 1, "username": "john_doe", "email": "john@example.com", "role": "citizen" }
}Errors: 400 missing fields · 401 invalid credentials · 403 email not verified
Send a password reset email.
Request body: { "email": "john@example.com" }
Response 200: Always returns success (prevents email enumeration).
Set a new password using the token from the reset email.
Request body: { "new_password": "NewPass1!" }
Response 200: { "success": true, "message": "Password updated successfully" }
Confirm email address using the token from the verification email.
Response 200: { "success": true, "message": "Email verified" }
Resend the email verification link.
Request body: { "email": "john@example.com" }
Get all reports with optional filters and pagination. Public endpoint.
Query parameters:
| Param | Type | Default | Description |
|---|---|---|---|
borough |
string | — | Filter by borough |
rating |
string | — | good, fair, or poor |
start_date |
ISO date | — | Filter by created_at >= |
end_date |
ISO date | — | Filter by created_at <= |
sort_by |
string | created_at |
Sort field |
sort_order |
string | desc |
asc or desc |
limit |
int | 20 | Page size |
offset |
int | 0 | Page offset |
Response 200:
{
"success": true,
"reports": [
{
"id": 1,
"user_id": 3,
"latitude": 40.7128,
"longitude": -74.0060,
"borough": "Manhattan",
"rating": "poor",
"damage_types": ["cracked_base", "missing_cover"],
"created_at": "2026-05-01T10:30:00",
"photo_urls": ["https://res.cloudinary.com/..."]
}
],
"pagination": { "limit": 20, "offset": 0, "count": 20, "total": 142 }
}Same as GET /api/reports but pre-filtered to rating=poor.
Get only the authenticated user's own reports. Accepts the same query parameters as GET /api/reports.
Get all reports from all users. Accepts the same query parameters as GET /api/reports.
Submit a new streetlight damage report. Uses multipart/form-data.
Form fields:
| Field | Required | Description |
|---|---|---|
latitude |
Yes | GPS latitude |
longitude |
Yes | GPS longitude |
borough |
No | NYC borough |
rating |
Yes | good, fair, or poor |
damage_types |
No | JSON array string, e.g. ["cracked_base"] |
photo |
No | First photo file |
photo_2 |
No | Second photo file |
photo_3 |
No | Third photo file |
Response 201:
{
"success": true,
"report_id": 57,
"badges_awarded": [{ "key": "rookie_reporter", "name": "Rookie Reporter" }],
"challenges_awarded": [{ "key": "daily_reporter", "name": "Daily Reporter", "points": 20 }],
"tier_reached": "Explorer"
}Update rating or damage types on an existing report.
Request body (JSON):
{ "rating": "fair", "damage_types": ["cracked_base"] }Response 200: { "success": true, "report": { ...updated report... } }
Errors: 403 if report belongs to another user · 404 report not found
Delete a report and its images.
Response 200: { "success": true, "message": "Report deleted" }
High-level report counts.
Response 200:
{
"success": true,
"total_reports": 342,
"current_month_reports": 48,
"last_month_reports": 71
}Aggregated chart data. Accepts the same filter query params as GET /api/reports.
Response 200:
{
"success": true,
"analytics": {
"counts_by_rating": [{ "rating": "poor", "count": 180 }],
"counts_by_borough": [{ "borough": "Manhattan", "count": 95 }],
"reports_over_time": [{ "bucket": "2026-04-01", "count": 42 }],
"poor_reports_over_time": [{ "bucket": "2026-04-01", "count": 18 }],
"damage_type_counts": [{ "damage_type": "cracked_base", "count": 120 }]
}
}Geographic density buckets for the heatmap visualization.
Additional query param: grid_size (float, default 0.01, max 10.0) — bucket size in degrees.
Response 200:
{
"success": true,
"heatmap": [{ "latitude": 40.71, "longitude": -74.01, "count": 12 }]
}Get the top-ranked users by points. Public endpoint.
Query parameters:
| Param | Default | Description |
|---|---|---|
period |
all_time |
daily, weekly, monthly, or all_time |
borough |
all |
Filter by borough or all |
limit |
10 |
Number of results |
Response 200:
{
"success": true,
"leaderboard": [
{ "rank": 1, "username": "jane_smith", "total_points": 4200 }
]
}Get the current user's rank and stats on the leaderboard.
Query parameters: Same period and borough as above.
Response 200:
{
"success": true,
"total_reports": 342,
"active_users": 28,
"user_rank": 5,
"user_points": 980,
"top_pct": 18,
"borough": "all"
}Get all challenges and current progress for the authenticated user.
Response 200:
{
"success": true,
"challenges": [
{
"key": "daily_reporter",
"name": "Daily Reporter",
"description": "Submit your first report today",
"type": "daily",
"target": 1,
"progress": 1,
"points": 20,
"completed": true
}
]
}Challenge types: daily (resets each day), weekly (resets each ISO week), special (one-time)
All challenges:
| Key | Type | Target | Points | Description |
|---|---|---|---|---|
daily_reporter |
daily | 1 report | 20 | First report today |
active_reporter |
daily | 3 reports | 60 | Three reports today |
street_inspector |
daily | 5 reports | 100 | Five reports today |
neighborhood_guardian |
weekly | 10 reports | 200 | Ten reports this week |
streetlight_specialist |
weekly | 15 reports | 300 | Fifteen reports this week |
determined |
weekly | 5 streak days | 250 | Report 5 consecutive days |
first_step |
special | 1 badge | 50 | Earn your first badge |
connector |
special | 2 boroughs | 150 | Report from 2 different boroughs |
network_leader |
special | 3 boroughs | 200 | Report from 3 different boroughs |
Get all badges and whether the user has earned each one.
Response 200:
{
"success": true,
"badges": [
{
"key": "rookie_reporter",
"name": "Rookie Reporter",
"description": "Submit your first report",
"earned": true,
"earned_at": "2026-05-01T09:12:00"
}
]
}All badges:
| Key | Trigger | Description |
|---|---|---|
rookie_reporter |
1 total report | Submit your first report |
community_guardian |
50 total reports | Submit 50 reports |
century_reporter |
100 total reports | Submit 100 reports |
across_the_boroughs |
5 distinct boroughs | One report per borough |
Get tier progress for the authenticated user.
Response 200:
{
"success": true,
"total_points": 1250,
"current_tier": "Explorer",
"progress_to_next": {
"next_tier": "Elite",
"points_earned": 250,
"points_needed": 9000,
"points_remaining": 8750
},
"tiers": [
{ "key": "beginner", "name": "Beginner", "min_points": 0, "max_points": 999, "reached": true }
]
}Tier thresholds:
| Tier | Min Points | Max Points |
|---|---|---|
| Beginner | 0 | 999 |
| Explorer | 1,000 | 9,999 |
| Elite | 10,000 | 99,999 |
| Legendary | 100,000 | — |
Update username and email.
Request body: { "username": "new_name", "email": "new@example.com" }
Response 200: { "success": true, "user": { ...updated fields... } }
Change password (requires current password).
Request body: { "currentPassword": "OldPass1!", "newPassword": "NewPass1!" }
Delete the authenticated user's account and all associated data (cascade).
Response 200: { "success": true }
Download all reports as a GeoJSON FeatureCollection file.
Query parameters:
| Param | Description |
|---|---|
borough |
Filter by borough |
rating |
good, fair, or poor |
srs |
Spatial reference: 4326 (WGS84, default) or 2263 (NY State Plane) |
updated_after |
ISO date — only include reports updated after this date |
Response: Content-Type: application/geo+json file download.
Sample feature:
{
"type": "Feature",
"id": "57",
"geometry": { "type": "Point", "coordinates": [-74.006, 40.7128] },
"properties": {
"report_id": 57,
"rating": "poor",
"damage_types": ["cracked_base"],
"borough": "Manhattan",
"photo_url": "https://res.cloudinary.com/...",
"created_at": "2026-05-01T10:30:00"
}
}- Navigate to the app and click Sign Up.
- Fill in username, email, first/last name, and a password.
- Password requirements: minimum 8 characters, at least one uppercase letter, one number, and one special character (
!@#$%^&*...). - Check your email inbox for a verification link and click it within 30 minutes.
- Return to the login page. Select the User tab and enter your credentials.
- From the Home page, tap or click Submit Report.
- Allow the browser to access your location — this auto-fills your GPS coordinates and detects your NYC borough.
- Take or upload up to 3 photos of the damaged streetlight base.
- Select a condition rating: Good, Fair, or Poor. Use the reference guide (?) icon for examples.
- Select one or more damage types from the checklist.
- Click Submit. Any newly earned badges or challenges will be shown immediately.
- Go to My Reports to view all your past submissions.
- Click Edit on any report to update the rating or damage types.
- Click Delete to remove a report (this also removes it from your points history).
- View the Leaderboard to see top contributors by total points.
- Filter by period (Today, This Week, This Month, All Time) and borough using the dropdowns.
- Your own rank and points are shown at the top of the page.
- Visit the Challenges page to see available challenges.
- Daily challenges reset at midnight UTC; weekly challenges reset each Monday.
- Progress bars show your current progress toward each challenge target.
- Completing a challenge awards bonus points automatically on your next report submission.
- The Progress page shows your total points, current tier, and badge collection.
- Badges are awarded automatically when you hit a milestone (e.g., 1st report, 50 reports).
- Tier levels (Beginner → Explorer → Elite → Legendary) unlock as you accumulate points.
- Go to Settings to update your username, email, or password.
- You can also permanently delete your account from the Settings page.
- On the login page, select the DOT Admin tab.
- Enter your admin credentials.
- You will be redirected to the Dashboard automatically.
The dashboard provides five main visualizations:
| Chart | Description |
|---|---|
| Summary Cards | Total reports, this month vs. last month |
| Bar Chart | Report counts by borough |
| Pie Chart | Reports broken down by damage type |
| Line Chart | Report volume over time (all ratings and poor only) |
| Heatmap | Geographic density map of report locations |
Use the date range and borough filters at the top to narrow the data.
- Navigate to All Reports from the admin navigation bar.
- Use the filter panel to search by borough, rating, and date range.
- Reports are paginated; use the navigation controls to move through pages.
- Go to Export in the admin navigation bar.
- Select optional filters (borough, rating, date range).
- Choose the spatial reference system: WGS84 (EPSG:4326) for standard GPS or NY State Plane (EPSG:2263) for NYC-specific GIS tools.
- Click Download. A
.jsonGeoJSON file will be saved to your computer. - This file can be opened in QGIS, ArcGIS, or any GeoJSON-compatible mapping tool. It is also compatible with Cyclomedia for field inspection integration.
Tests are located in Backend/tests/ and run with pytest against a live database.
cd Backend
pytest tests/ -v
pytest tests/test_challenges.py -v # challenges only
pytest tests/test_reports_all.py -v # report access control only| Test | Description | Expected |
|---|---|---|
test_streak_empty_list |
Consecutive streak with no dates | Returns 0 |
test_streak_single_day |
Streak with one day | Returns 1 |
test_streak_two_consecutive |
Two consecutive days | Returns 2 |
test_streak_five_consecutive |
Five consecutive days Mon–Fri | Returns 5 |
test_streak_gap_resets_count |
Gap on Wednesday resets count | Returns 3 (Thu–Sat) |
test_streak_all_separate |
Non-consecutive dates | Returns 1 |
test_daily_reporter_awarded_on_first_report |
Daily challenge after 1 report | Challenge key stored in DB |
test_active_reporter_awarded_on_three_reports |
Active Reporter after 3 reports | Challenge awarded |
test_street_inspector_awarded_on_five_reports |
Street Inspector after 5 reports | Challenge awarded |
test_all_three_daily_challenges_awarded_at_five_reports |
All three daily challenges at once | All three keys present |
test_challenge_not_awarded_early_two_reports |
Active Reporter NOT awarded at 2 reports | Key absent |
test_challenge_not_double_awarded |
Calling award function twice | Only 1 DB row |
test_no_duplicate_points_on_double_call |
Points not doubled on second call | Exactly 1 points entry |
test_correct_cumulative_points_for_five_reports |
Sum of daily challenge points | 20 + 60 + 100 = 180 |
test_points_log_null_report_id_for_challenge_award |
Challenge bonus uses NULL report_id | report_id is NULL |
test_weekly_streak_requires_consecutive_days |
"Determined" challenge on Mon–Fri streak | Awarded |
test_weekly_streak_not_awarded_with_gap |
"Determined" with a mid-week gap | Not awarded |
test_weekly_challenges_awarded_per_week |
Same challenge earned in W14 and W15 | Two distinct DB keys |
test_weekly_award_does_not_bleed_across_weeks |
W14 completion does not satisfy W15 | W15 key absent |
test_connector_awarded_on_two_distinct_boroughs |
Two different boroughs | "connector" awarded |
test_connector_not_awarded_on_one_borough |
Both reports same borough | "connector" absent |
test_network_leader_awarded_on_three_distinct_boroughs |
Three different boroughs | Awarded |
test_progress_never_exceeds_target |
Progress field capped at target | progress ≤ target |
test_completed_true_when_progress_equals_target |
API returns completed=true when done | completed: true |
test_get_challenges_requires_auth |
No token → 401 | HTTP 401 |
test_get_challenges_returns_expected_shape |
Response schema validation | All required fields present |
test_post_report_flips_daily_reporter_completed |
Report submission triggers challenge | completed flips to true |
| Test | Description | Expected |
|---|---|---|
test_all_reports_requires_dot_or_ppl_role |
Citizen cannot access /api/reports/all |
HTTP 403 |
test_all_reports_returns_everyones_reports_for_dot_role |
DOT admin sees all users' reports | Both report IDs present |
test_all_reports_returns_everyones_reports_for_ppl_role |
ppl role has same access as dot_admin |
Report ID present |
test_dot_cannot_edit_someone_elses_report |
Admin cannot edit a citizen's report | HTTP 403 |
- Minimum 8 characters
- At least 1 uppercase letter (A–Z)
- At least 1 number (0–9)
- At least 1 special character:
!@#$%^&*(),.?":{}|<>
latitudeandlongitudemust be present and valid (−90 to 90, −180 to 180)ratingmust be one of:good,fair,poor- Coordinates of exactly
(0, 0)are rejected (likely a GPS failure) - Maximum 3 photos per report; uploaded to Cloudinary CDN
damage_typesis an optional array of strings; values are normalized (lowercased, trimmed)
username,email, andpasswordare required- Email must be unique across all users
- Username must be unique across all users
- Email verification required before first login
srsmust be4326or2263ratingmust begood,fair, orpoorif providedstatusmust be one of:pending,reviewed,resolved,rejectedif provided
periodmust bedaily,weekly,monthly, orall_timelimitdefaults to 10
| Issue | Severity | Description |
|---|---|---|
| No rate limiting | Medium | /api/register and /api/login have no request rate limiting; brute-force attacks are possible |
| Borough auto-detection | Low | Borough is currently provided by the client (frontend reverse-geocoding); server does not independently validate it |