// 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 can use. // // It exists because an 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 // 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), updateSite: (site, input) => send('PATCH', `/api/sites/${encodeURIComponent(site)}`, input), deleteSite: (site) => send('DELETE', `/api/sites/${encodeURIComponent(site)}`), // 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 . // // Frames arrive base64 over SSE for the same reason the snapshot is fetched // rather than linked: an 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 } }