Files
Behavision/web/src/api.js
Suriyakumarvijayanayagam 3f9fb33b24 Accounts people can create, and photos on a server with no bucket
A tenant had exactly the users somebody had created with a command on the
server. That is not a missing screen: a shop with an owner and four staff
either shared one password or raised a ticket per person, and a phone app
for the shop floor could not exist while there was one account to sign in
as.

Registration is by invitation, never open signup - the same line already
drawn around creating a company. The code carries the address and the role
and the request carries only a password, so a code that gets forwarded
cannot become somebody else's account, and a staff invitation cannot be
redeemed as an owner. Single use lives in the UPDATE and the account is
created in the same transaction.

Deactivating a member revokes their sessions in that transaction too. An
access token lives twelve hours, so without it "remove their access"
removed it sometime tomorrow. The session list and revoke that go with it
are the benefit of opaque tokens the product had been paying for and never
collecting: nothing could say what was signed in, let alone stop one.

Face images now work on a deployment with no object storage, which was
every local install and every self-hosted site - the arrivals feed said
"not storing customer photos" for every customer forever, on the screen
whose whole job is to show a face. Bounded to one row per visitor, so it
grows with the customer base and not with footfall; the bucket stays
primary wherever one exists.

Image.auth says whether a URL needs the session, because a browser img
cannot load one that does, a mobile image view can, and a webview can do
neither - the desktop client resolves those to a data URI in Go.

Found by running it, not by tests:

  * UPDATE ... RETURNING gives the value AFTER the update, so the prune
    read back empty keys, deleted nothing, and the table grew with
    footfall exactly as if it were not there. The fake agreed with either
    version; only the live Postgres test caught it.
  * Trusting only the auth flag broke every shop card, because Sites.jsx
    rebuilt a partial snapshot object and dropped it. A relative URL is
    now sufficient on its own.
  * ago() renders a future time as "just now", so a code valid for a week
    read "expires just now".

Verified live against real Postgres: invite, preview, escalation refused,
register into a session, replay 404, staff forbidden, device revoked and
401 at once, last owner refused, and a 92,405-byte camera JPEG stored,
served to its owner, 401 with no session, 404 to another tenant, and
rendered in a browser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HViLj9gYNRtSr7YVZmW5sn
2026-09-05 11:45:42 +05:30

400 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'),
// 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 }
}