API.md: the nine routes that shipped, and the boundary they exposed

The admin drill-down, the sales reads and the dashboard summary, each
with the shape production actually returns - copied from live responses
rather than written from the structs, because that is the difference
between documentation and a guess.

Three things stated because a client would otherwise get them wrong:
admin camera rows are a DIFFERENT shape from GET /api/cameras and carry
no host, port, path, username or has_password; a sale with no visitor is
listed rather than joined away, so this agrees with the conversion report
over the same rows; and the sales list has no cursor, with the reason,
because purchases has no monotonic column and a cursor would imply a
delivery guarantee it cannot make.

Also the boundary the work exposed: 'authed' meant any signed-in user,
and a platform admin is signed in with no company at all. That now has a
sentence and a code (403 not_a_tenant_account) instead of being a 500
nobody had called.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
This commit is contained in:
2026-09-28 19:28:28 +05:30
parent 359d48e1c4
commit f4102443ea

115
API.md
View File

@@ -53,15 +53,23 @@ user; the tenant is always taken from the session and never from the request.
| `POST /api/sites/{site}/enrolment-code` | manager | | `POST /api/sites/{site}/enrolment-code` | manager |
| `GET /api/team` | authed (tenant users only) | | `GET /api/team` | authed (tenant users only) |
| `POST /api/team/members` · `POST /api/team/{id}/password` · `PATCH /api/team/{id}` · `/api/team/invitations*` | manager | | `POST /api/team/members` · `POST /api/team/{id}/password` · `PATCH /api/team/{id}` · `/api/team/invitations*` | manager |
| `GET /api/reports/footfall` · `GET /api/reports/conversion` | authed | | `GET /api/reports/footfall` · `GET /api/reports/conversion` · `GET /api/sales` · `GET /api/sales/{id}` · `GET /api/dashboard/summary` | authed |
| `POST /api/assistant` | authed | | `POST /api/assistant` | authed |
| `GET` / `POST /api/admin/clients` · `PATCH /api/admin/clients/{id}` · `POST /api/admin/clients/{id}/owner-password` · `DELETE /api/admin/clients/{id}` | **platform admin** | | `GET` / `POST /api/admin/clients` · `PATCH /api/admin/clients/{id}` · `POST /api/admin/clients/{id}/owner-password` · `DELETE /api/admin/clients/{id}` | **platform admin** |
| `GET /api/admin/clients/{id}` · `…/{id}/sites` · `…/sites/{site}` · `…/sites/{site}/cameras` · `…/cameras/{camera}` · `GET /api/admin/monitoring/summary` | **platform admin** |
| `/api/agent/*` | **shop PC token** — never a user | | `/api/agent/*` | **shop PC token** — never a user |
A role that may not call something gets **403 `forbidden`** with a message A role that may not call something gets **403 `forbidden`** with a message
saying who can. Another tenant's data returns **404**, never 403: a tenant user saying who can. Another tenant's data returns **404**, never 403: a tenant user
has no business learning that a resource exists. has no business learning that a resource exists.
`authed` above means any signed-in user **of a company**. A platform admin has
no company — that absence is what defines one — so a company's own routes
answer them **403 `not_a_tenant_account`**, naming the `/api/admin/clients/{id}/…`
route that reads the same data. Their own `/api/auth/*` keeps working: a session
is not a company's data, and revoking a lost device must not depend on having a
tenant.
--- ---
## The onboarding chain — who creates whom ## The onboarding chain — who creates whom
@@ -687,6 +695,58 @@ record of what you were permitted to do is what an auditor asks for.
Links a sale to a customer so the conversion report can say who bought. **One Links a sale to a customer so the conversion report can say who bought. **One
currency per report** — see §9. currency per report** — see §9.
### `GET /api/sales` — anyone in the company
The purchases behind the conversion report. That report has always summed this
table; until 28 Sep nothing could read a row of it, so "revenue was 41,000"
could not be checked against a till.
Takes the same window as a report: `from`, `to` (`YYYY-MM-DD`), `site` or
`site_id` (slug or uuid), plus `customer` (`V-42` or a uuid) and `limit`
(default 50, max 200). An unknown shop or customer is a **400**, not a silently
ignored filter.
```json
[{ "id": "8525ef18-…", "occurred_at": "2026-09-19T10:48:44Z",
"site_id": "93d0565f-…", "site": "TeNext Coimbatore", "site_slug": "chennai",
"amount": 1000, "currency": "INR",
"visitor_id": "ba5e5d2e-…", "visitor_ref": "V-1", "visitor_label": "Visitor 1",
"visit_id": "3bcd53ca-…", "items": ["Headphones"], "source": "manual" }]
```
- **A sale with no `visitor_id` is listed**, not joined away. An unidentified
walk-in is still revenue, and hiding it would make this disagree with the
conversion report computed over the same rows.
- `items` is always an array, never `null`.
- **No cursor, deliberately.** A keyset cursor needs a monotonic
server-assigned column and `purchases` has none; ordering by
`(occurred_at, id)` with a random uuid tie-break is the shape that silently
dropped visits from the arrivals feed before `visits.seq` existed. Narrow by
date and `limit` instead.
### `GET /api/sales/{id}`
One sale, same shape. Another company's sale is **404**.
### `GET /api/dashboard/summary` — anyone in the company
The home screen in one call, so a client does not combine four.
Takes `site`/`site_id` and `tz` (IANA, default the company's). "Today" is cut
in **that timezone** — a dashboard that says today and means UTC is five and a
half hours wrong in India.
```json
{ "date": "2026-09-28", "visitors": 0, "visits": 0,
"sites_total": 4, "sites_online": 0, "cameras_total": 1, "cameras_up": 1,
"fraction_below_gate": 0.59, "worst_site": "TeNext Coimbatore",
"timezone": "Asia/Kolkata" }
```
`visitors` is unique people and `visits` is arrivals — **do not add the daily
bars of a footfall report to get either.** `fraction_below_gate` travels with
them because it is what says whether the count is a number or a floor.
### `DELETE /api/visitors/{id}` — **erasure**, manager and above ### `DELETE /api/visitors/{id}` — **erasure**, manager and above
Destroys the face template and the photo outright. Keeps the visit rows, Destroys the face template and the photo outright. Keeps the visit rows,
@@ -1091,6 +1151,57 @@ first — a failure there is `502 storage_error` and nothing else is touched —
then the shop PCs' broker logins, then every row (templates, visits, users, then the shop PCs' broker logins, then every row (templates, visits, users,
sessions, cameras) by cascade. sessions, cameras) by cascade.
### The drill-down: `GET /api/admin/clients/{id}` and below
A platform admin has **no company**, so the tenant routes cannot serve this —
they scope by the signed-in account's client, and an admin has none. These take
the merchant in the path instead. `{site}` accepts a slug or a uuid; `{camera}`
accepts the engine's camera id or a uuid.
| | |
|---|---|
| `GET /api/admin/clients/{id}` | the list row plus `owner_email`, `owner_name` |
| `GET …/{id}/sites` | same shape as a tenant's `GET /api/sites` |
| `GET …/{id}/sites/{site}` | one of them |
| `GET …/{id}/sites/{site}/cameras` | **redacted** — see below |
| `GET …/{id}/sites/{site}/cameras/{camera}` | one of them |
| `GET /api/admin/monitoring/summary` | `cameras_total`, `cameras_online`, `merchants_active`, `sites_total`, `events_today`, `as_of` |
**Camera rows here are a different shape from `GET /api/cameras`** and carry no
`host`, `port`, `path`, `username` or `has_password`. A company seeing those
for its own camera is correct; a platform admin browsing somebody else's estate
is a different question, and an RTSP host with a username beside it is most of
a live path into that customer's camera.
```json
[{ "id": "3a96a742-…", "site_id": "93d0565f-…", "site": "TeNext Coimbatore",
"camera_id": "cam2", "label": "Open office", "enabled": true,
"connected": true, "last_seen_at": "2026-09-24T08:33:10Z",
"snapshot_at": "2026-09-24T08:33:10Z", "check": { … } }]
```
`connected` is still three states: `null` = no shop PC has reported yet,
`false` = not connecting, `true` = up.
A shop or camera belonging to a **different** merchant is **404**, never an
empty list — `[]` would say "this shop has no cameras" when the truth is "not
your shop". A suspended merchant stays readable; that is what an admin opens
the console to see. Every read below the merchant list is written to
`audit_log`; the counts-only summary is not.
**Not built, and each is a decision rather than a missing handler:**
- `…/events` and `…/alerts` — there is no events table and the shop PC
deliberately does not send diagnostics (`camera.up`, `person.missed`) to the
server. This needs that decision, a table and a retention policy first; an
endpoint now would return `[]` forever.
- `POST /api/admin/assistant` — the assistant's tools take no client id by
design, which is what makes cross-tenant access impossible rather than merely
disallowed. An admin one needs a principal scoped to the merchant being
viewed, which weakens that. Deliberately not done quietly.
---
## 12. Errors ## 12. Errors
```json ```json
@@ -1105,7 +1216,7 @@ rewritten freely.
|---|---| |---|---|
| 400 | the request was wrong; `message` says how — including an unknown reference in a query filter | | 400 | the request was wrong; `message` says how — including an unknown reference in a query filter |
| 401 | not signed in, or `token_expired` → refresh once and retry | | 401 | not signed in, or `token_expired` → refresh once and retry |
| 403 | `forbidden` — signed in, but this role may not; `message` says who can | | 403 | `forbidden` — signed in, but this role may not; `message` says who can. Also `not_a_tenant_account`: a **platform admin** calling a company's own route, who reads that data through `/api/admin/clients/{id}/…` instead |
| 404 | not found — **also** what another tenant's data returns, always; and what admin routes return to non-admins | | 404 | not found — **also** what another tenant's data returns, always; and what admin routes return to non-admins |
| 409 | a conflict `message` explains (`last_owner`, duplicate address) | | 409 | a conflict `message` explains (`last_owner`, duplicate address) |
| 429 | `too_many_attempts` | | 429 | `too_many_attempts` |