Behavision: face recognition for retail, edge to head office
Five components that ship as one product:
- behavision/ the recognition engine. RTSP ingest, YuNet detection, IoU
tracking, ArcFace embeddings, a FAISS/SQLite gallery, and a
FastAPI dashboard. Identity is decided once per TRACK from an
average of at least three embeddings, never per frame.
- agent/ the Go edge agent: supervises the engine, holds a durable
spool, and drains it to MQTT. Nothing is acked before the
broker confirms.
- desktop/ the shop PC application (Wails + React + tray).
- server/ the cloud API, MQTT consumer, reports and assistant.
- web/ platform.loyaly.ai, the head-office app, embedded in the
server binary.
The gallery stores 512-float embeddings and timestamps - no images unless
`app.store_faces` is switched on. Those embeddings are biometric personal
data under GDPR and India's DPDP: template inversion reconstructs a
recognisable face from an ArcFace vector, so data/behavision.db is treated
as a biometric database and DELETE /api/visitors/{id} is a real erasure.
CLAUDE.md carries the reasoning behind every non-obvious decision here,
including the ones that were measured and the ones that were wrong first.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HViLj9gYNRtSr7YVZmW5sn
This commit is contained in:
229
web/src/api.js
Normal file
229
web/src/api.js
Normal file
@@ -0,0 +1,229 @@
|
||||
// The cloud API, as this browser sees it.
|
||||
//
|
||||
// Everything the platform does goes through `send` below, so there is exactly
|
||||
// one place that knows how a session is carried, refreshed and lost.
|
||||
|
||||
const ACCESS = 'bv.access'
|
||||
const REFRESH = 'bv.refresh'
|
||||
|
||||
// localStorage, not a cookie. The API authenticates with a bearer token and
|
||||
// sets no cookie of its own, so there is no CSRF surface to defend - and a
|
||||
// head-office user who closes the tab expects to still be signed in tomorrow.
|
||||
function read(k) { try { return localStorage.getItem(k) || '' } catch { return '' } }
|
||||
function write(k, v) { try { v ? localStorage.setItem(k, v) : localStorage.removeItem(k) } catch { /* private mode */ } }
|
||||
|
||||
export function tokens() { return { access: read(ACCESS), refresh: read(REFRESH) } }
|
||||
export function setTokens(access, refresh) { write(ACCESS, access); write(REFRESH, refresh) }
|
||||
export function clearTokens() { setTokens('', '') }
|
||||
export function signedIn() { return !!read(REFRESH) }
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(status, code, message) {
|
||||
super(message)
|
||||
this.status = status
|
||||
// The server's own code, kept alongside its prose. A caller that has to
|
||||
// match on English to tell a normal absence from a fault will get it wrong
|
||||
// the first time the wording is improved.
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
// One refresh at a time, ever.
|
||||
//
|
||||
// The refresh token is single-use and rotates. Four screens polling at once
|
||||
// would each spend it and three would lose, logging the user out at random -
|
||||
// so every caller that hits an expired token waits on the same promise.
|
||||
let refreshing = null
|
||||
|
||||
async function refresh() {
|
||||
if (refreshing) return refreshing
|
||||
refreshing = (async () => {
|
||||
const { refresh: rt } = tokens()
|
||||
if (!rt) throw new ApiError(401, 'unauthorized', 'Signed out.')
|
||||
const res = await fetch('/api/auth/refresh', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refresh_token: rt }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
clearTokens()
|
||||
throw new ApiError(res.status, 'unauthorized', 'Your session has ended. Sign in again.')
|
||||
}
|
||||
const s = await res.json()
|
||||
// Persisted BEFORE anything else runs. A tab that refreshes and is then
|
||||
// closed must not come back holding a token the server already retired.
|
||||
setTokens(s.access_token, s.refresh_token)
|
||||
return s
|
||||
})().finally(() => { refreshing = null })
|
||||
return refreshing
|
||||
}
|
||||
|
||||
async function send(method, path, body, retry = true) {
|
||||
const { access } = tokens()
|
||||
const headers = {}
|
||||
if (access) headers.Authorization = 'Bearer ' + access
|
||||
if (body !== undefined) headers['Content-Type'] = 'application/json'
|
||||
|
||||
const res = await fetch(path, {
|
||||
method,
|
||||
headers,
|
||||
// Serialised up front: a retry has to send the same body again, and a
|
||||
// stream would be spent after the first attempt.
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
})
|
||||
|
||||
if (res.status === 204) return null
|
||||
const text = await res.text()
|
||||
let parsed = null
|
||||
try { parsed = text ? JSON.parse(text) : null } catch { /* not json */ }
|
||||
|
||||
if (res.ok) return parsed
|
||||
|
||||
const code = parsed?.error || ''
|
||||
if (code === 'token_expired' && retry) {
|
||||
// Silent. A shop assistant should not be thrown back to a login form twice
|
||||
// a day because an access token reached twelve hours old.
|
||||
await refresh()
|
||||
return send(method, path, body, false)
|
||||
}
|
||||
if (res.status === 401) clearTokens()
|
||||
throw new ApiError(res.status, code,
|
||||
parsed?.message || `Something went wrong (${res.status}).`)
|
||||
}
|
||||
|
||||
const qs = (params) => {
|
||||
const p = new URLSearchParams()
|
||||
for (const [k, v] of Object.entries(params || {})) {
|
||||
if (v !== undefined && v !== null && v !== '') p.set(k, v)
|
||||
}
|
||||
const s = p.toString()
|
||||
return s ? '?' + s : ''
|
||||
}
|
||||
|
||||
export const api = {
|
||||
async login(email, password) {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
})
|
||||
const body = await res.json().catch(() => null)
|
||||
if (!res.ok) {
|
||||
throw new ApiError(res.status, body?.error || '',
|
||||
body?.message || 'Could not sign in.')
|
||||
}
|
||||
setTokens(body.access_token, body.refresh_token)
|
||||
return body.user
|
||||
},
|
||||
|
||||
async logout() {
|
||||
try { await send('POST', '/api/auth/logout') } catch { /* already gone */ }
|
||||
clearTokens()
|
||||
},
|
||||
|
||||
me: () => send('GET', '/api/auth/me'),
|
||||
|
||||
sites: () => send('GET', '/api/sites'),
|
||||
|
||||
// The live arrivals feed. `cursor` is opaque and must be echoed back.
|
||||
arrivals: (params) => send('GET', '/api/visits' + qs(params)),
|
||||
|
||||
visitors: (q, limit = 50) => send('GET', '/api/visitors' + qs({ q, limit })),
|
||||
visitorHistory: (id, limit = 50) =>
|
||||
send('GET', `/api/visitors/${encodeURIComponent(id)}/history` + qs({ limit })),
|
||||
saveProfile: (id, profile) =>
|
||||
send('PUT', `/api/visitors/${encodeURIComponent(id)}/profile`, profile),
|
||||
|
||||
footfall: (params) => send('GET', '/api/reports/footfall' + qs(params)),
|
||||
conversion: (params) => send('GET', '/api/reports/conversion' + qs(params)),
|
||||
|
||||
cameras: () => send('GET', '/api/cameras'),
|
||||
createCamera: (siteID, cam) =>
|
||||
send('POST', `/api/sites/${encodeURIComponent(siteID)}/cameras`, cam),
|
||||
updateCamera: (id, cam) => send('PATCH', `/api/cameras/${encodeURIComponent(id)}`, cam),
|
||||
deleteCamera: (id) => send('DELETE', `/api/cameras/${encodeURIComponent(id)}`),
|
||||
|
||||
// Prove a camera works. `kind` is "connection" (can the shop PC open the
|
||||
// stream) or "placement" (does someone walking past produce a usable view).
|
||||
checkCamera: (id, kind, seconds) =>
|
||||
send('POST', `/api/cameras/${encodeURIComponent(id)}/check`,
|
||||
{ kind, ...(seconds ? { seconds } : {}) }),
|
||||
siteCheck: (siteID) =>
|
||||
send('GET', `/api/sites/${encodeURIComponent(siteID)}/check`),
|
||||
|
||||
// The one-shot code a shop PC is claimed with. Returned in full exactly once
|
||||
// - only a hash is stored - so whatever calls this has to show it there and
|
||||
// then, and must not expect to read it back later.
|
||||
enrolmentCode: (siteID, input) =>
|
||||
send('POST', `/api/sites/${encodeURIComponent(siteID)}/enrolment-code`,
|
||||
input || {}),
|
||||
|
||||
ask: (history) => send('POST', '/api/assistant', { history }),
|
||||
|
||||
clients: () => send('GET', '/api/admin/clients'),
|
||||
createClient: (input) => send('POST', '/api/admin/clients', input),
|
||||
}
|
||||
|
||||
// The live stream, read with fetch rather than EventSource.
|
||||
//
|
||||
// EventSource cannot set headers, so using it would mean putting the session
|
||||
// token in the query string - where it lands in server logs, browser history
|
||||
// and any screenshot of the URL bar. Reading the stream by hand costs the
|
||||
// frame parsing below and keeps the token in an Authorization header.
|
||||
//
|
||||
// The cursor is held HERE, not derived from what is on screen. That is what
|
||||
// makes a reconnect lossless: whatever arrived while the connection was down
|
||||
// is delivered on the next one.
|
||||
export function streamArrivals({ cursor, siteId, onPage, onError, signal }) {
|
||||
let stopped = false
|
||||
let position = cursor || ''
|
||||
|
||||
const run = async () => {
|
||||
while (!stopped) {
|
||||
try {
|
||||
const { access } = tokens()
|
||||
const res = await fetch('/api/visits/stream' + qs({ cursor: position, site_id: siteId }), {
|
||||
headers: { Authorization: 'Bearer ' + access, Accept: 'text/event-stream' },
|
||||
signal,
|
||||
})
|
||||
if (res.status === 401) {
|
||||
await refresh()
|
||||
continue
|
||||
}
|
||||
if (!res.ok || !res.body) throw new Error('stream unavailable')
|
||||
|
||||
const reader = res.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buf = ''
|
||||
while (!stopped) {
|
||||
const { value, done } = await reader.read()
|
||||
if (done) break
|
||||
buf += decoder.decode(value, { stream: true })
|
||||
// Frames are separated by a blank line. Anything after the last one
|
||||
// is a partial frame and stays in the buffer.
|
||||
let split
|
||||
while ((split = buf.indexOf('\n\n')) !== -1) {
|
||||
const frame = buf.slice(0, split)
|
||||
buf = buf.slice(split + 2)
|
||||
for (const line of frame.split('\n')) {
|
||||
if (line.startsWith('id: ')) position = line.slice(4).trim()
|
||||
else if (line.startsWith('data: ')) {
|
||||
try { onPage(JSON.parse(line.slice(6))) } catch { /* partial */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (stopped || signal?.aborted) return
|
||||
onError?.(err)
|
||||
}
|
||||
if (stopped) return
|
||||
// Reconnect. `position` survives, so nothing that arrived while we were
|
||||
// away is missed - that is the whole reason the cursor is held here and
|
||||
// not derived from what is on screen.
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
}
|
||||
}
|
||||
run()
|
||||
return () => { stopped = true }
|
||||
}
|
||||
Reference in New Issue
Block a user