5 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
17 changed files with 1639 additions and 94 deletions

3
.gitignore vendored
View File

@@ -62,3 +62,6 @@ node_modules/
# reproducible Windows build needs. Only the compiled output is ignored. # reproducible Windows build needs. Only the compiled output is ignored.
/desktop/frontend/wailsjs/ /desktop/frontend/wailsjs/
/desktop/frontend/package.json.md5 /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

@@ -33,6 +33,7 @@ import (
"time" "time"
"github.com/loyaly/behavision-agent/pkg/config" "github.com/loyaly/behavision-agent/pkg/config"
"github.com/loyaly/behavision-agent/pkg/engine"
"github.com/loyaly/behavision-agent/pkg/paths" "github.com/loyaly/behavision-agent/pkg/paths"
) )
@@ -81,6 +82,17 @@ func run() error {
vpy := venvPython(venv) vpy := venvPython(venv)
step("Virtual environment", 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 // --upgrade so re-running after a new release replaces the engine rather
// than leaving the old one in place and reporting success. // than leaving the old one in place and reporting success.
if err := pipInstall(vpy, src); err != nil { if err := pipInstall(vpy, src); err != nil {
@@ -237,13 +249,81 @@ func pipInstall(vpy, src string) error {
"pip", "setuptools", "wheel"), "updating pip"); err != nil { "pip", "setuptools", "wheel"), "updating pip"); err != nil {
return err return err
} }
return stream(exec.Command(vpy, "-m", "pip", "install", "--upgrade", src),
// A wheel if the release ships one - nothing to build on the shop PC, and
// pip never has to touch the folder the release was unzipped into.
//
// That matters more than it sounds: `pip install <folder>` makes setuptools
// write behavision.egg-info INTO that folder, and the folder is read-only
// whenever the release was unzipped somewhere sensible - Program Files, or
// the shared drive INSTALL.txt says is fine. Found by running this in a
// container with the source mounted read-only: "could not create
// 'behavision.egg-info': Read-only file system". Falling back to source
// copies it somewhere writable first, for the same reason.
if wheels, _ := filepath.Glob(filepath.Join(src, "behavision-*.whl")); len(wheels) > 0 {
return stream(exec.Command(vpy, "-m", "pip", "install", "--upgrade", wheels[0]),
"installing the engine")
}
tmp, err := os.MkdirTemp("", "behavision-src-")
if err != nil {
return err
}
defer os.RemoveAll(tmp)
if err := copyTree(src, tmp); err != nil {
return fmt.Errorf("staging the engine source: %w", err)
}
return stream(exec.Command(vpy, "-m", "pip", "install", "--upgrade", tmp),
"installing the engine") "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 { func runEngine(vpy string, args ...string) error {
full := append([]string{"-m", "behavision"}, args...) full := append([]string{"-m", "behavision"}, args...)
return stream(exec.Command(vpy, full...), "running the engine") cmd := exec.Command(vpy, full...)
cmd.Env = engine.ChildEnv("")
return stream(cmd, "running the engine")
} }
// writeConfig records how to start the engine, in the same file and through // writeConfig records how to start the engine, in the same file and through
@@ -272,6 +352,7 @@ func smokeTest(vpy string) error {
defer cancel() defer cancel()
cmd := exec.CommandContext(ctx, vpy, "-m", "behavision", "run") cmd := exec.CommandContext(ctx, vpy, "-m", "behavision", "run")
cmd.Env = engine.ChildEnv("")
var log strings.Builder var log strings.Builder
cmd.Stdout, cmd.Stderr = &log, &log cmd.Stdout, cmd.Stderr = &log, &log
if err := cmd.Start(); err != nil { if err := cmd.Start(); err != nil {

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

@@ -114,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,

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

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