One Camera, Many Hands
It's 2 AM during a wind event. A CAL FIRE dispatcher in Sacramento and a county fire captain in San Diego are both watching the same ridge camera — and both want to point it at different smoke. Cameras are physical machines; they can only look one way at a time. The lease is how the platform decides whose hands are on the wheel, for how long, and what everyone else sees while they wait.
This deep dive is the whole protocol, verified line by line against source. Start by playing both sides of it — move as Operator A, then try to grab the camera as Operator B while A's lease is live:
Three things you just experienced are the real semantics: the lease was granted as a side effect of moving (nobody clicked "take lease"), moving again refreshed the clock, and the countdown was visible to both operators — not just the holder. Hold onto that last one; it surprises everyone. Now, the one idea that makes the rest fall into place.
The one idea to hold on to
The frontend does not own the lease. It never has a lease id, never sets a duration, and
never closes a lease. The backend (Frontend.API → CameraMovement) grants the lease as
a side effect of accepting a movement, decides how long it lasts, and expires it. The
browser learns about the lease in exactly two ways:
- A point-in-time question, asked immediately before a movement:
GET /api/lease— "who holds this camera right now?" This is the gate. - A push,
ReceiveCurrentConfigBroadcastover SignalR, which is the only thing that ever starts the countdown clock. This is the display.
Those two paths are independent, and conflating them is the single most common source of confusion in this area. The gate decides whether you are allowed to move the camera. The push decides whether a timer appears on screen — for everyone in the camera group, not just the lease holder. See Surprises.
The three endpoints
| Route | Method | Backend path | What it answers |
|---|---|---|---|
pages/api/lease.ts | GET | /CameraMovement/lease/ + camera id | Who holds the lease right now |
pages/api/leased.ts | GET | /CameraMovement/latest-move-by-camera/ + camera id | Who moved this camera last, and why |
pages/api/close.ts | PUT | /CameraMovement/close-lease/ + lease id | Release a lease early — not called anywhere in the UI |
lease and leased are different questions/api/lease is the concurrency gate. /api/leased is display metadata for the movement
modal ("Last Moved 4m ago — Jane Doe, CAL FIRE"). They hit different backend endpoints and
return different shapes. The one-letter difference has bitten people; read the
serviceTitle string in the route ('leasing camera' vs 'fetching lease data') if you
are not sure which one you are looking at.
All three are thin withApiHandler passthroughs. They do not implement any lease logic:
they validate input with Zod, forward req.headers.authorization (injected upstream by
proxy.ts — see security/authentication), and return the
backend's payload. All three are listed in the proxy.ts matcher, so they get a bearer
token; a route missing from that array would get none.
Validation is worth noting because it is the only place these routes can fail on their own:
lease.tsandleased.tsusequerySchema: z.object({ cameraId: CameraIdSchema }).CameraIdSchemaisz.string().min(1).regex(/^[a-zA-Z0-9_-]+$/)— a camera id with a dot or a space is rejected with400 Validation failedbefore the backend is ever called.close.tsusesbodySchema: CloseLeasePayloadSchema, which extends the generatedCameraLeaseRequestSchemaand requires the full lease record (id,cameraName,entraUserId,entraDisplayName,companyName,startTime,endTime,requestedDurationSeconds,actualDurationSeconds). It also returns204rather than200when the backend returns no content.
The multi-user protocol
Two operators, one camera. Operator A takes control; Operator B is watching the same camera in Location View.
The SignalR half of that diagram — how the connection is built, how a client joins exactly
one camera group, how ReceiveCurrentConfigBroadcast is parsed and routed into
cameraMovementStore.setLeaseFromSignalR — is documented in full in
deep-dives/signalr-realtime-architecture.
This page picks up where that handler ends.
The gate: moveCamera()
Every movement path in
XYMouseTrackerOverlay.tsx
— click, drag, zoom slider, focus, brightness — funnels into one function. The whole
concurrency decision is four lines:
const { leasedByAnother, leasedBySelf } = await checkCameraLeaseStatus(
cameraSelected.cId,
);
const freshRecentMovementPayload =
useCameraMovementStore.getState().recentMovementPayload;
const allowCamMove =
isLeaseConfirmed ||
leasedBySelf ||
(freshRecentMovementPayload != null && !leasedByAnother);
Read the three disjuncts as three ways of already having permission:
| Term | Meaning |
|---|---|
isLeaseConfirmed | The caller passed true. Exactly one call site does: onCamMovementModalConfirmed, i.e. the user already clicked Confirm in the modal. Every other call site passes false |
leasedBySelf | /api/lease says the current holder's entraUserId equals session.user.id — you already hold this camera, so keep going without another modal |
recentMovementPayload != null && !leasedByAnother | You have moved this camera recently in this session and nobody else has taken it — the "don't re-prompt me every click" path |
If allowCamMove is false, the payload is stashed in pendingModalPayloadRef before
the modal opens (so a second click during the async lease check cannot overwrite what the
user is about to confirm) and setIsMovementModalOpen(true) fires. leasedByAnother is
also pushed into isSelectedCameraLeased, which the modal reads as isLeaseWarningMode —
that is what switches the modal from "confirm your movement" to the "another user is
controlling this camera" acknowledgement screen.
The lease read itself is a small utility in
util/index.ts:
export const checkWhetherCameraIsLeasedByAnotherUserUtil = async (
cameraId,
sessionUserId,
isCameraLeasedFn,
onError,
) => {
const camLeasedResponse = await isCameraLeasedFn(cameraId);
const isLeased = !!camLeasedResponse && camLeasedResponse.entraUserId != null;
const leasedBySelf =
isLeased && camLeasedResponse.entraUserId === sessionUserId;
const leasedByAnother = isLeased && !leasedBySelf;
return { leasedByAnother, leasedBySelf };
};
It is injected with IsCameraLeased rather than importing it, purely so the unit tests can
substitute a fake. Note the failure mode: catch returns
{ leasedByAnother: false, leasedBySelf: false } and surfaces a
"Failed To Check Camera's Lease" notification. A failed lease check therefore does not
block the movement — it falls through to the modal, and the backend remains the real
authority.
The countdown
secondsRemainingInLeaseMode lives in
stores/cameraMovementStore.ts.
Only four things write it, and none of them is the movement API:
setLeaseFromSignalR(currentConfig, leaseModeEndDt)— the broadcast handler.- The
useCameraMovementEffectstick. onDialogClosedon the lease popup, which sets it to0.TileViewPage'suseCameraStaticByIdsuccess callback, which zeroes it when the selected camera changes.
Debugging checklist
- Timer never appears — check
enable-all-features-connected-to-signalrfirst, then whether the client actually joined the camera group (SignalR_ReceiveCurrentConfigBroadcastin App Insights carriesgroupIdand the full payload). - Timer appears but immediately reads
0—leaseModeEndDtfailed to parse. The App Insights event hasfullPayload; check whether the+00:00offset survived, and compare against the two formatsparseLeaseModeEndDtaccepts. - Modal appears on every single click —
recentMovementPayloadis being cleared. Either the countdown reached0(the effect clears it), or another operator took the lease soleasedByAnotheris true. - Movement modal never appears and the camera does not move — look for a backend
rejection on
/api/ptz; afalseresponse body shows asError Moving Camerawith no further detail on the client. 400 Validation failedfrom/api/lease— the camera id contains a character outside[a-zA-Z0-9_-].
Related
deep-dives/signalr-realtime-architecture— howReceiveCurrentConfigBroadcastis received, parsed and routed intosetLeaseFromSignalR, and what happens to group membership across a reconnect.deep-dives/feature-flags— the flag that switches the entire SignalR subsystem, and therefore the lease countdown, on and off.security/authentication— where theAuthorizationheader on/api/lease,/api/leased,/api/closeand/api/ptzcomes from, and why a route missing from theproxy.tsmatcher gets a401from the backend.features/camera-movement/xy-mouse-tracker-overlay— the overlay that computes the pan/tilt values fed into this flow.features/activity-log/overview— every accepted movement lands in the activity log with its reason code and message.