9 Commits

Author SHA1 Message Date
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
22196ab9ba Ship the engine from source, so a release can be built anywhere
The Go halves of this product cross-compile to Windows from any machine.
The engine does not: PyInstaller bundles the interpreter and the native
wheels of the machine it runs on, so a frozen engine can only be built on
Windows. That one fact was the entire reason no release had ever been
cut - two of the three binaries were ready for weeks.

behavision-setup installs the engine from source instead. It finds a
Python, builds a private virtual environment beside the database,
installs the engine into it, downloads the models, records how to start
it in the same agent.json the app reads, and then starts it and waits
for its API to answer.

That last step 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 somebody who did not install it.

The trade, since whoever runs this is standing in a shop: it needs
Python and internet at install time and takes minutes, where a frozen
build needs neither. What it buys is a release that exists.

Details that are not incidental:

  - `py -3` is tried before `python` on Windows. The launcher is what the
    official installer puts on PATH; `python` there is often the Store
    stub that prints an advert and exits 9009.
  - a virtual environment, not the system Python. A shop PC may have
    Python for something else, and the engine pins numpy below 2.0 -
    installing that into a shared interpreter breaks the other thing
    months later and silently.
  - EngineExe is written absolute. The app resolves a relative one
    against its install root under Program Files, where no interpreter
    lives.
  - pip's output is shown, not swallowed. When it fails on a proxy or a
    missing build tool it says exactly what is wrong, and hiding that
    leaves the operator with "setup failed" and nothing to act on.
  - the console pauses before closing. Double-clicked from Explorer, a
    program that finishes closes instantly and success and failure look
    identical.

Verified as far as a Mac can: `pip install .` builds the wheel and
resolves every dependency, and `python -m behavision` then runs from
site-packages rather than the working directory - which is the mechanism
this depends on and had never been exercised, because the project has
only ever been run out of its own checkout.

NOT verified: any of it on Windows. Nothing here has run on the target
platform, and the `py -3` path and the ProgramData layout are exactly
where that will show.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pcn9asw19WGBfCEaHvNug6
2026-09-10 20:21:32 +05:30
5e544eee3d The camera tiles put a password in the page, and loaded nothing
StreamURL built http://user:pass@127.0.0.1:8010/api/cameras/<id>/
stream.mjpeg and handed it to an <img>, with a comment saying the
credentials were inline "so an <img> tag can load it".

It cannot. Chromium strips credentials from subresource URLs and has
since M59, and WebView2 is Chromium - so on the one platform this
product ships to, every camera tile on a shop counter was a broken
image. Measured against a running engine: the app's Go-side calls
returned stats and people while an <img> on that very URL failed, and
curl proved the URL answered 200. The engine was never the problem.

The password now stays on this side of the process boundary. A loopback
relay attaches Basic auth and streams the engine's bytes back
unchanged - the same reasoning Shot.jsx already follows at head office,
where an <img> equally cannot carry a session.

What the relay is careful about, since it is a door onto the biometric
API with a credential attached:

  - loopback only, on a port the OS picks; a fixed one would collide
    with whatever else a shop PC runs and read as "the cameras broke"
  - a per-run random token in the path. The engine's own credential
    exists so the live face feed is never served open; an
    unauthenticated relay would hand that feed to any other process on
    the PC. Compared in constant time, and a wrong one is 404, not 403
  - an allow-list of stream.mjpeg and frame.jpg. Holding the token does
    not reach the identity list, the gallery, or erasure
  - camera ids validated, not interpolated
  - every chunk flushed; a buffered MJPEG stream is a tile that never
    paints, which looks identical to the bug being fixed

Two of those were written after a test failed, not before:

  - `..` MATCHES the id pattern, because real camera ids contain dots.
    `/api/cameras/../stream.mjpeg` is not the endpoint anyone intended.
    The id can never hold a slash, so `.` and `..` are the whole
    remaining traversal surface and are now refused by name.
  - the serve goroutine read p.srv off the struct while stop() was
    nilling it, so a quick start/stop dereferenced nil and took the
    process down. Captured before launching now.

FrameURL is deliberately not added. No screen asks for a still, and a
bound method nothing calls is the same defect as a capability the UI
cannot reach, only pointing the other way.

Verified: nine unit tests, plus a live test against the real engine and
the real office camera - two MJPEG frames, 90,793 bytes, no credential
in the URL. Windows and darwin both build; vet clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pcn9asw19WGBfCEaHvNug6
2026-09-10 19:53:28 +05:30
9521cb986b The shop PC's UI had never once been run
`wails build` had never been executed against this project - CLAUDE.md
says so plainly - so every screen the shop floor actually touches was
unreviewed. Running it found why nobody had.

fyne.io/systray's nativeLoop must own the main thread on macOS, a Cocoa
requirement, and Wails already holds it. Starting both kills the process
with a SIGTRAP inside cgo before a single pixel is drawn. On Windows,
which is what ships, a tray on its own goroutine is fine - so the one
platform the whole team develops on was the one platform that could not
open the app, and the UI went unlooked-at as a result.

BEHAVISION_NO_TRAY runs the window without the tray, the same escape
hatch BEHAVISION_ALLOW_PLAINTEXT_MQTT already is for the broker.
Deliberately an environment variable and NOT a GOOS check: a build that
quietly drops the tray is how a shop PC ends up with no control surface
at all, and it would fail where nobody is watching. The guard is on stop()
as well, because systray.Quit() on a systray that never started is not a
no-op in v1.12.2 - it would turn closing the window into a crash on exit,
the failure most likely to be shrugged off as "it closed, fine".

go.mod gains the indirect dependencies the darwin build pulls in. No
version moved: the committed list was written by a windows-only build,
which never resolves that part of the Wails tree.

Verified: GOOS=windows build, go vet, and the agent suite all still pass,
and the packaged .app runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qiy5iKfz4L8S4vRaYPBdaU
2026-09-09 13:06:51 +05:30
30e01765ae The live tests seeded a tenant per run and never took it back
Each live store test makes its own client - deliberately, so they can
run in any order and so the isolation assertions have a real neighbour
to be isolated from - and none of them removed it afterwards. The dev
database had reached 242 abandoned tenants against the one real
company.

That is not untidy, it is a broken screen. The platform admin's
Companies view lists every client, so the real company sat under pages
of `walk1788761685056287000`, which is the first thing anyone opening
tenant administration would see.

dropTenant registers the cleanup against the CLIENT rather than each
table: every foreign key onto clients is ON DELETE CASCADE, so one
delete takes the sites, visitors, visits, face images, embeddings,
cameras and agents with it. A per-table list would rot the first time a
migration adds a table, and it would rot silently - the same shape as
the leak it replaces.

A failed cleanup calls t.Errorf rather than being ignored. A tenant
left behind is precisely what this exists to prevent, and swallowing
the error would let the leak come back with nothing to show for it.

Verified against the live database: three consecutive runs of the store
suite leave clients, sites and visits unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qiy5iKfz4L8S4vRaYPBdaU
2026-09-09 12:43:21 +05:30
25 changed files with 2642 additions and 101 deletions

10
.gitignore vendored
View File

@@ -55,3 +55,13 @@ node_modules/
# Backups of .env made when editing camera credentials. # Backups of .env made when editing camera credentials.
/.env.bak-* /.env.bak-*
# Generated by the wails CLI on every dev run and build, not source.
# NOT /desktop/build/ as a whole: appicon.png, darwin/ and windows/ under it
# are the Wails project scaffolding (icon, Info.plist, manifest) that a
# reproducible Windows build needs. Only the compiled output is ignored.
/desktop/frontend/wailsjs/
/desktop/frontend/package.json.md5
# Left behind by `pip install .` of the engine (setuptools metadata), not source.
/behavision.egg-info/

820
API.md
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`). Base URL: `https://platform.loyaly.ai` (locally `http://127.0.0.1:8088`).
Everything is JSON unless stated. All times are RFC 3339 UTC unless a field says Everything is JSON unless stated. All times are RFC 3339 UTC unless a field says
otherwise. otherwise. Every shape below is taken from the server's own types, not written
from memory — if the two ever disagree, the server is right and this file has a
bug.
There is **one API**, not a web one and a mobile one. The web console in this There is **one API**, not a web one and a mobile one. The web console in this
repository uses exactly these calls; anything it can do, an app can do. repository uses exactly these calls; anything it can do, an app can do.
--- ---
## Who is calling: three audiences, one API
| audience | who they are | signs in with | what they see |
|---|---|---|---|
| **Merchant** | a company's owner, managers and shop-floor staff | email + password | their own company's shops, cameras, customers, arrivals |
| **Platform admin** | Loyaly, running the platform | email + password, an account with **no company** | the list of companies, and nothing inside any of them |
| **Shop PC** | the agent running on a till or back-office PC | an installation code, once; its own token thereafter | `/api/agent/*` only — **not for a web or mobile client** |
A merchant user has one of three roles. They are strictly nested — each can do
everything the one below can:
| role | can additionally |
|---|---|
| `staff` | see arrivals, search customers, edit a customer's profile, record a purchase |
| `manager` | manage cameras, create / invite / reset / remove team members, issue shop-PC installation codes, erase a customer |
| `owner` | promote somebody to owner |
A platform admin has `role: "admin"` **and an empty `client_id`** — both
together, never the role alone. A tenant-scoped account with the role set to
`admin` is rejected by every admin endpoint. Admins can call merchant endpoints
too, but with no company of their own they see empty lists; the admin screens
are `/api/admin/*`.
### Permission matrix
Every route, and the least role that may call it. `authed` means any signed-in
user; the tenant is always taken from the session and never from the request.
| route | least role |
|---|---|
| `POST /api/auth/login` `refresh` · `GET /api/auth/invitation` · `POST /api/auth/register` | **no auth** |
| `POST /api/auth/logout` · `GET /api/auth/me` · `/api/auth/sessions*` | authed |
| `GET /api/visits` · `GET /api/visits/stream` | authed |
| `GET /api/visitors` · `GET /api/visitors/{id}/history` · `GET /api/visitors/{id}/image` · `GET /api/faces/{id}` | authed |
| `PUT /api/visitors/{id}/profile` · `POST /api/purchases` | staff |
| `DELETE /api/visitors/{id}` — erasure | manager |
| `GET /api/sites` · `GET /api/sites/{site}/check` · `GET /api/cameras` · `GET /api/cameras/{id}/snapshot.jpg` · `GET /api/cameras/{id}/live` | authed |
| `POST /api/sites/{site}/cameras` · `PATCH` / `DELETE /api/cameras/{id}` · `POST /api/cameras/{id}/check` | manager |
| `POST /api/sites/{site}/enrolment-code` | manager |
| `GET /api/team` | authed (tenant users only) |
| `POST /api/team/members` · `POST /api/team/{id}/password` · `PATCH /api/team/{id}` · `/api/team/invitations*` | manager |
| `GET /api/reports/footfall` · `GET /api/reports/conversion` | authed |
| `POST /api/assistant` | authed |
| `GET` / `POST /api/admin/clients` | **platform admin** |
| `/api/agent/*` | **shop PC token** — never a user |
A role that may not call something gets **403 `forbidden`** with a message
saying who can. Another tenant's data returns **404**, never 403: a tenant user
has no business learning that a resource exists.
---
## The onboarding chain — who creates whom
Three tiers. Each one creates the login for the next, and nobody ever creates
their own from nothing.
```
┌──────────────────┐ creates ┌──────────────────┐ creates ┌──────────────────┐
│ Platform admin │ ───────────▶ │ Merchant owner │ ───────────▶ │ Sales staff │
│ (web) │ company + │ (web) │ login, pw │ (mobile) │
│ │ owner login │ │ shown once │ │
└──────────────────┘ └──────────────────┘ └──────────────────┘
POST /api/admin/clients POST /api/team/members POST /api/auth/login
(or /api/team/invitations → (or /api/auth/register
a code they redeem themselves) with the code)
```
### Tier 1 — the platform admin registers a merchant
Signed in as an account with `role: "admin"` and no company.
```
POST /api/auth/login
{ "email": "admin@loyaly.ai", "password": "…", "device": "Admin console" }
POST /api/admin/clients
{ "company_name": "TeNext Retail", "owner_email": "suriya@tenext.in",
"owner_name": "Suriya" }
→ 201 { "client_id": "…", "slug": "tenext-retail",
"owner_email": "suriya@tenext.in", "password": "xK9…" }
```
`password` is generated and shown **once**. The admin hands the owner their
email and that password — that is the merchant login. Leave `password` out of
the request; a password an operator invents for someone else is weak and
travels over chat.
```
GET /api/admin/clients → every merchant, with site and user counts
```
### Tier 2 — the merchant owner registers sales staff
Signed in as the owner (or any manager). Two ways to do it; use whichever fits
the moment.
**Directly — create the login and hand it over.** For a salesperson being set
up before their first shift, without a phone in hand. Exactly how the admin
created the merchant in Tier 1.
```
POST /api/auth/login
{ "email": "suriya@tenext.in", "password": "xK9…", "device": "Head office" }
POST /api/team/members
{ "email": "priya@tenext.in", "full_name": "Priya R", "role": "staff" }
→ 201 { "id": "…", "email": "priya@tenext.in", "full_name": "Priya R",
"role": "staff", "active": true, "created_at": "…",
"password": "m4kq…" } ← shown ONCE
```
Leave `password` out and one is generated; give one and it is used (8
characters minimum). Either way it is returned exactly once — write it on the
card now. The salesperson signs in on their phone with that email and
password, and the merchant login is done.
**By invitation — the salesperson chooses their own password.** Better when
they have their phone: the merchant never sees or handles a staff password.
```
POST /api/team/invitations
{ "email": "priya@tenext.in", "full_name": "Priya R", "role": "staff",
"expires_in_days": 7 }
→ 201 { "id": "…", "email": "priya@tenext.in", "role": "staff",
"code": "LQOUHR-AYYTPE-7Q756N-PGAAN6", "expires_at": "…" }
```
`code` is shown **once** and is what the merchant gives the salesperson —
read aloud, WhatsApp, printed on a card. Single-use, expires. They redeem it in
Tier 3 and pick a password there.
**When they forget it** — which is the everyday case on a shop floor:
```
POST /api/team/{id}/password { } or { "password": "chosen" }
→ 200 { "password": "n7xw…" } ← shown ONCE
```
Resets the password **and signs them out of every device** in one step,
because the other reason a manager resets a password is a lost phone, and a
reset that left that phone signed in would look complete while fixing nothing.
Managing the team afterwards:
```
GET /api/team → everyone: role, active, last login
GET /api/team/invitations → codes still unredeemed (without the code)
DELETE /api/team/invitations/{id} → withdraw one before it is used
PATCH /api/team/{id} { "role": "manager" } → promote
PATCH /api/team/{id} { "active": false } → they have left; signs them out now
```
The owner also sets the shop up from the same login — `POST
/api/sites/{site}/enrolment-code` for the shop PC, `POST
/api/sites/{site}/cameras` for cameras — see §8.
### Tier 3 — the salesperson gets their mobile login
If the merchant created the login directly, they already have an email and
password: skip straight to `POST /api/auth/login` below. Otherwise they have a
code.
```
GET /api/auth/invitation?code=LQOUHR-AYYTPE-7Q756N-PGAAN6 (no auth)
→ { "client_name": "TeNext Retail", "email": "priya@tenext.in",
"full_name": "Priya R", "role": "staff" }
```
Show *"Join TeNext Retail as Priya R"* and ask for a password. Then:
```
POST /api/auth/register (no auth)
{ "code": "LQOUHR-AYYTPE-7Q756N-PGAAN6",
"full_name": "Priya R", "password": "…", "device": "Pixel 8" }
→ 201 { "access_token": "…", "refresh_token": "…", "expires_at": "…",
"user": { …, "role": "staff", "client_name": "TeNext Retail" } }
```
**They are signed in.** Do not send them to a login form. From the next day:
```
POST /api/auth/login { "email": "priya@tenext.in", "password": "…", "device": "Pixel 8" }
POST /api/auth/refresh { "refresh_token": "…", "device": "Pixel 8" }
```
And the screens a salesperson uses — all `staff` may call:
```
GET /api/visits?limit=30 then ?cursor=… who just walked in
GET /api/visits/stream the same, pushed
GET /api/visitors?q=priya find a customer
GET /api/visitors/V-42/history their past visits
PUT /api/visitors/V-42/profile give them a name
POST /api/purchases record a sale
GET /api/visitors/V-42/image their photo, if any
```
Do **not** send `email` or `role` on register — they come from the code, and a
body naming either is refused. That is what stops a forwarded code becoming
somebody else's account.
### What does not exist, stated plainly
- **There is no mobile app in this repository.** Tier 3 is a complete API with
no client yet. Everything above is what that app will call.
- **The admin cannot reset a merchant owner's password over HTTP**, nor suspend
or delete a merchant. Today that is `behavision-server provision` on the
server.
---
## Quick starts
### A mobile app for shop-floor staff
The one screen this app exists for is *who just walked in*.
```
POST /api/auth/login → access_token, refresh_token
GET /api/visits?limit=30 → arrivals[], cursor (first load)
GET /api/visits?cursor=… → arrivals[], cursor (every 3–5 s)
tap a row →
GET /api/visitors/{visitor_ref}/history → their past visits
PUT /api/visitors/{visitor_ref}/profile → give them a name
```
Store `refresh_token` securely; read §1 for the three refresh rules before
writing the client, because all three have already been bugs here.
### A web console for an owner or manager
```
POST /api/auth/login
GET /api/sites → every shop: online? cameras up? faces usable?
GET /api/sites/{slug}/check → why a shop is or is not working
GET /api/cameras → every camera with its latest still
POST /api/sites/{slug}/cameras → add one
POST /api/cameras/{id}/check → prove it can see faces
GET /api/visits/stream → live arrivals (SSE)
GET /api/reports/footfall?from=&to= → the numbers, with their confidence
```
### Platform administration
```
POST /api/auth/login (an account with no company)
GET /api/admin/clients → every company
POST /api/admin/clients → create one, with its owner
```
That is the whole admin surface today. Everything inside a company is the
company's own business and is reached by signing in as one of its users.
---
## 0. Identifiers — you do not have to use uuids ## 0. Identifiers — you do not have to use uuids
Every id in the database is a uuid and every one of them still works. But a uuid Every id in the database is a uuid and every one of them still works. But a uuid
@@ -19,14 +277,14 @@ is not something a person can say, type or recognise, so **anywhere a path or a
|---|---|---| |---|---|---|
| customer | `V-<number>` | `V-42` — also accepts bare `42` | | customer | `V-<number>` | `V-42` — also accepts bare `42` |
| shop | its slug | `chennai` | | shop | its slug | `chennai` |
| camera | the id the engine knows it by | `Office1` | | camera | the id the engine knows it by | `cam1` |
| person | their email address | `priya@tenext.in` | | person | their email address | `priya@tenext.in` |
``` ```
GET /api/visitors/3446ec35-2c1f-4c8e-9a77-0d1e2f3a4b5c/history GET /api/visitors/3446ec35-2c1f-4c8e-9a77-0d1e2f3a4b5c/history
GET /api/visitors/V-42/history ← the same customer GET /api/visitors/V-42/history ← the same customer
GET /api/visits?site=chennai GET /api/visits?site=chennai
PATCH /api/cameras/Office1 PATCH /api/cameras/cam1
``` ```
The customer number is **per company**, so `V-42` at one tenant and `V-42` at The customer number is **per company**, so `V-42` at one tenant and `V-42` at
@@ -35,7 +293,7 @@ the session belongs to. It is also what the label says: a customer nobody has
named is called `Visitor 42`, and `ref` on every customer object carries `V-42` named is called `Visitor 42`, and `ref` on every customer object carries `V-42`
for display. for display.
Two shops in one company may each have a camera called `Office1`. That is Two shops in one company may each have a camera called `cam1`. That is
ambiguous, so it resolves to **nothing** rather than to a guess — use the uuid, ambiguous, so it resolves to **nothing** rather than to a guess — use the uuid,
or scope by site. or scope by site.
@@ -49,9 +307,6 @@ URL, a config file or a scheduled report. The **display name** beside it
(`"TeNext Chennai"`, `"Front door"`) is free to change and should be; do not key (`"TeNext Chennai"`, `"Front door"`) is free to change and should be; do not key
on it. on it.
The uuid is still returned everywhere and still works. Use it if you want a key
you never have to think about; use the reference when a person will read it.
--- ---
## 1. Signing in ## 1. Signing in
@@ -67,20 +322,19 @@ you never have to think about; use the reference when a person will read it.
"access_token": "…", "access_token": "…",
"refresh_token": "…", "refresh_token": "…",
"expires_at": "2026-09-05T18:00:00Z", "expires_at": "2026-09-05T18:00:00Z",
"user": { "id": "…", "email": "…", "full_name": "Priya R", "user": { "id": "…", "email": "priya@tenext.in", "full_name": "Priya R",
"role": "staff", "client_id": "…", "client_name": "TeNext Retail" } "role": "staff", "client_id": "…", "client_name": "TeNext Retail" }
} }
``` ```
Send `Authorization: Bearer <access_token>` on every other call. Send `Authorization: Bearer <access_token>` on every other call. A platform
admin's `user` has `"role": "admin"` and `"client_id": ""`.
**`device` is worth sending.** It is the only thing that lets somebody look at **`device` is worth sending.** It is the only thing that lets somebody look at
their list of signed-in devices and tell which one to sign out. Keep it coarse their list of signed-in devices and tell which one to sign out. Keep it coarse
and human — `"Pixel 8"`, `"Shop till"` — never a device identifier; a and human — `"Pixel 8"`, `"Shop till"` — never a device identifier; a
fingerprint here is a tracking signal nobody asked for. fingerprint here is a tracking signal nobody asked for.
Failures:
| status | `error` | what it means | | status | `error` | what it means |
|---|---|---| |---|---|---|
| 401 | `bad_credentials` | Wrong password **or** no such account. Deliberately the same answer: telling them apart turns this form into a way to find out who works at a customer. Show the server's `message`. | | 401 | `bad_credentials` | Wrong password **or** no such account. Deliberately the same answer: telling them apart turns this form into a way to find out who works at a customer. Show the server's `message`. |
@@ -132,9 +386,9 @@ and hands it over; the holder chooses their own password.
``` ```
```json ```json
{ "id": "…", "email": "arjun@tenext.in", "role": "manager", { "id": "…", "email": "arjun@tenext.in", "full_name": "Arjun", "role": "manager",
"code": "LQOUHR-AYYTPE-7Q756N-PGAAN6", "code": "LQOUHR-AYYTPE-7Q756N-PGAAN6",
"expires_at": "…", "created_at": "…" } "invited_by": "suriya@tenext.in", "expires_at": "…", "created_at": "…" }
``` ```
**`code` is returned exactly once and is not recoverable.** Only a hash is **`code` is returned exactly once and is not recoverable.** Only a hash is
@@ -178,9 +432,10 @@ wrong code) does **not** spend the invitation.
| 404 | `invalid_code` | | 404 | `invalid_code` |
| 409 | `conflict` — that address already has an account; sign in instead | | 409 | `conflict` — that address already has an account; sign in instead |
### `GET` / `DELETE /api/team/invitations[/{id}]` — manager or owner ### `GET /api/team/invitations` · `DELETE /api/team/invitations/{id}` — manager or owner
List what is still pending, or withdraw one before it is used. List what is still pending (same shape as above, **without** `code`), or
withdraw one before it is used.
--- ---
@@ -202,21 +457,74 @@ somebody signs out the device in their hand. `revoke-others` deliberately keeps
the caller's own session. the caller's own session.
A person can revoke only their own sessions. To remove a colleague's access, A person can revoke only their own sessions. To remove a colleague's access,
deactivate them (below); that revokes every session they hold. deactivate them (§4); that revokes every session they hold.
--- ---
## 4. The team ## 4. The team
| | | ### `GET /api/team` — anyone in the company
|---|---|
| `GET /api/team` | everybody in this company |
| `PATCH /api/team/{id}` | `{"role": "manager"}` and/or `{"active": false}` |
Deactivating signs that person out **immediately** and stops them signing back ```json
in. Reactivating restores the account but not their old sessions. [{ "id": "…", "email": "arjun@tenext.in", "full_name": "Arjun",
"role": "manager", "active": true,
"last_login_at": "…", "created_at": "…" }]
```
409 `last_owner` if the change would leave the company with no active owner. ### `POST /api/team/members` — manager or owner
Create a login directly and hand it over. The alternative to an invitation
(§2) for somebody without a phone in hand.
```json
{ "email": "priya@tenext.in", "full_name": "Priya R", "role": "staff",
"password": "" }
```
```json
{ "id": "…", "email": "priya@tenext.in", "full_name": "Priya R",
"role": "staff", "active": true, "last_login_at": "", "created_at": "…",
"password": "m4kq…" }
```
- **`password` is returned once** and is not recoverable. Leave it empty in
the request and one is generated; supply one and it must be 8+ characters.
- `role` is `staff` (default), `manager` or `owner`. Only an owner may create
an owner; `admin` is refused.
- **409 `conflict`** if that email already has an account anywhere.
### `POST /api/team/{id}/password` — manager or owner
```json
{ } or { "password": "chosen-one" }
```
```json
{ "password": "n7xw…" }
```
Sets a new password (generated unless given) and **revokes every session the
member holds**, in one transaction. Returns the new password once. A member of
another company is **404**, never 403.
There is deliberately no self-service reset and no reset-by-email: a shop-floor
account often has no mailbox anyone checks, and the person who can vouch for
the salesperson standing in front of them is their manager.
### `PATCH /api/team/{id}` — manager or owner
```json
{ "role": "manager" } or { "active": false } or both
```
Both fields optional; an omitted field is left alone. Deactivating signs that
person out **immediately** and stops them signing back in. Reactivating restores
the account but not their old sessions. A manager cannot promote anyone to
owner.
**409 `last_owner`** if the change would leave the company with no active owner.
There is no way back from that except a shell on the server, which is exactly
what this endpoint exists to stop needing.
--- ---
@@ -232,7 +540,7 @@ in. Reactivating restores the account but not their old sessions.
"visit_id": "…", "visit_id": "…",
"occurred_at": "2026-09-05T06:01:45Z", "occurred_at": "2026-09-05T06:01:45Z",
"site_id": "…", "site": "TeNext Chennai", "site_slug": "chennai", "site_id": "…", "site": "TeNext Chennai", "site_slug": "chennai",
"camera_id": "Office1", "camera_id": "cam1",
"visitor_id": "…", "visitor_ref": "V-42", "label": "Priya", "visitor_id": "…", "visitor_ref": "V-42", "label": "Priya",
"is_new_visitor": false, "similarity": 0.71, "quality": 0.66, "is_new_visitor": false, "similarity": 0.71, "quality": 0.66,
"attributes": { "gender": "Male", "age": 32, "emotion": "neutral" }, "attributes": { "gender": "Male", "age": 32, "emotion": "neutral" },
@@ -253,22 +561,20 @@ again without one.
An empty poll returns your own cursor back, not an empty string. An empty poll returns your own cursor back, not an empty string.
**There is no `seq` on the wire.** It existed as a convenience for "have I Of the three ids on an arrival, only one is a reference you would type:
fallen behind"; `visits.seq` is a plain bigserial, so it counted every visit on
the *platform* and put the total footfall of every customer we have on every row
of every tenant's feed. The cursor — opaque and version-prefixed — is the
supported way to know your position, and the only one you need.
Of the three ids on an arrival, only one of them is a reference you would type:
`site_slug`. `visit_id` addresses no route — it is a key for de-duplicating `site_slug`. `visit_id` addresses no route — it is a key for de-duplicating
rows, since delivery is at-least-once. And the uuid inside an image URL is rows, since delivery is at-least-once. And the uuid inside an image URL is
**deliberately random**: a derived or sequential one would let somebody **deliberately random**: a derived or sequential one would let somebody
enumerate a shop's customers by date. enumerate a shop's customers by date.
A visit may have **no `visitor_id`** (a shop counting footfall without
identifying people) or a blank `label` (a customer who was erased). Both are
real people who walked in; render them, do not drop them.
### `GET /api/visits/stream` — server-sent events ### `GET /api/visits/stream` — server-sent events
The same rows, pushed. Send `Authorization` (so `EventSource` will not do — The same rows, pushed. Send `Authorization` (so a browser `EventSource` will not
read the stream with an HTTP client) and resume with `Last-Event-ID` or do — read the stream with an HTTP client) and resume with `Last-Event-ID` or
`?cursor=`. Falls back to polling cleanly; the failure mode is latency, never `?cursor=`. Falls back to polling cleanly; the failure mode is latency, never
silence. silence.
@@ -292,7 +598,7 @@ how you tell them apart. Do not infer it from the shape of the URL.
| | `auth` | how to load it | | | `auth` | how to load it |
|---|---|---| |---|---|---|
| presigned object-storage link | absent/false | use it directly; it carries its own signature and expires in `expires_in` seconds | | presigned object-storage link | absent/false | use it directly; it carries its own signature and expires in `expires_in` seconds |
| served by this API | `true` | send `Authorization: Bearer …` | | served by this API (`/api/faces/{id}`) | `true` | send `Authorization: Bearer …` |
- **Mobile**: an image view can attach the header — - **Mobile**: an image view can attach the header —
`Image source={{ uri, headers: { Authorization: 'Bearer …' } }}`. `Image source={{ uri, headers: { Authorization: 'Bearer …' } }}`.
@@ -303,6 +609,12 @@ how you tell them apart. Do not infer it from the shape of the URL.
Prefix a relative URL with the base URL. Treat any relative URL as needing auth Prefix a relative URL with the base URL. Treat any relative URL as needing auth
whether or not the flag is set: there is no public one. whether or not the flag is set: there is no public one.
### `GET /api/faces/{id}`
The bytes behind an `auth: true` URL. `image/jpeg`, session required, **404 to
any other tenant**. You will not construct this URL yourself — it arrives inside
an `image` object.
### `GET /api/visitors/{id}/image` ### `GET /api/visitors/{id}/image`
The same `image` object for one customer's latest photo. 404 `no_image` (nothing The same `image` object for one customer's latest photo. 404 `no_image` (nothing
@@ -317,59 +629,400 @@ two rows in *"who looked at my customers"* for one glance at one person.
## 7. Customers ## 7. Customers
| | | ### `GET /api/visitors?q=…&limit=50` — search
|---|---|
| `GET /api/visitors?q=…` | search by name, phone, or customer number (`42`, `V-42`) |
| `GET /api/visitors/{id}/history` | their past visits |
| `PUT /api/visitors/{id}/profile` | name, phone, notes — staff and above |
| `DELETE /api/visitors/{id}` | **erasure** — manager and above |
| `POST /api/purchases` | link a sale to a visit |
`{id}` is a uuid **or** `V-42` **or** `42`. Every customer object carries `ref` `q` matches name, phone, email, or customer number (`42` or `V-42`). Omit `q`
("V-42") beside `id`, and `label` reads "Visitor 42" until somebody names them. for the most recently seen customers. `limit` defaults to 50, capped at 500.
Erased customers never appear.
Erasure destroys the face template and the photo outright and keeps the visit ```json
rows, unlinked. It is irreversible. If the photo cannot be deleted the whole [{ "id": "…", "ref": "V-42", "label": "Priya",
request fails with **502** and *nothing* is erased — so an error there means the "full_name": "Priya R", "phone": "+91 …", "email": "",
data is still there, and must be reported as a failure, never swallowed. "visit_count": 7, "first_seen_at": "…", "last_seen_at": "…",
"has_profile": true, "has_consent": false }]
```
`label` reads `"Visitor 42"` until somebody names them, then whatever they were
named. `ref` is what to show beside it.
### `GET /api/visitors/{id}/history`
```json
[{ "id": "…", "occurred_at": "…", "site": "TeNext Chennai", "camera_id": "cam1",
"is_new_visitor": false, "similarity": 0.71, "quality": 0.66,
"attributes": { "gender": "Male", "age": 32, "emotion": "neutral" } }]
```
Newest first. `is_new_visitor` is true on exactly one row — the visit that
enrolled them.
### `PUT /api/visitors/{id}/profile` — staff and above
```json
{ "full_name": "Priya R", "phone": "+91 …", "email": "",
"gender": "", "date_of_birth": "", "notes": "Prefers the window table",
"consent": true }
```
Whole-object replace. `consent` records that the customer agreed to be
recognised; it is kept — revoked, not deleted — through erasure, because the
record of what you were permitted to do is what an auditor asks for.
### `POST /api/purchases` — staff and above
```json
{ "visitor_id": "V-42", "site_id": "chennai",
"amount": 1250.00, "currency": "INR",
"items": ["…"], "source": "till", "notes": "" }
```
Links a sale to a customer so the conversion report can say who bought. **One
currency per report** — see §9.
### `DELETE /api/visitors/{id}` — **erasure**, manager and above
Destroys the face template and the photo outright. Keeps the visit rows,
unlinked (they are the shop's own footfall history). Keeps the consent record,
revoked. Keeps the customer row with a deletion mark so the same face is not
re-enrolled next week as a brand-new person.
It is irreversible. If the photo cannot be deleted the whole request fails with
**502** and *nothing* is erased — so an error there means the data is still
there, and must be reported as a failure, never swallowed.
--- ---
## 8. Shops, cameras, reports ## 8. Shops and cameras
| | | ### `GET /api/sites`
|---|---|
| `GET /api/sites` | estate health: online, cameras up, `fraction_below_gate` |
| `GET /api/sites/{id}/check` | five-step smoke test for one shop |
| `GET /api/cameras` | cameras and their latest still |
| `GET /api/cameras/{id}/live` | live view relayed from the shop PC (SSE) |
| `GET /api/reports/footfall` | `?from=&to=&site=&tz=&bucket=` |
| `GET /api/reports/conversion` | same parameters; revenue and basket size |
**`site` and `site_id` are both accepted everywhere**, and either may be a slug Every shop in the company, with the three facts that tell a quiet week from an
or a uuid. They used to differ per endpoint, which mattered because an unknown unplugged PC.
query parameter is silently ignored — so getting it the wrong way round returned
the whole estate instead of an error. `site` is the documented spelling.
Dates are `YYYY-MM-DD`. `to` is **inclusive**: "1st to the 7th" includes the ```json
7th. [{ "site_id": "…", "slug": "chennai", "name": "TeNext Chennai",
"timezone": "Asia/Kolkata",
"online": true, "last_heartbeat_at": "…", "last_event_at": "…",
"recognition_model": "w600k_r50", "agent_version": "0.3.0",
"cameras_up": 2, "cameras_total": 2,
"fraction_below_gate": 0.47,
"queued": 0, "dropped": 0 }]
```
Two arithmetic traps the API is explicit about, so a client does not reinvent - `online` is **three missed heartbeats**, not one. One is a dropped packet.
- `fraction_below_gate` is the share of faces the cameras saw that were too
poor to use. It is the number that decides whether the footfall figure means
anything: under 0.2 is good, under 0.5 is marginal, above is a camera that
needs moving. It reports the **worst** camera, not the average.
- `queued` is footfall waiting on the shop PC's disk to be sent; `dropped` is
footfall lost because that queue overflowed. Non-zero `dropped` is a report
that is wrong in a way the report itself cannot show.
### `GET /api/sites/{site}/check`
Five ordered steps that answer *is this shop working*, assembled from what head
office already knows — so it costs no round trip and works when the PC is off,
which is itself one of the answers.
```json
{ "site_id": "…", "site": "TeNext Chennai", "ok": false,
"steps": [
{ "name": "The shop's PC is online", "status": "pass", "detail": "…" },
{ "name": "Recognition is running", "status": "pass", "detail": "…" },
{ "name": "Cameras are connected", "status": "pass", "detail": "2 of 2" },
{ "name": "Cameras can recognise faces", "status": "fail",
"detail": "47% of faces too poor to enrol", "advice": "…" },
{ "name": "Visits are reaching head office","status": "unknown", "detail": "…" }
] }
```
`status` is `pass | warn | fail | unknown`. **Checking stops at the first
failure**; later steps report `unknown`, which is its own state — asking whether
cameras see faces on a PC that is switched off produces an answer that means
nothing. `ok` is true only when every step passed.
### `GET /api/cameras`
Every camera in the company — how it is configured **and** whether it is
working, in one object, because those are the two halves of the only question
anyone asks.
```json
[{ "id": "…", "site_id": "…", "site": "TeNext Chennai",
"camera_id": "cam1", "label": "Front door",
"host": "192.168.1.122", "port": 554, "path": "/ch0_0.264",
"username": "admin", "has_password": true,
"max_width": 1280, "tuning": {}, "enabled": true, "revision": 6,
"connected": true, "last_seen_at": "…",
"snapshot": { "available": true, "url": "/api/cameras/…/snapshot.jpg", "auth": true },
"snapshot_at": "…",
"check": { "kind": "placement", "state": "done", "ok": false,
"verdict": "marginal",
"headline": "Half the faces this camera sees are too poor to enrol",
"advice": ["Lower the camera to head height", "…"],
"detail": { "faces": 31, "fraction_below_gate": 0.47 },
"image": { "available": true, "url": "…", "auth": true } } }]
```
Three things a client must render correctly:
- **`has_password`, never the password.** A camera credential is a live path
into the camera. The API structurally cannot return it to a user.
- **`connected` is a pointer**: `null` means *"no shop PC has reported on this
camera yet"*, `false` means *"not connecting"*. A bare `false` says the second
when it means the first, and sends an installer to check cabling on a camera
nobody has tried to reach.
- **`check` is always present.** An empty one (`state` absent) means *never
checked*; `state: "done", ok: false` means *checked and failed*. Those are
different situations and a client must not guess from an absent field.
### `POST /api/sites/{site}/cameras` — manager
```json
{ "camera_id": "cam1", "label": "Front door",
"host": "192.168.1.122", "port": 554, "path": "/ch0_0.264",
"username": "admin", "password": "…", "max_width": 1280 }
```
Returns the `Camera` object. The shop PC picks it up on its next sync (under a
minute) and only then can it be tested — until then `connected` is `null`.
`camera_id` is what the engine will know it by and what lands on every visit.
**It cannot be changed later.** Two shops may each have a `cam1`; one shop
cannot.
`path` is the field nobody can look up — it is model-specific. The web console
fills it in from a make picker (`shared/cameraMakes.js`); an app should offer
the same list rather than expecting a shop owner to know `/ch0_0.264`.
### `PATCH /api/cameras/{id}` — manager
Same fields, all optional. **An omitted field is left alone; do not send blank
strings to mean "unchanged".** In particular, omit `password` unless the user
typed a new one — the API never returns the old one, so a form that round-trips
an empty field would wipe it on every save. `camera_id` is refused.
Every edit bumps `revision`, and the shop PC restarts that camera's connection
when it applies it.
### `DELETE /api/cameras/{id}` — manager
A tombstone, not a hard delete: the shop PC is told the camera was removed,
rather than not told about it — otherwise its next sync would offer the camera
back up and it would reappear.
### `POST /api/cameras/{id}/check` — manager
```json
{ "kind": "connection" } or
{ "kind": "placement", "seconds": 25 }
```
Returns **202** and the `check` object in `state: "requested"`. Poll
`GET /api/cameras` until `state: "done"`.
Two different questions, deliberately:
- **`connection`** — can the shop PC open the stream. Answers in seconds.
- **`placement`** — does somebody walking past produce a view worth enrolling.
Runs for `seconds` while a person walks through the frame. This is the one
that matters: a camera can pass the first and fail the second, and did, for
weeks, at the pilot site.
**Only `verdict: "good"` is a pass.** `marginal` means half the visitors are
silently discarded, which is not a working camera. The engine's `headline` and
`advice` are written for the person standing next to the camera — show them
verbatim.
A check is a job the shop PC claims. If the PC is off, `state` stays
`requested`; after five minutes the server releases it so the button works
again. A camera added seconds ago reports that the PC has not set it up yet,
rather than pretending it is broken.
### `GET /api/cameras/{id}/snapshot.jpg`
The bytes behind a snapshot `url` with `auth: true`. Refreshed by the shop PC
about once a minute. Session required.
### `GET /api/cameras/{id}/live` — server-sent events
Live view relayed through the shop PC's outbound connection, roughly 13 frames
a second of 640-px JPEG.
```
event: waiting
data: {}
event: frame
data: <base64 JPEG>
```
`waiting` arrives immediately and means the request has reached head office and
the shop PC has been asked; the first `frame` follows when it answers. **Nothing
is uploaded when nobody is watching** — closing the connection stops the shop
PC's upload within seconds, and one view is capped at five minutes (reconnect
to continue). Open one camera at a time; a grid of live tiles puts an estate's
worth of video on the wire because somebody opened a page.
### `POST /api/sites/{site}/enrolment-code` — manager
How a new shop PC gets linked to a shop. The installer types this code once.
```json
{ "label": "till PC", "days": 7 } ← both optional
```
```json
{ "code": "SG26HN-WJFMUP-FRFTHW-MQJB33",
"site_id": "…", "site_name": "TeNext Chennai",
"label": "till PC", "expires_at": "…" }
```
**Single use, shown once, capped at 30 days.** It is read aloud and pasted into
chat on its way to a shop, so it is a credential, not a convenience — which is
why staff may not mint one. Minting writes an audit row naming who asked.
---
## 9. Reports
### `GET /api/reports/footfall`
`?from=2026-09-01&to=2026-09-07&site=chennai&tz=Asia/Kolkata&bucket=day`
`bucket` is `hour | day | week | month` (default `day`). `site` optional —
omit for the whole company. Dates are `YYYY-MM-DD`; **`to` is inclusive**.
```json
{ "from": "2026-09-01", "to": "2026-09-07", "bucket": "day",
"timezone": "Asia/Kolkata",
"points": [{ "bucket": "2026-09-01T00:00:00", "visitors": 41, "new": 12, "returning": 26 }],
"total": 183, "visits": 247,
"fraction_below_gate": 0.47, "worst_site": "TeNext Chennai" }
```
Three arithmetic traps the API is explicit about, so a client does not reinvent
them wrongly: them wrongly:
- **`total` is unique people over the window; the chart does not sum to it.** - **`total` is unique people over the window; the chart does not sum to it.**
Somebody who came Monday and Thursday is one person and two bucket-visitors. Somebody who came Monday and Thursday is one person and two bucket-visitors.
Show the server's `total`, with `visits` underneath. Show the server's `total`, with `visits` underneath.
- **`new + returning` can be less than the total.** A site sending counts - **`new + returning` can be less than `visitors`.** A shop sending counts
without templates records real footfall by an unidentified person, which without templates records real footfall by an unidentified person, who
belongs to neither. belongs to neither. Do not force the two to add up.
- **`new` means first-ever, not first-in-window.** Otherwise every report
re-labels regulars as new customers the day after the window starts.
Report buckets are **local wall time with no offset**, labelled by `timezone`. `fraction_below_gate` travels with the numbers because a footfall figure from a
Do not parse them as a `Date` — the viewer's own zone would shift every label. badly placed camera is wrong in a way the figure itself cannot show. Surface it
next to the total, not in a footnote.
**Bucket labels are local wall time with no offset**, in `timezone`. Do not
parse them as a `Date` — the viewer's own zone would shift every label by
hours.
### `GET /api/reports/conversion`
Same parameters.
```json
{ "visitors": 183, "purchasers": 41, "conversion": 0.224,
"revenue": 51250.00, "average_basket": 1250.00, "currency": "INR" }
```
- **`revenue` is summed for ONE currency** — whichever accounts for most of it,
named in `currency`. Adding rupees to dollars produces something that looks
like money and is not.
- **`average_basket` is per basket, not per purchaser.** Someone who bought
twice had two baskets.
--- ---
## 9. Errors ## 10. The assistant
### `POST /api/assistant`
A question about the company's shops, answered in plain language from the same
data as the reports — never from raw SQL, so it cannot invent the arithmetic
above.
```json
{ "history": [
{ "role": "user", "text": "Is everything working today?" },
{ "role": "assistant", "text": "…" },
{ "role": "user", "text": "Why is footfall low at Chennai?" }
] }
```
```json
{ "text": "Chennai is online with both cameras connected, but 47% of the faces …",
"used": ["sites", "footfall", "camera_check"] }
```
**The client holds the history and resends it.** The server keeps no transcript
— there is no per-user chat log in a database nobody agreed to. Cap it at 24
turns and 2,000 characters per question; the server refuses more.
`used` names the tools it consulted. Show them: an assistant that silently ran
a camera check is alarming, and naming what it looked at makes a wrong answer
traceable rather than mysterious.
It acts as the signed-in user — a staff member asking for a placement check is
told a manager can. It cannot see another company's shops, structurally.
| status | `error` | meaning |
|---|---|---|
| 501 | `assistant_off` | not configured on this deployment — a normal state, show it as such |
| 503 | `assistant_misconfigured` | configured incorrectly; the message names what |
---
## 11. Platform administration — `/api/admin/*`
Only an account with `role: "admin"` **and no company**. Everything else gets
**404**, not 403 — a tenant user has no business learning this surface exists.
### `GET /api/admin/clients`
```json
[{ "id": "…", "slug": "tenext-retail", "name": "TeNext Retail",
"sites": 1, "users": 4, "created_at": "…" }]
```
### `POST /api/admin/clients`
Creates a company **and its owner, in one transaction.** A company with no owner
is a tenant nobody can sign into, and it looks normal in every list — the
operator finds out weeks later when the customer says their login does not
work.
```json
{ "company_name": "TeNext Retail", "slug": "tenext-retail",
"owner_email": "suriya@tenext.in", "owner_name": "Suriya",
"password": "" }
```
```json
{ "client_id": "…", "slug": "tenext-retail",
"owner_email": "suriya@tenext.in",
"password": "xK9…" }
```
- **Leave `password` empty.** The server generates one. An operator inventing a
password for somebody else invents a weak one and sends it over chat.
- **`password` is returned exactly once** and is bcrypt-hashed on the way in.
Not recoverable. Show it, or send it, immediately.
- `slug` is optional and is derived from the name when omitted. It becomes part
of the company's message-broker topic, so `/`, `+` and `#` are stripped, and
**it can never be changed**.
That is the whole admin API. There is no endpoint to delete a company, suspend
one, reset an owner's password, or look inside one — those are done by signing
in as the company's owner, or by `behavision-server provision` on the server.
---
## 12. Errors
```json ```json
{ "error": "invalid_code", "message": "That invitation code is not valid. Ask for a new one." } { "error": "invalid_code", "message": "That invitation code is not valid. Ask for a new one." }
@@ -381,15 +1034,26 @@ rewritten freely.
| status | meaning | | status | meaning |
|---|---| |---|---|
| 400 | the request was wrong; `message` says how | | 400 | the request was wrong; `message` says how — including an unknown reference in a query filter |
| 401 | not signed in, or `token_expired` → refresh once and retry | | 401 | not signed in, or `token_expired` → refresh once and retry |
| 403 | signed in, but this role may not | | 403 | `forbidden` — signed in, but this role may not; `message` says who can |
| 404 | not found — **also** what another tenant's data returns, always | | 404 | not found — **also** what another tenant's data returns, always; and what admin routes return to non-admins |
| 409 | a conflict `message` explains (`last_owner`, duplicate address) | | 409 | a conflict `message` explains (`last_owner`, duplicate address) |
| 429 | throttled | | 429 | `too_many_attempts` |
| 501 | the feature is off for this deployment, not an error | | 501 | the feature is off for this deployment (`assistant_off`, `images_disabled`) — a state, not an error |
| 502 | a downstream failure; for erasure it means **nothing was deleted** | | 502 | a downstream failure; for erasure it means **nothing was deleted** |
| 503 | misconfigured (`assistant_misconfigured`); the message names what |
Roles, in increasing order: `staff` → `manager` → `owner`. A platform admin has ---
`role: "admin"` **and an empty `client_id`** — the two together, never the role
alone. ## Not for you: `/api/agent/*`
Ten routes under `/api/agent/` are how a **shop PC** talks to head office:
enrolling with an installation code, pulling its camera list (with passwords —
it is the thing that has to connect), reporting camera state, uploading
snapshots and face images, claiming and answering check jobs, and relaying live
video. They authenticate with the PC's own token, issued once at enrolment, and
a user session is refused.
A web or mobile client never calls them. They are listed here so nobody wonders
what they are.

View File

@@ -0,0 +1,412 @@
// Command behavision-setup prepares a shop PC to run the recognition engine.
//
// It exists because the engine is Python and the rest of the product is Go.
// The Go halves cross-compile to Windows from any machine; the engine, frozen
// with PyInstaller, does not - PyInstaller bundles the interpreter and native
// wheels of the machine it runs on, so a frozen engine can only be built on
// Windows. That single fact was the whole reason a release could not be cut.
//
// So this installs the engine from source instead of shipping it frozen: find
// a Python, build a private virtual environment beside the database, install
// the engine into it, fetch the models, and record how to start it. Everything
// in the release can then be built anywhere.
//
// The trade, stated plainly because whoever runs this is standing in a shop:
// it needs Python and a working internet connection at install time, and it
// takes minutes rather than seconds. A frozen build needs neither. What it
// buys is a release that exists.
package main
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
"github.com/loyaly/behavision-agent/pkg/config"
"github.com/loyaly/behavision-agent/pkg/engine"
"github.com/loyaly/behavision-agent/pkg/paths"
)
// The engine needs 3.10; nothing here works below it and the failure would
// otherwise arrive as a syntax error deep inside a dependency.
const minMinor = 10
func main() {
if err := run(); err != nil {
fmt.Fprintf(os.Stderr, "\n Setup did not finish: %v\n\n", err)
pause()
os.Exit(1)
}
pause()
}
func run() error {
fmt.Println()
fmt.Println(" Behavision setup")
fmt.Println(" ----------------")
fmt.Println()
state := paths.StateRoot()
src, err := engineSource()
if err != nil {
return err
}
fmt.Printf(" engine source %s\n", src)
fmt.Printf(" install into %s\n", state)
fmt.Println()
if err := paths.EnsureState(); err != nil {
return fmt.Errorf("could not create %s: %w", state, err)
}
py, ver, err := findPython()
if err != nil {
return err
}
step("Python", fmt.Sprintf("%s (%s)", ver, py))
venv := filepath.Join(state, "runtime")
if err := makeVenv(py, venv); err != nil {
return err
}
vpy := venvPython(venv)
step("Virtual environment", venv)
// The engine reads its settings from <state>/config/default.yaml and will
// seed that from beside its own code on first run - which works when its
// code is a checkout or a frozen folder and not when it is a package in
// site-packages, where there is no config beside it. Seeded here, from the
// copy the release ships. Never overwritten: an upgrade must not revert an
// operator's thresholds.
if err := seedConfig(src, state); err != nil {
return err
}
step("Settings", filepath.Join(state, "config", "default.yaml"))
// --upgrade so re-running after a new release replaces the engine rather
// than leaving the old one in place and reporting success.
if err := pipInstall(vpy, src); err != nil {
return err
}
step("Engine and dependencies", "installed")
if err := runEngine(vpy, "setup-models"); err != nil {
return fmt.Errorf("downloading the recognition models: %w", err)
}
step("Recognition models", "downloaded")
if err := writeConfig(vpy); err != nil {
return err
}
step("Startup settings", filepath.Join(state, "agent.json"))
// Proving it starts is the point. An installer that reports success and
// leaves a shop with an engine that will not run has done worse than
// failing: the failure surfaces later, to someone who did not install it.
if err := smokeTest(vpy); err != nil {
return fmt.Errorf("the engine installed but would not start: %w", err)
}
step("Engine starts and answers", "verified")
fmt.Println()
fmt.Println(" Done. Start Behavision from the Start menu or the desktop icon.")
fmt.Println(" It appears in the system tray; right-click there to stop it.")
fmt.Println()
return nil
}
func step(label, detail string) {
fmt.Printf(" [ok] %-24s %s\n", label, detail)
}
// engineSource finds the Python source shipped beside this executable. Beside,
// not downloaded: the engine and the app must be the same release, and a
// version skew between them is the class of bug nobody can reproduce.
func engineSource() (string, error) {
candidates := []string{
filepath.Join(paths.InstallRoot(), "engine-src"),
filepath.Join(paths.InstallRoot(), "..", "engine-src"),
}
if wd, err := os.Getwd(); err == nil {
candidates = append(candidates, filepath.Join(wd, "engine-src"), wd)
}
for _, c := range candidates {
if _, err := os.Stat(filepath.Join(c, "pyproject.toml")); err == nil {
abs, _ := filepath.Abs(c)
return abs, nil
}
}
return "", errors.New("could not find the engine source (expected an " +
"engine-src folder with pyproject.toml beside this program). " +
"Unzip the whole release together rather than moving this file out of it")
}
// findPython returns the first interpreter that is new enough.
//
// `py -3` first on Windows: the launcher is what the official installer puts
// on PATH, and `python` there is often the Microsoft Store stub that prints an
// advert and exits 9009 instead of running anything.
func findPython() (string, string, error) {
type cand struct {
exe string
args []string
}
var cands []cand
if runtime.GOOS == "windows" {
cands = append(cands, cand{"py", []string{"-3"}})
}
cands = append(cands, cand{"python3", nil}, cand{"python", nil})
var tried []string
for _, c := range cands {
exe, err := exec.LookPath(c.exe)
if err != nil {
continue
}
args := append(append([]string{}, c.args...), "-c",
"import sys;print('%d.%d'%sys.version_info[:2])")
out, err := exec.Command(exe, args...).Output()
if err != nil {
continue
}
ver := strings.TrimSpace(string(out))
tried = append(tried, c.exe+" "+ver)
if major, minor, ok := parseVer(ver); ok && (major > 3 || (major == 3 && minor >= minMinor)) {
full := exe
if len(c.args) > 0 {
full = exe + " " + strings.Join(c.args, " ")
}
return full, "Python " + ver, nil
}
}
msg := "no Python 3.10 or newer was found on this PC.\n\n" +
" Install it from https://www.python.org/downloads/windows/\n" +
" and tick \"Add python.exe to PATH\" on the first screen,\n" +
" then run this again."
if len(tried) > 0 {
msg += "\n\n Found, but too old: " + strings.Join(tried, ", ")
}
return "", "", errors.New(msg)
}
func parseVer(s string) (int, int, bool) {
parts := strings.Split(s, ".")
if len(parts) < 2 {
return 0, 0, false
}
major, err1 := strconv.Atoi(parts[0])
minor, err2 := strconv.Atoi(parts[1])
return major, minor, err1 == nil && err2 == nil
}
// splitLauncher turns `py -3` back into a command and its arguments.
func splitLauncher(s string) (string, []string) {
f := strings.Fields(s)
if len(f) == 0 {
return s, nil
}
return f[0], f[1:]
}
func venvPython(venv string) string {
if runtime.GOOS == "windows" {
return filepath.Join(venv, "Scripts", "python.exe")
}
return filepath.Join(venv, "bin", "python")
}
// makeVenv builds the engine's own interpreter under the writable state root.
//
// A virtual environment rather than the system Python: a shop PC may have
// Python there for something else, and pinning numpy below 2.0 - which the
// engine requires - inside a shared interpreter is how you break the other
// thing months later, silently.
func makeVenv(py, venv string) error {
if _, err := os.Stat(venvPython(venv)); err == nil {
return nil // already built; pip below brings it up to date
}
exe, args := splitLauncher(py)
args = append(args, "-m", "venv", venv)
return stream(exec.Command(exe, args...), "creating the virtual environment")
}
func pipInstall(vpy, src string) error {
fmt.Println(" Installing the engine and its libraries. This downloads a few")
fmt.Println(" hundred megabytes and takes a while on a slow connection.")
fmt.Println()
if err := stream(exec.Command(vpy, "-m", "pip", "install", "--upgrade",
"pip", "setuptools", "wheel"), "updating pip"); err != nil {
return err
}
// A wheel if the release ships one - nothing to build on the shop PC, and
// pip never has to touch the folder the release was unzipped into.
//
// That matters more than it sounds: `pip install <folder>` makes setuptools
// write behavision.egg-info INTO that folder, and the folder is read-only
// whenever the release was unzipped somewhere sensible - Program Files, or
// the shared drive INSTALL.txt says is fine. Found by running this in a
// container with the source mounted read-only: "could not create
// 'behavision.egg-info': Read-only file system". Falling back to source
// copies it somewhere writable first, for the same reason.
if wheels, _ := filepath.Glob(filepath.Join(src, "behavision-*.whl")); len(wheels) > 0 {
return stream(exec.Command(vpy, "-m", "pip", "install", "--upgrade", wheels[0]),
"installing the engine")
}
tmp, err := os.MkdirTemp("", "behavision-src-")
if err != nil {
return err
}
defer os.RemoveAll(tmp)
if err := copyTree(src, tmp); err != nil {
return fmt.Errorf("staging the engine source: %w", err)
}
return stream(exec.Command(vpy, "-m", "pip", "install", "--upgrade", tmp),
"installing the engine")
}
// seedConfig puts the shipped default.yaml where the engine will look for it,
// and leaves an existing one alone.
func seedConfig(src, state string) error {
dst := filepath.Join(state, "config", "default.yaml")
if _, err := os.Stat(dst); err == nil {
return nil
}
from := filepath.Join(src, "config", "default.yaml")
b, err := os.ReadFile(from)
if err != nil {
return fmt.Errorf("the release is missing config/default.yaml: %w", err)
}
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
return os.WriteFile(dst, b, 0o644)
}
// copyTree copies a source tree, skipping the caches a checkout accumulates.
func copyTree(from, to string) error {
return filepath.WalkDir(from, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
rel, _ := filepath.Rel(from, path)
if d.IsDir() {
if d.Name() == "__pycache__" || strings.HasSuffix(d.Name(), ".egg-info") {
return filepath.SkipDir
}
return os.MkdirAll(filepath.Join(to, rel), 0o755)
}
b, err := os.ReadFile(path)
if err != nil {
return err
}
return os.WriteFile(filepath.Join(to, rel), b, 0o644)
})
}
// runEngine runs the engine exactly as the app will later: same interpreter,
// same environment. In particular ChildEnv sets BEHAVISION_DATA_DIR, without
// which a pip-installed engine decides its state lives in site-packages and
// downloads the models to a place the app never looks.
func runEngine(vpy string, args ...string) error {
full := append([]string{"-m", "behavision"}, args...)
cmd := exec.Command(vpy, full...)
cmd.Env = engine.ChildEnv("")
return stream(cmd, "running the engine")
}
// writeConfig records how to start the engine, in the same file and through
// the same type the app reads, so the two cannot disagree about it.
func writeConfig(vpy string) error {
path := paths.AgentConfig()
cfg, err := config.Load(path)
if err != nil {
return fmt.Errorf("reading %s: %w", path, err)
}
// An absolute path: the app resolves a relative EngineExe against its own
// install root under Program Files, and the interpreter is not there.
cfg.EngineExe = vpy
cfg.EngineArgs = []string{"-m", "behavision", "run"}
if cfg.APIBase == "" {
cfg.APIBase = "http://127.0.0.1:8010"
}
return cfg.Save(path)
}
// smokeTest starts the engine exactly as the app will and waits for its API to
// answer. Any reply counts, including 401: the engine invents its own
// credential when none is configured, and a refusal proves it is serving.
func smokeTest(vpy string) error {
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, vpy, "-m", "behavision", "run")
cmd.Env = engine.ChildEnv("")
var log strings.Builder
cmd.Stdout, cmd.Stderr = &log, &log
if err := cmd.Start(); err != nil {
return err
}
defer func() {
_ = cmd.Process.Kill()
_, _ = cmd.Process.Wait()
}()
client := &http.Client{Timeout: 3 * time.Second}
deadline := time.Now().Add(75 * time.Second)
for time.Now().Before(deadline) {
resp, err := client.Get("http://127.0.0.1:8010/api/health")
if err == nil {
_, _ = io.Copy(io.Discard, resp.Body)
resp.Body.Close()
return nil
}
if cmd.ProcessState != nil && cmd.ProcessState.Exited() {
break
}
time.Sleep(2 * time.Second)
}
return fmt.Errorf("it did not answer within 75 seconds.\n\n%s",
tail(log.String(), 15))
}
func tail(s string, n int) string {
lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
if len(lines) > n {
lines = lines[len(lines)-n:]
}
return " " + strings.Join(lines, "\n ")
}
// stream runs a command and shows its output. Shown, not swallowed: pip failing
// on a missing build tool prints exactly what is wrong, and hiding that leaves
// the operator with "setup failed" and nothing to act on.
func stream(cmd *exec.Cmd, what string) error {
cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("%s failed: %w", what, err)
}
return nil
}
// pause keeps the window open. Double-clicked from Explorer, a console program
// that finishes closes instantly and the operator sees nothing at all -
// success and failure look identical.
func pause() {
if runtime.GOOS != "windows" {
return
}
fmt.Print(" Press Enter to close. ")
_, _ = bufio.NewReader(os.Stdin).ReadString('\n')
}

View File

@@ -232,7 +232,7 @@ func cmdRun() error {
// nothing - the URL was returned, logged and even exposed on the // nothing - the URL was returned, logged and even exposed on the
// desktop's status object, and never actually given to the engine. // desktop's status object, and never actually given to the engine.
// A claimed shop PC published heartbeats and zero visits. // A claimed shop PC published heartbeats and zero visits.
cmd.Env = append(os.Environ(), "BEHAVISION_WEBHOOK_URL="+hookURL) cmd.Env = engine.ChildEnv(hookURL)
return cmd return cmd
}, },
LogWriter: logFile, LogWriter: logFile,

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

@@ -43,6 +43,9 @@ type App struct {
broker *agentmqtt.Client broker *agentmqtt.Client
stopBridge func() stopBridge func()
hookURL string hookURL string
// Relays camera feeds to the webview so the engine's credential never has
// to travel in an <img> src, which a Chromium webview would strip anyway.
proxy *streamProxy
// Set once the operator logs in. Until then the UI shows the login sheet // Set once the operator logs in. Until then the UI shows the login sheet
// and nothing else is reachable. // and nothing else is reachable.
onSessionChange func(bool) onSessionChange func(bool)
@@ -63,6 +66,7 @@ func NewApp() *App {
cfg: cfg, cfg: cfg,
cloud: cloud.New(envOr("BEHAVISION_CLOUD", "https://mcp.loyaly.ai")), cloud: cloud.New(envOr("BEHAVISION_CLOUD", "https://mcp.loyaly.ai")),
local: local.New(base, cfg.APIUser, cfg.APIPassword), local: local.New(base, cfg.APIUser, cfg.APIPassword),
proxy: newStreamProxy(),
} }
} }
@@ -70,6 +74,14 @@ func (a *App) startup(ctx context.Context) {
a.ctx = ctx a.ctx = ctx
_ = agentpaths.EnsureState() _ = agentpaths.EnsureState()
// Before any screen asks for a camera URL. A failure here is logged and
// not fatal: the rest of the app - people, cameras, the engine controls -
// works without a picture, and refusing to start over a broken tile would
// take a working shop offline.
if err := a.proxy.start(a.local.Base, a.local.User, a.local.Password); err != nil {
log.Printf("camera relay unavailable, tiles will not load: %v", err)
}
// A saved session means a shop PC that rebooted overnight comes back // A saved session means a shop PC that rebooted overnight comes back
// working instead of waiting for someone to log in. // working instead of waiting for someone to log in.
if a.cfg.SessionToken != "" { if a.cfg.SessionToken != "" {
@@ -102,7 +114,7 @@ func (a *App) startup(ctx context.Context) {
// be told again. Without it the engine recognised people and the // be told again. Without it the engine recognised people and the
// bridge received nothing: a claimed shop PC published heartbeats // bridge received nothing: a claimed shop PC published heartbeats
// and zero visits. // and zero visits.
cmd.Env = append(os.Environ(), "BEHAVISION_WEBHOOK_URL="+a.webhookURL()) cmd.Env = agentengine.ChildEnv(a.webhookURL())
return cmd return cmd
}, },
LogWriter: logFile, LogWriter: logFile,
@@ -273,8 +285,8 @@ type PipelineStatus struct {
// Standalone separates "nothing is being sent because this PC is set up on // Standalone separates "nothing is being sent because this PC is set up on
// its own" from "nothing is being sent and something is wrong". They look // its own" from "nothing is being sent and something is wrong". They look
// identical from the counters alone, and only one of them is a fault. // identical from the counters alone, and only one of them is a fault.
Standalone bool `json:"standalone"` Standalone bool `json:"standalone"`
BrokerUp bool `json:"broker_up"` BrokerUp bool `json:"broker_up"`
Accepted uint64 `json:"accepted"` Accepted uint64 `json:"accepted"`
} }
@@ -549,15 +561,25 @@ func (a *App) PlacementResult(id string) (map[string]any, error) {
return a.local.PlacementResult(ctx, id) return a.local.PlacementResult(ctx, id)
} }
// StreamURL is the MJPEG endpoint for a camera, with credentials inline so an // StreamURL is the MJPEG endpoint for a camera tile.
// <img> tag can load it. Loopback only - it never leaves this machine. //
// It points at this app's own loopback relay, not at the engine directly. The
// previous version put the engine's Basic credentials inline in the URL, with
// a comment saying they were there "so an <img> tag can load it" - which a
// browser will not do. Chromium strips credentials from subresource URLs, and
// WebView2 is Chromium, so every camera tile on a shop PC was a broken image.
// See stream_proxy.go for the measurement.
//
// The relay is also why no password appears in the page any more. If it is not
// running the fallback is the bare engine URL with no credential: correct for
// an engine configured without auth, and for one with auth a tile that fails
// to load rather than a password sitting in the DOM.
func (a *App) StreamURL(cameraID string) string { func (a *App) StreamURL(cameraID string) string {
base := strings.TrimPrefix(strings.TrimPrefix(a.local.Base, "http://"), "https://") if u := a.proxy.urlFor(cameraID, "stream.mjpeg"); u != "" {
if a.local.User == "" { return u
return fmt.Sprintf("http://%s/api/cameras/%s/stream.mjpeg", base, cameraID)
} }
return fmt.Sprintf("http://%s:%s@%s/api/cameras/%s/stream.mjpeg", base := strings.TrimPrefix(strings.TrimPrefix(a.local.Base, "http://"), "https://")
a.local.User, a.local.Password, base, cameraID) return fmt.Sprintf("http://%s/api/cameras/%s/stream.mjpeg", base, cameraID)
} }
// ------------------------------------------------------------------- live -- // ------------------------------------------------------------------- live --

View File

@@ -14,16 +14,34 @@ require (
) )
require ( require (
github.com/bep/debounce v1.2.1 // indirect
github.com/eclipse/paho.mqtt.golang v1.4.3 // indirect github.com/eclipse/paho.mqtt.golang v1.4.3 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/gorilla/websocket v1.5.0 // indirect github.com/gorilla/websocket v1.5.0 // indirect
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
github.com/labstack/echo/v4 v4.10.2 // indirect
github.com/labstack/gommon v0.4.0 // indirect
github.com/leaanthony/go-ansi-parser v1.6.0 // indirect github.com/leaanthony/go-ansi-parser v1.6.0 // indirect
github.com/leaanthony/gosod v1.0.3 // indirect
github.com/leaanthony/slicer v1.6.0 // indirect github.com/leaanthony/slicer v1.6.0 // indirect
github.com/leaanthony/u v1.1.0 // indirect github.com/leaanthony/u v1.1.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 // indirect
github.com/pkg/errors v0.9.1 // indirect github.com/pkg/errors v0.9.1 // indirect
github.com/rivo/uniseg v0.4.4 // indirect github.com/rivo/uniseg v0.4.4 // indirect
github.com/samber/lo v1.38.1 // indirect
github.com/tkrajina/go-reflector v0.5.6 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/wailsapp/go-webview2 v1.0.16 // indirect github.com/wailsapp/go-webview2 v1.0.16 // indirect
github.com/wailsapp/mimetype v1.4.1 // indirect
golang.org/x/crypto v0.23.0 // indirect
golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1 // indirect
golang.org/x/net v0.25.0 // indirect golang.org/x/net v0.25.0 // indirect
golang.org/x/sync v0.1.0 // indirect golang.org/x/sync v0.1.0 // indirect
golang.org/x/sys v0.20.0 // indirect golang.org/x/sys v0.20.0 // indirect
golang.org/x/text v0.15.0 // indirect
) )

View File

@@ -3,6 +3,7 @@ fyne.io/systray v1.12.2/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/eclipse/paho.mqtt.golang v1.4.3 h1:2kwcUGn8seMUfWndX0hGbvH8r7crgcJguQNCyp70xik= github.com/eclipse/paho.mqtt.golang v1.4.3 h1:2kwcUGn8seMUfWndX0hGbvH8r7crgcJguQNCyp70xik=
github.com/eclipse/paho.mqtt.golang v1.4.3/go.mod h1:CSYvoAlsMkhYOXh/oKyxa8EcBci6dVkLCbo5tTC1RIE= github.com/eclipse/paho.mqtt.golang v1.4.3/go.mod h1:CSYvoAlsMkhYOXh/oKyxa8EcBci6dVkLCbo5tTC1RIE=
@@ -20,6 +21,7 @@ github.com/labstack/echo/v4 v4.10.2 h1:n1jAhnq/elIFTHr1EYpiYtyKgx4RW9ccVgkqByZaN
github.com/labstack/echo/v4 v4.10.2/go.mod h1:OEyqf2//K1DFdE57vw2DRgWY0M7s65IVQO2FzvI4J5k= github.com/labstack/echo/v4 v4.10.2/go.mod h1:OEyqf2//K1DFdE57vw2DRgWY0M7s65IVQO2FzvI4J5k=
github.com/labstack/gommon v0.4.0 h1:y7cvthEAEbU0yHOf4axH8ZG2NH8knB9iNSoTO8dyIk8= github.com/labstack/gommon v0.4.0 h1:y7cvthEAEbU0yHOf4axH8ZG2NH8knB9iNSoTO8dyIk8=
github.com/labstack/gommon v0.4.0/go.mod h1:uW6kP17uPlLJsD3ijUYn3/M5bAxtlZhMI6m3MFxTMTM= github.com/labstack/gommon v0.4.0/go.mod h1:uW6kP17uPlLJsD3ijUYn3/M5bAxtlZhMI6m3MFxTMTM=
github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=
github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA= github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=
github.com/leaanthony/go-ansi-parser v1.6.0 h1:T8TuMhFB6TUMIUm0oRrSbgJudTFw9csT3ZK09w0t4Pg= github.com/leaanthony/go-ansi-parser v1.6.0 h1:T8TuMhFB6TUMIUm0oRrSbgJudTFw9csT3ZK09w0t4Pg=
github.com/leaanthony/go-ansi-parser v1.6.0/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU= github.com/leaanthony/go-ansi-parser v1.6.0/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
@@ -30,6 +32,7 @@ github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8= github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
github.com/leaanthony/u v1.1.0 h1:2n0d2BwPVXSUq5yhe8lJPHdxevE2qK5G99PMStMZMaI= github.com/leaanthony/u v1.1.0 h1:2n0d2BwPVXSUq5yhe8lJPHdxevE2qK5G99PMStMZMaI=
github.com/leaanthony/u v1.1.0/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI= github.com/leaanthony/u v1.1.0/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE=
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
@@ -42,6 +45,7 @@ github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI= github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
@@ -50,6 +54,8 @@ github.com/samber/lo v1.38.1 h1:j2XEAqXKb09Am4ebOg31SpvzUTTs6EN3VfgeLUhPdXM=
github.com/samber/lo v1.38.1/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA= github.com/samber/lo v1.38.1/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/tkrajina/go-reflector v0.5.6 h1:hKQ0gyocG7vgMD2M3dRlYN6WBBOmdoOzJ6njQSepKdE= github.com/tkrajina/go-reflector v0.5.6 h1:hKQ0gyocG7vgMD2M3dRlYN6WBBOmdoOzJ6njQSepKdE=
github.com/tkrajina/go-reflector v0.5.6/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4= github.com/tkrajina/go-reflector v0.5.6/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
@@ -92,3 +98,5 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -49,6 +49,7 @@ func main() {
}, },
OnShutdown: func(ctx context.Context) { OnShutdown: func(ctx context.Context) {
tray.stop() tray.stop()
app.proxy.stop()
app.StopEngine() app.StopEngine()
}, },
Bind: []any{app}, Bind: []any{app},

249
desktop/stream_proxy.go Normal file
View File

@@ -0,0 +1,249 @@
package main
// streamProxy serves the engine's camera feeds to this app's own webview
// without putting a credential in the page.
//
// What this replaces: StreamURL used to build
// http://user:pass@127.0.0.1:8010/api/cameras/<id>/stream.mjpeg and hand it
// to an <img>, with a comment saying the credentials were inline "so an <img>
// tag can load it". It cannot. Chromium strips credentials from subresource
// URLs and has since M59, and WebView2 is Chromium - so on the one platform
// this product ships to, every camera tile on the shop floor renders as a
// broken image. Measured against the same running engine: the app's Go-side
// calls returned stats and people while an <img> on the very same URL failed,
// and curl proved the URL itself answered 200. The engine was never the
// problem; the browser was throwing the password away before it asked.
//
// So the password stays on this side of the process boundary. The webview
// asks this loopback listener, the listener attaches Basic auth and relays
// the engine's bytes back unchanged. It is the same reasoning the head-office
// web app already follows in Shot.jsx, where an <img> equally cannot carry a
// session and the bytes are fetched and handed over as an object URL.
import (
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"fmt"
"net"
"net/http"
"net/url"
"regexp"
"strings"
"sync"
"time"
)
// A camera id reaches this from the engine and from a person typing into the
// Add Camera form. Validated rather than interpolated: without this a `..`
// would climb out of the two paths below and turn a camera relay into a proxy
// for any engine endpoint, with the credential helpfully attached.
var safeCameraIDChars = regexp.MustCompile(`^[A-Za-z0-9_.-]{1,64}$`)
// safeCameraID is the character check AND the two names that pass it and still
// mean something to a path resolver.
//
// The pattern allows `.` because real camera ids contain them - which means it
// also allows exactly `.` and `..`, and `/api/cameras/../stream.mjpeg` is not
// the endpoint anyone intended. The id can never contain a slash (the path is
// split on them before we get here), so these two strings are the entire
// remaining traversal surface. Found by the test, not by reading the regex.
func safeCameraID(id string) bool {
if id == "." || id == ".." {
return false
}
return safeCameraIDChars.MatchString(id)
}
type streamProxy struct {
mu sync.RWMutex
ln net.Listener
srv *http.Server
client *http.Client
token string
target string // engine origin, e.g. http://127.0.0.1:8010
user string
pass string
}
func newStreamProxy() *streamProxy { return &streamProxy{} }
// start binds a loopback listener and begins relaying. Calling it again while
// running is a no-op, so a restarted engine cannot leave two listeners behind.
func (p *streamProxy) start(base, user, pass string) error {
p.mu.Lock()
defer p.mu.Unlock()
if p.srv != nil {
return nil
}
if !strings.HasPrefix(base, "http://") && !strings.HasPrefix(base, "https://") {
base = "http://" + base
}
if _, err := url.Parse(base); err != nil {
return fmt.Errorf("engine base %q: %w", base, err)
}
// The engine's own credential exists precisely so that the live face feed
// is never served open - CLAUDE.md is explicit that an unauthenticated
// listener would expose it. An unauthenticated loopback relay would hand
// that same feed to any other process on this PC, which on a shop counter
// is not a theoretical set. A per-run token, minted here and given only to
// this app's own webview, keeps the relay as private as the engine is.
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return fmt.Errorf("proxy token: %w", err)
}
// Port 0: the OS picks a free one. A fixed port would collide with
// whatever else a shop PC happens to be running, and the failure would be
// "the cameras stopped working" with nothing pointing at the cause.
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return fmt.Errorf("stream proxy listen: %w", err)
}
p.ln = ln
p.token = hex.EncodeToString(raw)
p.target = strings.TrimRight(base, "/")
p.user, p.pass = user, pass
// No client timeout: an MJPEG stream is endless by design and any deadline
// would cut the picture off mid-shift. The request context ends it when
// the webview navigates away or the tile is replaced.
p.client = &http.Client{
Transport: &http.Transport{
DialContext: (&net.Dialer{Timeout: 5 * time.Second}).DialContext,
TLSHandshakeTimeout: 5 * time.Second,
},
}
srv := &http.Server{Handler: http.HandlerFunc(p.handle)}
p.srv = srv
// srv and ln are captured, not read off the struct inside the goroutine:
// stop() sets both to nil, so a serve loop that reached for them after a
// quick start/stop would dereference nil and take the whole app down. The
// test that stops the relay found exactly that.
go func() { _ = srv.Serve(ln) }()
return nil
}
func (p *streamProxy) stop() {
p.mu.Lock()
srv, ln := p.srv, p.ln
p.srv, p.ln, p.token = nil, nil, ""
p.mu.Unlock()
if srv != nil {
_ = srv.Close()
}
if ln != nil {
_ = ln.Close()
}
}
// urlFor returns the loopback URL for one camera resource, or "" when the
// proxy is not running so the caller can fall back.
func (p *streamProxy) urlFor(cameraID, file string) string {
p.mu.RLock()
defer p.mu.RUnlock()
if p.ln == nil || p.token == "" || !safeCameraID(cameraID) {
return ""
}
return fmt.Sprintf("http://%s/s/%s/%s/%s",
p.ln.Addr().String(), p.token, cameraID, file)
}
func (p *streamProxy) handle(w http.ResponseWriter, r *http.Request) {
p.mu.RLock()
token, target, user, pass, client := p.token, p.target, p.user, p.pass, p.client
p.mu.RUnlock()
if token == "" || client == nil {
http.NotFound(w, r)
return
}
// /s/<token>/<camera>/<file>
parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/"), "/")
if len(parts) != 4 || parts[0] != "s" {
http.NotFound(w, r)
return
}
// Constant time: the token is the only thing standing between another
// local process and a live view of customers' faces.
if subtle.ConstantTimeCompare([]byte(parts[1]), []byte(token)) != 1 {
// 404 rather than 403. There is nothing here to tell an unwelcome
// caller they have found the right door with the wrong key.
http.NotFound(w, r)
return
}
cameraID := parts[2]
if !safeCameraID(cameraID) {
http.NotFound(w, r)
return
}
// An allow-list, not a prefix match. Everything else the engine serves -
// the identity list, the gallery, erasure - stays unreachable through here
// even for a caller holding the token.
//
// frame.jpg is listed although no screen asks for one yet. It is reachable
// only through urlFor, which is internal, so it adds no bound API nobody
// calls; it is here so that adding a still later is a change to a screen
// rather than a change to the one file where a mistake is a credentialed
// proxy onto the biometric API.
var enginePath string
switch parts[3] {
case "stream.mjpeg":
enginePath = "/api/cameras/" + cameraID + "/stream.mjpeg"
case "frame.jpg":
enginePath = "/api/cameras/" + cameraID + "/frame.jpg"
default:
http.NotFound(w, r)
return
}
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, target+enginePath, nil)
if err != nil {
http.Error(w, "bad upstream request", http.StatusInternalServerError)
return
}
// frame.jpg takes width and quality; the engine re-encodes on demand.
req.URL.RawQuery = r.URL.RawQuery
if user != "" {
req.SetBasicAuth(user, pass)
}
resp, err := client.Do(req)
if err != nil {
http.Error(w, "engine unreachable", http.StatusBadGateway)
return
}
defer resp.Body.Close()
for _, h := range []string{"Content-Type", "Cache-Control", "Pragma", "Expires"} {
if v := resp.Header.Get(h); v != "" {
w.Header().Set(h, v)
}
}
w.WriteHeader(resp.StatusCode)
// Copied by hand rather than with io.Copy so every chunk is flushed. An
// MJPEG stream never ends, so anything buffered waiting for a full buffer
// is a tile that stays blank forever - which is the same symptom as the
// bug this file exists to fix, and would look like it had not worked.
flusher, _ := w.(http.Flusher)
buf := make([]byte, 32*1024)
for {
n, rerr := resp.Body.Read(buf)
if n > 0 {
if _, werr := w.Write(buf[:n]); werr != nil {
return // webview went away
}
if flusher != nil {
flusher.Flush()
}
}
if rerr != nil {
return
}
}
}

View File

@@ -0,0 +1,296 @@
package main
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
)
// fakeEngine stands in for the Python engine: it demands Basic auth exactly as
// the real one does when a credential is configured, and records what it was
// asked for.
type fakeEngine struct {
*httptest.Server
gotPath string
gotUser string
gotPass string
hadAuth bool
}
func newFakeEngine(t *testing.T, body string) *fakeEngine {
t.Helper()
f := &fakeEngine{}
f.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
f.gotPath = r.URL.Path
if r.URL.RawQuery != "" {
f.gotPath += "?" + r.URL.RawQuery
}
f.gotUser, f.gotPass, f.hadAuth = r.BasicAuth()
if !f.hadAuth {
w.Header().Set("WWW-Authenticate", `Basic realm="behavision"`)
w.WriteHeader(http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "multipart/x-mixed-replace; boundary=frame")
_, _ = io.WriteString(w, body)
}))
t.Cleanup(f.Close)
return f
}
func startProxy(t *testing.T, engine string, user, pass string) *streamProxy {
t.Helper()
p := newStreamProxy()
if err := p.start(engine, user, pass); err != nil {
t.Fatalf("start: %v", err)
}
t.Cleanup(p.stop)
return p
}
func get(t *testing.T, url string) (int, string) {
t.Helper()
c := &http.Client{Timeout: 5 * time.Second}
resp, err := c.Get(url)
if err != nil {
t.Fatalf("get %s: %v", url, err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return resp.StatusCode, string(b)
}
// The whole point: the webview gets a URL it can actually load, and the
// password stays behind. A credential in the src is both unloadable in a
// Chromium webview and readable by anything that can see the DOM.
func TestTheCameraURLCarriesNoPassword(t *testing.T) {
engine := newFakeEngine(t, "frames")
p := startProxy(t, engine.URL, "behavision", "hunter2-the-real-one")
u := p.urlFor("cam2", "stream.mjpeg")
if u == "" {
t.Fatal("no url while the proxy is running")
}
if strings.Contains(u, "hunter2-the-real-one") || strings.Contains(u, "behavision:") {
t.Fatalf("credential leaked into the tile URL: %s", u)
}
if !strings.HasPrefix(u, "http://127.0.0.1:") {
t.Fatalf("relay must be loopback only, got %s", u)
}
}
func TestTheRelayAttachesTheCredentialItself(t *testing.T) {
engine := newFakeEngine(t, "frame-bytes")
p := startProxy(t, engine.URL, "behavision", "s3cret")
code, body := get(t, p.urlFor("cam2", "stream.mjpeg"))
if code != http.StatusOK {
t.Fatalf("want 200 through the relay, got %d", code)
}
if body != "frame-bytes" {
t.Fatalf("body not relayed unchanged: %q", body)
}
if !engine.hadAuth || engine.gotUser != "behavision" || engine.gotPass != "s3cret" {
t.Fatalf("engine did not receive the credential: auth=%v user=%q",
engine.hadAuth, engine.gotUser)
}
if engine.gotPath != "/api/cameras/cam2/stream.mjpeg" {
t.Fatalf("wrong upstream path: %s", engine.gotPath)
}
}
// The token is what keeps every other process on a shop PC from opening a live
// view of customers' faces, now that the relay itself has no password.
func TestAnotherProcessCannotGuessItsWayIn(t *testing.T) {
engine := newFakeEngine(t, "frames")
p := startProxy(t, engine.URL, "behavision", "s3cret")
addr := p.ln.Addr().String()
for _, bad := range []string{"", "0", strings.Repeat("a", 64), "wrong-token"} {
url := fmt.Sprintf("http://%s/s/%s/cam2/stream.mjpeg", addr, bad)
if code, _ := get(t, url); code != http.StatusNotFound {
t.Fatalf("token %q got %d, want 404", bad, code)
}
}
if engine.hadAuth {
t.Fatal("a rejected request still reached the engine")
}
}
// A camera id is interpolated into the upstream path, so it has to be a camera
// id and not a way to walk to a different endpoint with the credential
// attached.
func TestACameraIdCannotClimbOutOfItsPath(t *testing.T) {
engine := newFakeEngine(t, "frames")
p := startProxy(t, engine.URL, "behavision", "s3cret")
addr := p.ln.Addr().String()
for _, bad := range []string{"..", "%2e%2e", "cam2/../../api/identities", "cam 2", ""} {
url := fmt.Sprintf("http://%s/s/%s/%s/stream.mjpeg", addr, p.token, bad)
code, _ := get(t, url)
if code != http.StatusNotFound {
t.Fatalf("camera id %q got %d, want 404", bad, code)
}
}
if strings.Contains(engine.gotPath, "identities") {
t.Fatalf("reached a non-camera endpoint: %s", engine.gotPath)
}
}
// Only the two files a tile needs. The engine also serves the identity list and
// the erasure endpoint; holding the token must not open those.
func TestOnlyTheTwoCameraFilesAreReachable(t *testing.T) {
engine := newFakeEngine(t, "frames")
p := startProxy(t, engine.URL, "behavision", "s3cret")
addr := p.ln.Addr().String()
for _, bad := range []string{"identities", "stats", "commission", "stream.mjpeg.bak"} {
url := fmt.Sprintf("http://%s/s/%s/cam2/%s", addr, p.token, bad)
if code, _ := get(t, url); code != http.StatusNotFound {
t.Fatalf("file %q got %d, want 404", bad, code)
}
}
for _, good := range []string{"stream.mjpeg", "frame.jpg"} {
url := fmt.Sprintf("http://%s/s/%s/cam2/%s", addr, p.token, good)
if code, _ := get(t, url); code != http.StatusOK {
t.Fatalf("file %q got %d, want 200", good, code)
}
}
}
// frame.jpg takes width and quality - the engine re-encodes on demand, and a
// relay that dropped the query would silently serve full-size frames.
func TestTheQueryStringSurvivesTheRelay(t *testing.T) {
engine := newFakeEngine(t, "frames")
p := startProxy(t, engine.URL, "behavision", "s3cret")
url := p.urlFor("cam2", "frame.jpg") + "?width=640&quality=70"
if code, _ := get(t, url); code != http.StatusOK {
t.Fatalf("got %d", code)
}
if !strings.Contains(engine.gotPath, "width=640") ||
!strings.Contains(engine.gotPath, "quality=70") {
t.Fatalf("query dropped: %s", engine.gotPath)
}
}
// An engine that is not running must read as a bad gateway, not as a hang. A
// blank tile that never resolves is the symptom this whole file exists to end.
func TestAnEngineThatIsDownFailsQuickly(t *testing.T) {
// Port 1 on loopback: nothing listens, and the connection is refused
// rather than dropped, so this is fast and deterministic.
p := startProxy(t, "http://127.0.0.1:1", "behavision", "s3cret")
done := make(chan int, 1)
go func() { code, _ := get(t, p.urlFor("cam2", "stream.mjpeg")); done <- code }()
select {
case code := <-done:
if code != http.StatusBadGateway {
t.Fatalf("want 502, got %d", code)
}
case <-time.After(8 * time.Second):
t.Fatal("a dead engine left the request hanging")
}
}
// Stopping must actually free the port, or a restarted engine leaves listeners
// behind for the life of the process.
func TestStoppingReleasesEverything(t *testing.T) {
engine := newFakeEngine(t, "frames")
p := newStreamProxy()
if err := p.start(engine.URL, "u", "p"); err != nil {
t.Fatalf("start: %v", err)
}
url := p.urlFor("cam2", "stream.mjpeg")
if code, _ := get(t, url); code != http.StatusOK {
t.Fatalf("want 200 before stop, got %d", code)
}
p.stop()
if got := p.urlFor("cam2", "stream.mjpeg"); got != "" {
t.Fatalf("still handing out URLs after stop: %s", got)
}
c := &http.Client{Timeout: 3 * time.Second}
if resp, err := c.Get(url); err == nil {
resp.Body.Close()
t.Fatal("listener still accepting after stop")
}
}
// start twice must not leave two listeners, which is what a restarted engine
// would otherwise cause.
func TestStartingTwiceIsANoOp(t *testing.T) {
engine := newFakeEngine(t, "frames")
p := startProxy(t, engine.URL, "u", "p")
first := p.urlFor("cam2", "stream.mjpeg")
if err := p.start(engine.URL, "u", "p"); err != nil {
t.Fatalf("second start: %v", err)
}
if second := p.urlFor("cam2", "stream.mjpeg"); second != first {
t.Fatalf("second start moved the relay: %s -> %s", first, second)
}
}
// Against the real engine, which the unit tests above deliberately do not
// touch. Skipped unless TEST_ENGINE_URL is set, the same rule the server's
// live store tests follow: the suite must stay runnable with no services.
//
// TEST_ENGINE_URL=http://127.0.0.1:8010 \
// TEST_ENGINE_USER=... TEST_ENGINE_PASS=... go test ./desktop/ -run Live
//
// It exists because everything above proves the relay against a fake that
// agrees with me. Only a real engine proves the thing that was actually
// broken: that a multipart MJPEG stream arrives through the relay in pieces,
// rather than being buffered into a tile that never paints.
func TestLiveRelayCarriesRealMJPEGFrames(t *testing.T) {
base := os.Getenv("TEST_ENGINE_URL")
if base == "" {
t.Skip("set TEST_ENGINE_URL to run the live relay test")
}
cam := os.Getenv("TEST_ENGINE_CAMERA")
if cam == "" {
cam = "cam2"
}
p := startProxy(t, base, os.Getenv("TEST_ENGINE_USER"), os.Getenv("TEST_ENGINE_PASS"))
url := p.urlFor(cam, "stream.mjpeg")
req, _ := http.NewRequest(http.MethodGet, url, nil)
resp, err := (&http.Client{}).Do(req)
if err != nil {
t.Fatalf("relay: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("relay returned %d - the credential did not reach the engine", resp.StatusCode)
}
if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "multipart") {
t.Fatalf("not a stream: Content-Type %q", ct)
}
// Read until two JPEG start markers have gone past. One proves it opened;
// two prove it is still delivering, which is the difference between a
// working tile and a single frozen frame.
deadline := time.Now().Add(15 * time.Second)
var seen, total int
buf := make([]byte, 16*1024)
for seen < 2 && time.Now().Before(deadline) {
n, rerr := resp.Body.Read(buf)
total += n
seen += strings.Count(string(buf[:n]), "\xff\xd8\xff")
if rerr != nil {
break
}
}
if seen < 2 {
t.Fatalf("only %d JPEG frames in %d bytes - the relay is not streaming", seen, total)
}
t.Logf("relayed %d frames in %d bytes with no credential in the URL", seen, total)
}

View File

@@ -3,6 +3,7 @@ package main
import ( import (
"context" "context"
"fmt" "fmt"
"os"
"sync" "sync"
"time" "time"
@@ -10,6 +11,25 @@ import (
"github.com/wailsapp/wails/v2/pkg/runtime" "github.com/wailsapp/wails/v2/pkg/runtime"
) )
// BEHAVISION_NO_TRAY runs the window with no tray icon.
//
// It exists so the UI can be looked at on a Mac. fyne.io/systray's nativeLoop
// must own the main thread on macOS - a Cocoa requirement, not a library
// choice - and Wails already holds it, so starting both kills the process with
// a SIGTRAP inside cgo before a single screen is drawn. On Windows, which is
// what ships, a tray on its own goroutine is fine. That asymmetry is why this
// went unnoticed for so long: the shop-floor UI had never once been run on the
// platform it is developed on, so every screen in it was unreviewed.
//
// Deliberately an environment variable and NOT a GOOS check. A build that
// quietly drops the tray on some platform is how a shop PC ends up with no
// control surface at all - the one thing a shop manager has - and it would
// fail exactly where nobody is watching. Nothing is skipped unless a person
// asked for it, by name, on this run.
const noTrayEnv = "BEHAVISION_NO_TRAY"
func trayDisabled() bool { return os.Getenv(noTrayEnv) != "" }
// tray is the always-present control surface. Wails v2 has no systray of its // tray is the always-present control surface. Wails v2 has no systray of its
// own, so this drives fyne.io/systray alongside the window. // own, so this drives fyne.io/systray alongside the window.
// //
@@ -32,6 +52,9 @@ type tray struct {
func newTray(a *App) *tray { return &tray{app: a, quit: make(chan struct{})} } func newTray(a *App) *tray { return &tray{app: a, quit: make(chan struct{})} }
func (t *tray) start(ctx context.Context) { func (t *tray) start(ctx context.Context) {
if trayDisabled() {
return
}
t.once.Do(func() { t.once.Do(func() {
go systray.Run(func() { t.onReady(ctx) }, func() {}) go systray.Run(func() { t.onReady(ctx) }, func() {})
}) })
@@ -43,6 +66,13 @@ func (t *tray) stop() {
default: default:
close(t.quit) close(t.quit)
} }
// systray.Quit() on a systray that was never started is not a no-op in
// v1.12.2, so the guard has to be on both ends or quitting the window
// takes the process down with it - a crash on exit, which is the failure
// most likely to be shrugged off as "it closed, fine".
if trayDisabled() {
return
}
systray.Quit() systray.Quit()
} }

113
installer/INSTALL.txt Normal file
View File

@@ -0,0 +1,113 @@
Behavision — installing on a shop PC
====================================
This is a source install. It needs Python and a working internet connection
once, at setup. After that the shop PC runs on its own.
WHAT YOU NEED FIRST
-------------------
Python 3.10 or newer.
https://www.python.org/downloads/windows/
On the very first screen of the Python installer, tick
"Add python.exe to PATH". If you miss it, setup cannot find Python and
you will have to run the Python installer again.
SETTING UP
----------
1. Unzip this whole folder somewhere permanent — for example
C:\Behavision. Keep the files together; behavision-setup.exe looks for
the engine-src folder next to itself.
2. Double-click behavision-setup.exe
It will:
- find your Python and check it is new enough
- build a private Python environment under
C:\ProgramData\Behavision\runtime
- install the recognition engine and its libraries (from the wheel
in engine-src; the folder you unzipped is never written to)
- download the recognition models (a few hundred megabytes)
- start the engine once to prove it works
This takes several minutes. Leave the window open until it says Done.
If anything fails it prints why, and running it again is safe.
3. Double-click Behavision.exe
The window opens and an icon appears in the system tray, next to the
clock. Right-click the tray icon to open the window again, or to stop
recognition.
CONNECTING IT TO HEAD OFFICE
----------------------------
The first screen asks for an installation code. Ask whoever manages your
shops — they create one from the Behavision platform, under the shop.
No head office? Choose "set this PC up on its own" on the same screen.
Recognition, the cameras and the customer list all work locally; nothing is
sent anywhere.
ADDING A CAMERA
---------------
Cameras → Add. You need the camera's address on the shop network, its
username and password. Choose your camera's make from the list and the
stream path is filled in for you — that is the field nobody can look up.
Press "Test" before saving. Then press "Check placement" and walk past the
camera a few times. It will tell you whether the camera can actually
recognise faces from where it is mounted, which is not the same question as
whether it is connected.
Camera placement matters more than camera quality. Aim for roughly head
height, facing the direction people walk in. A camera high in a corner
looking down, or pointing at a bright window or glass door, will connect
perfectly and recognise almost nobody.
WHERE THINGS LIVE
-----------------
C:\ProgramData\Behavision\ database, logs, camera list, models
C:\ProgramData\Behavision\runtime the engine's own Python
Everything the software writes is under ProgramData. The folder you unzipped
is never written to, so you can keep it on a shared drive.
STOPPING IT
-----------
Right-click the tray icon and choose Quit. That stops recognition as well —
leaving it running with no visible control would be worse than stopping it.
Closing the window does NOT stop recognition. The window hides and the tray
icon stays, because a shop assistant clicking X should not switch the shop's
footfall counting off for the rest of the day.
IF SOMETHING IS WRONG
---------------------
"No Python 3.10 or newer was found"
Python is missing, too old, or was installed without the
"Add python.exe to PATH" tick. Reinstall Python with that ticked.
Setup fails while installing libraries
Almost always no internet, or a proxy in the way. The error printed
just above the failure says which.
The window opens but says the engine is not running
Run behavision-setup.exe again; it will report what is missing.
Logs
C:\ProgramData\Behavision\engine.log

View File

@@ -63,6 +63,14 @@ type Store interface {
RedeemInvitation(ctx context.Context, hash []byte, fullName, passwordHash string) (UserRecord, error) RedeemInvitation(ctx context.Context, hash []byte, fullName, passwordHash string) (UserRecord, error)
Team(ctx context.Context, clientID string) ([]TeamMember, error) Team(ctx context.Context, clientID string) ([]TeamMember, error)
UpdateTeamMember(ctx context.Context, clientID, userID string, up TeamUpdate) (TeamMember, error) UpdateTeamMember(ctx context.Context, clientID, userID string, up TeamUpdate) (TeamMember, error)
// CreateMember inserts an active account into the caller's tenant. The
// hash is computed by the handler, so the plaintext never reaches the
// store - same boundary invitations and sessions already keep.
CreateMember(ctx context.Context, clientID string, in NewMemberInput, hash string) (TeamMember, error)
// ResetMemberPassword replaces the hash and revokes every session the
// member holds, in one transaction. A reset is what happens after a lost
// phone; leaving that phone signed in would defeat it.
ResetMemberPassword(ctx context.Context, clientID, userID, hash string) (TeamMember, error)
// --- public references --- // --- public references ---
// Resolving the names people actually use to the uuids the schema stores. // Resolving the names people actually use to the uuids the schema stores.
@@ -246,6 +254,8 @@ func (s *Server) Routes() *http.ServeMux {
// --- the people who work here --- // --- the people who work here ---
mux.HandleFunc("GET /api/team", s.authed(s.handleTeam)) mux.HandleFunc("GET /api/team", s.authed(s.handleTeam))
mux.HandleFunc("PATCH /api/team/{id}", s.authed(s.handleUpdateTeamMember)) mux.HandleFunc("PATCH /api/team/{id}", s.authed(s.handleUpdateTeamMember))
mux.HandleFunc("POST /api/team/members", s.authed(s.handleCreateMember))
mux.HandleFunc("POST /api/team/{id}/password", s.authed(s.handleResetPassword))
mux.HandleFunc("GET /api/team/invitations", s.authed(s.handleInvitations)) mux.HandleFunc("GET /api/team/invitations", s.authed(s.handleInvitations))
mux.HandleFunc("POST /api/team/invitations", s.authed(s.handleInvite)) mux.HandleFunc("POST /api/team/invitations", s.authed(s.handleInvite))
mux.HandleFunc("DELETE /api/team/invitations/{id}", mux.HandleFunc("DELETE /api/team/invitations/{id}",

View File

@@ -995,3 +995,59 @@ func (f *fakeStore) VisitorIDByNumber(_ context.Context, clientID string, number
} }
return "", nil return "", nil
} }
// CreateMember behaves like the real store on the two things the handler
// branches on: the account lands in the caller's tenant and nowhere else, and
// an address that already exists anywhere is a conflict named the way Postgres
// names it, so conflictMessage recognises it.
func (f *fakeStore) CreateMember(_ context.Context, clientID string,
in NewMemberInput, hash string) (TeamMember, error) {
f.mu.Lock()
defer f.mu.Unlock()
if _, taken := f.users[in.Email]; taken {
return TeamMember{}, errors.New(`duplicate key value violates unique constraint "app_users_email_idx"`)
}
// The real UserByEmail joins clients for the name; this fake reads it off
// the record, so copy it from a tenant-mate or a login as the new member
// comes back with no company name and looks like it landed nowhere.
clientName := ""
for _, u := range f.users {
if u.ClientID == clientID && u.ClientName != "" {
clientName = u.ClientName
break
}
}
id := "member-" + itoa(len(f.users)+1)
f.users[in.Email] = UserRecord{
ID: id, ClientID: clientID, ClientName: clientName,
Email: in.Email, FullName: in.FullName,
Role: in.Role, Active: true, PasswordHash: hash, Found: true,
}
return TeamMember{ID: id, Email: in.Email, FullName: in.FullName,
Role: in.Role, Active: true}, nil
}
// ResetMemberPassword mirrors the real one: tenant-scoped, and every session
// the member holds is revoked with it.
func (f *fakeStore) ResetMemberPassword(_ context.Context, clientID, userID,
hash string) (TeamMember, error) {
f.mu.Lock()
defer f.mu.Unlock()
for email, u := range f.users {
if u.ID != userID || u.ClientID != clientID {
continue
}
u.PasswordHash = hash
f.users[email] = u
for _, s := range f.sessions {
if s.p.UserID == userID {
s.revoked = true
}
}
return TeamMember{ID: u.ID, Email: u.Email, FullName: u.FullName,
Role: u.Role, Active: u.Active}, nil
}
return TeamMember{}, errors.New("no such team member")
}

View File

@@ -388,3 +388,134 @@ func (s *Server) lastOwner(r *http.Request, userID string) (bool, error) {
} }
return isOwner && owners == 1, nil return isOwner && owners == 1, nil
} }
// handleCreateMember is a manager creating a salesperson's login directly and
// handing it over - the path for somebody being set up before their first
// shift, without a phone in hand.
//
// Same rules as an invitation for who may create whom: manager and above, and
// only an owner mints an owner. Same rule as the platform admin creating a
// merchant for the password: generated unless given, returned exactly once.
func (s *Server) handleCreateMember(w http.ResponseWriter, r *http.Request) {
p := PrincipalFrom(r.Context())
if !p.CanManageSites() || p.ClientID == "" {
writeErr(w, http.StatusForbidden, "forbidden",
"Only a manager or owner can add team members.")
return
}
var in NewMemberInput
if err := decode(w, r, &in); err != nil {
badRequest(w, err.Error())
return
}
in.Email = auth.NormalizeEmail(in.Email)
if in.Email == "" || !strings.Contains(in.Email, "@") {
badRequest(w, "an email address is required - it is what they will sign in with")
return
}
in.FullName = clip(trim(in.FullName), 200)
in.Role = strings.ToLower(trim(in.Role))
if in.Role == "" {
in.Role = "staff"
}
switch in.Role {
case "owner", "manager", "staff":
default:
badRequest(w, "role must be owner, manager or staff")
return
}
if in.Role == "owner" && p.Role != "owner" && p.Role != "admin" {
writeErr(w, http.StatusForbidden, "forbidden",
"Only an owner can create another owner.")
return
}
password := in.Password
if password == "" {
generated, err := auth.RandomPassword()
if err != nil {
s.serverError(w, "generate password", err)
return
}
password = generated
}
hash, err := auth.HashPassword(password)
if err != nil {
// The policy message ("at least 8 characters") is written for the
// person who typed it, so it goes out as-is.
badRequest(w, err.Error())
return
}
m, err := s.Store.CreateMember(r.Context(), p.ClientID, in, hash)
if err != nil {
if msg, ok := conflictMessage(err); ok {
writeErr(w, http.StatusConflict, "conflict", msg)
return
}
s.serverError(w, "create member", err)
return
}
s.Store.Audit(r.Context(), AuditEntry{
ClientID: p.ClientID, ActorID: p.UserID, ActorKind: "user",
Action: "team.create", Entity: "user", EntityID: m.ID,
Detail: map[string]any{"email": m.Email, "role": m.Role},
})
// The plaintext exists here and in this response, and nowhere else.
writeJSON(w, http.StatusCreated, NewMemberResult{TeamMember: m, Password: password})
}
// handleResetPassword is a manager resetting a member's password: the
// salesperson forgot it, or lost the phone it was on. Returns the new one
// once, and signs the member out everywhere - see the store for why those are
// one operation.
//
// Deliberately not self-service and not "send an email": a shop-floor account
// often has no mailbox anyone checks, and the person who can vouch for the
// salesperson standing in front of them is their manager.
func (s *Server) handleResetPassword(w http.ResponseWriter, r *http.Request) {
p := PrincipalFrom(r.Context())
if !p.CanManageSites() || p.ClientID == "" {
writeErr(w, http.StatusForbidden, "forbidden",
"Only a manager or owner can reset a team member's password.")
return
}
userID := r.PathValue("id")
var in PasswordReset
if err := decodeOptional(w, r, &in); err != nil {
badRequest(w, err.Error())
return
}
password := in.Password
if password == "" {
generated, err := auth.RandomPassword()
if err != nil {
s.serverError(w, "generate password", err)
return
}
password = generated
}
hash, err := auth.HashPassword(password)
if err != nil {
badRequest(w, err.Error())
return
}
m, err := s.Store.ResetMemberPassword(r.Context(), p.ClientID, userID, hash)
if err != nil {
// A user id from another tenant matches nothing, so it reads as 404 -
// a tenant user has no business learning the id was real.
writeErr(w, http.StatusNotFound, "not_found", "No such team member.")
return
}
s.Store.Audit(r.Context(), AuditEntry{
ClientID: p.ClientID, ActorID: p.UserID, ActorKind: "user",
Action: "team.reset_password", Entity: "user", EntityID: m.ID,
Detail: map[string]any{"email": m.Email},
})
writeJSON(w, http.StatusOK, PasswordReset{Password: password})
}

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"` Active *bool `json:"active,omitempty"`
} }
// NewMemberInput is a staff account created directly by a manager, with a
// password the manager hands over.
//
// The other path - an invitation the salesperson redeems on their own phone -
// is better when it fits: the manager never touches the password. It does not
// fit a salesperson being set up before their first shift, without a phone in
// hand, by somebody who wants to write a login on a card and be done. This is
// that path, and it mirrors how the platform admin creates a merchant owner:
// same generated password, same shown-once rule.
type NewMemberInput struct {
Email string `json:"email"`
FullName string `json:"full_name"`
Role string `json:"role"`
// Password is optional. Empty means "generate one", which is the better
// default for the same reason it is on the admin side.
Password string `json:"password"`
}
// NewMemberResult is the member plus the one moment their password is readable.
type NewMemberResult struct {
TeamMember
// Password is shown once. It is bcrypt-hashed on the way in and is not
// recoverable afterwards.
Password string `json:"password"`
}
// PasswordReset is both the optional request ("use this one") and the response
// ("here is the one that was set") for a manager resetting a member's password.
type PasswordReset struct {
Password string `json:"password"`
}
// ==================================================== devices and sessions == // ==================================================== devices and sessions ==
// DeviceSession is one signed-in device, as its owner sees it. // DeviceSession is one signed-in device, as its owner sees it.

View File

@@ -71,6 +71,21 @@ func UseTestCost() func() {
return func() { bcryptCost = previous; DummyHash = previousDummy } return func() { bcryptCost = previous; DummyHash = previousDummy }
} }
// RandomPassword mints a credential for somebody else - a merchant owner
// created by the platform admin, a salesperson created by their manager, a
// reset. 80 bits as 16 lowercase base32 characters: long enough that guessing
// it is not a plan, and a shape a person can read down a phone line without
// spelling out case. One generator rather than one per caller, so nobody
// later writes a shorter one for the "less important" account.
func RandomPassword() (string, error) {
b := make([]byte, 10)
if _, err := rand.Read(b); err != nil {
return "", err
}
return strings.ToLower(base32.StdEncoding.
WithPadding(base32.NoPadding).EncodeToString(b)), nil
}
func HashPassword(plain string) (string, error) { func HashPassword(plain string) (string, error) {
if err := CheckPasswordPolicy(plain); err != nil { if err := CheckPasswordPolicy(plain); err != nil {
return "", err return "", err

View File

@@ -2,10 +2,7 @@ package store
import ( import (
"context" "context"
"crypto/rand"
"encoding/base32"
"fmt" "fmt"
"strings"
"time" "time"
"github.com/loyaly/behavision-server/internal/api" "github.com/loyaly/behavision-server/internal/api"
@@ -28,7 +25,7 @@ func (s *Store) CreateClientWithOwner(ctx context.Context, in api.NewClientInput
if password == "" { if password == "" {
// Generated rather than defaulted. An operator inventing a password for // Generated rather than defaulted. An operator inventing a password for
// somebody else invents a weak one and then sends it over chat. // somebody else invents a weak one and then sends it over chat.
p, err := randomPassword() p, err := auth.RandomPassword()
if err != nil { if err != nil {
return out, err return out, err
} }
@@ -107,11 +104,3 @@ func (s *Store) ListClients(ctx context.Context) ([]api.ClientRow, error) {
// base32 without padding, matching the rest of this system's generated // base32 without padding, matching the rest of this system's generated
// secrets: it gets read down a phone line and pasted into a form, and base64's // secrets: it gets read down a phone line and pasted into a form, and base64's
// + / = survive neither. // + / = survive neither.
func randomPassword() (string, error) {
b := make([]byte, 10) // 80 bits -> 16 characters
if _, err := rand.Read(b); err != nil {
return "", err
}
return strings.ToLower(base32.StdEncoding.
WithPadding(base32.NoPadding).EncodeToString(b)), nil
}

View File

@@ -39,6 +39,38 @@ func liveStore(t *testing.T) *Store {
return st return st
} }
// dropTenant removes a seeded tenant when the test that made it finishes.
//
// Without this these tests are a slow leak. Every one of them seeds its own
// tenant - deliberately, so they can run in any order and so the isolation
// assertions have a real neighbour - and none of them ever removed it. A dev
// database reached 242 abandoned tenants against the single real one, which is
// not merely untidy: the platform admin's Companies screen lists every client,
// so the one real company was buried under pages of `walk1788761685056287000`.
//
// Registered against the CLIENT rather than each table because every foreign
// key onto clients is ON DELETE CASCADE, so one delete takes the sites,
// visitors, visits, face images, embeddings, cameras and agents with it. A
// per-table list would rot the first time a migration adds a table, and it
// would rot silently - which is the shape of the bug it is cleaning up after.
//
// t.Cleanup runs LIFO and liveStore registers st.Close before any seeding, so
// the delete still has a live pool when it runs.
func dropTenant(t *testing.T, st *Store, clientID string) {
t.Helper()
t.Cleanup(func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if _, err := st.pool.Exec(ctx,
`DELETE FROM clients WHERE id = $1::uuid`, clientID); err != nil {
// Reported rather than ignored: a tenant left behind is the very
// thing this exists to prevent, and swallowing the error would let
// the leak return with nothing to show for it.
t.Errorf("cleanup tenant %s: %v", clientID, err)
}
})
}
// seedTenant builds a client, a site and n visits, and returns the client id. // seedTenant builds a client, a site and n visits, and returns the client id.
// Every test gets its own tenant so they can run in any order without a // Every test gets its own tenant so they can run in any order without a
// truncate between them - and so the isolation assertions below have a real // truncate between them - and so the isolation assertions below have a real
@@ -52,6 +84,7 @@ func seedTenant(t *testing.T, st *Store, name string, n int, withImages bool) (c
if err != nil { if err != nil {
t.Fatalf("seed client: %v", err) t.Fatalf("seed client: %v", err)
} }
dropTenant(t, st, clientID)
err = st.pool.QueryRow(ctx, ` err = st.pool.QueryRow(ctx, `
INSERT INTO sites (client_id, name, slug) VALUES ($1::uuid, $2, $3) INSERT INTO sites (client_id, name, slug) VALUES ($1::uuid, $2, $3)
RETURNING id::text`, clientID, name+" Main", name+"-main").Scan(&siteID) RETURNING id::text`, clientID, name+" Main", name+"-main").Scan(&siteID)

View File

@@ -26,6 +26,7 @@ func seedAgentSite(t *testing.T, st *Store, name string) ingest.Site {
name).Scan(&site.ClientID); err != nil { name).Scan(&site.ClientID); err != nil {
t.Fatalf("seed client: %v", err) t.Fatalf("seed client: %v", err)
} }
dropTenant(t, st, site.ClientID)
if err := st.pool.QueryRow(ctx, ` if err := st.pool.QueryRow(ctx, `
INSERT INTO sites (client_id, name, slug) VALUES ($1::uuid, $2, $3) INSERT INTO sites (client_id, name, slug) VALUES ($1::uuid, $2, $3)
RETURNING id::text`, site.ClientID, name, name).Scan(&site.SiteID); err != nil { RETURNING id::text`, site.ClientID, name, name).Scan(&site.SiteID); err != nil {

View File

@@ -345,3 +345,72 @@ func (s *Store) RevokeOtherSessions(ctx context.Context, userID, keepSessionID s
} }
return int(tag.RowsAffected()), nil return int(tag.RowsAffected()), nil
} }
// CreateMember inserts an active account into a tenant.
//
// The email uniqueness constraint is global (migration 007), and a clash here
// is an ordinary typing mistake - somebody already has that address - so it
// surfaces as a conflict the manager can act on, not a 500.
func (s *Store) CreateMember(ctx context.Context, clientID string,
in api.NewMemberInput, hash string) (api.TeamMember, error) {
var m api.TeamMember
err := s.pool.QueryRow(ctx, `
INSERT INTO app_users (client_id, email, password_hash, full_name, role)
VALUES ($1::uuid, $2, $3, $4, $5)
RETURNING id::text, email, full_name, role, active, '',
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`,
clientID, in.Email, hash, in.FullName, in.Role,
).Scan(&m.ID, &m.Email, &m.FullName, &m.Role, &m.Active,
&m.LastLoginAt, &m.CreatedAt)
if err != nil {
return api.TeamMember{}, fmt.Errorf("create member: %w", err)
}
return m, nil
}
// ResetMemberPassword replaces a member's password and signs them out
// everywhere, in one transaction.
//
// The two go together because of why a manager resets a password at all: the
// salesperson forgot it, or lost the phone it was saved on. In the second case
// the old sessions are the problem, and a reset that left them valid would
// look complete while changing nothing that mattered. Scoped to the caller's
// tenant in the UPDATE itself, so a user id from another company matches no
// row rather than being reset.
func (s *Store) ResetMemberPassword(ctx context.Context, clientID, userID,
hash string) (api.TeamMember, error) {
tx, err := s.pool.Begin(ctx)
if err != nil {
return api.TeamMember{}, err
}
defer tx.Rollback(ctx) //nolint:errcheck // no-op once committed
var m api.TeamMember
err = tx.QueryRow(ctx, `
UPDATE app_users SET password_hash = $3
WHERE id = $2::uuid AND client_id = $1::uuid
RETURNING id::text, email, full_name, role, active,
COALESCE(to_char(last_login_at AT TIME ZONE 'UTC',
'YYYY-MM-DD"T"HH24:MI:SS"Z"'), ''),
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`,
clientID, userID, hash,
).Scan(&m.ID, &m.Email, &m.FullName, &m.Role, &m.Active,
&m.LastLoginAt, &m.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return api.TeamMember{}, errors.New("no such team member")
}
if err != nil {
return api.TeamMember{}, fmt.Errorf("reset password: %w", err)
}
if _, err := tx.Exec(ctx, `
UPDATE sessions SET revoked_at = now()
WHERE user_id = $1::uuid AND revoked_at IS NULL`, userID); err != nil {
return api.TeamMember{}, fmt.Errorf("revoke sessions: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return api.TeamMember{}, err
}
return m, nil
}

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