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:
115
API.md
115
API.md
@@ -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` |
|
||||||
|
|||||||
Reference in New Issue
Block a user