GeoNutria
A precision-agriculture platform: soil and leaf telemetry over MQTT, on-premise computer-vision and tabular models for crop diagnosis, satellite field analysis, and an LLM agronomy assistant — served to a React dashboard and a Flutter mobile app from one FastAPI backend.
Overview
GeoNutria is a monorepo containing three deployable units and the infrastructure that joins them. A single FastAPI service owns all business logic, model inference, and device communication; both user-facing clients are thin consumers of its REST API.
Backend
FastAPI on :8009. 12 routers, ~36 Python modules. Loads ML models into memory at startup and runs a persistent MQTT client in a background thread.
Frontend
Create React App dashboard, ~37 source files across 10 views. Built into a static bundle and served by nginx inside its own container.
Mobile
Flutter app, 85 Dart files. Bloc/Cubit state, go_router navigation, English + Arabic with RTL. Reproduces the dashboard minus the admin panel.
Devices
Field nodes publishing soil and leaf sensor readings to an MQTT broker, and — since the My Devices feature — subscribing to control and OTA firmware topics.
What the platform does
- IoT monitoring — live sensor readings per device, historical charts, weather overlay, and a manual-entry lab for users without hardware.
- Plant diagnosis — leaf disease classification, palm disease segmentation and classification, soil type classification, and aerial palm-tree counting, all from local model weights.
- Advisory — crop recommendation, yield prediction, and a conversational agronomy assistant backed by a local Gemma model with hosted-LLM fallback.
- Satellite analysis — field imagery analysis rendered to plots under
static/satellite_plots. - Device control — bind a device, publish switch/value commands, read back reported state, and push firmware over the air.
- Accounts — OTP and Google registration, credit-metered features, team credit pooling, billing receipts, PDF reports, and live support chat.
Architecture
Everything the user sees arrives through one nginx instance on the host. Everything a device says arrives through a broker the backend subscribes to. These are two separate paths that meet only in the database and in memory.
/ → frontend · /api → backend (prefix stripped)Request path
The host nginx terminates TLS for app.geonutria.ai and splits traffic: the site root proxies to the frontend container, and /api proxies to the backend with the prefix stripped. That prefix-stripping is the single most important deployment fact in the system, and it has one consequence worth internalising:
FastAPI mounts static files at /static, but from the public internet those files live at /api/static/.... Any client resolving a relative media path returned by the API — satellite plots, profile pictures, asset photos, firmware binaries — must prefix it with the /api base, not the bare domain. Both clients do this deliberately: the mobile app's Env.staticBaseUrl is set to the /api base for exactly this reason, and the backend's PUBLIC_BASE_URL defaults to https://app.geonutria.ai/api so OTA firmware URLs are reachable by devices.
Two brokers
The compose file configures two distinct MQTT endpoints, and they serve different purposes:
| Setting | Role |
|---|---|
MQTT_BROKER / MQTT_PORT | External broker the field devices actually use. This is what iot_manager connects to for both the subscriber and the publisher. Credentials are a username hardcoded as pop plus MQTT_PASSWORD. |
NEW_MQTT_BROKER_NEW et al. | Points at the bundled mosquitto container. Provisioned in compose with its own credentials, configured by mosquitto.conf with allow_anonymous false and a password file. |
The live telemetry and control path runs entirely over the external broker. The local mosquitto service is started and available but is not what iot_manager binds to.
Repository layout
The mobile app is a separate git repository. mobile/ has its own .git and pushes to AmirHarounOfficial/geonutria-flutter. The rest of the monorepo — including all of Backend/ — is not under version control, and is deployed by uploading files to the server. Backend changes therefore have no history, no diff, and no rollback.
Backend service
Application lifecycle
main.py builds the FastAPI app and wires two startup concerns that dominate the service's resource profile:
- Model loading.
model_manager.load_all_models()pulls every PyTorch, YOLO, joblib and XGBoost artefact into RAM synchronously. The first request is fast; the first boot is not. - IoT thread.
start_iot_thread()restores the latest reading per device from the database into the in-memory store, starts the MQTT subscriber on a daemon thread runningloop_forever(), starts the publisher client onloop_start(), and schedules a 30-minute APScheduler job that persists the in-memory snapshot back to the database.
Shutdown calls model_manager.unload_all_models(). This is deliberate — without it PyTorch leaves pt_main_thread processes resident.
Middleware
- SecurityHeadersMiddleware — sets a Content-Security-Policy on every response, permitting
selfplus Google Fonts for styles and fonts, anddata:/blob:for images and media. Note it allowsunsafe-evalandunsafe-inlinefor scripts, which is what the CRA bundle requires. - CORS — an explicit origin allowlist covering localhost development ports and a legacy IP. The production domain is absent, which is correct: the browser reaches the API same-origin through nginx, so no preflight occurs. The mobile app is a native client and is not subject to CORS at all.
Static mounts
| Mount | Serves |
|---|---|
/static | Satellite plots, uploaded media, profile pictures, firmware binaries. Publicly reachable at /api/static/.... |
/frontend | static/new_ai_frontend — a standalone bundle for the experimental AI portal. |
/new_ai_portal | Route returning that bundle's index.html. |
Services
iot_manager
Owns IoTDataStore (latest sensor values per device), ControlStateStore (last raw payload per control state topic), the subscriber IoTService, and the IoTPublisher. Thread-safe via explicit locks.
model_manager
Singleton (__new__-guarded) holding every model handle. Each load is individually try-wrapped, so a missing weight file degrades one feature rather than preventing boot.
access_control
verify_credits_and_permission() — the single gate for metered features. Resolves permission state, deducts credits, falls back to team pool, raises 402/403.
weather_service
Open-Meteo client. Supplies ambient temperature and humidity when sensors report zero, and logs a six-value macro-weather snapshot per device.
API reference
All paths are relative to https://app.geonutria.ai/api. Unless noted, the caller identifies itself with a user_id query parameter (GET/DELETE) or body field (POST/PUT) — see Auth & permissions.
auth
| Method | Path | Purpose |
|---|---|---|
| POST | /register | Create account, issue OTP |
| POST | /verify-otp | Confirm OTP, mark verified |
| POST | /login | bcrypt password check; returns user_id, credits |
| GET | /me | Current credits, picture, team credits |
| POST | /google-login | Exchange Google access token |
| POST | /google-register | First-time Google signup |
| GET | /user-hierarchy/{user_id} | Farms, crops and devices tree |
iot
| Method | Path | Purpose |
|---|---|---|
| GET | /iot-status | Live sensor snapshot, MQTT state, AI diagnosis |
| GET | /iot/refresh/{device_id} | Force re-read and re-diagnose (metered) |
| GET | /iot-history/{device_id} | Historical readings by interval |
| GET | /weather-charts/{device_id} | Macro weather series |
| GET | /devices | Devices visible to the user |
| GET | /devices/{device_id} | Single device detail |
| POST | /manual-diagnosis | Diagnose hand-entered sensor values |
| GET | /ai-credits/refresh | Re-read credit balance |
my-devices recent
| Method | Path | Purpose |
|---|---|---|
| GET | /my-devices | Devices owned by or shared with the user |
| GET | /my-devices/states | Last reported payload per control state topic |
| POST | /my-devices/bind | Self-register a device and its topics |
| PUT | /my-devices/{id} | Edit name, location, topics, controls |
| DELETE | /my-devices/{id} | Unbind — owner only |
| POST | /my-devices/{id}/publish | Publish a command to a registered topic |
| POST | /my-devices/{id}/firmware | Upload firmware and trigger OTA — Admin only |
models — inference
| Method | Path | Purpose |
|---|---|---|
| POST | /diagnose-leaf | Leaf disease classification |
| POST | /diagnose-palm-disease | Palm segmentation then disease classification |
| POST | /segment-general-leaf | General leaf segmentation |
| POST | /count-palm-trees | Aerial palm counting |
| POST | /classify-soil | Soil type from image |
| POST | /recommend-crops | XGBoost crop recommendation |
| POST | /predict-yield | Yield estimate |
ai & new_ai — assistants
| Method | Path | Purpose |
|---|---|---|
| GET | /ai-consultant/options | Available consultation parameters |
| POST | /ai-consultant/start | Begin async consultation, returns task_id |
| GET | /ai-consultant/status/{task_id} | Poll consultation result |
| POST | /ai-consultant/local-gemma/start | Same, routed to the local Ollama Gemma model |
| POST | /v1/chat | Direct chat completion |
| POST | /v1/analyze-image | Multimodal image question |
| POST | /v1/openrouter-chat | Hosted-model fallback path |
| GET | /v1/health | Assistant availability |
assets, profile, support, admin
| Router | Endpoints |
|---|---|
/assets |
CRUD for /farms, /crops, /trees; plus POST /upload-media and GET /media. |
/profile |
GET /, PUT /update, POST /password, POST /picture; team management via GET /team, POST /team/add, DELETE /team/remove/{id}. |
/support |
WS /ws/{user_id} live chat, GET /{user_id}/history, admin GET /admin/threads and POST /admin/reply, plus GET /guides/{tab_id}. |
/admin |
Analytics, payment approval queue, user listing and profiles, device CRUD, per-user device access grants, feature toggles and credit adjustment. Every handler re-checks role = 'Admin' inline. |
| root | POST /satellite-analysis, POST /generate-report (PDF). |
Authentication & permissions
How a session works today
POST /login looks the user up by email, verifies the password against a bcrypt hash, refuses unverified accounts, and returns a payload containing user_id, credit balances, and a token field. That token is the literal string user_token_ followed by the user's id.
Critical The token is decorative. There is no session verification anywhere in the API. No JWT is issued, signed, or validated — the only Bearer headers in the codebase are outbound calls to Google and OpenRouter. Every endpoint establishes identity by reading a caller-supplied user_id parameter and trusting it.
The practical consequence: anyone who can reach the API can act as any user by changing a number — read their devices, spend their credits, publish to their hardware. Admin-gated routes are no exception, since they check the role of whatever admin_id the caller passes. Ownership checks like _assert_access in the My Devices router are correctly written, but they authorise a claimed identity rather than a proven one.
Fixing this means issuing a signed token at login and deriving user_id from it server-side via a FastAPI dependency, rather than accepting it as a parameter. It is a cross-cutting change touching every router.
Roles
users.role is an enum with three values:
| Role | Meaning |
|---|---|
Admin | Bypasses the credit and permission gate entirely. Required for the admin router and for firmware OTA. |
Specific Access | Standard metering, intended for accounts with a curated feature set. |
Normal User | Default. Fully metered. |
Credits and feature metering
Metered features call verify_credits_and_permission(user_id, feature_name, db, cost) before doing any work. The resolution order is:
- If the user is
Admin, return immediately — no charge, no permission check. - Join
system_featurestouser_permissionsto find this user'saccess_stateand effective cost (custom_credit_cost, elsedefault_credit_cost). With no row, default tocredit_basedat the caller's suggested cost. blockedraises 403.unlimitedpasses free.credit_basedcontinues.- If personal
ai_creditscover the cost, deduct and commit. - Otherwise compute the deficit and try to draw it from a team's
shared_credits(selectedFOR UPDATE), zeroing the personal balance. If no team can cover it, raise 402.
Both clients treat 402 as a paywall signal rather than a generic error. The mobile app's ApiClient installs a Dio interceptor that converts a 402 into an InsufficientCreditsException and fires a PaywallNotifier; the shell listens and raises the top-up popup.
Device access
Device visibility is the union of ownership (devices.user_id) and explicit grants in the user_device_access join table, which an admin manages. The My Devices router enforces this in _assert_access, with an owner_only flag for destructive operations like unbinding.
Database
MySQL 8, schema geonutria_db, running on the host rather than in a container. The backend reaches it through the Docker alias host.docker.internal:3306.
Core tables
| Table | Holds | Notable columns |
|---|---|---|
users | Accounts | role, ai_credits, is_verified, otp_code, feature_access JSON, permissions JSON, google_sub |
farms | Land parcels | user_id, address, total_area |
crops | Plantings per farm | farm_id, crop_category, planted_area |
devices | Field hardware | mqtt_topics JSON, latitude/longitude, and from the My Devices migration control_topics JSON, ota_topic, firmware_version |
iot_readings | Persisted telemetry | device_id, soil_moisture, soil_temp, soil_ph, nitrogen_n, phosphorus_p, potassium_k, salinity, ec, ambient_temp, ambient_humidity, tds, epsilon |
user_device_access | Shared device grants | user_id, device_id |
system_features | Meterable feature catalogue | feature_name, default_credit_cost |
user_permissions | Per-user overrides | access_state (blocked/credit_based/unlimited), custom_credit_cost |
billing | Top-up receipts | amount, transfer_screenshot_path, status |
support_conversations / support_messages | Support threads | status, sender_type |
activity_logs | Feature usage audit | feature_used, inputs_json, outputs_json |
Schema drift
Attention There is no single source of truth for the schema. Migrations are a set of hand-rolled, individually-run scripts, and the checked-in dump predates several of them. Specifically:
iot_readingsin the dump still has genericsensor_1 … sensor_29columns.execute_sql.pyrenames the first ten to their semantic names. Thetdsandepsiloncolumns the code writes are not covered by any script in the repo.user_teams(team credit pooling) anddevice_weather_readings(macro weather log) are read and written by the code but created by nothing in the repository. They exist only on the live database.run_migrations.pyadds anALTER TABLE devices ADD COLUMN mqtt_topic— singular — whichmigrate_wave2.pylater supersedes with the JSONmqtt_topics.
Rebuilding this database from the repository alone is not currently possible. Consolidating the scripts into an ordered, idempotent migration set — or adopting Alembic — would remove a standing recovery risk.
The dump also contains a set of dim_* tables (dim_glaccount, dim_item, dim_site, dim_storeroom, dim_transactiontype, dim_vendor) forming an inventory star schema that no application code references — apparently legacy or from a different project sharing the database.
Migration scripts
| Script | Does |
|---|---|
run_migrations.py | OTP columns, permissions JSON, device coordinates, user_device_access, support tables, billing status |
execute_sql.py | Renames sensor_N to semantic names; creates aerial_palms_count |
migrate_wave2.py | Device latitude/longitude as DOUBLE, mqtt_topics JSON |
migrate_my_devices.py | control_topics JSON, ota_topic, firmware_version |
seed_db.py, create_admin.py, insert_device.py, create_ai_tasks.py | Fixture and bootstrap helpers |
IoT & MQTT layer
The backend keeps a live picture of every device in memory and only writes to disk twice an hour. Read paths hit RAM; the database is the durable log, not the source of truth for "now".
Inbound telemetry
Each device row carries mqtt_topics, a JSON array of topic roots. On connect the subscriber reads every device's topics from the database and subscribes to {topic}/#, keeping a topic_to_device map. subscribe_topic() lets a newly bound or edited device start being monitored without restarting the service.
Incoming messages are keyed by the last path segment of the topic, mapped to canonical field names:
| Topic suffix | Stored as |
|---|---|
Moisture, VWC | Soil_Moisture |
Temperature | Soil_Temperature |
PH, PH_PH | Soil_pH |
Nitrogen / Phosphorus / Potassium | Nitrogen_Level / Phosphorus_Level / Potassium_Level |
EC | Electrochemical_Signal |
Salinity, TDS, Epsilon | Salinity, TDS, Epsilon |
LF_Temperature, LF_Moisture | Ambient_Temperature, Humidity |
Anything whose suffix is not in that map is discarded. Payloads are parsed as floats, so a non-numeric message on a sensor topic is logged and dropped.
Persistence and weather fallback
Every 30 minutes the scheduler snapshots the in-memory store and, per device, calls _apply_weather_fallback_and_log: if the device has coordinates it fetches Open-Meteo, always logs a six-value weather row, and substitutes API temperature and humidity wherever the sensor reported exactly zero. The adjusted values are then inserted into iot_readings.
IoTDataStore.get_data() returns None — not a zero-filled dictionary — for an unknown device. This is load-bearing: a zero payload would be read by the diagnosis model as a critically unhealthy plant, so callers must check for None and skip inference.
Outbound control
Control endpoints live in devices.control_topics as a JSON array. Each entry describes one control:
{
"label": "Irrigation pump",
"topic": "farm101/pump/set", // device subscribes here
"state_topic": "farm101/pump/state", // device reports here (optional)
"type": "switch", // or "value"
"on_payload": "1",
"off_payload": "0",
"min": 0, "max": 100, "unit": "%"
}
POST /my-devices/{id}/publish validates the requested topic against that device's registered control topics plus its OTA topic, and refuses anything else with a 400. Publishing itself goes through the persistent IoTPublisher at QoS 1, so broker credentials never leave the server.
State feedback
A control may declare a state_topic. The backend subscribes to it exactly — no wildcard — and stores raw payloads verbatim in ControlStateStore. This check runs at the very top of the message handler, before the numeric sensor parsing, because feedback payloads like ON are not floats.
- User → AppToggles the switch.
- AppShows the new position immediately as an optimistic guess.
- App → Backend
POST /my-devices/{id}/publish— topic validated against the device's registered controls. - Backend → BrokerPublishes
on_payloadat QoS 1 via the persistent publisher. - Broker → DeviceCommand delivered on the command topic.
- DeviceActuates, then publishes its real state — ideally retained.
- Broker → BackendState message stored verbatim in
ControlStateStore, before any numeric parsing. - App → Backend
GET /my-devices/states— polled every 5s, and 800ms after a publish. - AppAdopts the reported state and drops the optimistic guess.
The app polls /my-devices/states every 5 seconds, but only while at least one control declares a state topic, and re-checks 800 ms after any publish. The switch shows the device's reported position; the user's toggle is an optimistic guess displayed only until the device confirms, and is dropped immediately if the publish fails.
If firmware publishes state with the retain flag, the broker delivers it the moment the backend subscribes, so the correct position appears immediately after a restart. Without retain, the control reads awaiting device until the next publication.
OTA firmware
Admins upload a binary to POST /my-devices/{id}/firmware. It is stored under static/firmware/{device_id}/ with a UUID filename, the version and OTA topic are persisted, and a command is published to the device's OTA topic:
{"url": "https://app.geonutria.ai/api/static/firmware/12/<uuid>.bin", "version": "1.4.2"}
The device is expected to subscribe to that topic, download the URL, and flash. The absolute URL is built from PUBLIC_BASE_URL, which must include the /api prefix or devices will receive an unreachable link.
Operational The backend publishes as MQTT user pop, a username hardcoded in iot_manager. Historically that account only ever subscribed. If the broker ACL does not also grant it publish rights on the control and OTA topics, every control action returns 502 MQTT publish failed.
Machine-learning models
All inference is local. ModelManager is a singleton loaded once at startup; nothing is fetched at request time.
| Model | Artefact | Used by |
|---|---|---|
| Plant health (tabular) | plant_health_model.pkl — joblib | IoT diagnosis, manual diagnosis |
| Leaf disease (vision) | plant-disease-model-complete.pth — ResNet9 | /diagnose-leaf |
| Soil classifier | custom_cnn_model.pth + soil_classes.pkl — CustomCNN | /classify-soil |
| Crop recommendation | crop_recommendation_xgb_model.pkl + scaler.pkl + label_encoder.pkl | /recommend-crops |
| Palm leaf segmentation | Yolo-palm-segm-leaves.pt | /diagnose-palm-disease |
| Palm disease classification | Yolo-palm-class-disease.pt | /diagnose-palm-disease |
| General leaf segmentation | general-segm-leaves.pt | /segment-general-leaf |
| Aerial palm counting | yolov8m_palm_aerial.pt | /count-palm-trees |
Model definitions for the PyTorch networks (ResNet9, CustomCNN, ImageClassificationBase) live in services/models_def.py — required because the checkpoints are loaded with weights_only=False and need the class definitions present.
Language models
The agronomy assistant runs against a local Ollama container serving a quantised Gemma model. A hosted fallback path exists through OpenRouter/OpenAI, with config.fallback_api_keys assembling an ordered, de-duplicated key list from AI_API_KEYS, OPENROUTER_API_KEY and OPENAI_API_KEY so exhausted keys can rotate.
Web dashboard
Create React App, built to a static bundle at image build time with REACT_APP_API_URL=/api and served by nginx inside the frontend container.
Views
| View | Covers |
|---|---|
LoginView | Password and Google sign-in, OTP verification |
IoTView | Device selector, live sensors, history, weather, manual entry |
LeafDoctorView | Leaf and palm disease diagnosis |
SatelliteView | Field imagery analysis |
CropRecommendView | Crop recommendation |
YieldPredictionView | Yield estimation |
AIConsultantView | Conversational agronomy assistant |
NewAIView | Experimental multimodal assistant |
ProfileView | Account, assets, team, billing |
AdminDashboardView | Analytics, users, devices, payments, feature toggles |
Shared UI lives in components/ui — GaugeChart, IoTMap, SensorCard, SatelliteImage, MapLocationPicker, MarkdownText, Toast. Cross-cutting widgets include OnboardingWizard, PaywallPopup, PaymentCheckout, SupportChatWidget, ExportReportModal and HelpGuideModal. All HTTP goes through services/apiService.js; services/translationService.js handles localisation.
Mobile app
Flutter, Dart SDK ^3.11.5, 85 source files. Full farmer-facing parity with the dashboard excluding the admin panel — the one admin endpoint it uses is payment receipt upload for billing.
Stack
| Concern | Package |
|---|---|
| State | flutter_bloc 9.1.1 with equatable |
| Navigation | go_router 14.6.2 |
| HTTP | dio 5.7.0 |
| Credentials | flutter_secure_storage 9.2.4 |
| Maps | flutter_map 7.0.2 + latlong2 — OSM and ArcGIS tiles, no API key |
| Charts | fl_chart 0.69.2 |
| Realtime | web_socket_channel 3.0.1 for support chat |
| Media / files | image_picker, file_picker 11, cached_network_image, share_plus |
| Auth | google_sign_in 6.2.2 |
Structure
Each feature follows the same three-layer split: data/ for models and repositories, bloc/ for Cubits, ui/ for screens and widgets.
Configuration
Env exposes compile-time constants overridable with --dart-define: API_BASE_URL, STATIC_BASE_URL, WS_BASE_URL and GOOGLE_SERVER_CLIENT_ID. All default to the production /api origin. Env.resolveMedia() turns the relative paths the API returns into absolute URLs.
Startup
main.dart renders immediately and runs bootstrap after first paint, with a timeout on each step. This is deliberate: a hanging secure-storage read on some devices would otherwise freeze the launch screen indefinitely. Auth always resolves to a terminal state — login or home.
Navigation
The shell exposes five primary destinations in the bottom bar — Dashboard, My Devices, Leaf Doctor, Satellite, Smart Assistant — with the full feature list in the drawer. Screens are held in an IndexedStack, so state survives tab switches.
My Devices
The device feature is the app's only write path to hardware.
- Bind — register a device with a name, map location, monitored MQTT topics and an optional OTA topic.
- Controls — define switch or value endpoints; each publishes its configured payload and reflects reported state.
- Quick controls — every configured control across all bound devices also renders at the top of the dashboard's Live tab, so acting on hardware never requires navigating into the device screen.
- Firmware — visible only to admins; everyone else sees a read-only version card.
DevicesCubit is provided at the shell rather than inside the devices screen, so the dashboard and My Devices share one device list and one polling timer. DeviceControlTile is the single shared switch/slider widget used by both surfaces.
Deployment
The server runs aaPanel with Docker. The project lives at /www/wwwroot/app.geonutria.ai. Host nginx reverse-proxies the domain to the frontend container and /api to the backend.
Compose services (production)
| Service | Image / build | Ports | Notes |
|---|---|---|---|
backend | ./Backend, target production | 8009:8009 | Reads Backend/.env; DB via host.docker.internal |
frontend | ./Frontend/app, target production | 3000:80 | Built with REACT_APP_API_URL=/api |
mosquitto | eclipse-mosquitto:latest | 1883:1883 | Mounts mosquitto.conf and passwd |
ollama | ollama/ollama:latest | 11434:11434 | Named volume ollama_data |
All four restart always; the backend declares depends_on for ollama and mosquitto. Every service uses extra_hosts: host.docker.internal:host-gateway so containers can reach host MySQL.
Attention Two image tags are :latest (mosquitto, ollama), so a rebuild can silently pull a new major version. Pinning them would make deployments reproducible.
Deploying a backend change
Because the monorepo is not version-controlled, backend deployment is a file upload followed by a rebuild. Preserve the directory structure — routers/ files must land in Backend/routers/.
cd /www/wwwroot/app.geonutria.ai
docker compose -f docker-compose.prod.yml up -d --build --no-deps backend
Then run any pending migration inside the container:
docker compose -f docker-compose.prod.yml exec backend python migrate_my_devices.py
Building the mobile app
cd mobile
flutter build apk --debug
Output lands at build/app/outputs/flutter-apk/app-debug.apk. On the current Windows build machine the Android Studio JBR must be selected as JAVA_HOME, and the Dart analyzer needs a raised heap (DART_VM_OPTIONS=--old_gen_heap_size=1024).
Security findings
These are observations from reading the code, ordered by severity. None are theoretical — each is verifiable in the current source.
Critical Unauthenticated identity
Covered in full under Auth & permissions. The login token is a formatted string, never validated; identity is a caller-supplied parameter on every endpoint. Any client can act as any user, including administrators, by supplying a different id. This undermines every other access control in the system.
Critical Secrets committed in the working tree
docker-compose.prod.yml contains the production MySQL password and the MQTT broker password inline as environment values, rather than being read from an untracked .env. They are visible to anyone with repository or filesystem access, and are shipped in every copy of the project.
Additionally, DatabaseBackup.sql sits at the repository root and contains real user records — names, email addresses, phone numbers, and bcrypt password hashes. It should be removed from the tree and stored as an access-controlled backup artefact; the credentials in it should be treated as exposed and rotated.
Moderate Broad script CSP
The security-headers middleware allows unsafe-eval and unsafe-inline for scripts. This is what the CRA bundle needs to run, but it removes most of the XSS mitigation a CSP is there to provide.
Moderate Error responses leak internals
The login handler catches unexpected exceptions and returns traceback.format_exc() in the 500 response body, exposing file paths and library versions to the caller.
Sound What is done well
- Passwords are bcrypt-hashed, and login does not distinguish "no such user" from "wrong password".
- MQTT publishing is server-side only — broker credentials never reach a client — and
/publishis restricted to topics the device has actually registered, so it cannot be used as an open relay. - Firmware OTA is enforced admin-only in the backend, not merely hidden in the app.
- Every query uses SQLAlchemy bound parameters; there is no string-interpolated SQL on user input.
- The broker runs with
allow_anonymous falseand a password file.
Runbook
Connect and inspect
ssh root@<server-ip>
cd /www/wwwroot/app.geonutria.ai
docker compose -f docker-compose.prod.yml ps
docker compose -f docker-compose.prod.yml logs -f --tail=100 backend
Verify the MQTT layer
On boot the backend logs a connection banner and one line per subscribed topic. Look for MQTT Connected, MQTT Publisher connected, and Subscribed to Device N topic: …. A rejection prints the numeric reason code. Unmatched inbound topics log a dropped-message warning, which is the fastest way to spot a device publishing somewhere unexpected.
Common failures
| Symptom | Likely cause |
|---|---|
502 MQTT publish failed on any control | Broker ACL does not grant the pop account publish rights |
| Control shows awaiting device forever | No state_topic configured, device not publishing, or publishing without retain after a backend restart |
| Device selector empty but hardware is live | mqtt_topics not set on the device row, so nothing is subscribed |
| Sensor stuck at zero | Payload not numeric, or topic suffix outside the sensor name map |
| Firmware link unreachable from the device | PUBLIC_BASE_URL missing the /api prefix |
| Images 404 in a client | Relative /static path resolved against the bare domain instead of the /api base |
| Backend slow to accept traffic after deploy | Expected — all ML weights load synchronously at startup |
Recommended next steps
- Replace parameter-based identity with a signed token resolved by a FastAPI dependency.
- Move the compose secrets into an untracked env file and rotate the exposed credentials; remove the database dump from the tree.
- Put the monorepo under version control so backend changes are reviewable and reversible.
- Consolidate the migration scripts into an ordered, idempotent set that can rebuild the schema from scratch.
- Pin the
:latestimage tags.