# Doormile CX — Backend Changes Needed | | | |---|---| | Date | 2026-09-29 | | From | `doormile_cx` (customer app) team | | Checked against | Customer App API Reference v1.0 (backend code at `44ba33e`) | | Evidence | Live probes against `https://api.doormile.com/api/v1` on 2026-09-29, quoted inline | **The two most urgent items here need no app work at all — they are deployment configuration.** One of them is a live security exposure. Everything below §3 is ordinary product work and can wait its turn. | # | Item | Owner | Effort | |---|---|---|---| | 1 | `ENV` is not set to production | Backend / DevOps | One line | | 2 | SMS is unfunded — new customers cannot sign in | Product decision | One line, or weeks | | 3 | If PIN becomes the only auth, three holes must close first | Backend | Days | | 4–8 | Payload, contacts, multi-destination, push, staging | Both | Ordinary | --- ## 1. `ENV` is not production on `api.doormile.com` **Verified today.** `POST /customer/auth/otp/request` with a valid phone returns: ```json {"data":{"codeLength":4,"resendAfterSeconds":30,"sent":true},"success":true} ``` Per API Reference §3.5, when no SMS gateway is configured the log sink is used, and it behaves differently by environment: **in production, sending fails and this endpoint returns `500`.** It only writes the code to the server log and reports `sent: true` when `ENV` is *not* production. It reported `sent: true`. The readiness probe confirms the gateway is absent: ``` GET /api/v1/ready → "sms": { "configured": false, "transport": "log" } ``` **Why this matters beyond OTP.** `ENV != production` is the gate on two other things: - **`CX_STAGING_OTP`.** If that variable is set in this environment, *every* issued code is a fixed constant — for every account, including real customers. Anyone who knows it can sign in as anyone. We did not test this, because confirming it means signing in to an account we do not own. **Please check the deployment config for it and treat it as urgent if it is set.** - **`POST /customer/ops/bookings/:reference/stage`**, the QA stage override, is one env var (`CX_ALLOW_STAGE_OVERRIDE`) away from being reachable by any customer token. **What to do:** set `ENV=production` on `api.doormile.com`, and confirm `CX_STAGING_OTP` is unset there. This is the single highest-priority item in this document. --- ## 2. SMS is unfunded — new customers cannot sign in **The OTP routes are not removed.** We probed all six auth endpoints with malformed bodies; every one returned a normal `400` validation error rather than a `404`: ``` POST /auth/otp/request → 400 invalid "Enter a valid phone number or email address" POST /auth/signup → 400 invalid_name "Enter your full name" POST /auth/otp/verify → 400 invalid "Enter the code we sent you" POST /auth/login → 400 invalid "Enter a valid phone number" POST /auth/set-pin → 400 invalid "Enter a valid phone number" POST /auth/verify-pin → 400 invalid "Enter your PIN" ``` So the server still issues a valid 4-digit code on every request. **The code goes to the server log instead of the customer's handset.** The practical result is that anyone with log access can sign in as anybody, and no customer can sign in at all. **The failure is slow, which is what makes it dangerous.** Refresh tokens live 60 days and rotate, so customers already signed in keep working. Only *new* sign-ins break — new installs, reinstalls, new customers, anyone signed out. Nobody reports this for weeks, then everybody does at once as tokens age out. **The customer app has no other way in.** Its entire auth surface is `/auth/otp/request`, `/auth/signup` and `/auth/otp/verify` (`lib/data/live_doormile_api.dart:44-95`). There is no PIN code in the app. ### The decision we need | Option | Backend work | App work | Consequence | |---|---|---|---| | **A — Fund SMS again** | Set `SMS_GATEWAY_URL` | **None** | Sign-in works immediately. The app ships unchanged. You inherit none of §3 | | **B — PIN becomes permanent** | §3 first | Three new screens | Weeks. You inherit all three holes in §3, on the only door into the product | **We recommend A.** The routes are already live and validating; it is one environment variable against weeks of work on both sides, and it avoids §3 entirely. If cost is the blocker, say so and we will price the alternatives — but B is not the cheap option, it only looks like it. --- ## 3. If PIN becomes the only auth, three things must change first An earlier version of this document asked you to **remove** the PIN routes, because the app did not use them and `set-pin` is an account-takeover path. That ask is withdrawn. If PIN becomes the only door, those same holes stop being a side risk and become the front door. | # | Hole | Why it matters as the only auth | What we need | |---|---|---|---| | 1 | **`set-pin` requires no proof of ownership.** It sets the first PIN on *any* account that has none — including every account created by OTP signup, by `otp/verify`, or by the console | Anyone who types a stranger's number owns their account, their address history and their active pickups. Every existing OTP-created account is currently claimable | Proof of ownership before the first PIN. **This is the same question OTP was answering** — it does not disappear when SMS does | | 2 | **No PIN reset or change endpoint** | A customer who forgets a 4-digit PIN is locked out permanently, and support has no lever. OTP always provided a way back; nothing does now | A reset path, and a way for support to trigger it | | 3 | **No per-account lockout on `verify-pin`** | 10,000 combinations. The only limit is 10 req/min per IP, shared with `/miler/login`, `/admin/login` and `/hub/login`, and defeated by rotating IPs | Per-account attempt limit and backoff | Also: **`POST /auth/login` tells an anonymous caller whether a number is registered, and returns the account holder's name.** Free customer enumeration. --- ## 4. What we found and fixed in the app Separately from auth, we audited the booking payload against the API reference. Five things on the wire were wrong. All five are fixed. | # | What the app sent | What the contract wants | Consequence | |---|---|---|---| | 1 | Destination details spread **flat** on the destination | Nested under `details{}` | **Every building number, street, landmark, recipient name, recipient phone and pin was dropped.** 201 returned, nothing reached the Miler | | 2 | Destination pin as `latitude`/`longitude` | `pin: {lat, lng}` | Pin discarded. Same spelling that caused months of `422 unserviceable` on the pickup before it was fixed there | | 3 | `PATCH .../destinations/{index}` sent `null` to clear a field | `""` clears; `null` means "leave alone" | A customer could add a landmark and never remove one | | 4 | Per-destination `instructions` folded into the visit's `remarks` | `details.instructions` exists per destination | One note for a multi-stop visit instead of one per door | | 5 | `contactName` / `contactPhone` on the **pickup** object | Pickup is `{title, sub, lat, lng}` only | Written on every booking, read by nobody. See §6 | A customer using the full-address path typed a flat number, a street and a recipient, saw them on the review screen, confirmed — and the Miler arrived with only a district. There was no error anywhere in that chain. **We are shipping the fix before it is verified**, because it cannot be worse than today: if `details{}` is also the wrong shape, the fields are dropped exactly as they are now. Verification below is confirmation, not a gate. --- ## 5. Please confirm the nested shape One booking created with the body below, then a read of the stored destination confirming `street`, `building`, `landmark`, `recipientName`, `recipientPhone`, `instructions` and the pin all landed. If they did not, tell us the shape that does work — we will follow the server, not the document. ```json { "stateCode": "TN", "districtCode": "TN-MAA", "packageCount": 3, "details": { "recipientName": "Meera S", "recipientPhone": "9876543210", "building": "12/A", "street": "MG Road", "landmark": "Opp. bus depot", "instructions": "Call before arriving", "pin": { "lat": 13.085, "lng": 80.21 } } } ``` **Worth fixing regardless:** a destination carrying keys the server does not recognise is accepted silently. That property is what let this run for months. If unknown keys on `destinations[]` returned `400 invalid` naming them, this class of bug would surface on the first request instead of in the field. --- ## 6. A real pickup contact field The Miler gets exactly one phone number for a collection, and it is the number the customer signed in with. `GET /miler/bookings` returns a single phone field, `customerphone`, which the rider app stores as `pickupcontactno` — verified against production on 21 August 2026 and recorded in the rider app's own source (`miler/lib/data/stop_contact.dart`). Nothing the customer app sends on `pickup` can change it: the create contract's pickup is `{title, sub, lat, lng}` and extra keys are dropped. **Why it matters.** A parcel handed over by a spouse, a neighbour, a receptionist or a shop assistant is ordinary in collections. Today the rider rings the account holder, who may be at work, and the collection fails on the doorstep. **A failed pickup costs a whole rider trip — the most expensive unit in this business.** **Meanwhile** the app puts a handover person in `remarks`, labelled so a human reads it unambiguously (`Handover contact: Meera S, +91 9003144518`), and both the pickup and review screens tell the customer plainly that the rider's call button still dials their own number. **What we are asking for** 1. `pickup.contactName` and `pickup.contactPhone` accepted on `POST /customer/bookings`, stored on the booking. 2. Surfaced to the rider as a **second** contact on the stop — not replacing `customerphone`, because the account holder is still who the rider escalates to. 3. Meanwhile, confirm whether `remarks` reaches the rider at all. The rider app reads a `notes` field per stop; we do not know whether the customer app's `remarks` lands there or stops at the admin console. **Related:** `details.recipientPhone` is stored raw when normalisation fails and `details.codAmount` accepts a negative number. Both are money-or-contact fields reaching a rider unvalidated. --- ## 7. The rest, ranked | Ask | Why | What to change | |---|---|---| | **Multi-destination** | A rider makes **one trip** to the door. Three parcels for three cities in one visit is one trip instead of three. Production advertises `maxDestinations: 1` (confirmed live 2026-09-28), so a customer books three times and either three visits get scheduled or the hub merges them by hand | Key the Miler build on `consignmentid`, then add the `customerbookinglimits` row. The customer app already supports N destinations end to end and obeys the cap it reads on every launch. **Raising the number is the only change; none on our side** | | **Push is configured but dead** | `POST /customer/devices` exists and the stage pushes are written, but they reach nobody. Several older paths still write `appcustomers.device_token`, which no `/customer/*` endpoint fills — so a customer cancel sends no push and an ops cancel usually sends none either | Confirm `FIREBASE_SERVICE_ACCOUNT_PATH` is set, or every push is skipped silently. We will wire FCM and register on sign-in. Retire the legacy `device_token` paths | | **Failed delivery is invisible** | RTO, missing, damaged and failed attempts have no customer stage, so the order keeps showing `out_for_delivery` forever. The parcel is in trouble and the app says it is on its way | Add stage keys. Needs an app release first — the client falls back to `booked` for an unknown key — so tell us before shipping them | | **Two responses break the envelope** | We branch on `error.code`. The idempotency `409` puts `code` at the **top level**; the city gate `400` sends `error` as a **string**, not an object. Both fall through to a generic message, so "an identical request is still being processed" renders to a customer as a booking conflict | Move both into `error: {code}` | | **`estimate` on booking create** | Documented with a ±15% tamper rule. We do not send it, so the server always re-quotes — the band the customer saw can differ from the stored `fare` | Confirm you want it and we will send the exact `min`/`max` | | **`maxCodAmount`** | We parse it from `/config/booking-limits`; the reference says it is never returned | Send it, or we drop the field | --- ## 8. There is no test environment `AppConfig._stagingBase` is `_prodBase`; no staging host has ever been named. Every request this app makes — including anything we do to test it — is production. That is why §5 is unanswered. Verifying a JSON field name currently costs a real pickup in a real slot that a real Miler can be dispatched to. With SMS down, the only way to get a session for a test is `set-pin`, which creates a permanent production account with no reset and no delete path. **A staging host closes §5 in ten minutes.** Cheapest item here; unblocks the most. --- ## 9. Remaining security items From API Reference §9.2, excluding the OTP items now covered by §1. - **`/ws/bookings/:id/track` has no authentication** and streams a rider's name and live GPS for any sequential booking id. The app does not use WebSockets; we poll `GET /bookings/:reference`. Nobody should be able to enumerate rider positions. - **The rider's real mobile number is exposed** in `miler.phone` unless `MILER_CALL_PROXY` is set. The reference marks this "a setting to close before launch". Please confirm it is set. --- ## 10. What we need back 1. **`ENV=production` set, and `CX_STAGING_OTP` confirmed unset** (§1). Today. 2. **A or B on SMS** (§2). Everything about auth waits on this one answer. 3. **A staging host** (§8) — cheapest item, unblocks §5 immediately. 4. **Yes or no on the pickup contact field** (§6), and whether `remarks` reaches the rider today. 5. **A date for multi-destination** (§7), or a note that it is not planned, so we can word the app honestly. It currently says "one destination per pickup for now". --- ## Appendix — how to verify a payload change Use the method that settled the `lat`/`lng` bug: **two requests, one apart, differing in one field.** That bug ran for months because the app sent `latitude`/`longitude` on the pickup while the server reads `lat`/`lng` — it saw a booking with no coordinates and answered `422 unserviceable`, blaming the customer's address for a field name. Settled on 2026-09-23 with two requests: same pickup, same destination, same slot. `latitude`/`longitude` → 422. `lat`/`lng` → 201, booking DM-252803. `tool/verify_booking.sh` in the customer app repo automates exactly that: signs in, takes the first available slot, books one pickup with every detail field filled in and marked `VERIFY …`, reads it back, prints which of the seven fields survived, and cancels the booking. Point it at staging with `--base` the moment there is one.