utils.Forbidden and utils.NotFound write the response and return c.JSON's
nil. Any helper that signalled refusal by returning one of them handed its
caller a nil error, so every `if err != nil { return err }` guard passed and
the handler carried straight on.
The observable result: GET /admin/milers?tenantid=14 as a DailyGrubs login
returned HTTP 403 with all 30 of the network's riders in the body. Status
line correct, payload leaked.
Three helpers were affected:
effectiveTenantID (yesterday, mine) — cross-tenant read returned the
unfiltered list under a 403
canAccessBooking (was assertBookingAccess, shipped in 6d9232f) — four
mutating booking handlers were unguarded
findMilerForConsole (was assertMilerAccess) — worse, callers went on to
dereference the nil profile
All three now return a bool and the caller writes the refusal itself, so the
control flow is visible at the call site instead of hiding in a helper.
Adds a test that pins utils.Forbidden/NotFound returning nil, so if that ever
changes the assumption breaks loudly rather than silently, plus table tests
for effectiveTenantID.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two halves of the same thing. A client login was already pinned to its own
tenant on most reads, but the roster, the B2C customer list and the dashboard
counters were not — a DailyGrubs login listed the whole network's riders.
The other half was missing entirely: Doormile's own staff had no way to look
at one client's slice. Reports accepted ?tenantid= but applied it only to the
consignment count, and bookings accepted it while milers, customers,
consignments and the dashboard ignored it.
effectiveTenantID(c) now resolves both cases in one place — the caller's own
tenant for a client login, the requested one for Doormile staff, 0 for the
whole network. A client asking for someone else's tenantid is refused rather
than silently handed their own data back under the wrong label.
Applied to: milers, customers, bookings, consignments, dashboard, reports and
the rider summary.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A client login could list its own bookings but had no way to see what its
riders were actually doing. jupiter's console gave them getridersummary and
the rider/delivery logs; Doormile records all of it and exposed none of it.
New console endpoints, all tenant-scoped:
GET /admin/milers/summary roster with live state + range totals
GET /admin/milers/:id/logs GPS trail from the Redis telemetry index
GET /admin/milers/:id/activity one rider's assignments, duty and breaks
GET /admin/consignments/:id/logs event history + telemetry + proof
GET /admin/bookings/:id/track booking -> assignments -> parcel -> proof
Also closes a rider IDOR: GetMilers scoped the roster to the caller's own
fleet, but reading, editing, blocking, notifying or assigning a vehicle to a
single rider by id did not, so a client login could walk the whole network's
riders by incrementing the id. All five now go through assertMilerAccess.
And the client dashboard no longer reports milers/customers/exceptions as
zero — those have no tenant column, so they are counted through appusers,
bookings and consignments respectively.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The first scoping pass covered tables carrying a tenantid column. These five
have no such column and were still returning every client's data to a client
login, which is what made creating info@dailygrubs.com unsafe:
- GET /admin/customers — scoped through the bookings placed for them, since a
customer carries no tenant of their own (the same person can order from two
clients).
- GET /admin/exceptions — scoped through the consignment the exception was
raised against.
- GET /admin/tenantcustomers — the legacy customers table predates tenant
attribution entirely, so no row can be proven to belong to a client. Returns
empty for client logins rather than handing over the whole list.
- GET /admin/tripsheets — a vehicle run routinely carries several clients'
parcels on one manifest, so there is no honest per-client view. Doormile
staff only.
- GET /admin/milers — scoped through appusers.tenantid, so a client sees their
own riders rather than the whole roster.
Also guards PUT /admin/consignments/:id/status, which took the id straight from
the path and would have let a client move another client's parcel through the
network.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Security
- Express console had no tenant scoping at all: LoginAdmin hardcoded tenantid 0
into every JWT and none of the 85 admin handlers filtered by tenant, so any
client given a console login would read every other client's bookings,
customers, pricing and reports. Adds DoormileAuth.Tenantid (nil = Doormile
staff, unrestricted; set = client, scoped), emits it in the token, and scopes
reads, guards writes and pins tenantid on create.
- Miler telemetry (/miler/logs, /miler/status, /miler/consignments/logs) took
userid from the request body, letting any authenticated rider write another
rider's status and GPS trail — data the dispatch layer reasons over. Identity
now comes from the token.
- POST /miler/reset-pin was unauthenticated and overwrote a PIN given only a
phone number, so reset-pin + verify-pin took over any rider account. Now
requires admin/manager/executive auth.
Correctness
- Date ranges compared the container's UTC clock against timestamps the DB
writes as IST wall-clock (DSN sets TimeZone=Asia/Kolkata), so "today so far"
ended 5h30m in the past and silently dropped everything created after noon
IST from every report. Sets TZ in the image and adds utils.DBNow/DBToday,
which stay correct regardless of container timezone.
- CreateMiler never set Configid, so console-created riders got the column
default of 1 while LoginMiler looks up configid 1001 — every such rider was
unable to log in, reported as "no miler account found".
- Delivery wrote no consignment history row, so a tracking timeline never
showed the parcel arriving.
Features
- Delivery OTP is now real (crypto/rand, issued to the receiver, verified and
cleared on delivery) but opt-in per client via Tenant.Requiredeliveryotp,
defaulting off — friction worth it for a courier parcel, not a food order.
- Express bookings accept pickuplocationid, so the console can name a client
site (a DailyGrubs kitchen) instead of retyping its address; validated
against the tenant and carried through to the consignment.
- TenantLocation.Locationname, miler tenantid/hubid, Nagercoil (629) opened.
- PUT /miler/availability accepts both "status" and "availabilitystatus", and
/miler/location no longer drops speed/heading — both were contract
mismatches against the doc the Flutter dev was given.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Hardening pass over the API surface. No route's auth requirements change.
Resilience:
- Add recover middleware. There was none, so an unhandled panic in any
handler propagated out of the process instead of becoming a 500.
- Add a centralized ErrorHandler so errors and recovered panics return the
same {success,message} envelope as the utils helpers, not Fiber's default
plain-text body. 5xx responses are logged with method and path.
Rate limiting:
- Global 300/min per IP as an abuse backstop, exempting health/readiness
probes and websocket upgrades.
- 10/min shared across every credential endpoint (customer/miler/admin/hub
login, verify-pin, reset-pin, email OTP). PINs are 4 digits, so the whole
keyspace was previously walkable in seconds. One shared limiter instance
means rotating between endpoints doesn't reset the budget.
- Add TRUSTED_PROXIES config. Limits key on c.IP(), which behind a TLS
terminator is the proxy, collapsing every client into one bucket. When set,
X-Forwarded-For is honoured only from those proxies so the header can't be
spoofed to dodge the limit. Logs a warning when unset.
Transactions:
- Check the error on all 51 previously-unchecked tx.Save/Create/Delete/
Model(...).Update/Commit calls across 6 controllers. A failed write inside
a transaction was silently ignored and the request still reported success;
an unchecked Commit could fail with the caller told everything worked.
Each site now rolls back and returns a specific message.
Pagination:
- Add utils.ParsePage/Paginated, reusing the pageno/pagesize convention
GetAdminBookings already established. Default 500, hard cap 1000.
- Apply to the previously unbounded consignments, tripsheets, exceptions,
app-users and clients endpoints. Defaults are high so existing consoles
that don't paginate keep working; the cap only stops a growing table from
being loaded wholesale. total is now a real COUNT, not len(data).
- GetClients also loaded the entire auth table to join in memory; it now
fetches only the current page's rows.
Tests (first in the repo):
- Extract the hyperlocal pincode rule out of BookingPickupComplete into
isHyperlocal so it is testable, covering the short/empty pincode fallback.
- Cover calculateVolumetricWeight and the ParsePage clamping rules.
Repo hygiene:
- Tag scratch/*.go with //go:build ignore. Each declared its own main(), so
`go build ./...` failed on redeclaration; it now passes repo-wide.
- Untrack scratch/node_modules (216 files) and ignore node_modules, test
artifacts, and the `doormile` binary `go build .` emits.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>