Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e1dcf7050 | |||
| 92573e9067 | |||
| 92b12bcb1c | |||
| c50a74de47 | |||
| 0a423ed8cc | |||
| 22196ab9ba | |||
| 5e544eee3d | |||
| 9521cb986b | |||
| 30e01765ae |
10
.gitignore
vendored
10
.gitignore
vendored
@@ -55,3 +55,13 @@ node_modules/
|
|||||||
|
|
||||||
# Backups of .env made when editing camera credentials.
|
# Backups of .env made when editing camera credentials.
|
||||||
/.env.bak-*
|
/.env.bak-*
|
||||||
|
|
||||||
|
# Generated by the wails CLI on every dev run and build, not source.
|
||||||
|
# NOT /desktop/build/ as a whole: appicon.png, darwin/ and windows/ under it
|
||||||
|
# are the Wails project scaffolding (icon, Info.plist, manifest) that a
|
||||||
|
# reproducible Windows build needs. Only the compiled output is ignored.
|
||||||
|
/desktop/frontend/wailsjs/
|
||||||
|
/desktop/frontend/package.json.md5
|
||||||
|
|
||||||
|
# Left behind by `pip install .` of the engine (setuptools metadata), not source.
|
||||||
|
/behavision.egg-info/
|
||||||
|
|||||||
820
API.md
820
API.md
@@ -1,14 +1,272 @@
|
|||||||
# Behavision API — for the web console and a mobile app
|
# Behavision API — for the web console, a mobile app, and platform administration
|
||||||
|
|
||||||
Base URL: `https://platform.loyaly.ai` (locally `http://127.0.0.1:8088`).
|
Base URL: `https://platform.loyaly.ai` (locally `http://127.0.0.1:8088`).
|
||||||
Everything is JSON unless stated. All times are RFC 3339 UTC unless a field says
|
Everything is JSON unless stated. All times are RFC 3339 UTC unless a field says
|
||||||
otherwise.
|
otherwise. Every shape below is taken from the server's own types, not written
|
||||||
|
from memory — if the two ever disagree, the server is right and this file has a
|
||||||
|
bug.
|
||||||
|
|
||||||
There is **one API**, not a web one and a mobile one. The web console in this
|
There is **one API**, not a web one and a mobile one. The web console in this
|
||||||
repository uses exactly these calls; anything it can do, an app can do.
|
repository uses exactly these calls; anything it can do, an app can do.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Who is calling: three audiences, one API
|
||||||
|
|
||||||
|
| audience | who they are | signs in with | what they see |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Merchant** | a company's owner, managers and shop-floor staff | email + password | their own company's shops, cameras, customers, arrivals |
|
||||||
|
| **Platform admin** | Loyaly, running the platform | email + password, an account with **no company** | the list of companies, and nothing inside any of them |
|
||||||
|
| **Shop PC** | the agent running on a till or back-office PC | an installation code, once; its own token thereafter | `/api/agent/*` only — **not for a web or mobile client** |
|
||||||
|
|
||||||
|
A merchant user has one of three roles. They are strictly nested — each can do
|
||||||
|
everything the one below can:
|
||||||
|
|
||||||
|
| role | can additionally |
|
||||||
|
|---|---|
|
||||||
|
| `staff` | see arrivals, search customers, edit a customer's profile, record a purchase |
|
||||||
|
| `manager` | manage cameras, create / invite / reset / remove team members, issue shop-PC installation codes, erase a customer |
|
||||||
|
| `owner` | promote somebody to owner |
|
||||||
|
|
||||||
|
A platform admin has `role: "admin"` **and an empty `client_id`** — both
|
||||||
|
together, never the role alone. A tenant-scoped account with the role set to
|
||||||
|
`admin` is rejected by every admin endpoint. Admins can call merchant endpoints
|
||||||
|
too, but with no company of their own they see empty lists; the admin screens
|
||||||
|
are `/api/admin/*`.
|
||||||
|
|
||||||
|
### Permission matrix
|
||||||
|
|
||||||
|
Every route, and the least role that may call it. `authed` means any signed-in
|
||||||
|
user; the tenant is always taken from the session and never from the request.
|
||||||
|
|
||||||
|
| route | least role |
|
||||||
|
|---|---|
|
||||||
|
| `POST /api/auth/login` `refresh` · `GET /api/auth/invitation` · `POST /api/auth/register` | **no auth** |
|
||||||
|
| `POST /api/auth/logout` · `GET /api/auth/me` · `/api/auth/sessions*` | authed |
|
||||||
|
| `GET /api/visits` · `GET /api/visits/stream` | authed |
|
||||||
|
| `GET /api/visitors` · `GET /api/visitors/{id}/history` · `GET /api/visitors/{id}/image` · `GET /api/faces/{id}` | authed |
|
||||||
|
| `PUT /api/visitors/{id}/profile` · `POST /api/purchases` | staff |
|
||||||
|
| `DELETE /api/visitors/{id}` — erasure | manager |
|
||||||
|
| `GET /api/sites` · `GET /api/sites/{site}/check` · `GET /api/cameras` · `GET /api/cameras/{id}/snapshot.jpg` · `GET /api/cameras/{id}/live` | authed |
|
||||||
|
| `POST /api/sites/{site}/cameras` · `PATCH` / `DELETE /api/cameras/{id}` · `POST /api/cameras/{id}/check` | manager |
|
||||||
|
| `POST /api/sites/{site}/enrolment-code` | manager |
|
||||||
|
| `GET /api/team` | authed (tenant users only) |
|
||||||
|
| `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 |
|
||||||
|
| `POST /api/assistant` | authed |
|
||||||
|
| `GET` / `POST /api/admin/clients` | **platform admin** |
|
||||||
|
| `/api/agent/*` | **shop PC token** — never a user |
|
||||||
|
|
||||||
|
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
|
||||||
|
has no business learning that a resource exists.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The onboarding chain — who creates whom
|
||||||
|
|
||||||
|
Three tiers. Each one creates the login for the next, and nobody ever creates
|
||||||
|
their own from nothing.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────┐ creates ┌──────────────────┐ creates ┌──────────────────┐
|
||||||
|
│ Platform admin │ ───────────▶ │ Merchant owner │ ───────────▶ │ Sales staff │
|
||||||
|
│ (web) │ company + │ (web) │ login, pw │ (mobile) │
|
||||||
|
│ │ owner login │ │ shown once │ │
|
||||||
|
└──────────────────┘ └──────────────────┘ └──────────────────┘
|
||||||
|
POST /api/admin/clients POST /api/team/members POST /api/auth/login
|
||||||
|
(or /api/team/invitations → (or /api/auth/register
|
||||||
|
a code they redeem themselves) with the code)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tier 1 — the platform admin registers a merchant
|
||||||
|
|
||||||
|
Signed in as an account with `role: "admin"` and no company.
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/auth/login
|
||||||
|
{ "email": "admin@loyaly.ai", "password": "…", "device": "Admin console" }
|
||||||
|
|
||||||
|
POST /api/admin/clients
|
||||||
|
{ "company_name": "TeNext Retail", "owner_email": "suriya@tenext.in",
|
||||||
|
"owner_name": "Suriya" }
|
||||||
|
→ 201 { "client_id": "…", "slug": "tenext-retail",
|
||||||
|
"owner_email": "suriya@tenext.in", "password": "xK9…" }
|
||||||
|
```
|
||||||
|
|
||||||
|
`password` is generated and shown **once**. The admin hands the owner their
|
||||||
|
email and that password — that is the merchant login. Leave `password` out of
|
||||||
|
the request; a password an operator invents for someone else is weak and
|
||||||
|
travels over chat.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/admin/clients → every merchant, with site and user counts
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tier 2 — the merchant owner registers sales staff
|
||||||
|
|
||||||
|
Signed in as the owner (or any manager). Two ways to do it; use whichever fits
|
||||||
|
the moment.
|
||||||
|
|
||||||
|
**Directly — create the login and hand it over.** For a salesperson being set
|
||||||
|
up before their first shift, without a phone in hand. Exactly how the admin
|
||||||
|
created the merchant in Tier 1.
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/auth/login
|
||||||
|
{ "email": "suriya@tenext.in", "password": "xK9…", "device": "Head office" }
|
||||||
|
|
||||||
|
POST /api/team/members
|
||||||
|
{ "email": "priya@tenext.in", "full_name": "Priya R", "role": "staff" }
|
||||||
|
→ 201 { "id": "…", "email": "priya@tenext.in", "full_name": "Priya R",
|
||||||
|
"role": "staff", "active": true, "created_at": "…",
|
||||||
|
"password": "m4kq…" } ← shown ONCE
|
||||||
|
```
|
||||||
|
|
||||||
|
Leave `password` out and one is generated; give one and it is used (8
|
||||||
|
characters minimum). Either way it is returned exactly once — write it on the
|
||||||
|
card now. The salesperson signs in on their phone with that email and
|
||||||
|
password, and the merchant login is done.
|
||||||
|
|
||||||
|
**By invitation — the salesperson chooses their own password.** Better when
|
||||||
|
they have their phone: the merchant never sees or handles a staff password.
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/team/invitations
|
||||||
|
{ "email": "priya@tenext.in", "full_name": "Priya R", "role": "staff",
|
||||||
|
"expires_in_days": 7 }
|
||||||
|
→ 201 { "id": "…", "email": "priya@tenext.in", "role": "staff",
|
||||||
|
"code": "LQOUHR-AYYTPE-7Q756N-PGAAN6", "expires_at": "…" }
|
||||||
|
```
|
||||||
|
|
||||||
|
`code` is shown **once** and is what the merchant gives the salesperson —
|
||||||
|
read aloud, WhatsApp, printed on a card. Single-use, expires. They redeem it in
|
||||||
|
Tier 3 and pick a password there.
|
||||||
|
|
||||||
|
**When they forget it** — which is the everyday case on a shop floor:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/team/{id}/password { } or { "password": "chosen" }
|
||||||
|
→ 200 { "password": "n7xw…" } ← shown ONCE
|
||||||
|
```
|
||||||
|
|
||||||
|
Resets the password **and signs them out of every device** in one step,
|
||||||
|
because the other reason a manager resets a password is a lost phone, and a
|
||||||
|
reset that left that phone signed in would look complete while fixing nothing.
|
||||||
|
|
||||||
|
Managing the team afterwards:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/team → everyone: role, active, last login
|
||||||
|
GET /api/team/invitations → codes still unredeemed (without the code)
|
||||||
|
DELETE /api/team/invitations/{id} → withdraw one before it is used
|
||||||
|
PATCH /api/team/{id} { "role": "manager" } → promote
|
||||||
|
PATCH /api/team/{id} { "active": false } → they have left; signs them out now
|
||||||
|
```
|
||||||
|
|
||||||
|
The owner also sets the shop up from the same login — `POST
|
||||||
|
/api/sites/{site}/enrolment-code` for the shop PC, `POST
|
||||||
|
/api/sites/{site}/cameras` for cameras — see §8.
|
||||||
|
|
||||||
|
### Tier 3 — the salesperson gets their mobile login
|
||||||
|
|
||||||
|
If the merchant created the login directly, they already have an email and
|
||||||
|
password: skip straight to `POST /api/auth/login` below. Otherwise they have a
|
||||||
|
code.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/auth/invitation?code=LQOUHR-AYYTPE-7Q756N-PGAAN6 (no auth)
|
||||||
|
→ { "client_name": "TeNext Retail", "email": "priya@tenext.in",
|
||||||
|
"full_name": "Priya R", "role": "staff" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Show *"Join TeNext Retail as Priya R"* and ask for a password. Then:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/auth/register (no auth)
|
||||||
|
{ "code": "LQOUHR-AYYTPE-7Q756N-PGAAN6",
|
||||||
|
"full_name": "Priya R", "password": "…", "device": "Pixel 8" }
|
||||||
|
→ 201 { "access_token": "…", "refresh_token": "…", "expires_at": "…",
|
||||||
|
"user": { …, "role": "staff", "client_name": "TeNext Retail" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
**They are signed in.** Do not send them to a login form. From the next day:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/auth/login { "email": "priya@tenext.in", "password": "…", "device": "Pixel 8" }
|
||||||
|
POST /api/auth/refresh { "refresh_token": "…", "device": "Pixel 8" }
|
||||||
|
```
|
||||||
|
|
||||||
|
And the screens a salesperson uses — all `staff` may call:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/visits?limit=30 then ?cursor=… who just walked in
|
||||||
|
GET /api/visits/stream the same, pushed
|
||||||
|
GET /api/visitors?q=priya find a customer
|
||||||
|
GET /api/visitors/V-42/history their past visits
|
||||||
|
PUT /api/visitors/V-42/profile give them a name
|
||||||
|
POST /api/purchases record a sale
|
||||||
|
GET /api/visitors/V-42/image their photo, if any
|
||||||
|
```
|
||||||
|
|
||||||
|
Do **not** send `email` or `role` on register — they come from the code, and a
|
||||||
|
body naming either is refused. That is what stops a forwarded code becoming
|
||||||
|
somebody else's account.
|
||||||
|
|
||||||
|
### What does not exist, stated plainly
|
||||||
|
|
||||||
|
- **There is no mobile app in this repository.** Tier 3 is a complete API with
|
||||||
|
no client yet. Everything above is what that app will call.
|
||||||
|
- **The admin cannot reset a merchant owner's password over HTTP**, nor suspend
|
||||||
|
or delete a merchant. Today that is `behavision-server provision` on the
|
||||||
|
server.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick starts
|
||||||
|
|
||||||
|
### A mobile app for shop-floor staff
|
||||||
|
|
||||||
|
The one screen this app exists for is *who just walked in*.
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/auth/login → access_token, refresh_token
|
||||||
|
GET /api/visits?limit=30 → arrivals[], cursor (first load)
|
||||||
|
GET /api/visits?cursor=… → arrivals[], cursor (every 3–5 s)
|
||||||
|
tap a row →
|
||||||
|
GET /api/visitors/{visitor_ref}/history → their past visits
|
||||||
|
PUT /api/visitors/{visitor_ref}/profile → give them a name
|
||||||
|
```
|
||||||
|
|
||||||
|
Store `refresh_token` securely; read §1 for the three refresh rules before
|
||||||
|
writing the client, because all three have already been bugs here.
|
||||||
|
|
||||||
|
### A web console for an owner or manager
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/auth/login
|
||||||
|
GET /api/sites → every shop: online? cameras up? faces usable?
|
||||||
|
GET /api/sites/{slug}/check → why a shop is or is not working
|
||||||
|
GET /api/cameras → every camera with its latest still
|
||||||
|
POST /api/sites/{slug}/cameras → add one
|
||||||
|
POST /api/cameras/{id}/check → prove it can see faces
|
||||||
|
GET /api/visits/stream → live arrivals (SSE)
|
||||||
|
GET /api/reports/footfall?from=&to= → the numbers, with their confidence
|
||||||
|
```
|
||||||
|
|
||||||
|
### Platform administration
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/auth/login (an account with no company)
|
||||||
|
GET /api/admin/clients → every company
|
||||||
|
POST /api/admin/clients → create one, with its owner
|
||||||
|
```
|
||||||
|
|
||||||
|
That is the whole admin surface today. Everything inside a company is the
|
||||||
|
company's own business and is reached by signing in as one of its users.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 0. Identifiers — you do not have to use uuids
|
## 0. Identifiers — you do not have to use uuids
|
||||||
|
|
||||||
Every id in the database is a uuid and every one of them still works. But a uuid
|
Every id in the database is a uuid and every one of them still works. But a uuid
|
||||||
@@ -19,14 +277,14 @@ is not something a person can say, type or recognise, so **anywhere a path or a
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| customer | `V-<number>` | `V-42` — also accepts bare `42` |
|
| customer | `V-<number>` | `V-42` — also accepts bare `42` |
|
||||||
| shop | its slug | `chennai` |
|
| shop | its slug | `chennai` |
|
||||||
| camera | the id the engine knows it by | `Office1` |
|
| camera | the id the engine knows it by | `cam1` |
|
||||||
| person | their email address | `priya@tenext.in` |
|
| person | their email address | `priya@tenext.in` |
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /api/visitors/3446ec35-2c1f-4c8e-9a77-0d1e2f3a4b5c/history
|
GET /api/visitors/3446ec35-2c1f-4c8e-9a77-0d1e2f3a4b5c/history
|
||||||
GET /api/visitors/V-42/history ← the same customer
|
GET /api/visitors/V-42/history ← the same customer
|
||||||
GET /api/visits?site=chennai
|
GET /api/visits?site=chennai
|
||||||
PATCH /api/cameras/Office1
|
PATCH /api/cameras/cam1
|
||||||
```
|
```
|
||||||
|
|
||||||
The customer number is **per company**, so `V-42` at one tenant and `V-42` at
|
The customer number is **per company**, so `V-42` at one tenant and `V-42` at
|
||||||
@@ -35,7 +293,7 @@ the session belongs to. It is also what the label says: a customer nobody has
|
|||||||
named is called `Visitor 42`, and `ref` on every customer object carries `V-42`
|
named is called `Visitor 42`, and `ref` on every customer object carries `V-42`
|
||||||
for display.
|
for display.
|
||||||
|
|
||||||
Two shops in one company may each have a camera called `Office1`. That is
|
Two shops in one company may each have a camera called `cam1`. That is
|
||||||
ambiguous, so it resolves to **nothing** rather than to a guess — use the uuid,
|
ambiguous, so it resolves to **nothing** rather than to a guess — use the uuid,
|
||||||
or scope by site.
|
or scope by site.
|
||||||
|
|
||||||
@@ -49,9 +307,6 @@ URL, a config file or a scheduled report. The **display name** beside it
|
|||||||
(`"TeNext Chennai"`, `"Front door"`) is free to change and should be; do not key
|
(`"TeNext Chennai"`, `"Front door"`) is free to change and should be; do not key
|
||||||
on it.
|
on it.
|
||||||
|
|
||||||
The uuid is still returned everywhere and still works. Use it if you want a key
|
|
||||||
you never have to think about; use the reference when a person will read it.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Signing in
|
## 1. Signing in
|
||||||
@@ -67,20 +322,19 @@ you never have to think about; use the reference when a person will read it.
|
|||||||
"access_token": "…",
|
"access_token": "…",
|
||||||
"refresh_token": "…",
|
"refresh_token": "…",
|
||||||
"expires_at": "2026-09-05T18:00:00Z",
|
"expires_at": "2026-09-05T18:00:00Z",
|
||||||
"user": { "id": "…", "email": "…", "full_name": "Priya R",
|
"user": { "id": "…", "email": "priya@tenext.in", "full_name": "Priya R",
|
||||||
"role": "staff", "client_id": "…", "client_name": "TeNext Retail" }
|
"role": "staff", "client_id": "…", "client_name": "TeNext Retail" }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Send `Authorization: Bearer <access_token>` on every other call.
|
Send `Authorization: Bearer <access_token>` on every other call. A platform
|
||||||
|
admin's `user` has `"role": "admin"` and `"client_id": ""`.
|
||||||
|
|
||||||
**`device` is worth sending.** It is the only thing that lets somebody look at
|
**`device` is worth sending.** It is the only thing that lets somebody look at
|
||||||
their list of signed-in devices and tell which one to sign out. Keep it coarse
|
their list of signed-in devices and tell which one to sign out. Keep it coarse
|
||||||
and human — `"Pixel 8"`, `"Shop till"` — never a device identifier; a
|
and human — `"Pixel 8"`, `"Shop till"` — never a device identifier; a
|
||||||
fingerprint here is a tracking signal nobody asked for.
|
fingerprint here is a tracking signal nobody asked for.
|
||||||
|
|
||||||
Failures:
|
|
||||||
|
|
||||||
| status | `error` | what it means |
|
| status | `error` | what it means |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 401 | `bad_credentials` | Wrong password **or** no such account. Deliberately the same answer: telling them apart turns this form into a way to find out who works at a customer. Show the server's `message`. |
|
| 401 | `bad_credentials` | Wrong password **or** no such account. Deliberately the same answer: telling them apart turns this form into a way to find out who works at a customer. Show the server's `message`. |
|
||||||
@@ -132,9 +386,9 @@ and hands it over; the holder chooses their own password.
|
|||||||
```
|
```
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "id": "…", "email": "arjun@tenext.in", "role": "manager",
|
{ "id": "…", "email": "arjun@tenext.in", "full_name": "Arjun", "role": "manager",
|
||||||
"code": "LQOUHR-AYYTPE-7Q756N-PGAAN6",
|
"code": "LQOUHR-AYYTPE-7Q756N-PGAAN6",
|
||||||
"expires_at": "…", "created_at": "…" }
|
"invited_by": "suriya@tenext.in", "expires_at": "…", "created_at": "…" }
|
||||||
```
|
```
|
||||||
|
|
||||||
**`code` is returned exactly once and is not recoverable.** Only a hash is
|
**`code` is returned exactly once and is not recoverable.** Only a hash is
|
||||||
@@ -178,9 +432,10 @@ wrong code) does **not** spend the invitation.
|
|||||||
| 404 | `invalid_code` |
|
| 404 | `invalid_code` |
|
||||||
| 409 | `conflict` — that address already has an account; sign in instead |
|
| 409 | `conflict` — that address already has an account; sign in instead |
|
||||||
|
|
||||||
### `GET` / `DELETE /api/team/invitations[/{id}]` — manager or owner
|
### `GET /api/team/invitations` · `DELETE /api/team/invitations/{id}` — manager or owner
|
||||||
|
|
||||||
List what is still pending, or withdraw one before it is used.
|
List what is still pending (same shape as above, **without** `code`), or
|
||||||
|
withdraw one before it is used.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -202,21 +457,74 @@ somebody signs out the device in their hand. `revoke-others` deliberately keeps
|
|||||||
the caller's own session.
|
the caller's own session.
|
||||||
|
|
||||||
A person can revoke only their own sessions. To remove a colleague's access,
|
A person can revoke only their own sessions. To remove a colleague's access,
|
||||||
deactivate them (below); that revokes every session they hold.
|
deactivate them (§4); that revokes every session they hold.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. The team
|
## 4. The team
|
||||||
|
|
||||||
| | |
|
### `GET /api/team` — anyone in the company
|
||||||
|---|---|
|
|
||||||
| `GET /api/team` | everybody in this company |
|
|
||||||
| `PATCH /api/team/{id}` | `{"role": "manager"}` and/or `{"active": false}` |
|
|
||||||
|
|
||||||
Deactivating signs that person out **immediately** and stops them signing back
|
```json
|
||||||
in. Reactivating restores the account but not their old sessions.
|
[{ "id": "…", "email": "arjun@tenext.in", "full_name": "Arjun",
|
||||||
|
"role": "manager", "active": true,
|
||||||
|
"last_login_at": "…", "created_at": "…" }]
|
||||||
|
```
|
||||||
|
|
||||||
409 `last_owner` if the change would leave the company with no active owner.
|
### `POST /api/team/members` — manager or owner
|
||||||
|
|
||||||
|
Create a login directly and hand it over. The alternative to an invitation
|
||||||
|
(§2) for somebody without a phone in hand.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "email": "priya@tenext.in", "full_name": "Priya R", "role": "staff",
|
||||||
|
"password": "" }
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "id": "…", "email": "priya@tenext.in", "full_name": "Priya R",
|
||||||
|
"role": "staff", "active": true, "last_login_at": "", "created_at": "…",
|
||||||
|
"password": "m4kq…" }
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`password` is returned once** and is not recoverable. Leave it empty in
|
||||||
|
the request and one is generated; supply one and it must be 8+ characters.
|
||||||
|
- `role` is `staff` (default), `manager` or `owner`. Only an owner may create
|
||||||
|
an owner; `admin` is refused.
|
||||||
|
- **409 `conflict`** if that email already has an account anywhere.
|
||||||
|
|
||||||
|
### `POST /api/team/{id}/password` — manager or owner
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ } or { "password": "chosen-one" }
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "password": "n7xw…" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Sets a new password (generated unless given) and **revokes every session the
|
||||||
|
member holds**, in one transaction. Returns the new password once. A member of
|
||||||
|
another company is **404**, never 403.
|
||||||
|
|
||||||
|
There is deliberately no self-service reset and no reset-by-email: a shop-floor
|
||||||
|
account often has no mailbox anyone checks, and the person who can vouch for
|
||||||
|
the salesperson standing in front of them is their manager.
|
||||||
|
|
||||||
|
### `PATCH /api/team/{id}` — manager or owner
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "role": "manager" } or { "active": false } or both
|
||||||
|
```
|
||||||
|
|
||||||
|
Both fields optional; an omitted field is left alone. Deactivating signs that
|
||||||
|
person out **immediately** and stops them signing back in. Reactivating restores
|
||||||
|
the account but not their old sessions. A manager cannot promote anyone to
|
||||||
|
owner.
|
||||||
|
|
||||||
|
**409 `last_owner`** if the change would leave the company with no active owner.
|
||||||
|
There is no way back from that except a shell on the server, which is exactly
|
||||||
|
what this endpoint exists to stop needing.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -232,7 +540,7 @@ in. Reactivating restores the account but not their old sessions.
|
|||||||
"visit_id": "…",
|
"visit_id": "…",
|
||||||
"occurred_at": "2026-09-05T06:01:45Z",
|
"occurred_at": "2026-09-05T06:01:45Z",
|
||||||
"site_id": "…", "site": "TeNext Chennai", "site_slug": "chennai",
|
"site_id": "…", "site": "TeNext Chennai", "site_slug": "chennai",
|
||||||
"camera_id": "Office1",
|
"camera_id": "cam1",
|
||||||
"visitor_id": "…", "visitor_ref": "V-42", "label": "Priya",
|
"visitor_id": "…", "visitor_ref": "V-42", "label": "Priya",
|
||||||
"is_new_visitor": false, "similarity": 0.71, "quality": 0.66,
|
"is_new_visitor": false, "similarity": 0.71, "quality": 0.66,
|
||||||
"attributes": { "gender": "Male", "age": 32, "emotion": "neutral" },
|
"attributes": { "gender": "Male", "age": 32, "emotion": "neutral" },
|
||||||
@@ -253,22 +561,20 @@ again without one.
|
|||||||
|
|
||||||
An empty poll returns your own cursor back, not an empty string.
|
An empty poll returns your own cursor back, not an empty string.
|
||||||
|
|
||||||
**There is no `seq` on the wire.** It existed as a convenience for "have I
|
Of the three ids on an arrival, only one is a reference you would type:
|
||||||
fallen behind"; `visits.seq` is a plain bigserial, so it counted every visit on
|
|
||||||
the *platform* and put the total footfall of every customer we have on every row
|
|
||||||
of every tenant's feed. The cursor — opaque and version-prefixed — is the
|
|
||||||
supported way to know your position, and the only one you need.
|
|
||||||
|
|
||||||
Of the three ids on an arrival, only one of them is a reference you would type:
|
|
||||||
`site_slug`. `visit_id` addresses no route — it is a key for de-duplicating
|
`site_slug`. `visit_id` addresses no route — it is a key for de-duplicating
|
||||||
rows, since delivery is at-least-once. And the uuid inside an image URL is
|
rows, since delivery is at-least-once. And the uuid inside an image URL is
|
||||||
**deliberately random**: a derived or sequential one would let somebody
|
**deliberately random**: a derived or sequential one would let somebody
|
||||||
enumerate a shop's customers by date.
|
enumerate a shop's customers by date.
|
||||||
|
|
||||||
|
A visit may have **no `visitor_id`** (a shop counting footfall without
|
||||||
|
identifying people) or a blank `label` (a customer who was erased). Both are
|
||||||
|
real people who walked in; render them, do not drop them.
|
||||||
|
|
||||||
### `GET /api/visits/stream` — server-sent events
|
### `GET /api/visits/stream` — server-sent events
|
||||||
|
|
||||||
The same rows, pushed. Send `Authorization` (so `EventSource` will not do —
|
The same rows, pushed. Send `Authorization` (so a browser `EventSource` will not
|
||||||
read the stream with an HTTP client) and resume with `Last-Event-ID` or
|
do — read the stream with an HTTP client) and resume with `Last-Event-ID` or
|
||||||
`?cursor=`. Falls back to polling cleanly; the failure mode is latency, never
|
`?cursor=`. Falls back to polling cleanly; the failure mode is latency, never
|
||||||
silence.
|
silence.
|
||||||
|
|
||||||
@@ -292,7 +598,7 @@ how you tell them apart. Do not infer it from the shape of the URL.
|
|||||||
| | `auth` | how to load it |
|
| | `auth` | how to load it |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| presigned object-storage link | absent/false | use it directly; it carries its own signature and expires in `expires_in` seconds |
|
| presigned object-storage link | absent/false | use it directly; it carries its own signature and expires in `expires_in` seconds |
|
||||||
| served by this API | `true` | send `Authorization: Bearer …` |
|
| served by this API (`/api/faces/{id}`) | `true` | send `Authorization: Bearer …` |
|
||||||
|
|
||||||
- **Mobile**: an image view can attach the header —
|
- **Mobile**: an image view can attach the header —
|
||||||
`Image source={{ uri, headers: { Authorization: 'Bearer …' } }}`.
|
`Image source={{ uri, headers: { Authorization: 'Bearer …' } }}`.
|
||||||
@@ -303,6 +609,12 @@ how you tell them apart. Do not infer it from the shape of the URL.
|
|||||||
Prefix a relative URL with the base URL. Treat any relative URL as needing auth
|
Prefix a relative URL with the base URL. Treat any relative URL as needing auth
|
||||||
whether or not the flag is set: there is no public one.
|
whether or not the flag is set: there is no public one.
|
||||||
|
|
||||||
|
### `GET /api/faces/{id}`
|
||||||
|
|
||||||
|
The bytes behind an `auth: true` URL. `image/jpeg`, session required, **404 to
|
||||||
|
any other tenant**. You will not construct this URL yourself — it arrives inside
|
||||||
|
an `image` object.
|
||||||
|
|
||||||
### `GET /api/visitors/{id}/image`
|
### `GET /api/visitors/{id}/image`
|
||||||
|
|
||||||
The same `image` object for one customer's latest photo. 404 `no_image` (nothing
|
The same `image` object for one customer's latest photo. 404 `no_image` (nothing
|
||||||
@@ -317,59 +629,400 @@ two rows in *"who looked at my customers"* for one glance at one person.
|
|||||||
|
|
||||||
## 7. Customers
|
## 7. Customers
|
||||||
|
|
||||||
| | |
|
### `GET /api/visitors?q=…&limit=50` — search
|
||||||
|---|---|
|
|
||||||
| `GET /api/visitors?q=…` | search by name, phone, or customer number (`42`, `V-42`) |
|
|
||||||
| `GET /api/visitors/{id}/history` | their past visits |
|
|
||||||
| `PUT /api/visitors/{id}/profile` | name, phone, notes — staff and above |
|
|
||||||
| `DELETE /api/visitors/{id}` | **erasure** — manager and above |
|
|
||||||
| `POST /api/purchases` | link a sale to a visit |
|
|
||||||
|
|
||||||
`{id}` is a uuid **or** `V-42` **or** `42`. Every customer object carries `ref`
|
`q` matches name, phone, email, or customer number (`42` or `V-42`). Omit `q`
|
||||||
("V-42") beside `id`, and `label` reads "Visitor 42" until somebody names them.
|
for the most recently seen customers. `limit` defaults to 50, capped at 500.
|
||||||
|
Erased customers never appear.
|
||||||
|
|
||||||
Erasure destroys the face template and the photo outright and keeps the visit
|
```json
|
||||||
rows, unlinked. It is irreversible. If the photo cannot be deleted the whole
|
[{ "id": "…", "ref": "V-42", "label": "Priya",
|
||||||
request fails with **502** and *nothing* is erased — so an error there means the
|
"full_name": "Priya R", "phone": "+91 …", "email": "",
|
||||||
data is still there, and must be reported as a failure, never swallowed.
|
"visit_count": 7, "first_seen_at": "…", "last_seen_at": "…",
|
||||||
|
"has_profile": true, "has_consent": false }]
|
||||||
|
```
|
||||||
|
|
||||||
|
`label` reads `"Visitor 42"` until somebody names them, then whatever they were
|
||||||
|
named. `ref` is what to show beside it.
|
||||||
|
|
||||||
|
### `GET /api/visitors/{id}/history`
|
||||||
|
|
||||||
|
```json
|
||||||
|
[{ "id": "…", "occurred_at": "…", "site": "TeNext Chennai", "camera_id": "cam1",
|
||||||
|
"is_new_visitor": false, "similarity": 0.71, "quality": 0.66,
|
||||||
|
"attributes": { "gender": "Male", "age": 32, "emotion": "neutral" } }]
|
||||||
|
```
|
||||||
|
|
||||||
|
Newest first. `is_new_visitor` is true on exactly one row — the visit that
|
||||||
|
enrolled them.
|
||||||
|
|
||||||
|
### `PUT /api/visitors/{id}/profile` — staff and above
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "full_name": "Priya R", "phone": "+91 …", "email": "",
|
||||||
|
"gender": "", "date_of_birth": "", "notes": "Prefers the window table",
|
||||||
|
"consent": true }
|
||||||
|
```
|
||||||
|
|
||||||
|
Whole-object replace. `consent` records that the customer agreed to be
|
||||||
|
recognised; it is kept — revoked, not deleted — through erasure, because the
|
||||||
|
record of what you were permitted to do is what an auditor asks for.
|
||||||
|
|
||||||
|
### `POST /api/purchases` — staff and above
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "visitor_id": "V-42", "site_id": "chennai",
|
||||||
|
"amount": 1250.00, "currency": "INR",
|
||||||
|
"items": ["…"], "source": "till", "notes": "" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Links a sale to a customer so the conversion report can say who bought. **One
|
||||||
|
currency per report** — see §9.
|
||||||
|
|
||||||
|
### `DELETE /api/visitors/{id}` — **erasure**, manager and above
|
||||||
|
|
||||||
|
Destroys the face template and the photo outright. Keeps the visit rows,
|
||||||
|
unlinked (they are the shop's own footfall history). Keeps the consent record,
|
||||||
|
revoked. Keeps the customer row with a deletion mark so the same face is not
|
||||||
|
re-enrolled next week as a brand-new person.
|
||||||
|
|
||||||
|
It is irreversible. If the photo cannot be deleted the whole request fails with
|
||||||
|
**502** and *nothing* is erased — so an error there means the data is still
|
||||||
|
there, and must be reported as a failure, never swallowed.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. Shops, cameras, reports
|
## 8. Shops and cameras
|
||||||
|
|
||||||
| | |
|
### `GET /api/sites`
|
||||||
|---|---|
|
|
||||||
| `GET /api/sites` | estate health: online, cameras up, `fraction_below_gate` |
|
|
||||||
| `GET /api/sites/{id}/check` | five-step smoke test for one shop |
|
|
||||||
| `GET /api/cameras` | cameras and their latest still |
|
|
||||||
| `GET /api/cameras/{id}/live` | live view relayed from the shop PC (SSE) |
|
|
||||||
| `GET /api/reports/footfall` | `?from=&to=&site=&tz=&bucket=` |
|
|
||||||
| `GET /api/reports/conversion` | same parameters; revenue and basket size |
|
|
||||||
|
|
||||||
**`site` and `site_id` are both accepted everywhere**, and either may be a slug
|
Every shop in the company, with the three facts that tell a quiet week from an
|
||||||
or a uuid. They used to differ per endpoint, which mattered because an unknown
|
unplugged PC.
|
||||||
query parameter is silently ignored — so getting it the wrong way round returned
|
|
||||||
the whole estate instead of an error. `site` is the documented spelling.
|
|
||||||
|
|
||||||
Dates are `YYYY-MM-DD`. `to` is **inclusive**: "1st to the 7th" includes the
|
```json
|
||||||
7th.
|
[{ "site_id": "…", "slug": "chennai", "name": "TeNext Chennai",
|
||||||
|
"timezone": "Asia/Kolkata",
|
||||||
|
"online": true, "last_heartbeat_at": "…", "last_event_at": "…",
|
||||||
|
"recognition_model": "w600k_r50", "agent_version": "0.3.0",
|
||||||
|
"cameras_up": 2, "cameras_total": 2,
|
||||||
|
"fraction_below_gate": 0.47,
|
||||||
|
"queued": 0, "dropped": 0 }]
|
||||||
|
```
|
||||||
|
|
||||||
Two arithmetic traps the API is explicit about, so a client does not reinvent
|
- `online` is **three missed heartbeats**, not one. One is a dropped packet.
|
||||||
|
- `fraction_below_gate` is the share of faces the cameras saw that were too
|
||||||
|
poor to use. It is the number that decides whether the footfall figure means
|
||||||
|
anything: under 0.2 is good, under 0.5 is marginal, above is a camera that
|
||||||
|
needs moving. It reports the **worst** camera, not the average.
|
||||||
|
- `queued` is footfall waiting on the shop PC's disk to be sent; `dropped` is
|
||||||
|
footfall lost because that queue overflowed. Non-zero `dropped` is a report
|
||||||
|
that is wrong in a way the report itself cannot show.
|
||||||
|
|
||||||
|
### `GET /api/sites/{site}/check`
|
||||||
|
|
||||||
|
Five ordered steps that answer *is this shop working*, assembled from what head
|
||||||
|
office already knows — so it costs no round trip and works when the PC is off,
|
||||||
|
which is itself one of the answers.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "site_id": "…", "site": "TeNext Chennai", "ok": false,
|
||||||
|
"steps": [
|
||||||
|
{ "name": "The shop's PC is online", "status": "pass", "detail": "…" },
|
||||||
|
{ "name": "Recognition is running", "status": "pass", "detail": "…" },
|
||||||
|
{ "name": "Cameras are connected", "status": "pass", "detail": "2 of 2" },
|
||||||
|
{ "name": "Cameras can recognise faces", "status": "fail",
|
||||||
|
"detail": "47% of faces too poor to enrol", "advice": "…" },
|
||||||
|
{ "name": "Visits are reaching head office","status": "unknown", "detail": "…" }
|
||||||
|
] }
|
||||||
|
```
|
||||||
|
|
||||||
|
`status` is `pass | warn | fail | unknown`. **Checking stops at the first
|
||||||
|
failure**; later steps report `unknown`, which is its own state — asking whether
|
||||||
|
cameras see faces on a PC that is switched off produces an answer that means
|
||||||
|
nothing. `ok` is true only when every step passed.
|
||||||
|
|
||||||
|
### `GET /api/cameras`
|
||||||
|
|
||||||
|
Every camera in the company — how it is configured **and** whether it is
|
||||||
|
working, in one object, because those are the two halves of the only question
|
||||||
|
anyone asks.
|
||||||
|
|
||||||
|
```json
|
||||||
|
[{ "id": "…", "site_id": "…", "site": "TeNext Chennai",
|
||||||
|
"camera_id": "cam1", "label": "Front door",
|
||||||
|
"host": "192.168.1.122", "port": 554, "path": "/ch0_0.264",
|
||||||
|
"username": "admin", "has_password": true,
|
||||||
|
"max_width": 1280, "tuning": {}, "enabled": true, "revision": 6,
|
||||||
|
"connected": true, "last_seen_at": "…",
|
||||||
|
"snapshot": { "available": true, "url": "/api/cameras/…/snapshot.jpg", "auth": true },
|
||||||
|
"snapshot_at": "…",
|
||||||
|
"check": { "kind": "placement", "state": "done", "ok": false,
|
||||||
|
"verdict": "marginal",
|
||||||
|
"headline": "Half the faces this camera sees are too poor to enrol",
|
||||||
|
"advice": ["Lower the camera to head height", "…"],
|
||||||
|
"detail": { "faces": 31, "fraction_below_gate": 0.47 },
|
||||||
|
"image": { "available": true, "url": "…", "auth": true } } }]
|
||||||
|
```
|
||||||
|
|
||||||
|
Three things a client must render correctly:
|
||||||
|
|
||||||
|
- **`has_password`, never the password.** A camera credential is a live path
|
||||||
|
into the camera. The API structurally cannot return it to a user.
|
||||||
|
- **`connected` is a pointer**: `null` means *"no shop PC has reported on this
|
||||||
|
camera yet"*, `false` means *"not connecting"*. A bare `false` says the second
|
||||||
|
when it means the first, and sends an installer to check cabling on a camera
|
||||||
|
nobody has tried to reach.
|
||||||
|
- **`check` is always present.** An empty one (`state` absent) means *never
|
||||||
|
checked*; `state: "done", ok: false` means *checked and failed*. Those are
|
||||||
|
different situations and a client must not guess from an absent field.
|
||||||
|
|
||||||
|
### `POST /api/sites/{site}/cameras` — manager
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "camera_id": "cam1", "label": "Front door",
|
||||||
|
"host": "192.168.1.122", "port": 554, "path": "/ch0_0.264",
|
||||||
|
"username": "admin", "password": "…", "max_width": 1280 }
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns the `Camera` object. The shop PC picks it up on its next sync (under a
|
||||||
|
minute) and only then can it be tested — until then `connected` is `null`.
|
||||||
|
|
||||||
|
`camera_id` is what the engine will know it by and what lands on every visit.
|
||||||
|
**It cannot be changed later.** Two shops may each have a `cam1`; one shop
|
||||||
|
cannot.
|
||||||
|
|
||||||
|
`path` is the field nobody can look up — it is model-specific. The web console
|
||||||
|
fills it in from a make picker (`shared/cameraMakes.js`); an app should offer
|
||||||
|
the same list rather than expecting a shop owner to know `/ch0_0.264`.
|
||||||
|
|
||||||
|
### `PATCH /api/cameras/{id}` — manager
|
||||||
|
|
||||||
|
Same fields, all optional. **An omitted field is left alone; do not send blank
|
||||||
|
strings to mean "unchanged".** In particular, omit `password` unless the user
|
||||||
|
typed a new one — the API never returns the old one, so a form that round-trips
|
||||||
|
an empty field would wipe it on every save. `camera_id` is refused.
|
||||||
|
|
||||||
|
Every edit bumps `revision`, and the shop PC restarts that camera's connection
|
||||||
|
when it applies it.
|
||||||
|
|
||||||
|
### `DELETE /api/cameras/{id}` — manager
|
||||||
|
|
||||||
|
A tombstone, not a hard delete: the shop PC is told the camera was removed,
|
||||||
|
rather than not told about it — otherwise its next sync would offer the camera
|
||||||
|
back up and it would reappear.
|
||||||
|
|
||||||
|
### `POST /api/cameras/{id}/check` — manager
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "kind": "connection" } or
|
||||||
|
{ "kind": "placement", "seconds": 25 }
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns **202** and the `check` object in `state: "requested"`. Poll
|
||||||
|
`GET /api/cameras` until `state: "done"`.
|
||||||
|
|
||||||
|
Two different questions, deliberately:
|
||||||
|
|
||||||
|
- **`connection`** — can the shop PC open the stream. Answers in seconds.
|
||||||
|
- **`placement`** — does somebody walking past produce a view worth enrolling.
|
||||||
|
Runs for `seconds` while a person walks through the frame. This is the one
|
||||||
|
that matters: a camera can pass the first and fail the second, and did, for
|
||||||
|
weeks, at the pilot site.
|
||||||
|
|
||||||
|
**Only `verdict: "good"` is a pass.** `marginal` means half the visitors are
|
||||||
|
silently discarded, which is not a working camera. The engine's `headline` and
|
||||||
|
`advice` are written for the person standing next to the camera — show them
|
||||||
|
verbatim.
|
||||||
|
|
||||||
|
A check is a job the shop PC claims. If the PC is off, `state` stays
|
||||||
|
`requested`; after five minutes the server releases it so the button works
|
||||||
|
again. A camera added seconds ago reports that the PC has not set it up yet,
|
||||||
|
rather than pretending it is broken.
|
||||||
|
|
||||||
|
### `GET /api/cameras/{id}/snapshot.jpg`
|
||||||
|
|
||||||
|
The bytes behind a snapshot `url` with `auth: true`. Refreshed by the shop PC
|
||||||
|
about once a minute. Session required.
|
||||||
|
|
||||||
|
### `GET /api/cameras/{id}/live` — server-sent events
|
||||||
|
|
||||||
|
Live view relayed through the shop PC's outbound connection, roughly 13 frames
|
||||||
|
a second of 640-px JPEG.
|
||||||
|
|
||||||
|
```
|
||||||
|
event: waiting
|
||||||
|
data: {}
|
||||||
|
|
||||||
|
event: frame
|
||||||
|
data: <base64 JPEG>
|
||||||
|
```
|
||||||
|
|
||||||
|
`waiting` arrives immediately and means the request has reached head office and
|
||||||
|
the shop PC has been asked; the first `frame` follows when it answers. **Nothing
|
||||||
|
is uploaded when nobody is watching** — closing the connection stops the shop
|
||||||
|
PC's upload within seconds, and one view is capped at five minutes (reconnect
|
||||||
|
to continue). Open one camera at a time; a grid of live tiles puts an estate's
|
||||||
|
worth of video on the wire because somebody opened a page.
|
||||||
|
|
||||||
|
### `POST /api/sites/{site}/enrolment-code` — manager
|
||||||
|
|
||||||
|
How a new shop PC gets linked to a shop. The installer types this code once.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "label": "till PC", "days": 7 } ← both optional
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "code": "SG26HN-WJFMUP-FRFTHW-MQJB33",
|
||||||
|
"site_id": "…", "site_name": "TeNext Chennai",
|
||||||
|
"label": "till PC", "expires_at": "…" }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Single use, shown once, capped at 30 days.** It is read aloud and pasted into
|
||||||
|
chat on its way to a shop, so it is a credential, not a convenience — which is
|
||||||
|
why staff may not mint one. Minting writes an audit row naming who asked.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Reports
|
||||||
|
|
||||||
|
### `GET /api/reports/footfall`
|
||||||
|
|
||||||
|
`?from=2026-09-01&to=2026-09-07&site=chennai&tz=Asia/Kolkata&bucket=day`
|
||||||
|
|
||||||
|
`bucket` is `hour | day | week | month` (default `day`). `site` optional —
|
||||||
|
omit for the whole company. Dates are `YYYY-MM-DD`; **`to` is inclusive**.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "from": "2026-09-01", "to": "2026-09-07", "bucket": "day",
|
||||||
|
"timezone": "Asia/Kolkata",
|
||||||
|
"points": [{ "bucket": "2026-09-01T00:00:00", "visitors": 41, "new": 12, "returning": 26 }],
|
||||||
|
"total": 183, "visits": 247,
|
||||||
|
"fraction_below_gate": 0.47, "worst_site": "TeNext Chennai" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Three arithmetic traps the API is explicit about, so a client does not reinvent
|
||||||
them wrongly:
|
them wrongly:
|
||||||
|
|
||||||
- **`total` is unique people over the window; the chart does not sum to it.**
|
- **`total` is unique people over the window; the chart does not sum to it.**
|
||||||
Somebody who came Monday and Thursday is one person and two bucket-visitors.
|
Somebody who came Monday and Thursday is one person and two bucket-visitors.
|
||||||
Show the server's `total`, with `visits` underneath.
|
Show the server's `total`, with `visits` underneath.
|
||||||
- **`new + returning` can be less than the total.** A site sending counts
|
- **`new + returning` can be less than `visitors`.** A shop sending counts
|
||||||
without templates records real footfall by an unidentified person, which
|
without templates records real footfall by an unidentified person, who
|
||||||
belongs to neither.
|
belongs to neither. Do not force the two to add up.
|
||||||
|
- **`new` means first-ever, not first-in-window.** Otherwise every report
|
||||||
|
re-labels regulars as new customers the day after the window starts.
|
||||||
|
|
||||||
Report buckets are **local wall time with no offset**, labelled by `timezone`.
|
`fraction_below_gate` travels with the numbers because a footfall figure from a
|
||||||
Do not parse them as a `Date` — the viewer's own zone would shift every label.
|
badly placed camera is wrong in a way the figure itself cannot show. Surface it
|
||||||
|
next to the total, not in a footnote.
|
||||||
|
|
||||||
|
**Bucket labels are local wall time with no offset**, in `timezone`. Do not
|
||||||
|
parse them as a `Date` — the viewer's own zone would shift every label by
|
||||||
|
hours.
|
||||||
|
|
||||||
|
### `GET /api/reports/conversion`
|
||||||
|
|
||||||
|
Same parameters.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "visitors": 183, "purchasers": 41, "conversion": 0.224,
|
||||||
|
"revenue": 51250.00, "average_basket": 1250.00, "currency": "INR" }
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`revenue` is summed for ONE currency** — whichever accounts for most of it,
|
||||||
|
named in `currency`. Adding rupees to dollars produces something that looks
|
||||||
|
like money and is not.
|
||||||
|
- **`average_basket` is per basket, not per purchaser.** Someone who bought
|
||||||
|
twice had two baskets.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 9. Errors
|
## 10. The assistant
|
||||||
|
|
||||||
|
### `POST /api/assistant`
|
||||||
|
|
||||||
|
A question about the company's shops, answered in plain language from the same
|
||||||
|
data as the reports — never from raw SQL, so it cannot invent the arithmetic
|
||||||
|
above.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "history": [
|
||||||
|
{ "role": "user", "text": "Is everything working today?" },
|
||||||
|
{ "role": "assistant", "text": "…" },
|
||||||
|
{ "role": "user", "text": "Why is footfall low at Chennai?" }
|
||||||
|
] }
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "text": "Chennai is online with both cameras connected, but 47% of the faces …",
|
||||||
|
"used": ["sites", "footfall", "camera_check"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
**The client holds the history and resends it.** The server keeps no transcript
|
||||||
|
— there is no per-user chat log in a database nobody agreed to. Cap it at 24
|
||||||
|
turns and 2,000 characters per question; the server refuses more.
|
||||||
|
|
||||||
|
`used` names the tools it consulted. Show them: an assistant that silently ran
|
||||||
|
a camera check is alarming, and naming what it looked at makes a wrong answer
|
||||||
|
traceable rather than mysterious.
|
||||||
|
|
||||||
|
It acts as the signed-in user — a staff member asking for a placement check is
|
||||||
|
told a manager can. It cannot see another company's shops, structurally.
|
||||||
|
|
||||||
|
| status | `error` | meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| 501 | `assistant_off` | not configured on this deployment — a normal state, show it as such |
|
||||||
|
| 503 | `assistant_misconfigured` | configured incorrectly; the message names what |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Platform administration — `/api/admin/*`
|
||||||
|
|
||||||
|
Only an account with `role: "admin"` **and no company**. Everything else gets
|
||||||
|
**404**, not 403 — a tenant user has no business learning this surface exists.
|
||||||
|
|
||||||
|
### `GET /api/admin/clients`
|
||||||
|
|
||||||
|
```json
|
||||||
|
[{ "id": "…", "slug": "tenext-retail", "name": "TeNext Retail",
|
||||||
|
"sites": 1, "users": 4, "created_at": "…" }]
|
||||||
|
```
|
||||||
|
|
||||||
|
### `POST /api/admin/clients`
|
||||||
|
|
||||||
|
Creates a company **and its owner, in one transaction.** A company with no owner
|
||||||
|
is a tenant nobody can sign into, and it looks normal in every list — the
|
||||||
|
operator finds out weeks later when the customer says their login does not
|
||||||
|
work.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "company_name": "TeNext Retail", "slug": "tenext-retail",
|
||||||
|
"owner_email": "suriya@tenext.in", "owner_name": "Suriya",
|
||||||
|
"password": "" }
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "client_id": "…", "slug": "tenext-retail",
|
||||||
|
"owner_email": "suriya@tenext.in",
|
||||||
|
"password": "xK9…" }
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Leave `password` empty.** The server generates one. An operator inventing a
|
||||||
|
password for somebody else invents a weak one and sends it over chat.
|
||||||
|
- **`password` is returned exactly once** and is bcrypt-hashed on the way in.
|
||||||
|
Not recoverable. Show it, or send it, immediately.
|
||||||
|
- `slug` is optional and is derived from the name when omitted. It becomes part
|
||||||
|
of the company's message-broker topic, so `/`, `+` and `#` are stripped, and
|
||||||
|
**it can never be changed**.
|
||||||
|
|
||||||
|
That is the whole admin API. There is no endpoint to delete a company, suspend
|
||||||
|
one, reset an owner's password, or look inside one — those are done by signing
|
||||||
|
in as the company's owner, or by `behavision-server provision` on the server.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Errors
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "error": "invalid_code", "message": "That invitation code is not valid. Ask for a new one." }
|
{ "error": "invalid_code", "message": "That invitation code is not valid. Ask for a new one." }
|
||||||
@@ -381,15 +1034,26 @@ rewritten freely.
|
|||||||
|
|
||||||
| status | meaning |
|
| status | meaning |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 400 | the request was wrong; `message` says how |
|
| 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 | signed in, but this role may not |
|
| 403 | `forbidden` — signed in, but this role may not; `message` says who can |
|
||||||
| 404 | not found — **also** what another tenant's data returns, always |
|
| 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 | throttled |
|
| 429 | `too_many_attempts` |
|
||||||
| 501 | the feature is off for this deployment, not an error |
|
| 501 | the feature is off for this deployment (`assistant_off`, `images_disabled`) — a state, not an error |
|
||||||
| 502 | a downstream failure; for erasure it means **nothing was deleted** |
|
| 502 | a downstream failure; for erasure it means **nothing was deleted** |
|
||||||
|
| 503 | misconfigured (`assistant_misconfigured`); the message names what |
|
||||||
|
|
||||||
Roles, in increasing order: `staff` → `manager` → `owner`. A platform admin has
|
---
|
||||||
`role: "admin"` **and an empty `client_id`** — the two together, never the role
|
|
||||||
alone.
|
## Not for you: `/api/agent/*`
|
||||||
|
|
||||||
|
Ten routes under `/api/agent/` are how a **shop PC** talks to head office:
|
||||||
|
enrolling with an installation code, pulling its camera list (with passwords —
|
||||||
|
it is the thing that has to connect), reporting camera state, uploading
|
||||||
|
snapshots and face images, claiming and answering check jobs, and relaying live
|
||||||
|
video. They authenticate with the PC's own token, issued once at enrolment, and
|
||||||
|
a user session is refused.
|
||||||
|
|
||||||
|
A web or mobile client never calls them. They are listed here so nobody wonders
|
||||||
|
what they are.
|
||||||
|
|||||||
412
agent/cmd/behavision-setup/main.go
Normal file
412
agent/cmd/behavision-setup/main.go
Normal file
@@ -0,0 +1,412 @@
|
|||||||
|
// Command behavision-setup prepares a shop PC to run the recognition engine.
|
||||||
|
//
|
||||||
|
// It exists because the engine is Python and the rest of the product is Go.
|
||||||
|
// The Go halves cross-compile to Windows from any machine; the engine, frozen
|
||||||
|
// with PyInstaller, does not - PyInstaller bundles the interpreter and native
|
||||||
|
// wheels of the machine it runs on, so a frozen engine can only be built on
|
||||||
|
// Windows. That single fact was the whole reason a release could not be cut.
|
||||||
|
//
|
||||||
|
// So this installs the engine from source instead of shipping it frozen: find
|
||||||
|
// a Python, build a private virtual environment beside the database, install
|
||||||
|
// the engine into it, fetch the models, and record how to start it. Everything
|
||||||
|
// in the release can then be built anywhere.
|
||||||
|
//
|
||||||
|
// The trade, stated plainly because whoever runs this is standing in a shop:
|
||||||
|
// it needs Python and a working internet connection at install time, and it
|
||||||
|
// takes minutes rather than seconds. A frozen build needs neither. What it
|
||||||
|
// buys is a release that exists.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/loyaly/behavision-agent/pkg/config"
|
||||||
|
"github.com/loyaly/behavision-agent/pkg/engine"
|
||||||
|
"github.com/loyaly/behavision-agent/pkg/paths"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The engine needs 3.10; nothing here works below it and the failure would
|
||||||
|
// otherwise arrive as a syntax error deep inside a dependency.
|
||||||
|
const minMinor = 10
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := run(); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "\n Setup did not finish: %v\n\n", err)
|
||||||
|
pause()
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
pause()
|
||||||
|
}
|
||||||
|
|
||||||
|
func run() error {
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println(" Behavision setup")
|
||||||
|
fmt.Println(" ----------------")
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
state := paths.StateRoot()
|
||||||
|
src, err := engineSource()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf(" engine source %s\n", src)
|
||||||
|
fmt.Printf(" install into %s\n", state)
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
if err := paths.EnsureState(); err != nil {
|
||||||
|
return fmt.Errorf("could not create %s: %w", state, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
py, ver, err := findPython()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
step("Python", fmt.Sprintf("%s (%s)", ver, py))
|
||||||
|
|
||||||
|
venv := filepath.Join(state, "runtime")
|
||||||
|
if err := makeVenv(py, venv); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
vpy := venvPython(venv)
|
||||||
|
step("Virtual environment", venv)
|
||||||
|
|
||||||
|
// The engine reads its settings from <state>/config/default.yaml and will
|
||||||
|
// seed that from beside its own code on first run - which works when its
|
||||||
|
// code is a checkout or a frozen folder and not when it is a package in
|
||||||
|
// site-packages, where there is no config beside it. Seeded here, from the
|
||||||
|
// copy the release ships. Never overwritten: an upgrade must not revert an
|
||||||
|
// operator's thresholds.
|
||||||
|
if err := seedConfig(src, state); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
step("Settings", filepath.Join(state, "config", "default.yaml"))
|
||||||
|
|
||||||
|
// --upgrade so re-running after a new release replaces the engine rather
|
||||||
|
// than leaving the old one in place and reporting success.
|
||||||
|
if err := pipInstall(vpy, src); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
step("Engine and dependencies", "installed")
|
||||||
|
|
||||||
|
if err := runEngine(vpy, "setup-models"); err != nil {
|
||||||
|
return fmt.Errorf("downloading the recognition models: %w", err)
|
||||||
|
}
|
||||||
|
step("Recognition models", "downloaded")
|
||||||
|
|
||||||
|
if err := writeConfig(vpy); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
step("Startup settings", filepath.Join(state, "agent.json"))
|
||||||
|
|
||||||
|
// Proving it starts is the point. An installer that reports success and
|
||||||
|
// leaves a shop with an engine that will not run has done worse than
|
||||||
|
// failing: the failure surfaces later, to someone who did not install it.
|
||||||
|
if err := smokeTest(vpy); err != nil {
|
||||||
|
return fmt.Errorf("the engine installed but would not start: %w", err)
|
||||||
|
}
|
||||||
|
step("Engine starts and answers", "verified")
|
||||||
|
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println(" Done. Start Behavision from the Start menu or the desktop icon.")
|
||||||
|
fmt.Println(" It appears in the system tray; right-click there to stop it.")
|
||||||
|
fmt.Println()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func step(label, detail string) {
|
||||||
|
fmt.Printf(" [ok] %-24s %s\n", label, detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
// engineSource finds the Python source shipped beside this executable. Beside,
|
||||||
|
// not downloaded: the engine and the app must be the same release, and a
|
||||||
|
// version skew between them is the class of bug nobody can reproduce.
|
||||||
|
func engineSource() (string, error) {
|
||||||
|
candidates := []string{
|
||||||
|
filepath.Join(paths.InstallRoot(), "engine-src"),
|
||||||
|
filepath.Join(paths.InstallRoot(), "..", "engine-src"),
|
||||||
|
}
|
||||||
|
if wd, err := os.Getwd(); err == nil {
|
||||||
|
candidates = append(candidates, filepath.Join(wd, "engine-src"), wd)
|
||||||
|
}
|
||||||
|
for _, c := range candidates {
|
||||||
|
if _, err := os.Stat(filepath.Join(c, "pyproject.toml")); err == nil {
|
||||||
|
abs, _ := filepath.Abs(c)
|
||||||
|
return abs, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", errors.New("could not find the engine source (expected an " +
|
||||||
|
"engine-src folder with pyproject.toml beside this program). " +
|
||||||
|
"Unzip the whole release together rather than moving this file out of it")
|
||||||
|
}
|
||||||
|
|
||||||
|
// findPython returns the first interpreter that is new enough.
|
||||||
|
//
|
||||||
|
// `py -3` first on Windows: the launcher is what the official installer puts
|
||||||
|
// on PATH, and `python` there is often the Microsoft Store stub that prints an
|
||||||
|
// advert and exits 9009 instead of running anything.
|
||||||
|
func findPython() (string, string, error) {
|
||||||
|
type cand struct {
|
||||||
|
exe string
|
||||||
|
args []string
|
||||||
|
}
|
||||||
|
var cands []cand
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
cands = append(cands, cand{"py", []string{"-3"}})
|
||||||
|
}
|
||||||
|
cands = append(cands, cand{"python3", nil}, cand{"python", nil})
|
||||||
|
|
||||||
|
var tried []string
|
||||||
|
for _, c := range cands {
|
||||||
|
exe, err := exec.LookPath(c.exe)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
args := append(append([]string{}, c.args...), "-c",
|
||||||
|
"import sys;print('%d.%d'%sys.version_info[:2])")
|
||||||
|
out, err := exec.Command(exe, args...).Output()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ver := strings.TrimSpace(string(out))
|
||||||
|
tried = append(tried, c.exe+" "+ver)
|
||||||
|
if major, minor, ok := parseVer(ver); ok && (major > 3 || (major == 3 && minor >= minMinor)) {
|
||||||
|
full := exe
|
||||||
|
if len(c.args) > 0 {
|
||||||
|
full = exe + " " + strings.Join(c.args, " ")
|
||||||
|
}
|
||||||
|
return full, "Python " + ver, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := "no Python 3.10 or newer was found on this PC.\n\n" +
|
||||||
|
" Install it from https://www.python.org/downloads/windows/\n" +
|
||||||
|
" and tick \"Add python.exe to PATH\" on the first screen,\n" +
|
||||||
|
" then run this again."
|
||||||
|
if len(tried) > 0 {
|
||||||
|
msg += "\n\n Found, but too old: " + strings.Join(tried, ", ")
|
||||||
|
}
|
||||||
|
return "", "", errors.New(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseVer(s string) (int, int, bool) {
|
||||||
|
parts := strings.Split(s, ".")
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
major, err1 := strconv.Atoi(parts[0])
|
||||||
|
minor, err2 := strconv.Atoi(parts[1])
|
||||||
|
return major, minor, err1 == nil && err2 == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitLauncher turns `py -3` back into a command and its arguments.
|
||||||
|
func splitLauncher(s string) (string, []string) {
|
||||||
|
f := strings.Fields(s)
|
||||||
|
if len(f) == 0 {
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
return f[0], f[1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
func venvPython(venv string) string {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
return filepath.Join(venv, "Scripts", "python.exe")
|
||||||
|
}
|
||||||
|
return filepath.Join(venv, "bin", "python")
|
||||||
|
}
|
||||||
|
|
||||||
|
// makeVenv builds the engine's own interpreter under the writable state root.
|
||||||
|
//
|
||||||
|
// A virtual environment rather than the system Python: a shop PC may have
|
||||||
|
// Python there for something else, and pinning numpy below 2.0 - which the
|
||||||
|
// engine requires - inside a shared interpreter is how you break the other
|
||||||
|
// thing months later, silently.
|
||||||
|
func makeVenv(py, venv string) error {
|
||||||
|
if _, err := os.Stat(venvPython(venv)); err == nil {
|
||||||
|
return nil // already built; pip below brings it up to date
|
||||||
|
}
|
||||||
|
exe, args := splitLauncher(py)
|
||||||
|
args = append(args, "-m", "venv", venv)
|
||||||
|
return stream(exec.Command(exe, args...), "creating the virtual environment")
|
||||||
|
}
|
||||||
|
|
||||||
|
func pipInstall(vpy, src string) error {
|
||||||
|
fmt.Println(" Installing the engine and its libraries. This downloads a few")
|
||||||
|
fmt.Println(" hundred megabytes and takes a while on a slow connection.")
|
||||||
|
fmt.Println()
|
||||||
|
if err := stream(exec.Command(vpy, "-m", "pip", "install", "--upgrade",
|
||||||
|
"pip", "setuptools", "wheel"), "updating pip"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// A wheel if the release ships one - nothing to build on the shop PC, and
|
||||||
|
// pip never has to touch the folder the release was unzipped into.
|
||||||
|
//
|
||||||
|
// That matters more than it sounds: `pip install <folder>` makes setuptools
|
||||||
|
// write behavision.egg-info INTO that folder, and the folder is read-only
|
||||||
|
// whenever the release was unzipped somewhere sensible - Program Files, or
|
||||||
|
// the shared drive INSTALL.txt says is fine. Found by running this in a
|
||||||
|
// container with the source mounted read-only: "could not create
|
||||||
|
// 'behavision.egg-info': Read-only file system". Falling back to source
|
||||||
|
// copies it somewhere writable first, for the same reason.
|
||||||
|
if wheels, _ := filepath.Glob(filepath.Join(src, "behavision-*.whl")); len(wheels) > 0 {
|
||||||
|
return stream(exec.Command(vpy, "-m", "pip", "install", "--upgrade", wheels[0]),
|
||||||
|
"installing the engine")
|
||||||
|
}
|
||||||
|
tmp, err := os.MkdirTemp("", "behavision-src-")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmp)
|
||||||
|
if err := copyTree(src, tmp); err != nil {
|
||||||
|
return fmt.Errorf("staging the engine source: %w", err)
|
||||||
|
}
|
||||||
|
return stream(exec.Command(vpy, "-m", "pip", "install", "--upgrade", tmp),
|
||||||
|
"installing the engine")
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedConfig puts the shipped default.yaml where the engine will look for it,
|
||||||
|
// and leaves an existing one alone.
|
||||||
|
func seedConfig(src, state string) error {
|
||||||
|
dst := filepath.Join(state, "config", "default.yaml")
|
||||||
|
if _, err := os.Stat(dst); err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
from := filepath.Join(src, "config", "default.yaml")
|
||||||
|
b, err := os.ReadFile(from)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("the release is missing config/default.yaml: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(dst, b, 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyTree copies a source tree, skipping the caches a checkout accumulates.
|
||||||
|
func copyTree(from, to string) error {
|
||||||
|
return filepath.WalkDir(from, func(path string, d os.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rel, _ := filepath.Rel(from, path)
|
||||||
|
if d.IsDir() {
|
||||||
|
if d.Name() == "__pycache__" || strings.HasSuffix(d.Name(), ".egg-info") {
|
||||||
|
return filepath.SkipDir
|
||||||
|
}
|
||||||
|
return os.MkdirAll(filepath.Join(to, rel), 0o755)
|
||||||
|
}
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(filepath.Join(to, rel), b, 0o644)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// runEngine runs the engine exactly as the app will later: same interpreter,
|
||||||
|
// same environment. In particular ChildEnv sets BEHAVISION_DATA_DIR, without
|
||||||
|
// which a pip-installed engine decides its state lives in site-packages and
|
||||||
|
// downloads the models to a place the app never looks.
|
||||||
|
func runEngine(vpy string, args ...string) error {
|
||||||
|
full := append([]string{"-m", "behavision"}, args...)
|
||||||
|
cmd := exec.Command(vpy, full...)
|
||||||
|
cmd.Env = engine.ChildEnv("")
|
||||||
|
return stream(cmd, "running the engine")
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeConfig records how to start the engine, in the same file and through
|
||||||
|
// the same type the app reads, so the two cannot disagree about it.
|
||||||
|
func writeConfig(vpy string) error {
|
||||||
|
path := paths.AgentConfig()
|
||||||
|
cfg, err := config.Load(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reading %s: %w", path, err)
|
||||||
|
}
|
||||||
|
// An absolute path: the app resolves a relative EngineExe against its own
|
||||||
|
// install root under Program Files, and the interpreter is not there.
|
||||||
|
cfg.EngineExe = vpy
|
||||||
|
cfg.EngineArgs = []string{"-m", "behavision", "run"}
|
||||||
|
if cfg.APIBase == "" {
|
||||||
|
cfg.APIBase = "http://127.0.0.1:8010"
|
||||||
|
}
|
||||||
|
return cfg.Save(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// smokeTest starts the engine exactly as the app will and waits for its API to
|
||||||
|
// answer. Any reply counts, including 401: the engine invents its own
|
||||||
|
// credential when none is configured, and a refusal proves it is serving.
|
||||||
|
func smokeTest(vpy string) error {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, vpy, "-m", "behavision", "run")
|
||||||
|
cmd.Env = engine.ChildEnv("")
|
||||||
|
var log strings.Builder
|
||||||
|
cmd.Stdout, cmd.Stderr = &log, &log
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = cmd.Process.Kill()
|
||||||
|
_, _ = cmd.Process.Wait()
|
||||||
|
}()
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 3 * time.Second}
|
||||||
|
deadline := time.Now().Add(75 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
resp, err := client.Get("http://127.0.0.1:8010/api/health")
|
||||||
|
if err == nil {
|
||||||
|
_, _ = io.Copy(io.Discard, resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if cmd.ProcessState != nil && cmd.ProcessState.Exited() {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("it did not answer within 75 seconds.\n\n%s",
|
||||||
|
tail(log.String(), 15))
|
||||||
|
}
|
||||||
|
|
||||||
|
func tail(s string, n int) string {
|
||||||
|
lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
|
||||||
|
if len(lines) > n {
|
||||||
|
lines = lines[len(lines)-n:]
|
||||||
|
}
|
||||||
|
return " " + strings.Join(lines, "\n ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// stream runs a command and shows its output. Shown, not swallowed: pip failing
|
||||||
|
// on a missing build tool prints exactly what is wrong, and hiding that leaves
|
||||||
|
// the operator with "setup failed" and nothing to act on.
|
||||||
|
func stream(cmd *exec.Cmd, what string) error {
|
||||||
|
cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return fmt.Errorf("%s failed: %w", what, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// pause keeps the window open. Double-clicked from Explorer, a console program
|
||||||
|
// that finishes closes instantly and the operator sees nothing at all -
|
||||||
|
// success and failure look identical.
|
||||||
|
func pause() {
|
||||||
|
if runtime.GOOS != "windows" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Print(" Press Enter to close. ")
|
||||||
|
_, _ = bufio.NewReader(os.Stdin).ReadString('\n')
|
||||||
|
}
|
||||||
@@ -232,7 +232,7 @@ func cmdRun() error {
|
|||||||
// nothing - the URL was returned, logged and even exposed on the
|
// nothing - the URL was returned, logged and even exposed on the
|
||||||
// desktop's status object, and never actually given to the engine.
|
// desktop's status object, and never actually given to the engine.
|
||||||
// A claimed shop PC published heartbeats and zero visits.
|
// A claimed shop PC published heartbeats and zero visits.
|
||||||
cmd.Env = append(os.Environ(), "BEHAVISION_WEBHOOK_URL="+hookURL)
|
cmd.Env = engine.ChildEnv(hookURL)
|
||||||
return cmd
|
return cmd
|
||||||
},
|
},
|
||||||
LogWriter: logFile,
|
LogWriter: logFile,
|
||||||
|
|||||||
41
agent/pkg/engine/env.go
Normal file
41
agent/pkg/engine/env.go
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/loyaly/behavision-agent/pkg/paths"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ChildEnv is the environment the engine is launched with, wherever it is
|
||||||
|
// launched from - the desktop app and the headless agent both go through
|
||||||
|
// here, so a third caller cannot get it half right.
|
||||||
|
//
|
||||||
|
// The line that matters is BEHAVISION_DATA_DIR.
|
||||||
|
//
|
||||||
|
// The engine's paths.py knows two worlds: frozen with PyInstaller, where state
|
||||||
|
// lives under ProgramData, and a checkout, where everything sits in the repo
|
||||||
|
// root. An engine installed from source into a virtual environment is neither.
|
||||||
|
// Left to itself it resolves its state root to site-packages - writes its
|
||||||
|
// database and camera list there, and generates its API credential into a
|
||||||
|
// folder this process never reads - while this process resolves the same
|
||||||
|
// state root to ProgramData. The two halves then disagree about where
|
||||||
|
// everything lives, and every call to the engine is 401 on a stock install,
|
||||||
|
// with nothing in either log saying why. Seen twice: once on a Mac checkout
|
||||||
|
// (the app in ~/Library, the engine in the repo) and once in a clean Linux
|
||||||
|
// container running the installer.
|
||||||
|
//
|
||||||
|
// Telling the engine where THIS process keeps state makes the two agree by
|
||||||
|
// construction, however the engine was installed. paths.py honours the
|
||||||
|
// override ahead of every other rule it has.
|
||||||
|
//
|
||||||
|
// hookURL is where the engine posts detections; empty is allowed and means
|
||||||
|
// the bridge has not started, which the engine treats as "no webhook".
|
||||||
|
func ChildEnv(hookURL string) []string {
|
||||||
|
env := append(os.Environ(),
|
||||||
|
"BEHAVISION_DATA_DIR="+paths.StateRoot(),
|
||||||
|
)
|
||||||
|
if hookURL != "" {
|
||||||
|
env = append(env, "BEHAVISION_WEBHOOK_URL="+hookURL)
|
||||||
|
}
|
||||||
|
return env
|
||||||
|
}
|
||||||
44
agent/pkg/engine/env_test.go
Normal file
44
agent/pkg/engine/env_test.go
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/loyaly/behavision-agent/pkg/paths"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The engine must be told where THIS process keeps state, or a pip-installed
|
||||||
|
// engine decides on site-packages and the two halves never find each other.
|
||||||
|
func TestTheEngineIsToldWhereStateLives(t *testing.T) {
|
||||||
|
t.Setenv("BEHAVISION_DATA_DIR", t.TempDir())
|
||||||
|
|
||||||
|
env := ChildEnv("http://127.0.0.1:5555/events")
|
||||||
|
|
||||||
|
want := "BEHAVISION_DATA_DIR=" + paths.StateRoot()
|
||||||
|
if !contains(env, want) {
|
||||||
|
t.Fatalf("engine env lacks %q - a source-installed engine would put its "+
|
||||||
|
"database and credential somewhere this process never looks", want)
|
||||||
|
}
|
||||||
|
if !contains(env, "BEHAVISION_WEBHOOK_URL=http://127.0.0.1:5555/events") {
|
||||||
|
t.Fatal("webhook url not passed to the engine")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Before the bridge has a port there is no webhook. An empty variable would be
|
||||||
|
// read by the engine as a webhook at "", which is not the same as none.
|
||||||
|
func TestNoWebhookMeansNoVariable(t *testing.T) {
|
||||||
|
for _, v := range ChildEnv("") {
|
||||||
|
if strings.HasPrefix(v, "BEHAVISION_WEBHOOK_URL=") {
|
||||||
|
t.Fatalf("empty hook still exported: %q", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(env []string, want string) bool {
|
||||||
|
for _, v := range env {
|
||||||
|
if v == want {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -43,6 +43,9 @@ type App struct {
|
|||||||
broker *agentmqtt.Client
|
broker *agentmqtt.Client
|
||||||
stopBridge func()
|
stopBridge func()
|
||||||
hookURL string
|
hookURL string
|
||||||
|
// Relays camera feeds to the webview so the engine's credential never has
|
||||||
|
// to travel in an <img> src, which a Chromium webview would strip anyway.
|
||||||
|
proxy *streamProxy
|
||||||
// Set once the operator logs in. Until then the UI shows the login sheet
|
// Set once the operator logs in. Until then the UI shows the login sheet
|
||||||
// and nothing else is reachable.
|
// and nothing else is reachable.
|
||||||
onSessionChange func(bool)
|
onSessionChange func(bool)
|
||||||
@@ -63,6 +66,7 @@ func NewApp() *App {
|
|||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
cloud: cloud.New(envOr("BEHAVISION_CLOUD", "https://mcp.loyaly.ai")),
|
cloud: cloud.New(envOr("BEHAVISION_CLOUD", "https://mcp.loyaly.ai")),
|
||||||
local: local.New(base, cfg.APIUser, cfg.APIPassword),
|
local: local.New(base, cfg.APIUser, cfg.APIPassword),
|
||||||
|
proxy: newStreamProxy(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,6 +74,14 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
a.ctx = ctx
|
a.ctx = ctx
|
||||||
_ = agentpaths.EnsureState()
|
_ = agentpaths.EnsureState()
|
||||||
|
|
||||||
|
// Before any screen asks for a camera URL. A failure here is logged and
|
||||||
|
// not fatal: the rest of the app - people, cameras, the engine controls -
|
||||||
|
// works without a picture, and refusing to start over a broken tile would
|
||||||
|
// take a working shop offline.
|
||||||
|
if err := a.proxy.start(a.local.Base, a.local.User, a.local.Password); err != nil {
|
||||||
|
log.Printf("camera relay unavailable, tiles will not load: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
// A saved session means a shop PC that rebooted overnight comes back
|
// A saved session means a shop PC that rebooted overnight comes back
|
||||||
// working instead of waiting for someone to log in.
|
// working instead of waiting for someone to log in.
|
||||||
if a.cfg.SessionToken != "" {
|
if a.cfg.SessionToken != "" {
|
||||||
@@ -102,7 +114,7 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
// be told again. Without it the engine recognised people and the
|
// be told again. Without it the engine recognised people and the
|
||||||
// bridge received nothing: a claimed shop PC published heartbeats
|
// bridge received nothing: a claimed shop PC published heartbeats
|
||||||
// and zero visits.
|
// and zero visits.
|
||||||
cmd.Env = append(os.Environ(), "BEHAVISION_WEBHOOK_URL="+a.webhookURL())
|
cmd.Env = agentengine.ChildEnv(a.webhookURL())
|
||||||
return cmd
|
return cmd
|
||||||
},
|
},
|
||||||
LogWriter: logFile,
|
LogWriter: logFile,
|
||||||
@@ -273,8 +285,8 @@ type PipelineStatus struct {
|
|||||||
// Standalone separates "nothing is being sent because this PC is set up on
|
// Standalone separates "nothing is being sent because this PC is set up on
|
||||||
// its own" from "nothing is being sent and something is wrong". They look
|
// its own" from "nothing is being sent and something is wrong". They look
|
||||||
// identical from the counters alone, and only one of them is a fault.
|
// identical from the counters alone, and only one of them is a fault.
|
||||||
Standalone bool `json:"standalone"`
|
Standalone bool `json:"standalone"`
|
||||||
BrokerUp bool `json:"broker_up"`
|
BrokerUp bool `json:"broker_up"`
|
||||||
Accepted uint64 `json:"accepted"`
|
Accepted uint64 `json:"accepted"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -549,15 +561,25 @@ func (a *App) PlacementResult(id string) (map[string]any, error) {
|
|||||||
return a.local.PlacementResult(ctx, id)
|
return a.local.PlacementResult(ctx, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// StreamURL is the MJPEG endpoint for a camera, with credentials inline so an
|
// StreamURL is the MJPEG endpoint for a camera tile.
|
||||||
// <img> tag can load it. Loopback only - it never leaves this machine.
|
//
|
||||||
|
// It points at this app's own loopback relay, not at the engine directly. The
|
||||||
|
// previous version put the engine's Basic credentials inline in the URL, with
|
||||||
|
// a comment saying they were there "so an <img> tag can load it" - which a
|
||||||
|
// browser will not do. Chromium strips credentials from subresource URLs, and
|
||||||
|
// WebView2 is Chromium, so every camera tile on a shop PC was a broken image.
|
||||||
|
// See stream_proxy.go for the measurement.
|
||||||
|
//
|
||||||
|
// The relay is also why no password appears in the page any more. If it is not
|
||||||
|
// running the fallback is the bare engine URL with no credential: correct for
|
||||||
|
// an engine configured without auth, and for one with auth a tile that fails
|
||||||
|
// to load rather than a password sitting in the DOM.
|
||||||
func (a *App) StreamURL(cameraID string) string {
|
func (a *App) StreamURL(cameraID string) string {
|
||||||
base := strings.TrimPrefix(strings.TrimPrefix(a.local.Base, "http://"), "https://")
|
if u := a.proxy.urlFor(cameraID, "stream.mjpeg"); u != "" {
|
||||||
if a.local.User == "" {
|
return u
|
||||||
return fmt.Sprintf("http://%s/api/cameras/%s/stream.mjpeg", base, cameraID)
|
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("http://%s:%s@%s/api/cameras/%s/stream.mjpeg",
|
base := strings.TrimPrefix(strings.TrimPrefix(a.local.Base, "http://"), "https://")
|
||||||
a.local.User, a.local.Password, base, cameraID)
|
return fmt.Sprintf("http://%s/api/cameras/%s/stream.mjpeg", base, cameraID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------- live --
|
// ------------------------------------------------------------------- live --
|
||||||
|
|||||||
@@ -14,16 +14,34 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/bep/debounce v1.2.1 // indirect
|
||||||
github.com/eclipse/paho.mqtt.golang v1.4.3 // indirect
|
github.com/eclipse/paho.mqtt.golang v1.4.3 // indirect
|
||||||
|
github.com/go-ole/go-ole v1.2.6 // indirect
|
||||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||||
|
github.com/google/uuid v1.3.0 // indirect
|
||||||
github.com/gorilla/websocket v1.5.0 // indirect
|
github.com/gorilla/websocket v1.5.0 // indirect
|
||||||
|
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
|
||||||
|
github.com/labstack/echo/v4 v4.10.2 // indirect
|
||||||
|
github.com/labstack/gommon v0.4.0 // indirect
|
||||||
github.com/leaanthony/go-ansi-parser v1.6.0 // indirect
|
github.com/leaanthony/go-ansi-parser v1.6.0 // indirect
|
||||||
|
github.com/leaanthony/gosod v1.0.3 // indirect
|
||||||
github.com/leaanthony/slicer v1.6.0 // indirect
|
github.com/leaanthony/slicer v1.6.0 // indirect
|
||||||
github.com/leaanthony/u v1.1.0 // indirect
|
github.com/leaanthony/u v1.1.0 // indirect
|
||||||
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||||
|
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 // indirect
|
||||||
github.com/pkg/errors v0.9.1 // indirect
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
github.com/rivo/uniseg v0.4.4 // indirect
|
github.com/rivo/uniseg v0.4.4 // indirect
|
||||||
|
github.com/samber/lo v1.38.1 // indirect
|
||||||
|
github.com/tkrajina/go-reflector v0.5.6 // indirect
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
|
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||||
github.com/wailsapp/go-webview2 v1.0.16 // indirect
|
github.com/wailsapp/go-webview2 v1.0.16 // indirect
|
||||||
|
github.com/wailsapp/mimetype v1.4.1 // indirect
|
||||||
|
golang.org/x/crypto v0.23.0 // indirect
|
||||||
|
golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1 // indirect
|
||||||
golang.org/x/net v0.25.0 // indirect
|
golang.org/x/net v0.25.0 // indirect
|
||||||
golang.org/x/sync v0.1.0 // indirect
|
golang.org/x/sync v0.1.0 // indirect
|
||||||
golang.org/x/sys v0.20.0 // indirect
|
golang.org/x/sys v0.20.0 // indirect
|
||||||
|
golang.org/x/text v0.15.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ fyne.io/systray v1.12.2/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
|
|||||||
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||||
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/eclipse/paho.mqtt.golang v1.4.3 h1:2kwcUGn8seMUfWndX0hGbvH8r7crgcJguQNCyp70xik=
|
github.com/eclipse/paho.mqtt.golang v1.4.3 h1:2kwcUGn8seMUfWndX0hGbvH8r7crgcJguQNCyp70xik=
|
||||||
github.com/eclipse/paho.mqtt.golang v1.4.3/go.mod h1:CSYvoAlsMkhYOXh/oKyxa8EcBci6dVkLCbo5tTC1RIE=
|
github.com/eclipse/paho.mqtt.golang v1.4.3/go.mod h1:CSYvoAlsMkhYOXh/oKyxa8EcBci6dVkLCbo5tTC1RIE=
|
||||||
@@ -20,6 +21,7 @@ github.com/labstack/echo/v4 v4.10.2 h1:n1jAhnq/elIFTHr1EYpiYtyKgx4RW9ccVgkqByZaN
|
|||||||
github.com/labstack/echo/v4 v4.10.2/go.mod h1:OEyqf2//K1DFdE57vw2DRgWY0M7s65IVQO2FzvI4J5k=
|
github.com/labstack/echo/v4 v4.10.2/go.mod h1:OEyqf2//K1DFdE57vw2DRgWY0M7s65IVQO2FzvI4J5k=
|
||||||
github.com/labstack/gommon v0.4.0 h1:y7cvthEAEbU0yHOf4axH8ZG2NH8knB9iNSoTO8dyIk8=
|
github.com/labstack/gommon v0.4.0 h1:y7cvthEAEbU0yHOf4axH8ZG2NH8knB9iNSoTO8dyIk8=
|
||||||
github.com/labstack/gommon v0.4.0/go.mod h1:uW6kP17uPlLJsD3ijUYn3/M5bAxtlZhMI6m3MFxTMTM=
|
github.com/labstack/gommon v0.4.0/go.mod h1:uW6kP17uPlLJsD3ijUYn3/M5bAxtlZhMI6m3MFxTMTM=
|
||||||
|
github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=
|
||||||
github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=
|
github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=
|
||||||
github.com/leaanthony/go-ansi-parser v1.6.0 h1:T8TuMhFB6TUMIUm0oRrSbgJudTFw9csT3ZK09w0t4Pg=
|
github.com/leaanthony/go-ansi-parser v1.6.0 h1:T8TuMhFB6TUMIUm0oRrSbgJudTFw9csT3ZK09w0t4Pg=
|
||||||
github.com/leaanthony/go-ansi-parser v1.6.0/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
|
github.com/leaanthony/go-ansi-parser v1.6.0/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
|
||||||
@@ -30,6 +32,7 @@ github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/
|
|||||||
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
|
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
|
||||||
github.com/leaanthony/u v1.1.0 h1:2n0d2BwPVXSUq5yhe8lJPHdxevE2qK5G99PMStMZMaI=
|
github.com/leaanthony/u v1.1.0 h1:2n0d2BwPVXSUq5yhe8lJPHdxevE2qK5G99PMStMZMaI=
|
||||||
github.com/leaanthony/u v1.1.0/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
|
github.com/leaanthony/u v1.1.0/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
|
||||||
|
github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE=
|
||||||
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||||
github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
|
github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
|
||||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||||
@@ -42,6 +45,7 @@ github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9
|
|||||||
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
|
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
|
||||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
|
github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
|
||||||
@@ -50,6 +54,8 @@ github.com/samber/lo v1.38.1 h1:j2XEAqXKb09Am4ebOg31SpvzUTTs6EN3VfgeLUhPdXM=
|
|||||||
github.com/samber/lo v1.38.1/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA=
|
github.com/samber/lo v1.38.1/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||||
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
github.com/tkrajina/go-reflector v0.5.6 h1:hKQ0gyocG7vgMD2M3dRlYN6WBBOmdoOzJ6njQSepKdE=
|
github.com/tkrajina/go-reflector v0.5.6 h1:hKQ0gyocG7vgMD2M3dRlYN6WBBOmdoOzJ6njQSepKdE=
|
||||||
github.com/tkrajina/go-reflector v0.5.6/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
|
github.com/tkrajina/go-reflector v0.5.6/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
|
||||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||||
@@ -92,3 +98,5 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm
|
|||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ func main() {
|
|||||||
},
|
},
|
||||||
OnShutdown: func(ctx context.Context) {
|
OnShutdown: func(ctx context.Context) {
|
||||||
tray.stop()
|
tray.stop()
|
||||||
|
app.proxy.stop()
|
||||||
app.StopEngine()
|
app.StopEngine()
|
||||||
},
|
},
|
||||||
Bind: []any{app},
|
Bind: []any{app},
|
||||||
|
|||||||
249
desktop/stream_proxy.go
Normal file
249
desktop/stream_proxy.go
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// streamProxy serves the engine's camera feeds to this app's own webview
|
||||||
|
// without putting a credential in the page.
|
||||||
|
//
|
||||||
|
// What this replaces: StreamURL used to build
|
||||||
|
// http://user:pass@127.0.0.1:8010/api/cameras/<id>/stream.mjpeg and hand it
|
||||||
|
// to an <img>, with a comment saying the credentials were inline "so an <img>
|
||||||
|
// tag can load it". It cannot. Chromium strips credentials from subresource
|
||||||
|
// URLs and has since M59, and WebView2 is Chromium - so on the one platform
|
||||||
|
// this product ships to, every camera tile on the shop floor renders as a
|
||||||
|
// broken image. Measured against the same running engine: the app's Go-side
|
||||||
|
// calls returned stats and people while an <img> on the very same URL failed,
|
||||||
|
// and curl proved the URL itself answered 200. The engine was never the
|
||||||
|
// problem; the browser was throwing the password away before it asked.
|
||||||
|
//
|
||||||
|
// So the password stays on this side of the process boundary. The webview
|
||||||
|
// asks this loopback listener, the listener attaches Basic auth and relays
|
||||||
|
// the engine's bytes back unchanged. It is the same reasoning the head-office
|
||||||
|
// web app already follows in Shot.jsx, where an <img> equally cannot carry a
|
||||||
|
// session and the bytes are fetched and handed over as an object URL.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A camera id reaches this from the engine and from a person typing into the
|
||||||
|
// Add Camera form. Validated rather than interpolated: without this a `..`
|
||||||
|
// would climb out of the two paths below and turn a camera relay into a proxy
|
||||||
|
// for any engine endpoint, with the credential helpfully attached.
|
||||||
|
var safeCameraIDChars = regexp.MustCompile(`^[A-Za-z0-9_.-]{1,64}$`)
|
||||||
|
|
||||||
|
// safeCameraID is the character check AND the two names that pass it and still
|
||||||
|
// mean something to a path resolver.
|
||||||
|
//
|
||||||
|
// The pattern allows `.` because real camera ids contain them - which means it
|
||||||
|
// also allows exactly `.` and `..`, and `/api/cameras/../stream.mjpeg` is not
|
||||||
|
// the endpoint anyone intended. The id can never contain a slash (the path is
|
||||||
|
// split on them before we get here), so these two strings are the entire
|
||||||
|
// remaining traversal surface. Found by the test, not by reading the regex.
|
||||||
|
func safeCameraID(id string) bool {
|
||||||
|
if id == "." || id == ".." {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return safeCameraIDChars.MatchString(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
type streamProxy struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
ln net.Listener
|
||||||
|
srv *http.Server
|
||||||
|
client *http.Client
|
||||||
|
token string
|
||||||
|
target string // engine origin, e.g. http://127.0.0.1:8010
|
||||||
|
user string
|
||||||
|
pass string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newStreamProxy() *streamProxy { return &streamProxy{} }
|
||||||
|
|
||||||
|
// start binds a loopback listener and begins relaying. Calling it again while
|
||||||
|
// running is a no-op, so a restarted engine cannot leave two listeners behind.
|
||||||
|
func (p *streamProxy) start(base, user, pass string) error {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
if p.srv != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.HasPrefix(base, "http://") && !strings.HasPrefix(base, "https://") {
|
||||||
|
base = "http://" + base
|
||||||
|
}
|
||||||
|
if _, err := url.Parse(base); err != nil {
|
||||||
|
return fmt.Errorf("engine base %q: %w", base, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The engine's own credential exists precisely so that the live face feed
|
||||||
|
// is never served open - CLAUDE.md is explicit that an unauthenticated
|
||||||
|
// listener would expose it. An unauthenticated loopback relay would hand
|
||||||
|
// that same feed to any other process on this PC, which on a shop counter
|
||||||
|
// is not a theoretical set. A per-run token, minted here and given only to
|
||||||
|
// this app's own webview, keeps the relay as private as the engine is.
|
||||||
|
raw := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(raw); err != nil {
|
||||||
|
return fmt.Errorf("proxy token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Port 0: the OS picks a free one. A fixed port would collide with
|
||||||
|
// whatever else a shop PC happens to be running, and the failure would be
|
||||||
|
// "the cameras stopped working" with nothing pointing at the cause.
|
||||||
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("stream proxy listen: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
p.ln = ln
|
||||||
|
p.token = hex.EncodeToString(raw)
|
||||||
|
p.target = strings.TrimRight(base, "/")
|
||||||
|
p.user, p.pass = user, pass
|
||||||
|
// No client timeout: an MJPEG stream is endless by design and any deadline
|
||||||
|
// would cut the picture off mid-shift. The request context ends it when
|
||||||
|
// the webview navigates away or the tile is replaced.
|
||||||
|
p.client = &http.Client{
|
||||||
|
Transport: &http.Transport{
|
||||||
|
DialContext: (&net.Dialer{Timeout: 5 * time.Second}).DialContext,
|
||||||
|
TLSHandshakeTimeout: 5 * time.Second,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
srv := &http.Server{Handler: http.HandlerFunc(p.handle)}
|
||||||
|
p.srv = srv
|
||||||
|
|
||||||
|
// srv and ln are captured, not read off the struct inside the goroutine:
|
||||||
|
// stop() sets both to nil, so a serve loop that reached for them after a
|
||||||
|
// quick start/stop would dereference nil and take the whole app down. The
|
||||||
|
// test that stops the relay found exactly that.
|
||||||
|
go func() { _ = srv.Serve(ln) }()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *streamProxy) stop() {
|
||||||
|
p.mu.Lock()
|
||||||
|
srv, ln := p.srv, p.ln
|
||||||
|
p.srv, p.ln, p.token = nil, nil, ""
|
||||||
|
p.mu.Unlock()
|
||||||
|
if srv != nil {
|
||||||
|
_ = srv.Close()
|
||||||
|
}
|
||||||
|
if ln != nil {
|
||||||
|
_ = ln.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// urlFor returns the loopback URL for one camera resource, or "" when the
|
||||||
|
// proxy is not running so the caller can fall back.
|
||||||
|
func (p *streamProxy) urlFor(cameraID, file string) string {
|
||||||
|
p.mu.RLock()
|
||||||
|
defer p.mu.RUnlock()
|
||||||
|
if p.ln == nil || p.token == "" || !safeCameraID(cameraID) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("http://%s/s/%s/%s/%s",
|
||||||
|
p.ln.Addr().String(), p.token, cameraID, file)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *streamProxy) handle(w http.ResponseWriter, r *http.Request) {
|
||||||
|
p.mu.RLock()
|
||||||
|
token, target, user, pass, client := p.token, p.target, p.user, p.pass, p.client
|
||||||
|
p.mu.RUnlock()
|
||||||
|
if token == "" || client == nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// /s/<token>/<camera>/<file>
|
||||||
|
parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/"), "/")
|
||||||
|
if len(parts) != 4 || parts[0] != "s" {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Constant time: the token is the only thing standing between another
|
||||||
|
// local process and a live view of customers' faces.
|
||||||
|
if subtle.ConstantTimeCompare([]byte(parts[1]), []byte(token)) != 1 {
|
||||||
|
// 404 rather than 403. There is nothing here to tell an unwelcome
|
||||||
|
// caller they have found the right door with the wrong key.
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cameraID := parts[2]
|
||||||
|
if !safeCameraID(cameraID) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// An allow-list, not a prefix match. Everything else the engine serves -
|
||||||
|
// the identity list, the gallery, erasure - stays unreachable through here
|
||||||
|
// even for a caller holding the token.
|
||||||
|
//
|
||||||
|
// frame.jpg is listed although no screen asks for one yet. It is reachable
|
||||||
|
// only through urlFor, which is internal, so it adds no bound API nobody
|
||||||
|
// calls; it is here so that adding a still later is a change to a screen
|
||||||
|
// rather than a change to the one file where a mistake is a credentialed
|
||||||
|
// proxy onto the biometric API.
|
||||||
|
var enginePath string
|
||||||
|
switch parts[3] {
|
||||||
|
case "stream.mjpeg":
|
||||||
|
enginePath = "/api/cameras/" + cameraID + "/stream.mjpeg"
|
||||||
|
case "frame.jpg":
|
||||||
|
enginePath = "/api/cameras/" + cameraID + "/frame.jpg"
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, target+enginePath, nil)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "bad upstream request", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// frame.jpg takes width and quality; the engine re-encodes on demand.
|
||||||
|
req.URL.RawQuery = r.URL.RawQuery
|
||||||
|
if user != "" {
|
||||||
|
req.SetBasicAuth(user, pass)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "engine unreachable", http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
for _, h := range []string{"Content-Type", "Cache-Control", "Pragma", "Expires"} {
|
||||||
|
if v := resp.Header.Get(h); v != "" {
|
||||||
|
w.Header().Set(h, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.WriteHeader(resp.StatusCode)
|
||||||
|
|
||||||
|
// Copied by hand rather than with io.Copy so every chunk is flushed. An
|
||||||
|
// MJPEG stream never ends, so anything buffered waiting for a full buffer
|
||||||
|
// is a tile that stays blank forever - which is the same symptom as the
|
||||||
|
// bug this file exists to fix, and would look like it had not worked.
|
||||||
|
flusher, _ := w.(http.Flusher)
|
||||||
|
buf := make([]byte, 32*1024)
|
||||||
|
for {
|
||||||
|
n, rerr := resp.Body.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
if _, werr := w.Write(buf[:n]); werr != nil {
|
||||||
|
return // webview went away
|
||||||
|
}
|
||||||
|
if flusher != nil {
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if rerr != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
296
desktop/stream_proxy_test.go
Normal file
296
desktop/stream_proxy_test.go
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeEngine stands in for the Python engine: it demands Basic auth exactly as
|
||||||
|
// the real one does when a credential is configured, and records what it was
|
||||||
|
// asked for.
|
||||||
|
type fakeEngine struct {
|
||||||
|
*httptest.Server
|
||||||
|
gotPath string
|
||||||
|
gotUser string
|
||||||
|
gotPass string
|
||||||
|
hadAuth bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFakeEngine(t *testing.T, body string) *fakeEngine {
|
||||||
|
t.Helper()
|
||||||
|
f := &fakeEngine{}
|
||||||
|
f.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
f.gotPath = r.URL.Path
|
||||||
|
if r.URL.RawQuery != "" {
|
||||||
|
f.gotPath += "?" + r.URL.RawQuery
|
||||||
|
}
|
||||||
|
f.gotUser, f.gotPass, f.hadAuth = r.BasicAuth()
|
||||||
|
if !f.hadAuth {
|
||||||
|
w.Header().Set("WWW-Authenticate", `Basic realm="behavision"`)
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "multipart/x-mixed-replace; boundary=frame")
|
||||||
|
_, _ = io.WriteString(w, body)
|
||||||
|
}))
|
||||||
|
t.Cleanup(f.Close)
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
func startProxy(t *testing.T, engine string, user, pass string) *streamProxy {
|
||||||
|
t.Helper()
|
||||||
|
p := newStreamProxy()
|
||||||
|
if err := p.start(engine, user, pass); err != nil {
|
||||||
|
t.Fatalf("start: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(p.stop)
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func get(t *testing.T, url string) (int, string) {
|
||||||
|
t.Helper()
|
||||||
|
c := &http.Client{Timeout: 5 * time.Second}
|
||||||
|
resp, err := c.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get %s: %v", url, err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
b, _ := io.ReadAll(resp.Body)
|
||||||
|
return resp.StatusCode, string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The whole point: the webview gets a URL it can actually load, and the
|
||||||
|
// password stays behind. A credential in the src is both unloadable in a
|
||||||
|
// Chromium webview and readable by anything that can see the DOM.
|
||||||
|
func TestTheCameraURLCarriesNoPassword(t *testing.T) {
|
||||||
|
engine := newFakeEngine(t, "frames")
|
||||||
|
p := startProxy(t, engine.URL, "behavision", "hunter2-the-real-one")
|
||||||
|
|
||||||
|
u := p.urlFor("cam2", "stream.mjpeg")
|
||||||
|
if u == "" {
|
||||||
|
t.Fatal("no url while the proxy is running")
|
||||||
|
}
|
||||||
|
if strings.Contains(u, "hunter2-the-real-one") || strings.Contains(u, "behavision:") {
|
||||||
|
t.Fatalf("credential leaked into the tile URL: %s", u)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(u, "http://127.0.0.1:") {
|
||||||
|
t.Fatalf("relay must be loopback only, got %s", u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheRelayAttachesTheCredentialItself(t *testing.T) {
|
||||||
|
engine := newFakeEngine(t, "frame-bytes")
|
||||||
|
p := startProxy(t, engine.URL, "behavision", "s3cret")
|
||||||
|
|
||||||
|
code, body := get(t, p.urlFor("cam2", "stream.mjpeg"))
|
||||||
|
if code != http.StatusOK {
|
||||||
|
t.Fatalf("want 200 through the relay, got %d", code)
|
||||||
|
}
|
||||||
|
if body != "frame-bytes" {
|
||||||
|
t.Fatalf("body not relayed unchanged: %q", body)
|
||||||
|
}
|
||||||
|
if !engine.hadAuth || engine.gotUser != "behavision" || engine.gotPass != "s3cret" {
|
||||||
|
t.Fatalf("engine did not receive the credential: auth=%v user=%q",
|
||||||
|
engine.hadAuth, engine.gotUser)
|
||||||
|
}
|
||||||
|
if engine.gotPath != "/api/cameras/cam2/stream.mjpeg" {
|
||||||
|
t.Fatalf("wrong upstream path: %s", engine.gotPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The token is what keeps every other process on a shop PC from opening a live
|
||||||
|
// view of customers' faces, now that the relay itself has no password.
|
||||||
|
func TestAnotherProcessCannotGuessItsWayIn(t *testing.T) {
|
||||||
|
engine := newFakeEngine(t, "frames")
|
||||||
|
p := startProxy(t, engine.URL, "behavision", "s3cret")
|
||||||
|
addr := p.ln.Addr().String()
|
||||||
|
|
||||||
|
for _, bad := range []string{"", "0", strings.Repeat("a", 64), "wrong-token"} {
|
||||||
|
url := fmt.Sprintf("http://%s/s/%s/cam2/stream.mjpeg", addr, bad)
|
||||||
|
if code, _ := get(t, url); code != http.StatusNotFound {
|
||||||
|
t.Fatalf("token %q got %d, want 404", bad, code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if engine.hadAuth {
|
||||||
|
t.Fatal("a rejected request still reached the engine")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A camera id is interpolated into the upstream path, so it has to be a camera
|
||||||
|
// id and not a way to walk to a different endpoint with the credential
|
||||||
|
// attached.
|
||||||
|
func TestACameraIdCannotClimbOutOfItsPath(t *testing.T) {
|
||||||
|
engine := newFakeEngine(t, "frames")
|
||||||
|
p := startProxy(t, engine.URL, "behavision", "s3cret")
|
||||||
|
addr := p.ln.Addr().String()
|
||||||
|
|
||||||
|
for _, bad := range []string{"..", "%2e%2e", "cam2/../../api/identities", "cam 2", ""} {
|
||||||
|
url := fmt.Sprintf("http://%s/s/%s/%s/stream.mjpeg", addr, p.token, bad)
|
||||||
|
code, _ := get(t, url)
|
||||||
|
if code != http.StatusNotFound {
|
||||||
|
t.Fatalf("camera id %q got %d, want 404", bad, code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(engine.gotPath, "identities") {
|
||||||
|
t.Fatalf("reached a non-camera endpoint: %s", engine.gotPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only the two files a tile needs. The engine also serves the identity list and
|
||||||
|
// the erasure endpoint; holding the token must not open those.
|
||||||
|
func TestOnlyTheTwoCameraFilesAreReachable(t *testing.T) {
|
||||||
|
engine := newFakeEngine(t, "frames")
|
||||||
|
p := startProxy(t, engine.URL, "behavision", "s3cret")
|
||||||
|
addr := p.ln.Addr().String()
|
||||||
|
|
||||||
|
for _, bad := range []string{"identities", "stats", "commission", "stream.mjpeg.bak"} {
|
||||||
|
url := fmt.Sprintf("http://%s/s/%s/cam2/%s", addr, p.token, bad)
|
||||||
|
if code, _ := get(t, url); code != http.StatusNotFound {
|
||||||
|
t.Fatalf("file %q got %d, want 404", bad, code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, good := range []string{"stream.mjpeg", "frame.jpg"} {
|
||||||
|
url := fmt.Sprintf("http://%s/s/%s/cam2/%s", addr, p.token, good)
|
||||||
|
if code, _ := get(t, url); code != http.StatusOK {
|
||||||
|
t.Fatalf("file %q got %d, want 200", good, code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// frame.jpg takes width and quality - the engine re-encodes on demand, and a
|
||||||
|
// relay that dropped the query would silently serve full-size frames.
|
||||||
|
func TestTheQueryStringSurvivesTheRelay(t *testing.T) {
|
||||||
|
engine := newFakeEngine(t, "frames")
|
||||||
|
p := startProxy(t, engine.URL, "behavision", "s3cret")
|
||||||
|
|
||||||
|
url := p.urlFor("cam2", "frame.jpg") + "?width=640&quality=70"
|
||||||
|
if code, _ := get(t, url); code != http.StatusOK {
|
||||||
|
t.Fatalf("got %d", code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(engine.gotPath, "width=640") ||
|
||||||
|
!strings.Contains(engine.gotPath, "quality=70") {
|
||||||
|
t.Fatalf("query dropped: %s", engine.gotPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An engine that is not running must read as a bad gateway, not as a hang. A
|
||||||
|
// blank tile that never resolves is the symptom this whole file exists to end.
|
||||||
|
func TestAnEngineThatIsDownFailsQuickly(t *testing.T) {
|
||||||
|
// Port 1 on loopback: nothing listens, and the connection is refused
|
||||||
|
// rather than dropped, so this is fast and deterministic.
|
||||||
|
p := startProxy(t, "http://127.0.0.1:1", "behavision", "s3cret")
|
||||||
|
|
||||||
|
done := make(chan int, 1)
|
||||||
|
go func() { code, _ := get(t, p.urlFor("cam2", "stream.mjpeg")); done <- code }()
|
||||||
|
select {
|
||||||
|
case code := <-done:
|
||||||
|
if code != http.StatusBadGateway {
|
||||||
|
t.Fatalf("want 502, got %d", code)
|
||||||
|
}
|
||||||
|
case <-time.After(8 * time.Second):
|
||||||
|
t.Fatal("a dead engine left the request hanging")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stopping must actually free the port, or a restarted engine leaves listeners
|
||||||
|
// behind for the life of the process.
|
||||||
|
func TestStoppingReleasesEverything(t *testing.T) {
|
||||||
|
engine := newFakeEngine(t, "frames")
|
||||||
|
p := newStreamProxy()
|
||||||
|
if err := p.start(engine.URL, "u", "p"); err != nil {
|
||||||
|
t.Fatalf("start: %v", err)
|
||||||
|
}
|
||||||
|
url := p.urlFor("cam2", "stream.mjpeg")
|
||||||
|
if code, _ := get(t, url); code != http.StatusOK {
|
||||||
|
t.Fatalf("want 200 before stop, got %d", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
p.stop()
|
||||||
|
|
||||||
|
if got := p.urlFor("cam2", "stream.mjpeg"); got != "" {
|
||||||
|
t.Fatalf("still handing out URLs after stop: %s", got)
|
||||||
|
}
|
||||||
|
c := &http.Client{Timeout: 3 * time.Second}
|
||||||
|
if resp, err := c.Get(url); err == nil {
|
||||||
|
resp.Body.Close()
|
||||||
|
t.Fatal("listener still accepting after stop")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// start twice must not leave two listeners, which is what a restarted engine
|
||||||
|
// would otherwise cause.
|
||||||
|
func TestStartingTwiceIsANoOp(t *testing.T) {
|
||||||
|
engine := newFakeEngine(t, "frames")
|
||||||
|
p := startProxy(t, engine.URL, "u", "p")
|
||||||
|
|
||||||
|
first := p.urlFor("cam2", "stream.mjpeg")
|
||||||
|
if err := p.start(engine.URL, "u", "p"); err != nil {
|
||||||
|
t.Fatalf("second start: %v", err)
|
||||||
|
}
|
||||||
|
if second := p.urlFor("cam2", "stream.mjpeg"); second != first {
|
||||||
|
t.Fatalf("second start moved the relay: %s -> %s", first, second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Against the real engine, which the unit tests above deliberately do not
|
||||||
|
// touch. Skipped unless TEST_ENGINE_URL is set, the same rule the server's
|
||||||
|
// live store tests follow: the suite must stay runnable with no services.
|
||||||
|
//
|
||||||
|
// TEST_ENGINE_URL=http://127.0.0.1:8010 \
|
||||||
|
// TEST_ENGINE_USER=... TEST_ENGINE_PASS=... go test ./desktop/ -run Live
|
||||||
|
//
|
||||||
|
// It exists because everything above proves the relay against a fake that
|
||||||
|
// agrees with me. Only a real engine proves the thing that was actually
|
||||||
|
// broken: that a multipart MJPEG stream arrives through the relay in pieces,
|
||||||
|
// rather than being buffered into a tile that never paints.
|
||||||
|
func TestLiveRelayCarriesRealMJPEGFrames(t *testing.T) {
|
||||||
|
base := os.Getenv("TEST_ENGINE_URL")
|
||||||
|
if base == "" {
|
||||||
|
t.Skip("set TEST_ENGINE_URL to run the live relay test")
|
||||||
|
}
|
||||||
|
cam := os.Getenv("TEST_ENGINE_CAMERA")
|
||||||
|
if cam == "" {
|
||||||
|
cam = "cam2"
|
||||||
|
}
|
||||||
|
p := startProxy(t, base, os.Getenv("TEST_ENGINE_USER"), os.Getenv("TEST_ENGINE_PASS"))
|
||||||
|
|
||||||
|
url := p.urlFor(cam, "stream.mjpeg")
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, url, nil)
|
||||||
|
resp, err := (&http.Client{}).Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("relay: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("relay returned %d - the credential did not reach the engine", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "multipart") {
|
||||||
|
t.Fatalf("not a stream: Content-Type %q", ct)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read until two JPEG start markers have gone past. One proves it opened;
|
||||||
|
// two prove it is still delivering, which is the difference between a
|
||||||
|
// working tile and a single frozen frame.
|
||||||
|
deadline := time.Now().Add(15 * time.Second)
|
||||||
|
var seen, total int
|
||||||
|
buf := make([]byte, 16*1024)
|
||||||
|
for seen < 2 && time.Now().Before(deadline) {
|
||||||
|
n, rerr := resp.Body.Read(buf)
|
||||||
|
total += n
|
||||||
|
seen += strings.Count(string(buf[:n]), "\xff\xd8\xff")
|
||||||
|
if rerr != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if seen < 2 {
|
||||||
|
t.Fatalf("only %d JPEG frames in %d bytes - the relay is not streaming", seen, total)
|
||||||
|
}
|
||||||
|
t.Logf("relayed %d frames in %d bytes with no credential in the URL", seen, total)
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -10,6 +11,25 @@ import (
|
|||||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// BEHAVISION_NO_TRAY runs the window with no tray icon.
|
||||||
|
//
|
||||||
|
// It exists so the UI can be looked at on a Mac. fyne.io/systray's nativeLoop
|
||||||
|
// must own the main thread on macOS - a Cocoa requirement, not a library
|
||||||
|
// choice - and Wails already holds it, so starting both kills the process with
|
||||||
|
// a SIGTRAP inside cgo before a single screen is drawn. On Windows, which is
|
||||||
|
// what ships, a tray on its own goroutine is fine. That asymmetry is why this
|
||||||
|
// went unnoticed for so long: the shop-floor UI had never once been run on the
|
||||||
|
// platform it is developed on, so every screen in it was unreviewed.
|
||||||
|
//
|
||||||
|
// Deliberately an environment variable and NOT a GOOS check. A build that
|
||||||
|
// quietly drops the tray on some platform is how a shop PC ends up with no
|
||||||
|
// control surface at all - the one thing a shop manager has - and it would
|
||||||
|
// fail exactly where nobody is watching. Nothing is skipped unless a person
|
||||||
|
// asked for it, by name, on this run.
|
||||||
|
const noTrayEnv = "BEHAVISION_NO_TRAY"
|
||||||
|
|
||||||
|
func trayDisabled() bool { return os.Getenv(noTrayEnv) != "" }
|
||||||
|
|
||||||
// tray is the always-present control surface. Wails v2 has no systray of its
|
// tray is the always-present control surface. Wails v2 has no systray of its
|
||||||
// own, so this drives fyne.io/systray alongside the window.
|
// own, so this drives fyne.io/systray alongside the window.
|
||||||
//
|
//
|
||||||
@@ -32,6 +52,9 @@ type tray struct {
|
|||||||
func newTray(a *App) *tray { return &tray{app: a, quit: make(chan struct{})} }
|
func newTray(a *App) *tray { return &tray{app: a, quit: make(chan struct{})} }
|
||||||
|
|
||||||
func (t *tray) start(ctx context.Context) {
|
func (t *tray) start(ctx context.Context) {
|
||||||
|
if trayDisabled() {
|
||||||
|
return
|
||||||
|
}
|
||||||
t.once.Do(func() {
|
t.once.Do(func() {
|
||||||
go systray.Run(func() { t.onReady(ctx) }, func() {})
|
go systray.Run(func() { t.onReady(ctx) }, func() {})
|
||||||
})
|
})
|
||||||
@@ -43,6 +66,13 @@ func (t *tray) stop() {
|
|||||||
default:
|
default:
|
||||||
close(t.quit)
|
close(t.quit)
|
||||||
}
|
}
|
||||||
|
// systray.Quit() on a systray that was never started is not a no-op in
|
||||||
|
// v1.12.2, so the guard has to be on both ends or quitting the window
|
||||||
|
// takes the process down with it - a crash on exit, which is the failure
|
||||||
|
// most likely to be shrugged off as "it closed, fine".
|
||||||
|
if trayDisabled() {
|
||||||
|
return
|
||||||
|
}
|
||||||
systray.Quit()
|
systray.Quit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
113
installer/INSTALL.txt
Normal file
113
installer/INSTALL.txt
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
Behavision — installing on a shop PC
|
||||||
|
====================================
|
||||||
|
|
||||||
|
This is a source install. It needs Python and a working internet connection
|
||||||
|
once, at setup. After that the shop PC runs on its own.
|
||||||
|
|
||||||
|
|
||||||
|
WHAT YOU NEED FIRST
|
||||||
|
-------------------
|
||||||
|
|
||||||
|
Python 3.10 or newer.
|
||||||
|
|
||||||
|
https://www.python.org/downloads/windows/
|
||||||
|
|
||||||
|
On the very first screen of the Python installer, tick
|
||||||
|
"Add python.exe to PATH". If you miss it, setup cannot find Python and
|
||||||
|
you will have to run the Python installer again.
|
||||||
|
|
||||||
|
|
||||||
|
SETTING UP
|
||||||
|
----------
|
||||||
|
|
||||||
|
1. Unzip this whole folder somewhere permanent — for example
|
||||||
|
C:\Behavision. Keep the files together; behavision-setup.exe looks for
|
||||||
|
the engine-src folder next to itself.
|
||||||
|
|
||||||
|
2. Double-click behavision-setup.exe
|
||||||
|
|
||||||
|
It will:
|
||||||
|
- find your Python and check it is new enough
|
||||||
|
- build a private Python environment under
|
||||||
|
C:\ProgramData\Behavision\runtime
|
||||||
|
- install the recognition engine and its libraries (from the wheel
|
||||||
|
in engine-src; the folder you unzipped is never written to)
|
||||||
|
- download the recognition models (a few hundred megabytes)
|
||||||
|
- start the engine once to prove it works
|
||||||
|
|
||||||
|
This takes several minutes. Leave the window open until it says Done.
|
||||||
|
If anything fails it prints why, and running it again is safe.
|
||||||
|
|
||||||
|
3. Double-click Behavision.exe
|
||||||
|
|
||||||
|
The window opens and an icon appears in the system tray, next to the
|
||||||
|
clock. Right-click the tray icon to open the window again, or to stop
|
||||||
|
recognition.
|
||||||
|
|
||||||
|
|
||||||
|
CONNECTING IT TO HEAD OFFICE
|
||||||
|
----------------------------
|
||||||
|
|
||||||
|
The first screen asks for an installation code. Ask whoever manages your
|
||||||
|
shops — they create one from the Behavision platform, under the shop.
|
||||||
|
|
||||||
|
No head office? Choose "set this PC up on its own" on the same screen.
|
||||||
|
Recognition, the cameras and the customer list all work locally; nothing is
|
||||||
|
sent anywhere.
|
||||||
|
|
||||||
|
|
||||||
|
ADDING A CAMERA
|
||||||
|
---------------
|
||||||
|
|
||||||
|
Cameras → Add. You need the camera's address on the shop network, its
|
||||||
|
username and password. Choose your camera's make from the list and the
|
||||||
|
stream path is filled in for you — that is the field nobody can look up.
|
||||||
|
|
||||||
|
Press "Test" before saving. Then press "Check placement" and walk past the
|
||||||
|
camera a few times. It will tell you whether the camera can actually
|
||||||
|
recognise faces from where it is mounted, which is not the same question as
|
||||||
|
whether it is connected.
|
||||||
|
|
||||||
|
Camera placement matters more than camera quality. Aim for roughly head
|
||||||
|
height, facing the direction people walk in. A camera high in a corner
|
||||||
|
looking down, or pointing at a bright window or glass door, will connect
|
||||||
|
perfectly and recognise almost nobody.
|
||||||
|
|
||||||
|
|
||||||
|
WHERE THINGS LIVE
|
||||||
|
-----------------
|
||||||
|
|
||||||
|
C:\ProgramData\Behavision\ database, logs, camera list, models
|
||||||
|
C:\ProgramData\Behavision\runtime the engine's own Python
|
||||||
|
|
||||||
|
Everything the software writes is under ProgramData. The folder you unzipped
|
||||||
|
is never written to, so you can keep it on a shared drive.
|
||||||
|
|
||||||
|
|
||||||
|
STOPPING IT
|
||||||
|
-----------
|
||||||
|
|
||||||
|
Right-click the tray icon and choose Quit. That stops recognition as well —
|
||||||
|
leaving it running with no visible control would be worse than stopping it.
|
||||||
|
|
||||||
|
Closing the window does NOT stop recognition. The window hides and the tray
|
||||||
|
icon stays, because a shop assistant clicking X should not switch the shop's
|
||||||
|
footfall counting off for the rest of the day.
|
||||||
|
|
||||||
|
|
||||||
|
IF SOMETHING IS WRONG
|
||||||
|
---------------------
|
||||||
|
|
||||||
|
"No Python 3.10 or newer was found"
|
||||||
|
Python is missing, too old, or was installed without the
|
||||||
|
"Add python.exe to PATH" tick. Reinstall Python with that ticked.
|
||||||
|
|
||||||
|
Setup fails while installing libraries
|
||||||
|
Almost always no internet, or a proxy in the way. The error printed
|
||||||
|
just above the failure says which.
|
||||||
|
|
||||||
|
The window opens but says the engine is not running
|
||||||
|
Run behavision-setup.exe again; it will report what is missing.
|
||||||
|
|
||||||
|
Logs
|
||||||
|
C:\ProgramData\Behavision\engine.log
|
||||||
@@ -63,6 +63,14 @@ type Store interface {
|
|||||||
RedeemInvitation(ctx context.Context, hash []byte, fullName, passwordHash string) (UserRecord, error)
|
RedeemInvitation(ctx context.Context, hash []byte, fullName, passwordHash string) (UserRecord, error)
|
||||||
Team(ctx context.Context, clientID string) ([]TeamMember, error)
|
Team(ctx context.Context, clientID string) ([]TeamMember, error)
|
||||||
UpdateTeamMember(ctx context.Context, clientID, userID string, up TeamUpdate) (TeamMember, error)
|
UpdateTeamMember(ctx context.Context, clientID, userID string, up TeamUpdate) (TeamMember, error)
|
||||||
|
// CreateMember inserts an active account into the caller's tenant. The
|
||||||
|
// hash is computed by the handler, so the plaintext never reaches the
|
||||||
|
// store - same boundary invitations and sessions already keep.
|
||||||
|
CreateMember(ctx context.Context, clientID string, in NewMemberInput, hash string) (TeamMember, error)
|
||||||
|
// ResetMemberPassword replaces the hash and revokes every session the
|
||||||
|
// member holds, in one transaction. A reset is what happens after a lost
|
||||||
|
// phone; leaving that phone signed in would defeat it.
|
||||||
|
ResetMemberPassword(ctx context.Context, clientID, userID, hash string) (TeamMember, error)
|
||||||
|
|
||||||
// --- public references ---
|
// --- public references ---
|
||||||
// Resolving the names people actually use to the uuids the schema stores.
|
// Resolving the names people actually use to the uuids the schema stores.
|
||||||
@@ -246,6 +254,8 @@ func (s *Server) Routes() *http.ServeMux {
|
|||||||
// --- the people who work here ---
|
// --- the people who work here ---
|
||||||
mux.HandleFunc("GET /api/team", s.authed(s.handleTeam))
|
mux.HandleFunc("GET /api/team", s.authed(s.handleTeam))
|
||||||
mux.HandleFunc("PATCH /api/team/{id}", s.authed(s.handleUpdateTeamMember))
|
mux.HandleFunc("PATCH /api/team/{id}", s.authed(s.handleUpdateTeamMember))
|
||||||
|
mux.HandleFunc("POST /api/team/members", s.authed(s.handleCreateMember))
|
||||||
|
mux.HandleFunc("POST /api/team/{id}/password", s.authed(s.handleResetPassword))
|
||||||
mux.HandleFunc("GET /api/team/invitations", s.authed(s.handleInvitations))
|
mux.HandleFunc("GET /api/team/invitations", s.authed(s.handleInvitations))
|
||||||
mux.HandleFunc("POST /api/team/invitations", s.authed(s.handleInvite))
|
mux.HandleFunc("POST /api/team/invitations", s.authed(s.handleInvite))
|
||||||
mux.HandleFunc("DELETE /api/team/invitations/{id}",
|
mux.HandleFunc("DELETE /api/team/invitations/{id}",
|
||||||
|
|||||||
@@ -995,3 +995,59 @@ func (f *fakeStore) VisitorIDByNumber(_ context.Context, clientID string, number
|
|||||||
}
|
}
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CreateMember behaves like the real store on the two things the handler
|
||||||
|
// branches on: the account lands in the caller's tenant and nowhere else, and
|
||||||
|
// an address that already exists anywhere is a conflict named the way Postgres
|
||||||
|
// names it, so conflictMessage recognises it.
|
||||||
|
func (f *fakeStore) CreateMember(_ context.Context, clientID string,
|
||||||
|
in NewMemberInput, hash string) (TeamMember, error) {
|
||||||
|
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
if _, taken := f.users[in.Email]; taken {
|
||||||
|
return TeamMember{}, errors.New(`duplicate key value violates unique constraint "app_users_email_idx"`)
|
||||||
|
}
|
||||||
|
// The real UserByEmail joins clients for the name; this fake reads it off
|
||||||
|
// the record, so copy it from a tenant-mate or a login as the new member
|
||||||
|
// comes back with no company name and looks like it landed nowhere.
|
||||||
|
clientName := ""
|
||||||
|
for _, u := range f.users {
|
||||||
|
if u.ClientID == clientID && u.ClientName != "" {
|
||||||
|
clientName = u.ClientName
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
id := "member-" + itoa(len(f.users)+1)
|
||||||
|
f.users[in.Email] = UserRecord{
|
||||||
|
ID: id, ClientID: clientID, ClientName: clientName,
|
||||||
|
Email: in.Email, FullName: in.FullName,
|
||||||
|
Role: in.Role, Active: true, PasswordHash: hash, Found: true,
|
||||||
|
}
|
||||||
|
return TeamMember{ID: id, Email: in.Email, FullName: in.FullName,
|
||||||
|
Role: in.Role, Active: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResetMemberPassword mirrors the real one: tenant-scoped, and every session
|
||||||
|
// the member holds is revoked with it.
|
||||||
|
func (f *fakeStore) ResetMemberPassword(_ context.Context, clientID, userID,
|
||||||
|
hash string) (TeamMember, error) {
|
||||||
|
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
for email, u := range f.users {
|
||||||
|
if u.ID != userID || u.ClientID != clientID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
u.PasswordHash = hash
|
||||||
|
f.users[email] = u
|
||||||
|
for _, s := range f.sessions {
|
||||||
|
if s.p.UserID == userID {
|
||||||
|
s.revoked = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return TeamMember{ID: u.ID, Email: u.Email, FullName: u.FullName,
|
||||||
|
Role: u.Role, Active: u.Active}, nil
|
||||||
|
}
|
||||||
|
return TeamMember{}, errors.New("no such team member")
|
||||||
|
}
|
||||||
|
|||||||
@@ -388,3 +388,134 @@ func (s *Server) lastOwner(r *http.Request, userID string) (bool, error) {
|
|||||||
}
|
}
|
||||||
return isOwner && owners == 1, nil
|
return isOwner && owners == 1, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleCreateMember is a manager creating a salesperson's login directly and
|
||||||
|
// handing it over - the path for somebody being set up before their first
|
||||||
|
// shift, without a phone in hand.
|
||||||
|
//
|
||||||
|
// Same rules as an invitation for who may create whom: manager and above, and
|
||||||
|
// only an owner mints an owner. Same rule as the platform admin creating a
|
||||||
|
// merchant for the password: generated unless given, returned exactly once.
|
||||||
|
func (s *Server) handleCreateMember(w http.ResponseWriter, r *http.Request) {
|
||||||
|
p := PrincipalFrom(r.Context())
|
||||||
|
if !p.CanManageSites() || p.ClientID == "" {
|
||||||
|
writeErr(w, http.StatusForbidden, "forbidden",
|
||||||
|
"Only a manager or owner can add team members.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var in NewMemberInput
|
||||||
|
if err := decode(w, r, &in); err != nil {
|
||||||
|
badRequest(w, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
in.Email = auth.NormalizeEmail(in.Email)
|
||||||
|
if in.Email == "" || !strings.Contains(in.Email, "@") {
|
||||||
|
badRequest(w, "an email address is required - it is what they will sign in with")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
in.FullName = clip(trim(in.FullName), 200)
|
||||||
|
in.Role = strings.ToLower(trim(in.Role))
|
||||||
|
if in.Role == "" {
|
||||||
|
in.Role = "staff"
|
||||||
|
}
|
||||||
|
switch in.Role {
|
||||||
|
case "owner", "manager", "staff":
|
||||||
|
default:
|
||||||
|
badRequest(w, "role must be owner, manager or staff")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if in.Role == "owner" && p.Role != "owner" && p.Role != "admin" {
|
||||||
|
writeErr(w, http.StatusForbidden, "forbidden",
|
||||||
|
"Only an owner can create another owner.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
password := in.Password
|
||||||
|
if password == "" {
|
||||||
|
generated, err := auth.RandomPassword()
|
||||||
|
if err != nil {
|
||||||
|
s.serverError(w, "generate password", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
password = generated
|
||||||
|
}
|
||||||
|
hash, err := auth.HashPassword(password)
|
||||||
|
if err != nil {
|
||||||
|
// The policy message ("at least 8 characters") is written for the
|
||||||
|
// person who typed it, so it goes out as-is.
|
||||||
|
badRequest(w, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
m, err := s.Store.CreateMember(r.Context(), p.ClientID, in, hash)
|
||||||
|
if err != nil {
|
||||||
|
if msg, ok := conflictMessage(err); ok {
|
||||||
|
writeErr(w, http.StatusConflict, "conflict", msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.serverError(w, "create member", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.Store.Audit(r.Context(), AuditEntry{
|
||||||
|
ClientID: p.ClientID, ActorID: p.UserID, ActorKind: "user",
|
||||||
|
Action: "team.create", Entity: "user", EntityID: m.ID,
|
||||||
|
Detail: map[string]any{"email": m.Email, "role": m.Role},
|
||||||
|
})
|
||||||
|
// The plaintext exists here and in this response, and nowhere else.
|
||||||
|
writeJSON(w, http.StatusCreated, NewMemberResult{TeamMember: m, Password: password})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleResetPassword is a manager resetting a member's password: the
|
||||||
|
// salesperson forgot it, or lost the phone it was on. Returns the new one
|
||||||
|
// once, and signs the member out everywhere - see the store for why those are
|
||||||
|
// one operation.
|
||||||
|
//
|
||||||
|
// Deliberately not self-service and not "send an email": a shop-floor account
|
||||||
|
// often has no mailbox anyone checks, and the person who can vouch for the
|
||||||
|
// salesperson standing in front of them is their manager.
|
||||||
|
func (s *Server) handleResetPassword(w http.ResponseWriter, r *http.Request) {
|
||||||
|
p := PrincipalFrom(r.Context())
|
||||||
|
if !p.CanManageSites() || p.ClientID == "" {
|
||||||
|
writeErr(w, http.StatusForbidden, "forbidden",
|
||||||
|
"Only a manager or owner can reset a team member's password.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID := r.PathValue("id")
|
||||||
|
|
||||||
|
var in PasswordReset
|
||||||
|
if err := decodeOptional(w, r, &in); err != nil {
|
||||||
|
badRequest(w, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
password := in.Password
|
||||||
|
if password == "" {
|
||||||
|
generated, err := auth.RandomPassword()
|
||||||
|
if err != nil {
|
||||||
|
s.serverError(w, "generate password", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
password = generated
|
||||||
|
}
|
||||||
|
hash, err := auth.HashPassword(password)
|
||||||
|
if err != nil {
|
||||||
|
badRequest(w, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
m, err := s.Store.ResetMemberPassword(r.Context(), p.ClientID, userID, hash)
|
||||||
|
if err != nil {
|
||||||
|
// A user id from another tenant matches nothing, so it reads as 404 -
|
||||||
|
// a tenant user has no business learning the id was real.
|
||||||
|
writeErr(w, http.StatusNotFound, "not_found", "No such team member.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.Store.Audit(r.Context(), AuditEntry{
|
||||||
|
ClientID: p.ClientID, ActorID: p.UserID, ActorKind: "user",
|
||||||
|
Action: "team.reset_password", Entity: "user", EntityID: m.ID,
|
||||||
|
Detail: map[string]any{"email": m.Email},
|
||||||
|
})
|
||||||
|
writeJSON(w, http.StatusOK, PasswordReset{Password: password})
|
||||||
|
}
|
||||||
|
|||||||
201
server/internal/api/team_members_test.go
Normal file
201
server/internal/api/team_members_test.go
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The second way a salesperson gets a login: their manager creates it and hands
|
||||||
|
// it over. Everything here is a property of the one rule that path lives by -
|
||||||
|
// the password is shown once, to the manager, and to nobody afterwards.
|
||||||
|
|
||||||
|
func createMember(t *testing.T, s *Server, token string, body map[string]any) (int, NewMemberResult, string) {
|
||||||
|
t.Helper()
|
||||||
|
rec := do(t, s, "POST", "/api/team/members", token, body)
|
||||||
|
var out NewMemberResult
|
||||||
|
if rec.Code == http.StatusCreated {
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rec.Code, out, rec.Body.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAManagerCanCreateALoginAndHandItOver(t *testing.T) {
|
||||||
|
s, fs := newServer(t)
|
||||||
|
seedUser(fs)
|
||||||
|
mgr := login(t, s, "manager@acme.com", "correct horse battery")
|
||||||
|
|
||||||
|
code, out, body := createMember(t, s, mgr.Token, map[string]any{
|
||||||
|
"email": "Priya@Acme.com", "full_name": "Priya R", "role": "staff"})
|
||||||
|
if code != http.StatusCreated {
|
||||||
|
t.Fatalf("create: got %d, body %s", code, body)
|
||||||
|
}
|
||||||
|
// Generated, not blank, and long enough to be a credential rather than a
|
||||||
|
// suggestion. The manager reads this off the screen onto a card.
|
||||||
|
if len(out.Password) < 12 {
|
||||||
|
t.Fatalf("password should be generated when not given, got %q", out.Password)
|
||||||
|
}
|
||||||
|
if out.Email != "priya@acme.com" || out.Role != "staff" || !out.Active {
|
||||||
|
t.Fatalf("member not as created: %+v", out.TeamMember)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The whole point: the salesperson can sign in with what the manager was
|
||||||
|
// shown, right now, on their own phone.
|
||||||
|
sess := login(t, s, "priya@acme.com", out.Password)
|
||||||
|
if sess.User.Client != "Acme Retail" || sess.User.Role != "staff" {
|
||||||
|
t.Fatalf("the new member landed somewhere odd: %+v", sess.User)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The password is returned by the request that set it and by nothing else. A
|
||||||
|
// credential a manager can look up later is one anybody at that screen can
|
||||||
|
// read off, and the team list is on screen all day.
|
||||||
|
func TestThePasswordIsShownOnceAndNeverListed(t *testing.T) {
|
||||||
|
s, fs := newServer(t)
|
||||||
|
seedUser(fs)
|
||||||
|
mgr := login(t, s, "manager@acme.com", "correct horse battery")
|
||||||
|
|
||||||
|
_, out, _ := createMember(t, s, mgr.Token, map[string]any{"email": "sam@acme.com"})
|
||||||
|
|
||||||
|
rec := do(t, s, "GET", "/api/team", mgr.Token, nil)
|
||||||
|
if strings.Contains(rec.Body.String(), out.Password) {
|
||||||
|
t.Fatal("the team list carries a password")
|
||||||
|
}
|
||||||
|
if strings.Contains(rec.Body.String(), `"password"`) {
|
||||||
|
t.Fatal("the team list has a password field at all")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same shape of permission as an invitation, on purpose: the two paths create
|
||||||
|
// the same thing, so a manager must not be able to do through one what they
|
||||||
|
// are refused through the other.
|
||||||
|
func TestStaffCannotCreateAndAManagerCannotCreateAnOwner(t *testing.T) {
|
||||||
|
s, fs := newServer(t)
|
||||||
|
seedUser(fs)
|
||||||
|
seedMember(fs, acmeStaffID, "staff@acme.com", "Sam", "staff")
|
||||||
|
seedMember(fs, acmeOwnerID, "owner@acme.com", "Olu", "owner")
|
||||||
|
|
||||||
|
staff := login(t, s, "staff@acme.com", "correct horse battery")
|
||||||
|
if code, _, _ := createMember(t, s, staff.Token, map[string]any{"email": "x@acme.com"}); code != http.StatusForbidden {
|
||||||
|
t.Fatalf("staff creating a login: want 403, got %d", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
mgr := login(t, s, "manager@acme.com", "correct horse battery")
|
||||||
|
if code, _, _ := createMember(t, s, mgr.Token, map[string]any{"email": "boss@acme.com", "role": "owner"}); code != http.StatusForbidden {
|
||||||
|
t.Fatalf("manager minting an owner: want 403, got %d", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
owner := login(t, s, "owner@acme.com", "correct horse battery")
|
||||||
|
if code, _, body := createMember(t, s, owner.Token, map[string]any{"email": "boss@acme.com", "role": "owner"}); code != http.StatusCreated {
|
||||||
|
t.Fatalf("owner minting an owner: want 201, got %d %s", code, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Never admin. A platform admin is defined by having no company, so this
|
||||||
|
// could only ever mint the tenant-scoped role='admin' row that adminOnly
|
||||||
|
// exists to reject.
|
||||||
|
if code, _, _ := createMember(t, s, owner.Token, map[string]any{"email": "root@acme.com", "role": "admin"}); code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("role=admin: want 400, got %d", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnAddressThatAlreadyExistsIsAConflictNotAFault(t *testing.T) {
|
||||||
|
s, fs := newServer(t)
|
||||||
|
seedUser(fs)
|
||||||
|
mgr := login(t, s, "manager@acme.com", "correct horse battery")
|
||||||
|
|
||||||
|
code, _, body := createMember(t, s, mgr.Token, map[string]any{"email": "manager@acme.com"})
|
||||||
|
if code != http.StatusConflict {
|
||||||
|
t.Fatalf("want 409, got %d %s", code, body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "already has an account") {
|
||||||
|
t.Fatalf("the message should say what to do about it: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A manager may choose the password, but not a bad one. The floor is the same
|
||||||
|
// as everywhere else, and the policy message goes to them unchanged.
|
||||||
|
func TestAChosenPasswordStillMeetsTheFloor(t *testing.T) {
|
||||||
|
s, fs := newServer(t)
|
||||||
|
seedUser(fs)
|
||||||
|
mgr := login(t, s, "manager@acme.com", "correct horse battery")
|
||||||
|
|
||||||
|
code, _, body := createMember(t, s, mgr.Token, map[string]any{"email": "a@acme.com", "password": "short"})
|
||||||
|
if code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("want 400, got %d %s", code, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
code, out, _ := createMember(t, s, mgr.Token, map[string]any{"email": "b@acme.com", "password": "chosen-by-manager"})
|
||||||
|
if code != http.StatusCreated || out.Password != "chosen-by-manager" {
|
||||||
|
t.Fatalf("a valid chosen password should be used and echoed once, got %d %q", code, out.Password)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Why a manager resets a password: the salesperson forgot it, or lost the
|
||||||
|
// phone it was saved on. In the second case the phone is the problem, so the
|
||||||
|
// reset that fixes the first must also fix the second.
|
||||||
|
func TestAResetSignsTheOldPhoneOutAndTheNewPasswordIn(t *testing.T) {
|
||||||
|
s, fs := newServer(t)
|
||||||
|
seedUser(fs)
|
||||||
|
seedMember(fs, acmeStaffID, "priya@acme.com", "Priya", "staff")
|
||||||
|
mgr := login(t, s, "manager@acme.com", "correct horse battery")
|
||||||
|
lostPhone := login(t, s, "priya@acme.com", "correct horse battery")
|
||||||
|
|
||||||
|
rec := do(t, s, "POST", "/api/team/"+acmeStaffID+"/password", mgr.Token, nil)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("reset: %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var out PasswordReset
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(out.Password) < 12 {
|
||||||
|
t.Fatalf("reset should hand back a generated password, got %q", out.Password)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The lost phone is out.
|
||||||
|
if rec := do(t, s, "GET", "/api/auth/me", lostPhone.Token, nil); rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("the old session should be revoked by a reset, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
// The old password is dead.
|
||||||
|
if rec := do(t, s, "POST", "/api/auth/login", "", map[string]any{
|
||||||
|
"email": "priya@acme.com", "password": "correct horse battery"}); rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("the old password still works after a reset, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
// The new one is alive.
|
||||||
|
login(t, s, "priya@acme.com", out.Password)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A user id is not a secret and this endpoint hands out a credential, so it
|
||||||
|
// must not be reachable across tenants - and it must read as "no such person",
|
||||||
|
// not as "that id is real but not yours".
|
||||||
|
func TestAResetCannotReachAnotherCompanysStaff(t *testing.T) {
|
||||||
|
s, fs := newServer(t)
|
||||||
|
seedUser(fs)
|
||||||
|
fs.addUser("theirs@other.com", "correct horse battery", UserRecord{
|
||||||
|
ID: acmeOtherID, ClientID: "client-other", ClientName: "Other Ltd",
|
||||||
|
FullName: "Theo", Role: "staff", Active: true,
|
||||||
|
})
|
||||||
|
mgr := login(t, s, "manager@acme.com", "correct horse battery")
|
||||||
|
|
||||||
|
rec := do(t, s, "POST", "/api/team/"+acmeOtherID+"/password", mgr.Token, nil)
|
||||||
|
if rec.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("cross-tenant reset: want 404, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
// And nothing happened to them.
|
||||||
|
login(t, s, "theirs@other.com", "correct horse battery")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStaffCannotResetAnyonesPassword(t *testing.T) {
|
||||||
|
s, fs := newServer(t)
|
||||||
|
seedUser(fs)
|
||||||
|
seedMember(fs, acmeStaffID, "staff@acme.com", "Sam", "staff")
|
||||||
|
staff := login(t, s, "staff@acme.com", "correct horse battery")
|
||||||
|
|
||||||
|
rec := do(t, s, "POST", "/api/team/"+acmeStaffID+"/password", staff.Token, nil)
|
||||||
|
if rec.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("want 403, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -695,6 +695,38 @@ type TeamUpdate struct {
|
|||||||
Active *bool `json:"active,omitempty"`
|
Active *bool `json:"active,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewMemberInput is a staff account created directly by a manager, with a
|
||||||
|
// password the manager hands over.
|
||||||
|
//
|
||||||
|
// The other path - an invitation the salesperson redeems on their own phone -
|
||||||
|
// is better when it fits: the manager never touches the password. It does not
|
||||||
|
// fit a salesperson being set up before their first shift, without a phone in
|
||||||
|
// hand, by somebody who wants to write a login on a card and be done. This is
|
||||||
|
// that path, and it mirrors how the platform admin creates a merchant owner:
|
||||||
|
// same generated password, same shown-once rule.
|
||||||
|
type NewMemberInput struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
FullName string `json:"full_name"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
// Password is optional. Empty means "generate one", which is the better
|
||||||
|
// default for the same reason it is on the admin side.
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMemberResult is the member plus the one moment their password is readable.
|
||||||
|
type NewMemberResult struct {
|
||||||
|
TeamMember
|
||||||
|
// Password is shown once. It is bcrypt-hashed on the way in and is not
|
||||||
|
// recoverable afterwards.
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PasswordReset is both the optional request ("use this one") and the response
|
||||||
|
// ("here is the one that was set") for a manager resetting a member's password.
|
||||||
|
type PasswordReset struct {
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
// ==================================================== devices and sessions ==
|
// ==================================================== devices and sessions ==
|
||||||
|
|
||||||
// DeviceSession is one signed-in device, as its owner sees it.
|
// DeviceSession is one signed-in device, as its owner sees it.
|
||||||
|
|||||||
@@ -71,6 +71,21 @@ func UseTestCost() func() {
|
|||||||
return func() { bcryptCost = previous; DummyHash = previousDummy }
|
return func() { bcryptCost = previous; DummyHash = previousDummy }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RandomPassword mints a credential for somebody else - a merchant owner
|
||||||
|
// created by the platform admin, a salesperson created by their manager, a
|
||||||
|
// reset. 80 bits as 16 lowercase base32 characters: long enough that guessing
|
||||||
|
// it is not a plan, and a shape a person can read down a phone line without
|
||||||
|
// spelling out case. One generator rather than one per caller, so nobody
|
||||||
|
// later writes a shorter one for the "less important" account.
|
||||||
|
func RandomPassword() (string, error) {
|
||||||
|
b := make([]byte, 10)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return strings.ToLower(base32.StdEncoding.
|
||||||
|
WithPadding(base32.NoPadding).EncodeToString(b)), nil
|
||||||
|
}
|
||||||
|
|
||||||
func HashPassword(plain string) (string, error) {
|
func HashPassword(plain string) (string, error) {
|
||||||
if err := CheckPasswordPolicy(plain); err != nil {
|
if err := CheckPasswordPolicy(plain); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
|
|||||||
@@ -2,10 +2,7 @@ package store
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
|
||||||
"encoding/base32"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/loyaly/behavision-server/internal/api"
|
"github.com/loyaly/behavision-server/internal/api"
|
||||||
@@ -28,7 +25,7 @@ func (s *Store) CreateClientWithOwner(ctx context.Context, in api.NewClientInput
|
|||||||
if password == "" {
|
if password == "" {
|
||||||
// Generated rather than defaulted. An operator inventing a password for
|
// Generated rather than defaulted. An operator inventing a password for
|
||||||
// somebody else invents a weak one and then sends it over chat.
|
// somebody else invents a weak one and then sends it over chat.
|
||||||
p, err := randomPassword()
|
p, err := auth.RandomPassword()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
@@ -107,11 +104,3 @@ func (s *Store) ListClients(ctx context.Context) ([]api.ClientRow, error) {
|
|||||||
// base32 without padding, matching the rest of this system's generated
|
// base32 without padding, matching the rest of this system's generated
|
||||||
// secrets: it gets read down a phone line and pasted into a form, and base64's
|
// secrets: it gets read down a phone line and pasted into a form, and base64's
|
||||||
// + / = survive neither.
|
// + / = survive neither.
|
||||||
func randomPassword() (string, error) {
|
|
||||||
b := make([]byte, 10) // 80 bits -> 16 characters
|
|
||||||
if _, err := rand.Read(b); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return strings.ToLower(base32.StdEncoding.
|
|
||||||
WithPadding(base32.NoPadding).EncodeToString(b)), nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -39,6 +39,38 @@ func liveStore(t *testing.T) *Store {
|
|||||||
return st
|
return st
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dropTenant removes a seeded tenant when the test that made it finishes.
|
||||||
|
//
|
||||||
|
// Without this these tests are a slow leak. Every one of them seeds its own
|
||||||
|
// tenant - deliberately, so they can run in any order and so the isolation
|
||||||
|
// assertions have a real neighbour - and none of them ever removed it. A dev
|
||||||
|
// database reached 242 abandoned tenants against the single real one, which is
|
||||||
|
// not merely untidy: the platform admin's Companies screen lists every client,
|
||||||
|
// so the one real company was buried under pages of `walk1788761685056287000`.
|
||||||
|
//
|
||||||
|
// Registered against the CLIENT rather than each table because every foreign
|
||||||
|
// key onto clients is ON DELETE CASCADE, so one delete takes the sites,
|
||||||
|
// visitors, visits, face images, embeddings, cameras and agents with it. A
|
||||||
|
// per-table list would rot the first time a migration adds a table, and it
|
||||||
|
// would rot silently - which is the shape of the bug it is cleaning up after.
|
||||||
|
//
|
||||||
|
// t.Cleanup runs LIFO and liveStore registers st.Close before any seeding, so
|
||||||
|
// the delete still has a live pool when it runs.
|
||||||
|
func dropTenant(t *testing.T, st *Store, clientID string) {
|
||||||
|
t.Helper()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if _, err := st.pool.Exec(ctx,
|
||||||
|
`DELETE FROM clients WHERE id = $1::uuid`, clientID); err != nil {
|
||||||
|
// Reported rather than ignored: a tenant left behind is the very
|
||||||
|
// thing this exists to prevent, and swallowing the error would let
|
||||||
|
// the leak return with nothing to show for it.
|
||||||
|
t.Errorf("cleanup tenant %s: %v", clientID, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// seedTenant builds a client, a site and n visits, and returns the client id.
|
// seedTenant builds a client, a site and n visits, and returns the client id.
|
||||||
// Every test gets its own tenant so they can run in any order without a
|
// Every test gets its own tenant so they can run in any order without a
|
||||||
// truncate between them - and so the isolation assertions below have a real
|
// truncate between them - and so the isolation assertions below have a real
|
||||||
@@ -52,6 +84,7 @@ func seedTenant(t *testing.T, st *Store, name string, n int, withImages bool) (c
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("seed client: %v", err)
|
t.Fatalf("seed client: %v", err)
|
||||||
}
|
}
|
||||||
|
dropTenant(t, st, clientID)
|
||||||
err = st.pool.QueryRow(ctx, `
|
err = st.pool.QueryRow(ctx, `
|
||||||
INSERT INTO sites (client_id, name, slug) VALUES ($1::uuid, $2, $3)
|
INSERT INTO sites (client_id, name, slug) VALUES ($1::uuid, $2, $3)
|
||||||
RETURNING id::text`, clientID, name+" Main", name+"-main").Scan(&siteID)
|
RETURNING id::text`, clientID, name+" Main", name+"-main").Scan(&siteID)
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ func seedAgentSite(t *testing.T, st *Store, name string) ingest.Site {
|
|||||||
name).Scan(&site.ClientID); err != nil {
|
name).Scan(&site.ClientID); err != nil {
|
||||||
t.Fatalf("seed client: %v", err)
|
t.Fatalf("seed client: %v", err)
|
||||||
}
|
}
|
||||||
|
dropTenant(t, st, site.ClientID)
|
||||||
if err := st.pool.QueryRow(ctx, `
|
if err := st.pool.QueryRow(ctx, `
|
||||||
INSERT INTO sites (client_id, name, slug) VALUES ($1::uuid, $2, $3)
|
INSERT INTO sites (client_id, name, slug) VALUES ($1::uuid, $2, $3)
|
||||||
RETURNING id::text`, site.ClientID, name, name).Scan(&site.SiteID); err != nil {
|
RETURNING id::text`, site.ClientID, name, name).Scan(&site.SiteID); err != nil {
|
||||||
|
|||||||
@@ -345,3 +345,72 @@ func (s *Store) RevokeOtherSessions(ctx context.Context, userID, keepSessionID s
|
|||||||
}
|
}
|
||||||
return int(tag.RowsAffected()), nil
|
return int(tag.RowsAffected()), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CreateMember inserts an active account into a tenant.
|
||||||
|
//
|
||||||
|
// The email uniqueness constraint is global (migration 007), and a clash here
|
||||||
|
// is an ordinary typing mistake - somebody already has that address - so it
|
||||||
|
// surfaces as a conflict the manager can act on, not a 500.
|
||||||
|
func (s *Store) CreateMember(ctx context.Context, clientID string,
|
||||||
|
in api.NewMemberInput, hash string) (api.TeamMember, error) {
|
||||||
|
|
||||||
|
var m api.TeamMember
|
||||||
|
err := s.pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO app_users (client_id, email, password_hash, full_name, role)
|
||||||
|
VALUES ($1::uuid, $2, $3, $4, $5)
|
||||||
|
RETURNING id::text, email, full_name, role, active, '',
|
||||||
|
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`,
|
||||||
|
clientID, in.Email, hash, in.FullName, in.Role,
|
||||||
|
).Scan(&m.ID, &m.Email, &m.FullName, &m.Role, &m.Active,
|
||||||
|
&m.LastLoginAt, &m.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return api.TeamMember{}, fmt.Errorf("create member: %w", err)
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResetMemberPassword replaces a member's password and signs them out
|
||||||
|
// everywhere, in one transaction.
|
||||||
|
//
|
||||||
|
// The two go together because of why a manager resets a password at all: the
|
||||||
|
// salesperson forgot it, or lost the phone it was saved on. In the second case
|
||||||
|
// the old sessions are the problem, and a reset that left them valid would
|
||||||
|
// look complete while changing nothing that mattered. Scoped to the caller's
|
||||||
|
// tenant in the UPDATE itself, so a user id from another company matches no
|
||||||
|
// row rather than being reset.
|
||||||
|
func (s *Store) ResetMemberPassword(ctx context.Context, clientID, userID,
|
||||||
|
hash string) (api.TeamMember, error) {
|
||||||
|
|
||||||
|
tx, err := s.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return api.TeamMember{}, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx) //nolint:errcheck // no-op once committed
|
||||||
|
|
||||||
|
var m api.TeamMember
|
||||||
|
err = tx.QueryRow(ctx, `
|
||||||
|
UPDATE app_users SET password_hash = $3
|
||||||
|
WHERE id = $2::uuid AND client_id = $1::uuid
|
||||||
|
RETURNING id::text, email, full_name, role, active,
|
||||||
|
COALESCE(to_char(last_login_at AT TIME ZONE 'UTC',
|
||||||
|
'YYYY-MM-DD"T"HH24:MI:SS"Z"'), ''),
|
||||||
|
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`,
|
||||||
|
clientID, userID, hash,
|
||||||
|
).Scan(&m.ID, &m.Email, &m.FullName, &m.Role, &m.Active,
|
||||||
|
&m.LastLoginAt, &m.CreatedAt)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return api.TeamMember{}, errors.New("no such team member")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return api.TeamMember{}, fmt.Errorf("reset password: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
UPDATE sessions SET revoked_at = now()
|
||||||
|
WHERE user_id = $1::uuid AND revoked_at IS NULL`, userID); err != nil {
|
||||||
|
return api.TeamMember{}, fmt.Errorf("revoke sessions: %w", err)
|
||||||
|
}
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return api.TeamMember{}, err
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|||||||
96
server/internal/store/api_team_live_test.go
Normal file
96
server/internal/store/api_team_live_test.go
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/loyaly/behavision-server/internal/api"
|
||||||
|
"github.com/loyaly/behavision-server/internal/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The in-memory fake agrees with whatever SQL I wrote. These run the two new
|
||||||
|
// statements against Postgres: the RETURNING list has to scan, the tenant
|
||||||
|
// scope has to hold, and a reset has to actually revoke the sessions row.
|
||||||
|
|
||||||
|
func TestLiveAManagerCreatedLoginRoundTrips(t *testing.T) {
|
||||||
|
st := liveStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
clientID, _ := seedTenant(t, st, "mem"+stamp(), 0, false)
|
||||||
|
|
||||||
|
hash, err := auth.HashPassword("a-perfectly-good-password")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m, err := st.CreateMember(ctx, clientID, api.NewMemberInput{
|
||||||
|
Email: "priya@" + stamp() + ".test", FullName: "Priya R", Role: "staff",
|
||||||
|
}, hash)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create: %v", err)
|
||||||
|
}
|
||||||
|
if m.ID == "" || !m.Active || m.Role != "staff" || m.CreatedAt == "" {
|
||||||
|
t.Fatalf("member not as created: %+v", m)
|
||||||
|
}
|
||||||
|
// LastLoginAt is RETURNED as '' for a brand-new row; it must scan into a
|
||||||
|
// string, not fail as an untyped literal.
|
||||||
|
if m.LastLoginAt != "" {
|
||||||
|
t.Fatalf("a new member has never logged in, got %q", m.LastLoginAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Findable by the login path, in the right tenant, with the hash intact.
|
||||||
|
rec, err := st.UserByEmail(ctx, m.Email)
|
||||||
|
if err != nil || !rec.Found {
|
||||||
|
t.Fatalf("new member not findable: %v found=%v", err, rec.Found)
|
||||||
|
}
|
||||||
|
if rec.ClientID != clientID || !auth.VerifyPassword(rec.PasswordHash, "a-perfectly-good-password") {
|
||||||
|
t.Fatalf("landed wrong: client=%s verify=%v", rec.ClientID, auth.VerifyPassword(rec.PasswordHash, "a-perfectly-good-password"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLiveAResetIsTenantScopedAndRevokesSessions(t *testing.T) {
|
||||||
|
st := liveStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
mine, _ := seedTenant(t, st, "rsa"+stamp(), 0, false)
|
||||||
|
theirs, _ := seedTenant(t, st, "rsb"+stamp(), 0, false)
|
||||||
|
|
||||||
|
oldHash, _ := auth.HashPassword("old-password-here")
|
||||||
|
m, err := st.CreateMember(ctx, mine, api.NewMemberInput{
|
||||||
|
Email: "sam@" + stamp() + ".test", FullName: "Sam", Role: "staff"}, oldHash)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Give them a live session to lose.
|
||||||
|
if _, err := st.pool.Exec(ctx, `
|
||||||
|
INSERT INTO sessions (user_id, client_id, access_hash, refresh_hash,
|
||||||
|
access_expires_at, refresh_expires_at, device)
|
||||||
|
VALUES ($1::uuid, $2::uuid, $3, $4, now() + interval '1 hour',
|
||||||
|
now() + interval '30 days', 'lost phone')`,
|
||||||
|
m.ID, mine, []byte("a"+stamp()), []byte("r"+stamp())); err != nil {
|
||||||
|
t.Fatalf("seed session: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Another tenant's manager cannot reset them, and it reads as no such row.
|
||||||
|
newHash, _ := auth.HashPassword("new-password-here")
|
||||||
|
if _, err := st.ResetMemberPassword(ctx, theirs, m.ID, newHash); err == nil {
|
||||||
|
t.Fatal("a reset from another tenant should find nobody")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Their own tenant can, and it takes the session with it.
|
||||||
|
if _, err := st.ResetMemberPassword(ctx, mine, m.ID, newHash); err != nil {
|
||||||
|
t.Fatalf("reset: %v", err)
|
||||||
|
}
|
||||||
|
var live int
|
||||||
|
if err := st.pool.QueryRow(ctx, `
|
||||||
|
SELECT count(*) FROM sessions WHERE user_id = $1::uuid AND revoked_at IS NULL`,
|
||||||
|
m.ID).Scan(&live); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if live != 0 {
|
||||||
|
t.Fatalf("%d session(s) survived a password reset", live)
|
||||||
|
}
|
||||||
|
rec, _ := st.UserByEmail(ctx, m.Email)
|
||||||
|
if !auth.VerifyPassword(rec.PasswordHash, "new-password-here") ||
|
||||||
|
auth.VerifyPassword(rec.PasswordHash, "old-password-here") {
|
||||||
|
t.Fatal("the hash did not change to the new password")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user