Files
Behavision/web/src/api.js
Suriyakumarvijayanayagam 4c750cb2ac Opening a shop is an API call; the broker learns of it in the same request
The last step of onboarding that needed a shell: provision site printed
a broker password and a person typed it into Mosquitto's passwd file on
the host - mounted read-only in the container, so the first attempt
failed silently and the password was re-rolled. No tenant could open a
second branch without us.

The server now drives Mosquitto's dynamic-security plugin over its own
broker login: POST /api/sites (owner) writes the row and the sealed
password, registers the login and a per-site role with literal topics
(the 2.0 plugin does not substitute %u - measured), and removes the row
again if the broker refuses, so a shop cannot exist in the database and
not on the broker. provision site goes through the same path. The
head-office Shops screen gets 'Open a new shop'.

broker-init converts the existing passwd file into the plugin's store
with every hash intact - PBKDF2-SHA512 both sides - so the cutover
re-claims no shop PC. Rehearsed locally: old logins keep working,
isolation holds, the health probe works, and a PC claiming a shop opened
through the API connects as that shop. run-local.sh now brings the
broker up the same way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-19 11:55:26 +05:30

401 lines
16 KiB
JavaScript

// 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}).`)
}
// fetchImage loads a picture this server holds itself, with the session's
// bearer token, and returns an object URL an <img> can use.
//
// It exists because an <img src> cannot carry an Authorization header. The
// object-storage path returns a presigned absolute URL that needs no auth,
// which is why it worked with a plain src; a picture served from our own
// database has no such link, and minting an unauthenticated one so that <img>
// could use it would add a way to reach a photograph of somebody's shop floor
// without a session - the opposite of what this path is for.
//
// The caller MUST revoke the returned URL when it is finished with it, or the
// browser keeps every blob it has ever loaded for the life of the page.
async function fetchImage(path, retry = true) {
const { access } = tokens()
const res = await fetch(path, {
headers: access ? { Authorization: 'Bearer ' + access } : {},
})
if (res.ok) return URL.createObjectURL(await res.blob())
let parsed = null
try { parsed = await res.json() } catch { /* an image endpoint may not answer json */ }
const code = parsed?.error || ''
if (code === 'token_expired' && retry) {
await refresh()
return fetchImage(path, false)
}
if (res.status === 401) clearTokens()
throw new ApiError(res.status, code,
parsed?.message || `That picture could not be loaded (${res.status}).`)
}
// A label for the session list, so somebody can tell which device to sign out.
// Deliberately coarse and never an identifier: a fingerprint here would be a
// tracking signal we have no reason to hold, and the question this answers is
// only "which of these is the one in my hand".
function deviceName() {
const ua = navigator.userAgent || ''
const os = /Windows/.test(ua) ? 'Windows'
: /Mac OS X|Macintosh/.test(ua) ? 'Mac'
: /Android/.test(ua) ? 'Android'
: /iPhone|iPad/.test(ua) ? 'iOS' : 'Unknown'
const browser = /Edg\//.test(ua) ? 'Edge'
: /Chrome\//.test(ua) ? 'Chrome'
: /Safari\//.test(ua) ? 'Safari'
: /Firefox\//.test(ua) ? 'Firefox' : 'browser'
return `${browser} on ${os}`
}
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, device: deviceName() }),
})
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
},
// What a code says it is for, before anybody is asked to choose a password.
// Unauthenticated by necessity: the holder has no account yet.
async previewInvitation(code) {
const res = await fetch('/api/auth/invitation' + qs({ code }))
const body = await res.json().catch(() => null)
if (!res.ok) {
throw new ApiError(res.status, body?.error || '',
body?.message || 'That invitation code is not valid.')
}
return body
},
// Redeem an invitation. Returns a signed-in session, not just an account:
// sending somebody who has just chosen a password to a sign-in form to type
// it again is the sort of thing that gets blamed on the password.
//
// The address and the role are NOT sent - they come from the invitation, and
// the server refuses a body that names either.
async register({ code, full_name, password }) {
const res = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, full_name, password, device: deviceName() }),
})
const body = await res.json().catch(() => null)
if (!res.ok) {
throw new ApiError(res.status, body?.error || '',
body?.message || 'Could not create the account.')
}
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'),
createSite: (input) => send('POST', '/api/sites', input),
// 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'),
// A camera picture this server holds itself. Returns an object URL the
// caller must revoke; see fetchImage.
cameraSnapshot: (url) => fetchImage(url),
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 people who work here.
team: () => send('GET', '/api/team'),
updateMember: (id, changes) =>
send('PATCH', `/api/team/${encodeURIComponent(id)}`, changes),
invitations: () => send('GET', '/api/team/invitations'),
// The code comes back 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. Same contract as enrolmentCode above.
invite: (input) => send('POST', '/api/team/invitations', input),
revokeInvitation: (id) =>
send('DELETE', `/api/team/invitations/${encodeURIComponent(id)}`),
// Devices this account is signed in on. The point of holding sessions in a
// table rather than issuing JWTs is that signing one out actually works.
sessions: () => send('GET', '/api/auth/sessions'),
revokeSession: (id) =>
send('DELETE', `/api/auth/sessions/${encodeURIComponent(id)}`),
signOutOthers: () => send('POST', '/api/auth/sessions/revoke-others'),
}
// 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 }
}
// streamCameraLive renders one camera's live view into an <img>.
//
// Frames arrive base64 over SSE for the same reason the snapshot is fetched
// rather than linked: an <img> cannot send an Authorization header, and minting
// a URL that works without a session — for LIVE video of a shop floor — would
// be a far worse trade than the 33% base64 costs.
//
// The frame is written straight into `img.src` as a data URL rather than an
// object URL. Object URLs would have to be revoked one per frame, several times
// a second, and a single missed revoke is a leak that grows for as long as the
// view is open. A data URL is owned by the element and replaced by the next one.
export function streamCameraLive({ cameraId, img, onState, signal }) {
let stopped = false
const run = async () => {
while (!stopped) {
try {
const { access } = tokens()
const res = await fetch(`/api/cameras/${cameraId}/live`, {
headers: { Authorization: 'Bearer ' + access, Accept: 'text/event-stream' },
signal,
})
if (res.status === 401) { await refresh(); continue }
if (!res.ok || !res.body) throw new Error('live view 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 })
let split
while ((split = buf.indexOf('\n\n')) !== -1) {
const chunk = buf.slice(0, split)
buf = buf.slice(split + 2)
let event = 'message', data = ''
for (const line of chunk.split('\n')) {
if (line.startsWith('event: ')) event = line.slice(7).trim()
else if (line.startsWith('data: ')) data = line.slice(6)
}
if (event === 'frame' && data) {
if (img.current) img.current.src = 'data:image/jpeg;base64,' + data
onState?.('live')
} else if (event === 'waiting') {
// The server has registered us; the shop PC has not started
// pushing yet. Saying so beats an empty box, because the wait is
// a real second or two while the agent is asked.
onState?.('waiting')
}
}
}
} catch (err) {
if (stopped || signal?.aborted) return
onState?.('reconnecting')
}
if (stopped) return
// The server caps one push so a tab left open for a week does not leave
// a shop uploading for a week. Reconnecting is how a viewer who IS still
// watching carries on, so this is a normal event, not an error.
await new Promise(r => setTimeout(r, 1500))
}
}
run()
return () => { stopped = true }
}