9 Commits

Author SHA1 Message Date
e262fc8482 The live picture was chained to the recognition pipeline
Reported from the first Windows install: the camera feed lags. It did,
and not because of the network, the proxy or the webview.

The MJPEG stream served _annotated_jpeg - the frame the pipeline had
most recently FINISHED with, encoded after detection, quality scoring,
tracking and identification had all run on it. On a modest shop PC that
is a few frames a second, and every picture was already as old as that
processing. It looked like lag because it was lag. On the fast machine
it was developed on the pipeline kept up with the stream's own 10 fps
cap, which is why nobody here ever saw it.

Two more things compounded it. Every processed frame was JPEG-encoded
whether or not a viewer existed - CPU spent on precisely the machine
short of it. And ffmpeg ran its RTSP demuxer with default buffering,
which holds a comfortable queue of frames before handing over the first:
half a second to two seconds a live view can never recover.

Now the picture and the boxes are decoupled. latest_jpeg_since takes the
capture thread's freshest frame at the camera's own rate and draws the
boxes from the last processed frame over it - encoded on demand, per
request, so a camera nobody watches costs no encode at all. The stream
sends a frame only when the camera has a newer one, capped at 15 fps;
nothing is sent twice. Boxes older than a second are not drawn, so a
stalled pipeline cannot leave one floating over an empty spot.
_publish_annotated becomes _remember_tracks: a handful of tuples under
the lock, no copy, no encode. ffmpeg gets nobuffer / low_delay /
max_delay.

Measured on cam2's sub-stream, same machine, ten seconds each:

  before   99 frames sent,  98 distinct    9.8 new pictures/s
  after   141 frames sent, 141 distinct   14.0 new pictures/s

against a 15 fps camera, with the pipeline still processing 166 of 181
captured frames alongside - and engine CPU DOWN from 90% with no viewer
to 62% with one attached.

Engine version 1.0.0 -> 1.1.0 so a re-run of setup reinstalls it rather
than pip deciding the requirement is already satisfied.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-11 16:28:05 +05:30
b59e667a68 A demo release with the office cameras sealed inside it
Wanted: install it and the two office cameras are already there - but
without the release carrying their admin password where anyone with the
zip can read it. "Encode it" does not achieve that; anything the
installer can decode, anyone holding the installer can decode.

pkg/demo seals the camera list with AES-256-GCM under a key that is NOT
in the package: a 120-bit unlock code minted when the bundle is sealed,
given to whoever runs setup by voice or message, typed once. The code
is random, so it is key material directly through SHA-256; a human-
chosen passphrase would need a KDF and a dependency, 120 random bits do
not. The sealed file contains the format marker and noise. Tested: the
password and the host do not appear in it, a wrong code and a flipped
byte are both refused as ErrWrongCode, every seal differs.

behavision-demo-pack seals; it runs on the build machine and is never
shipped. The code is printed once and stored nowhere.

behavision-setup, on finding demo-cameras.enc beside the engine source,
asks for the code BEFORE the ten-minute download so a mistyped one costs
seconds, and adds the cameras at the end - through the running engine's
own Add Camera endpoint, not by writing its file. The store's save() is
what applies DPAPI to the password on Windows, so this is how the
credential ends up encrypted and machine-bound on the demo PC rather
than in cameras.json for anyone who can read ProgramData. It then marks
the PC standalone, so the app opens on Live instead of asking for an
installation code it will never get.

Which found the gap that DPAPI only works if pywin32 is importable, and
nothing had ever pulled it in - every Windows install to date would have
logged the warning and written camera passwords in the clear. Added as
a Windows-only dependency.

Verified in a clean container: a wrong code refused, the right one
unlocks two cameras, every install step passes, both cameras added
through the API, standalone set.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-11 16:07:49 +05:30
70c447873d "Session expired" on a screen where nobody had signed in
The first Windows install reached the setup screen, typed an
installation code, and was told the session had expired. There was no
session. The code had been minted on a different head office, and the
server said so - 401 bad_token, "That installation code is not valid.
Ask for a new one." - and the client threw the message away, because it
mapped every 401 to the string "session expired".

A 401 on a call that carried a session is a session problem. A 401 on a
call that carried none is about the request, and the server's message is
the answer. The client now tells them apart by whether it sent a token.
Two tests, one for each side of the rule.

Also: a launcher for pointing a Windows PC at a head office on the LAN,
with the two settings that needs and a comment saying why neither is
acceptable outside a demo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-11 15:31:55 +05:30
719ba2c7f5 Recognition starts with the app, not with a button
The engine only ever started when somebody pressed Start. So a till
that rebooted overnight came back with the window open, the tray icon
showing, the session restored - and recognition off until a shop
assistant noticed. That is the failure the tray colours exist to catch,
and it should not be the default state every morning.

Guarded on the interpreter actually existing: on a PC where setup has
not run yet, the supervisor would loop on a missing executable with
nothing useful to say. Start and Stop remain for the case where somebody
has deliberately stopped it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-11 12:49:56 +05:30
5e1dcf7050 INSTALL.txt lives in the repo, not only inside a zip
The v0.3.0 release carried it and the repository did not, so rebuilding
the release from a clean state produced an empty file where the shop
operator's instructions should be. Caught by checking the byte count
before uploading, which is not a process.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-11 12:27:54 +05:30
92573e9067 The installer, run on a clean machine, found two bugs in itself
Ran behavision-setup in a fresh Linux container: Python 3.12, nothing
else, the release contents mounted read-only the way Program Files or a
shared drive would be. It failed, and then it failed differently, and
both failures would have been the client's first experience.

1. `pip install <folder>` makes setuptools write behavision.egg-info
   INTO the folder. The folder is read-only wherever a release is
   sensibly unzipped, so: "could not create 'behavision.egg-info':
   Read-only file system". The release now ships a wheel - pure Python,
   buildable anywhere, nothing to build on the shop PC, and pip never
   touches the unzipped folder. Source stays as a fallback and is copied
   somewhere writable first.

2. The engine's paths.py knows two worlds - frozen (ProgramData) and a
   checkout (the repo root) - and a pip-installed engine is neither. It
   resolved its state root to site-packages: database there, camera
   list there, and its generated API credential in a folder the app
   never reads, while the app looked in ProgramData. Every call would be
   401 on a stock install, with nothing in either log saying why. The
   same disease as the Mac checkout two days ago, now in production
   shape.

   engine.ChildEnv is the one place the engine's environment is built,
   used by the desktop app, the headless agent and the installer's own
   smoke test. It passes BEHAVISION_DATA_DIR = this process's state
   root, which paths.py honours ahead of every other rule, so the two
   halves agree by construction however the engine was installed.

   It also seeds config/default.yaml into the state root: a package in
   site-packages has no config beside it to seed from.

Re-run on the same clean container: seven steps, all pass, models
downloaded, engine started and answered, and its data/ landed beside
agent.json - not in site-packages.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-11 12:26:54 +05:30
92b12bcb1c A merchant can create a salesperson's login and hand it over
The flow this product is sold on is three tiers: the platform admin
registers a merchant, the merchant registers their sales staff, the
staff sign in on a phone. Tier 1 handed the new owner a password. Tier 2
could not - a manager could only mint an invitation code, which the
salesperson had to redeem themselves, on their own phone, choosing their
own password. Good practice, and no use to a manager setting somebody up
before their first shift with a card and a pen.

POST /api/team/members mirrors POST /api/admin/clients: generated
password unless one is given, returned exactly once, bcrypt-hashed on
the way in and not recoverable after. Same permission shape as an
invitation - manager and above, only an owner mints an owner, admin
refused - so a manager cannot do through one door what they are refused
at the other. The invitation path stays; it is the better one whenever
the salesperson has their phone.

POST /api/team/{id}/password is the everyday case on a shop floor:
they forgot it. It sets a new one AND revokes every session they hold,
in one transaction, 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. Tenant-scoped in the UPDATE
itself; another company's user id is 404, never 403. No self-service
and no reset-by-email, deliberately: a 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.

RandomPassword moves from a private helper in the store to auth, so the
admin path, the merchant path and the reset all mint the same 80-bit
credential - rather than someone later writing a shorter one for the
"less important" account.

Verified: eight handler tests, and two against a real Postgres for the
things a fake cannot see - the RETURNING list scans on a row with no
last_login_at, the tenant scope holds, and the sessions row is actually
revoked. The tenant cleanup from yesterday held throughout.

API.md now documents the chain with both paths, and the note saying a
merchant could not create a login directly is gone because it is no
longer true.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-11 12:12:54 +05:30
c50a74de47 The onboarding chain, as a chain
Admin creates the merchant, merchant invites the staff, staff redeem
the code on a phone. Every endpoint for it already existed and was
already documented - scattered across four sections in the order the
server groups them, not the order a person meets them.

Now one section, in tier order, each step with the request that makes
it and the response it hands to the next tier: the owner password shown
once, the invitation code shown once, the session returned by register
so a new salesperson is never sent to a login form. The status codes
were checked against the handlers: all three creations are 201.

Three absences named rather than left to be found: a merchant cannot
create a staff login directly (invitation only, on purpose); there is
no mobile app in this repository, only the API it will call; and an
admin cannot reset an owner's password or suspend a merchant over HTTP.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-11 11:54:47 +05:30
0a423ed8cc API.md documented 27 routes; the server has 48
A mobile developer builds against this file, so a gap in it is a gap in
the app. Checked route by route against the mux: nineteen routes had no
entry at all, including the ENTIRE platform-admin surface, adding and
checking cameras, issuing shop-PC installation codes, the assistant, and
the face bytes endpoint. Most of what was documented had no response
shape - a client had to guess the field names for shops, cameras, team,
customers, history and both reports.

Every shape here is now taken from the server's own types, and the
uncertain claims were checked against the handlers rather than written
from memory: check requests return 202, history is newest first, the
visitor list is most-recently-seen first and excludes the erased, an
admin slug is derived from the company name when omitted.

Restructured by audience, because "who may call this" was scattered:

  - three callers named up front - merchant, platform admin, shop PC -
    and what each one signs in with and sees
  - the three merchant roles and what each adds, taken from
    CanWriteProfiles / CanManageSites rather than paraphrased
  - a permission matrix: every route and the least role that may call it
  - quick starts for the three clients that will actually be written:
    a floor app for staff, a console for owners, and admin
  - /api/agent/* listed once as "not for you", so nobody wonders

The prose that explained WHY - refresh rules, the cursor, photos as data
not errors, the report arithmetic - is kept; that is the part a client
developer cannot get from the code.

Also recorded plainly: the admin API is two endpoints. There is no way
to suspend a company, delete one, or reset an owner's password over
HTTP. Written down rather than left for someone to discover.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-11 11:31:44 +05:30
30 changed files with 2384 additions and 122 deletions

3
.gitignore vendored
View File

@@ -62,3 +62,6 @@ node_modules/
# 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
View File

@@ -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`).
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
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
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` |
| 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` |
```
GET /api/visitors/3446ec35-2c1f-4c8e-9a77-0d1e2f3a4b5c/history
GET /api/visitors/V-42/history ← the same customer
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
@@ -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`
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,
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
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
@@ -67,20 +322,19 @@ you never have to think about; use the reference when a person will read it.
"access_token": "…",
"refresh_token": "…",
"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" }
}
```
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
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
fingerprint here is a tracking signal nobody asked for.
Failures:
| 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`. |
@@ -132,9 +386,9 @@ and hands it over; the holder chooses their own password.
```
```json
{ "id": "…", "email": "arjun@tenext.in", "role": "manager",
{ "id": "…", "email": "arjun@tenext.in", "full_name": "Arjun", "role": "manager",
"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
@@ -178,9 +432,10 @@ wrong code) does **not** spend the invitation.
| 404 | `invalid_code` |
| 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.
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
| | |
|---|---|
| `GET /api/team` | everybody in this company |
| `PATCH /api/team/{id}` | `{"role": "manager"}` and/or `{"active": false}` |
### `GET /api/team` — anyone in the company
Deactivating signs that person out **immediately** and stops them signing back
in. Reactivating restores the account but not their old sessions.
```json
[{ "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": "…",
"occurred_at": "2026-09-05T06:01:45Z",
"site_id": "…", "site": "TeNext Chennai", "site_slug": "chennai",
"camera_id": "Office1",
"camera_id": "cam1",
"visitor_id": "…", "visitor_ref": "V-42", "label": "Priya",
"is_new_visitor": false, "similarity": 0.71, "quality": 0.66,
"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.
**There is no `seq` on the wire.** It existed as a convenience for "have I
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:
Of the three ids on an arrival, only one is a reference you would type:
`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
**deliberately random**: a derived or sequential one would let somebody
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
The same rows, pushed. Send `Authorization` (so `EventSource` will not do —
read the stream with an HTTP client) and resume with `Last-Event-ID` or
The same rows, pushed. Send `Authorization` (so a browser `EventSource` will not
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
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 |
|---|---|---|
| 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 —
`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
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`
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
| | |
|---|---|
| `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 |
### `GET /api/visitors?q=…&limit=50` — search
`{id}` is a uuid **or** `V-42` **or** `42`. Every customer object carries `ref`
("V-42") beside `id`, and `label` reads "Visitor 42" until somebody names them.
`q` matches name, phone, email, or customer number (`42` or `V-42`). Omit `q`
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
rows, unlinked. 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.
```json
[{ "id": "…", "ref": "V-42", "label": "Priya",
"full_name": "Priya R", "phone": "+91 …", "email": "",
"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` | 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 |
### `GET /api/sites`
**`site` and `site_id` are both accepted everywhere**, and either may be a slug
or a uuid. They used to differ per endpoint, which mattered because an unknown
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.
Every shop in the company, with the three facts that tell a quiet week from an
unplugged PC.
Dates are `YYYY-MM-DD`. `to` is **inclusive**: "1st to the 7th" includes the
7th.
```json
[{ "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:
- **`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.
Show the server's `total`, with `visits` underneath.
- **`new + returning` can be less than the total.** A site sending counts
without templates records real footfall by an unidentified person, which
belongs to neither.
- **`new + returning` can be less than `visitors`.** A shop sending counts
without templates records real footfall by an unidentified person, who
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`.
Do not parse them as a `Date` — the viewer's own zone would shift every label.
`fraction_below_gate` travels with the numbers because a footfall figure from a
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
{ "error": "invalid_code", "message": "That invitation code is not valid. Ask for a new one." }
@@ -381,15 +1034,26 @@ rewritten freely.
| 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 |
| 403 | signed in, but this role may not |
| 404 | not found — **also** what another tenant's data returns, always |
| 403 | `forbidden` — signed in, but this role may not; `message` says who can |
| 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) |
| 429 | throttled |
| 501 | the feature is off for this deployment, not an error |
| 429 | `too_many_attempts` |
| 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** |
| 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.

View File

@@ -0,0 +1,80 @@
// Command behavision-demo-pack seals a camera list into demo-cameras.enc for a
// demo release. It runs on the machine that builds the release and is never
// shipped.
//
// behavision-demo-pack -cameras cameras.json -out demo-cameras.enc
//
// Prints the unlock code exactly once. It is not stored anywhere; a code you
// can look up later is a code anyone with access to the build machine holds.
// Lose it and seal again.
package main
import (
"encoding/json"
"flag"
"fmt"
"os"
"github.com/loyaly/behavision-agent/pkg/demo"
)
func main() {
in := flag.String("cameras", "", "JSON array of cameras (id, host, port, path, username, password)")
out := flag.String("out", "demo-cameras.enc", "sealed bundle to write")
flag.Parse()
if *in == "" {
fmt.Fprintln(os.Stderr, "usage: behavision-demo-pack -cameras cameras.json [-out demo-cameras.enc]")
os.Exit(2)
}
raw, err := os.ReadFile(*in)
if err != nil {
die("read cameras: %v", err)
}
var cams []demo.Camera
if err := json.Unmarshal(raw, &cams); err != nil {
die("cameras.json: %v", err)
}
if len(cams) == 0 {
die("no cameras in %s", *in)
}
for i, c := range cams {
switch {
case c.ID == "":
die("camera %d has no id", i)
case c.Host == "":
die("camera %q has no host", c.ID)
case c.Path == "":
die("camera %q has no path - the stream path is the field nobody can guess", c.ID)
}
}
// Re-marshal so only the fields the engine accepts travel, in a stable
// shape, whatever extra keys the input happened to carry.
plain, err := json.Marshal(cams)
if err != nil {
die("marshal: %v", err)
}
code, err := demo.NewCode()
if err != nil {
die("code: %v", err)
}
sealed, err := demo.Seal(code, plain)
if err != nil {
die("seal: %v", err)
}
if err := os.WriteFile(*out, sealed, 0o644); err != nil {
die("write: %v", err)
}
fmt.Printf("\n sealed %d camera(s) into %s (%d bytes)\n\n", len(cams), *out, len(sealed))
fmt.Printf(" unlock code: %s\n\n", code)
fmt.Println(" Shown once. Give it to whoever runs behavision-setup, by voice")
fmt.Println(" or message - not in the same place as the zip.")
fmt.Println()
}
func die(format string, args ...any) {
fmt.Fprintf(os.Stderr, " "+format+"\n", args...)
os.Exit(1)
}

View File

@@ -32,7 +32,12 @@ import (
"strings"
"time"
"bytes"
"encoding/json"
"github.com/loyaly/behavision-agent/pkg/config"
"github.com/loyaly/behavision-agent/pkg/demo"
"github.com/loyaly/behavision-agent/pkg/engine"
"github.com/loyaly/behavision-agent/pkg/paths"
)
@@ -68,6 +73,17 @@ func run() error {
return fmt.Errorf("could not create %s: %w", state, err)
}
// A demo release ships its cameras sealed. Ask for the code NOW, before
// the ten-minute download, so a mistyped one costs seconds; the cameras
// are actually added at the end, through the running engine.
demoCams, err := unlockDemo(src)
if err != nil {
return err
}
if demoCams != nil {
step("Demo cameras", fmt.Sprintf("%d unlocked", len(demoCams)))
}
py, ver, err := findPython()
if err != nil {
return err
@@ -81,6 +97,17 @@ func run() error {
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 {
@@ -101,10 +128,19 @@ func run() error {
// 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 {
if err := smokeTest(vpy, demoCams); err != nil {
return fmt.Errorf("the engine installed but would not start: %w", err)
}
step("Engine starts and answers", "verified")
if demoCams != nil {
step("Demo cameras", "added to the engine")
// No head office in a demo. Without this the app opens on "type an
// installation code" and sits there; with it, it opens on Live.
if err := markStandalone(); err != nil {
return err
}
step("Head office", "none - running on this PC only")
}
fmt.Println()
fmt.Println(" Done. Start Behavision from the Start menu or the desktop icon.")
@@ -237,13 +273,81 @@ func pipInstall(vpy, src string) error {
"pip", "setuptools", "wheel"), "updating pip"); err != nil {
return err
}
return stream(exec.Command(vpy, "-m", "pip", "install", "--upgrade", src),
// 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...)
return stream(exec.Command(vpy, full...), "running the engine")
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
@@ -267,11 +371,12 @@ func writeConfig(vpy string) error {
// 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)
func smokeTest(vpy string, demoCams []demo.Camera) error {
ctx, cancel := context.WithTimeout(context.Background(), 120*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 {
@@ -289,7 +394,15 @@ func smokeTest(vpy string) error {
if err == nil {
_, _ = io.Copy(io.Discard, resp.Body)
resp.Body.Close()
return nil
if demoCams == nil {
return nil
}
// Through the engine's own Add Camera, not written to its file:
// the store is what applies DPAPI to the password on Windows, so
// this is how the credential ends up encrypted on disk rather
// than sitting in cameras.json for anyone who can read
// ProgramData.
return addCameras(demoCams)
}
if cmd.ProcessState != nil && cmd.ProcessState.Exited() {
break
@@ -329,3 +442,102 @@ func pause() {
fmt.Print(" Press Enter to close. ")
_, _ = bufio.NewReader(os.Stdin).ReadString('\n')
}
// unlockDemo returns the sealed cameras a demo release ships, or nil when this
// is not a demo release. Asks for the unlock code on the console; three tries,
// because a code is read down a phone and typed by hand.
func unlockDemo(src string) ([]demo.Camera, error) {
sealed, err := os.ReadFile(filepath.Join(src, "demo-cameras.enc"))
if err != nil {
return nil, nil // not a demo release
}
fmt.Println()
fmt.Println(" This is a demo release with the cameras already set up.")
fmt.Println(" It needs the unlock code you were given.")
fmt.Println()
in := bufio.NewReader(os.Stdin)
for attempt := 1; attempt <= 3; attempt++ {
fmt.Print(" Unlock code: ")
line, _ := in.ReadString('\n')
plain, err := demo.Open(line, sealed)
if err == nil {
var cams []demo.Camera
if err := json.Unmarshal(plain, &cams); err != nil {
return nil, fmt.Errorf("the bundle unlocked but did not parse: %w", err)
}
fmt.Println()
return cams, nil
}
fmt.Printf(" %v\n", err)
}
return nil, errors.New("no valid unlock code after three tries. Check it " +
"with whoever gave you this release and run setup again")
}
// addCameras posts each demo camera to the running engine, with the credential
// the engine generated for itself on first start.
func addCameras(cams []demo.Camera) error {
user, pass, err := engineCredential()
if err != nil {
return err
}
client := &http.Client{Timeout: 30 * time.Second}
for _, c := range cams {
if c.Port == 0 {
c.Port = 554
}
body, _ := json.Marshal(c)
req, _ := http.NewRequest(http.MethodPost, "http://127.0.0.1:8010/api/cameras",
bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
if user != "" {
req.SetBasicAuth(user, pass)
}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("adding camera %s: %w", c.ID, err)
}
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
resp.Body.Close()
// 409 is "already there" - a re-run of setup, which is allowed.
if resp.StatusCode >= 300 && resp.StatusCode != http.StatusConflict {
return fmt.Errorf("adding camera %s: %s: %s", c.ID, resp.Status,
strings.TrimSpace(string(msg)))
}
}
return nil
}
// engineCredential reads the Basic credential the engine wrote on its first
// start. Empty when the engine is configured without one.
func engineCredential() (string, string, error) {
b, err := os.ReadFile(paths.APICredentials())
if err != nil {
if os.IsNotExist(err) {
return "", "", nil
}
return "", "", err
}
var user, pass string
for _, line := range strings.Split(string(b), "\n") {
if v, ok := strings.CutPrefix(line, "username="); ok {
user = strings.TrimSpace(v)
}
if v, ok := strings.CutPrefix(line, "password="); ok {
pass = strings.TrimSpace(v)
}
}
return user, pass, nil
}
// markStandalone records that this PC runs on its own, through the same
// config type the app reads.
func markStandalone() error {
path := paths.AgentConfig()
cfg, err := config.Load(path)
if err != nil {
return err
}
cfg.Standalone = true
return cfg.Save(path)
}

View File

@@ -232,7 +232,7 @@ func cmdRun() error {
// nothing - the URL was returned, logged and even exposed on the
// desktop's status object, and never actually given to the engine.
// 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
},
LogWriter: logFile,

114
agent/pkg/demo/bundle.go Normal file
View File

@@ -0,0 +1,114 @@
// Package demo seals a camera list so a release can carry it without carrying
// the credentials in any usable form.
//
// The need: a demo build that installs with the office cameras already set up,
// handed to people who should not be able to read the cameras' admin password
// out of the zip. "Encode it" does not do that - anything the installer can
// decode, anyone holding the installer can decode. So the bundle is encrypted
// with a key that is NOT in the package: a short unlock code, generated when
// the bundle is sealed, spoken or messaged to whoever runs setup, and typed
// once. Without it the file is noise.
//
// The code is random, not chosen, so it is used as key material directly
// (through SHA-256) rather than stretched with a KDF. A human-chosen
// passphrase would need argon2 and a dependency; 120 random bits do not.
package demo
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/base32"
"errors"
"fmt"
"strings"
)
// Magic identifies the file and the format version, so a future change can be
// told apart from corruption instead of failing as "authentication failed".
const magic = "BVDEMO1\n"
// Camera is one entry as the engine's Add Camera endpoint accepts it.
type Camera struct {
ID string `json:"id"`
Label string `json:"label,omitempty"`
Host string `json:"host"`
Port int `json:"port"`
Path string `json:"path"`
Username string `json:"username"`
Password string `json:"password"`
MaxWidth int `json:"max_width,omitempty"`
}
// NewCode mints an unlock code: 15 random bytes as 24 base32 characters in
// four groups, the same shape as an installation code, for the same reason -
// it gets read down a phone.
func NewCode() (string, error) {
raw := make([]byte, 15)
if _, err := rand.Read(raw); err != nil {
return "", err
}
s := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(raw)
return fmt.Sprintf("%s-%s-%s-%s", s[0:6], s[6:12], s[12:18], s[18:24]), nil
}
// NormalizeCode makes the typed and the printed form hash the same: case,
// spaces and dashes are all noise a person adds or drops.
func NormalizeCode(code string) string {
code = strings.ToUpper(code)
code = strings.NewReplacer("-", "", " ", "", "\t", "", "\r", "", "\n", "").Replace(code)
return code
}
func keyFor(code string) []byte {
sum := sha256.Sum256([]byte("behavision-demo-bundle:" + NormalizeCode(code)))
return sum[:]
}
// Seal encrypts plaintext under the code. Output is magic || nonce || ciphertext.
func Seal(code string, plaintext []byte) ([]byte, error) {
block, err := aes.NewCipher(keyFor(code))
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return nil, err
}
out := append([]byte(magic), nonce...)
return gcm.Seal(out, nonce, plaintext, []byte(magic)), nil
}
// ErrWrongCode is what a mistyped code looks like. GCM cannot tell a wrong key
// from a corrupted file, and neither can we, so both read as this.
var ErrWrongCode = errors.New("that unlock code does not open this bundle")
// Open decrypts a sealed bundle.
func Open(code string, sealed []byte) ([]byte, error) {
if !strings.HasPrefix(string(sealed), magic) {
return nil, errors.New("not a Behavision demo bundle")
}
body := sealed[len(magic):]
block, err := aes.NewCipher(keyFor(code))
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
if len(body) < gcm.NonceSize() {
return nil, errors.New("bundle is truncated")
}
nonce, ct := body[:gcm.NonceSize()], body[gcm.NonceSize():]
plain, err := gcm.Open(nil, nonce, ct, []byte(magic))
if err != nil {
return nil, ErrWrongCode
}
return plain, nil
}

View File

@@ -0,0 +1,87 @@
package demo
import (
"bytes"
"errors"
"strings"
"testing"
)
func TestSealedBundleRoundTripsWithTheCodeAsTyped(t *testing.T) {
code, err := NewCode()
if err != nil {
t.Fatal(err)
}
if len(NormalizeCode(code)) != 24 {
t.Fatalf("code should be 24 base32 chars, got %q", code)
}
secret := []byte(`[{"id":"cam1","password":"the-camera-admin-password"}]`)
sealed, err := Seal(code, secret)
if err != nil {
t.Fatal(err)
}
// People type codes in lower case, with the dashes dropped, with a space
// where a dash was. All of those are the same code.
for _, typed := range []string{
code,
strings.ToLower(code),
strings.ReplaceAll(code, "-", ""),
strings.ReplaceAll(code, "-", " "),
" " + code + "\n",
} {
got, err := Open(typed, sealed)
if err != nil {
t.Fatalf("open with %q: %v", typed, err)
}
if !bytes.Equal(got, secret) {
t.Fatalf("round trip changed the contents")
}
}
}
// The whole point of the file: the password is not in it.
func TestTheSealedFileDoesNotContainTheSecret(t *testing.T) {
code, _ := NewCode()
sealed, _ := Seal(code, []byte(`{"password":"the-camera-admin-password","host":"192.168.1.121"}`))
for _, leak := range []string{"the-camera-admin-password", "192.168.1.121", "password"} {
if bytes.Contains(sealed, []byte(leak)) {
t.Fatalf("sealed bundle contains %q in the clear", leak)
}
}
}
func TestAWrongCodeIsRefusedNotMisread(t *testing.T) {
code, _ := NewCode()
other, _ := NewCode()
sealed, _ := Seal(code, []byte("secret"))
if _, err := Open(other, sealed); !errors.Is(err, ErrWrongCode) {
t.Fatalf("a different code should be ErrWrongCode, got %v", err)
}
// One flipped byte in the ciphertext is the same answer: GCM refuses
// rather than returning garbage that then gets written into cameras.json.
tampered := append([]byte{}, sealed...)
tampered[len(tampered)-1] ^= 0x01
if _, err := Open(code, tampered); !errors.Is(err, ErrWrongCode) {
t.Fatalf("a tampered bundle should be refused, got %v", err)
}
}
func TestSomethingThatIsNotABundleSaysSo(t *testing.T) {
if _, err := Open("ABCDEF-GHIJKL-MNOPQR-STUVWX", []byte("hello")); err == nil ||
errors.Is(err, ErrWrongCode) {
t.Fatalf("a non-bundle should be named as such, not blamed on the code: %v", err)
}
}
// Two seals of the same plaintext under the same code must differ: a fixed
// nonce would let two releases' bundles be compared byte for byte.
func TestEverySealIsDifferent(t *testing.T) {
code, _ := NewCode()
a, _ := Seal(code, []byte("same"))
b, _ := Seal(code, []byte("same"))
if bytes.Equal(a, b) {
t.Fatal("nonce is not random")
}
}

41
agent/pkg/engine/env.go Normal file
View 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
}

View 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
}

View File

@@ -6,6 +6,7 @@ models finish loading without a single unguarded None dereference.
from __future__ import annotations
import asyncio
import time
import logging
import secrets
from pathlib import Path
@@ -370,11 +371,23 @@ def create_app(engine: Engine) -> FastAPI:
# Stop when the camera is deleted or its worker dies - otherwise a
# removed camera leaves this generator running for the life of the
# process, holding a reference to a worker nothing else can see.
# Driven by the camera, not a timer: a frame goes out when the
# capture thread has one newer than the last one sent, so nothing
# is sent twice and nothing waits on the recognition pipeline.
# Capped at 15 fps - the office cameras' own rate - so a viewer
# never costs more encodes than the camera produces pictures.
last_ts, min_gap, sent_at = 0.0, 1.0 / 15, 0.0
while engine.workers.get(camera_id) is worker and worker.is_alive():
jpeg = worker.latest_jpeg()
if jpeg is not None:
yield boundary + jpeg + b"\r\n"
await asyncio.sleep(0.1) # ~10 fps to the browser
now = time.time()
if now - sent_at < min_gap:
await asyncio.sleep(min_gap - (now - sent_at))
continue
jpeg, ts = worker.latest_jpeg_since(last_ts)
if jpeg is None:
await asyncio.sleep(0.02)
continue
last_ts, sent_at = ts, time.time()
yield boundary + jpeg + b"\r\n"
return StreamingResponse(
generate(),

View File

@@ -17,10 +17,20 @@ import numpy as np
log = logging.getLogger(__name__)
# Force TCP transport and a 5s socket timeout for RTSP before OpenCV loads
# ffmpeg. UDP is the default and silently drops frames on lossy Wi-Fi.
# Set before OpenCV loads ffmpeg, which reads this once.
#
# rtsp_transport=tcp: UDP is the default and silently drops frames on lossy
# Wi-Fi. stimeout: a 5s socket timeout so a dead camera is noticed.
#
# fflags=nobuffer and flags=low_delay: without them ffmpeg's RTSP demuxer
# holds a comfortable queue of frames before handing over the first, which
# on a live feed is half a second to two seconds of latency that no amount of
# work downstream can recover - the frame is already old when we get it. A
# recorder wants that buffer; a live view does not. max_delay caps the
# reorder wait for the same reason.
os.environ.setdefault(
"OPENCV_FFMPEG_CAPTURE_OPTIONS", "rtsp_transport;tcp|stimeout;5000000"
"OPENCV_FFMPEG_CAPTURE_OPTIONS",
"rtsp_transport;tcp|stimeout;5000000|fflags;nobuffer|flags;low_delay|max_delay;200000",
)

View File

@@ -162,7 +162,11 @@ class CameraWorker(threading.Thread):
# every test using a stubbed worker passed.
self._stopping = threading.Event()
self._lock = threading.Lock()
self._annotated_jpeg: Optional[bytes] = None
# What the live view draws over the freshest frame: the boxes from
# the most recent processed frame, and when they were computed. NOT a
# pre-rendered JPEG - see latest_jpeg for why.
self._overlay: "list[tuple[tuple[int, int, int, int], tuple[int, int, int], str]]" = []
self._overlay_ts = 0.0
self._last_frame_ts = 0.0
self._was_connected = False
self.frames_processed = 0
@@ -187,8 +191,46 @@ class CameraWorker(threading.Thread):
self.source.stop()
def latest_jpeg(self) -> Optional[bytes]:
jpeg, _ = self.latest_jpeg_since(0.0)
return jpeg
def latest_jpeg_since(self, known_ts: float) -> "tuple[Optional[bytes], float]":
"""The freshest captured frame with the latest boxes drawn on it, or
(None, known_ts) if the camera has produced nothing newer.
The live picture is deliberately NOT the frame the pipeline last
finished with. That version advanced only when detection, tracking and
identification had all completed on a frame - a few times a second on a
modest shop PC - and every picture it showed was already as old as that
processing. It looked like lag because it was lag. Here the picture runs
at the camera's rate off the capture thread's latest frame, and the
boxes - which genuinely can only update at pipeline rate - are drawn
over it from the last processed frame. Boxes may trail a fast walker by
one pipeline period; the picture never does.
Encoded on demand, per request, so a camera nobody is watching pays for
no JPEG at all. The old path encoded every processed frame whether or
not a viewer existed - CPU spent on precisely the machine short of it.
"""
frame, ts = self.source.latest_since(known_ts)
if frame is None:
return None, known_ts
with self._lock:
return self._annotated_jpeg
overlay, overlay_ts = list(self._overlay), self._overlay_ts
# A stalled pipeline must not leave a box floating over an empty spot.
# Older than a second and the person has walked out from under it.
draw = overlay if (time.time() - overlay_ts) < 1.0 else []
if draw:
frame = frame.copy()
for (x1, y1, x2, y2), color, text in draw:
cv2.rectangle(frame, (x1, y1), (x2, y2), color, 2)
if text:
cv2.putText(frame, text, (x1, max(20, y1 - 8)),
cv2.FONT_HERSHEY_SIMPLEX, 0.55, color, 2)
ok, buf = cv2.imencode(".jpg", frame, [int(cv2.IMWRITE_JPEG_QUALITY), 80])
if not ok:
return None, known_ts
return buf.tobytes(), ts
def stats(self) -> dict:
return {
@@ -236,7 +278,7 @@ class CameraWorker(threading.Thread):
for track in ended:
self._finish_track(track, ts)
self._publish_annotated(frame, active)
self._remember_tracks(active)
self.frames_processed += 1
except Exception:
log.exception("[%s] frame processing failed", self.cam_cfg.id)
@@ -426,12 +468,14 @@ class CameraWorker(threading.Thread):
track.quality, rcfg=self.rcfg):
track.reinforcements += 1
def _publish_annotated(self, frame: np.ndarray, tracks: "list[Track]") -> None:
canvas = frame.copy()
def _remember_tracks(self, tracks: "list[Track]") -> None:
"""Record what to draw. Cheap: a handful of tuples under the lock,
no frame copy and no encode. The encode happens in latest_jpeg_since,
only when somebody is looking."""
overlay = []
for t in tracks:
if t.misses > 0:
continue # only draw tracks matched in this frame
x1, y1, x2, y2 = t.box
if t.state == "resolved":
color = _COLORS["known"] if t.label and not str(t.label).startswith(
"Visitor") else _COLORS["new"]
@@ -440,15 +484,10 @@ class CameraWorker(threading.Thread):
color, text = _COLORS["ambiguous"], "?"
else:
color, text = _COLORS["pending"], ""
cv2.rectangle(canvas, (x1, y1), (x2, y2), color, 2)
if text:
cv2.putText(canvas, text, (x1, max(20, y1 - 8)),
cv2.FONT_HERSHEY_SIMPLEX, 0.55, color, 2)
ok, buf = cv2.imencode(".jpg", canvas,
[int(cv2.IMWRITE_JPEG_QUALITY), 80])
if ok:
with self._lock:
self._annotated_jpeg = buf.tobytes()
overlay.append((tuple(t.box), color, text))
with self._lock:
self._overlay = overlay
self._overlay_ts = time.time()
class Engine:

View File

@@ -114,7 +114,7 @@ func (a *App) startup(ctx context.Context) {
// be told again. Without it the engine recognised people and the
// bridge received nothing: a claimed shop PC published heartbeats
// and zero visits.
cmd.Env = append(os.Environ(), "BEHAVISION_WEBHOOK_URL="+a.webhookURL())
cmd.Env = agentengine.ChildEnv(a.webhookURL())
return cmd
},
LogWriter: logFile,
@@ -124,6 +124,23 @@ func (a *App) startup(ctx context.Context) {
})
a.startPipeline(ctx)
// Recognition starts with the app. Until this, the engine only ever
// started when somebody pressed Start - which meant a till that rebooted
// overnight came back with the window open, the tray icon showing, the
// session restored, and recognition off until a shop assistant noticed.
// That is the failure the tray colours exist to catch, and it should not
// be the default state every morning.
//
// Guarded on the interpreter actually being there: on a PC where setup has
// not run yet, starting the supervisor would loop on a missing executable
// with nothing useful to say. The Start button still exists for the one
// case where somebody has deliberately stopped it.
if _, err := os.Stat(exe); err == nil {
a.sup.Start()
} else {
log.Printf("engine not installed yet (%s); run behavision-setup, then Start", exe)
}
}
// webhookURL is the loopback address the bridge is listening on, or empty

View File

@@ -180,9 +180,16 @@ func (c *Client) send(ctx context.Context, method, path string, raw []byte, out
switch {
case resp.StatusCode == http.StatusUnauthorized && e.Error == "token_expired":
return errTokenExpired
case resp.StatusCode == http.StatusUnauthorized:
case resp.StatusCode == http.StatusUnauthorized && tok != "":
// A 401 on a call we sent a session with: the session is the problem.
return ErrUnauthorized
case resp.StatusCode >= 400:
// Every other 4xx/5xx - including a 401 on a call that carried NO
// session, such as redeeming an installation code - is about the
// request, and the server wrote its message for exactly this moment.
// Mapping those to "session expired" told an installer their session
// had lapsed on a screen where they had never signed in, and hid
// "That installation code is not valid" behind it.
msg := e.Message
if msg == "" {
msg = fmt.Sprintf("%s %s: %s", method, path, resp.Status)

View File

@@ -6,6 +6,7 @@ import (
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
@@ -137,3 +138,43 @@ func TestVisitorIDIsPathEscaped(t *testing.T) {
t.Errorf("path = %q", got)
}
}
// Redeeming an installation code is the one call a fresh PC makes before it
// has any session. When the server refuses it - wrong code, wrong head office -
// it answers 401 with a message written for the installer. That message must
// reach them: "session expired" on a screen where nobody has signed in sent a
// real installer looking for a login problem that did not exist.
func TestARefusedInstallationCodeSaysWhyNotSessionExpired(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "" {
t.Errorf("enrol must not carry a session, got %q", r.Header.Get("Authorization"))
}
fail(w, http.StatusUnauthorized, "bad_token",
"That installation code is not valid. Ask for a new one.")
}))
t.Cleanup(srv.Close)
c := New(srv.URL) // deliberately no session
_, err := c.Bootstrap(context.Background(), "KWFH5S-EH46LT-EE4X47-OSOH7D")
if err == nil {
t.Fatal("a refused code must be an error")
}
if errors.Is(err, ErrUnauthorized) {
t.Fatalf("a refused code is not a session problem, got %v", err)
}
if !strings.Contains(err.Error(), "installation code is not valid") {
t.Fatalf("the server's own words should reach the installer, got %v", err)
}
}
// The other side of the same rule: a 401 on a call that DID carry a session is
// a session problem, and must still read as one.
func TestARejectedSessionStillReadsAsSessionExpired(t *testing.T) {
c := serve(t, func(w http.ResponseWriter, r *http.Request) {
fail(w, http.StatusUnauthorized, "unauthorized", "Sign in again.")
})
err := c.do(context.Background(), http.MethodGet, "/api/auth/me", nil, nil)
if !errors.Is(err, ErrUnauthorized) {
t.Fatalf("a 401 with a session should be ErrUnauthorized, got %v", err)
}
}

120
installer/INSTALL.txt Normal file
View File

@@ -0,0 +1,120 @@
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.
DEMO RELEASE ONLY: if the release came with the cameras already set up,
setup first asks for an unlock code. Type the code you were given. The
camera details are sealed inside the release and cannot be read without
it; with it, both cameras are added and the PC is set to run on its own,
with no head office. Skip the installation-code screen - it will not
appear.
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

View File

@@ -0,0 +1,21 @@
@echo off
rem Start Behavision against a head office running on another PC on this LAN,
rem instead of the production server it uses by default.
rem
rem For demos and pilots only. Two things are deliberately weaker than
rem production and both are named here so nobody copies this into a shop:
rem
rem - head office over plain http, not https
rem - the message broker over plain tcp. The app REFUSES plaintext MQTT to
rem any address that is not its own machine, by design - the payloads are
rem customer visit records - so the second line below is the documented
rem escape hatch and must not be set anywhere that is not a demo.
rem
rem Edit the address to the PC running head office, then double-click this
rem instead of Behavision.exe. Everything else - the installation code, the
rem sign-in, the cameras - works exactly as INSTALL.txt describes.
set BEHAVISION_CLOUD=http://192.168.1.117:8088
set BEHAVISION_ALLOW_PLAINTEXT_MQTT=1
start "" "%~dp0Behavision.exe"

View File

@@ -1,6 +1,6 @@
[project]
name = "behavision"
version = "1.0.0"
version = "1.1.0"
description = "Production face recognition over RTSP"
requires-python = ">=3.10"
dependencies = [
@@ -14,6 +14,10 @@ dependencies = [
"python-dotenv>=1.0",
"faiss-cpu>=1.7.4",
"requests>=2.31",
# DPAPI for camera passwords at rest (behavision/cameras.py). Without it the
# store logs a warning and writes them in the clear - which is what every
# Windows install had been doing, since nothing pulled this in.
"pywin32>=306; sys_platform == 'win32'",
]
[project.optional-dependencies]

View File

@@ -8,3 +8,4 @@ PyYAML>=6.0
python-dotenv>=1.0
faiss-cpu>=1.7.4
requests>=2.31
pywin32>=306; sys_platform == "win32"

View File

@@ -63,6 +63,14 @@ type Store interface {
RedeemInvitation(ctx context.Context, hash []byte, fullName, passwordHash string) (UserRecord, error)
Team(ctx context.Context, clientID string) ([]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 ---
// 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 ---
mux.HandleFunc("GET /api/team", s.authed(s.handleTeam))
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("POST /api/team/invitations", s.authed(s.handleInvite))
mux.HandleFunc("DELETE /api/team/invitations/{id}",

View File

@@ -995,3 +995,59 @@ func (f *fakeStore) VisitorIDByNumber(_ context.Context, clientID string, number
}
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")
}

View File

@@ -388,3 +388,134 @@ func (s *Server) lastOwner(r *http.Request, userID string) (bool, error) {
}
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})
}

View 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)
}
}

View File

@@ -695,6 +695,38 @@ type TeamUpdate struct {
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 ==
// DeviceSession is one signed-in device, as its owner sees it.

View File

@@ -71,6 +71,21 @@ func UseTestCost() func() {
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) {
if err := CheckPasswordPolicy(plain); err != nil {
return "", err

View File

@@ -2,10 +2,7 @@ package store
import (
"context"
"crypto/rand"
"encoding/base32"
"fmt"
"strings"
"time"
"github.com/loyaly/behavision-server/internal/api"
@@ -28,7 +25,7 @@ func (s *Store) CreateClientWithOwner(ctx context.Context, in api.NewClientInput
if password == "" {
// Generated rather than defaulted. An operator inventing a password for
// somebody else invents a weak one and then sends it over chat.
p, err := randomPassword()
p, err := auth.RandomPassword()
if err != nil {
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
// secrets: it gets read down a phone line and pasted into a form, and base64's
// + / = 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
}

View File

@@ -345,3 +345,72 @@ func (s *Store) RevokeOtherSessions(ctx context.Context, userID, keepSessionID s
}
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
}

View 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")
}
}

View File

@@ -31,6 +31,7 @@ class FakeWorker:
def stats(self): return {"camera_id": self.cam_cfg.id, "connected": True,
"url": self.cam_cfg.safe_url()}
def latest_jpeg(self): return None
def latest_jpeg_since(self, known_ts): return None, known_ts
@pytest.fixture

144
tests/test_live_picture.py Normal file
View File

@@ -0,0 +1,144 @@
"""The live picture is the camera's latest frame, not the pipeline's.
Until this, the MJPEG stream served the frame the recognition pipeline had
most recently *finished* - so on a shop PC where detection plus identification
ran a few times a second, the live view ran a few times a second too, and every
picture it showed was already as old as that processing. It read as lag
because it was. These tests pin the decoupling: the picture comes from the
capture thread at its own rate, the boxes come from the pipeline at theirs,
and nothing is encoded for a camera nobody is watching.
"""
import time
import numpy as np
from behavision.config import CameraConfig, Config
from behavision.engine import CameraWorker
from behavision.tracking import Track
class StillSource:
"""A capture thread stand-in that hands out whatever frame it is given."""
def __init__(self):
self.frame = None
self.ts = 0.0
def set(self, frame):
self.frame, self.ts = frame, time.time()
def latest(self):
return self.frame, self.ts
def latest_since(self, known_ts):
if self.frame is None or self.ts <= known_ts:
return None, known_ts
return self.frame, self.ts
def stop(self):
pass
def make_worker(tmp_path):
cfg = Config()
cfg.app.data_dir = tmp_path
cam = CameraConfig(id="cam1", host="127.0.0.1", port=1, path="/none")
w = CameraWorker(cam, cfg, detector=None, encoder=None, gallery=None,
bus=None, attrs=None)
w.source = StillSource()
return w
def grey(v=90):
return np.full((120, 160, 3), v, dtype=np.uint8)
def resolved_track(box=(30, 30, 90, 100), label="Priya"):
t = Track(id=1, box=box, kps=np.zeros((5, 2), dtype=np.float32), score=0.9)
t.state, t.label, t.similarity = "resolved", label, 0.71
return t
def test_the_picture_advances_with_the_camera_not_the_pipeline(tmp_path):
w = make_worker(tmp_path)
# Nothing captured yet: nothing to show, and no encode happened.
assert w.latest_jpeg_since(0.0) == (None, 0.0)
# A frame arrives from the camera. The pipeline has not touched it - and
# the live view must not wait for it to.
w.source.set(grey(80))
jpeg1, ts1 = w.latest_jpeg_since(0.0)
assert jpeg1 is not None and ts1 > 0
# Same frame again: the stream asks "anything newer than ts1?" and the
# answer is no. This is what stops duplicates going down the wire.
assert w.latest_jpeg_since(ts1) == (None, ts1)
# The camera produces a new frame; the pipeline still has not run.
time.sleep(0.002)
w.source.set(grey(160))
jpeg2, ts2 = w.latest_jpeg_since(ts1)
assert jpeg2 is not None and ts2 > ts1 and jpeg2 != jpeg1
def test_boxes_from_the_last_processed_frame_are_drawn_on_the_fresh_one(tmp_path):
w = make_worker(tmp_path)
w.source.set(grey())
plain, _ = w.latest_jpeg_since(0.0)
# The pipeline finishes a frame with one recognised person in it.
w._remember_tracks([resolved_track()])
# The NEXT camera frame - which the pipeline has not seen - still carries
# the box, because a person does not vanish between two frames.
time.sleep(0.002)
w.source.set(grey())
boxed, _ = w.latest_jpeg_since(0.0)
assert boxed != plain, "a resolved track should be drawn on the live picture"
def test_a_stale_overlay_is_not_drawn(tmp_path):
"""A stalled pipeline must not leave a box floating over an empty spot."""
w = make_worker(tmp_path)
w.source.set(grey())
plain, _ = w.latest_jpeg_since(0.0)
w._remember_tracks([resolved_track()])
# Pretend the pipeline last ran a while ago.
with w._lock:
w._overlay_ts = time.time() - 2.0
time.sleep(0.002)
w.source.set(grey())
fresh, _ = w.latest_jpeg_since(0.0)
assert fresh == plain, "boxes older than a second should not be drawn"
def test_only_tracks_matched_in_the_frame_are_drawn(tmp_path):
"""A track being coasted on misses has no face under it right now."""
w = make_worker(tmp_path)
missed = resolved_track()
missed.misses = 3
w._remember_tracks([missed])
assert w._overlay == []
seen = resolved_track()
w._remember_tracks([seen])
assert len(w._overlay) == 1
(box, _color, text) = w._overlay[0]
assert box == (30, 30, 90, 100) and text.startswith("Priya (")
def test_remembering_tracks_does_not_encode_or_copy(tmp_path):
"""The whole CPU argument: recording what to draw is a few tuples, and the
frame is never touched. A shop PC with no viewer pays nothing."""
w = make_worker(tmp_path)
tracks = [resolved_track() for _ in range(5)]
t0 = time.perf_counter()
for _ in range(1000):
w._remember_tracks(tracks)
per_call_us = (time.perf_counter() - t0) / 1000 * 1e6
# A JPEG encode of even a small frame is hundreds of microseconds; this
# should be an order of magnitude under that.
assert per_call_us < 100, f"remembering tracks took {per_call_us:.0f}us"