The authoritative server contract for clients that aren't the web office —
today the iOS staff check-in/out app (docs/architecture/ios-staff-app.md), tomorrow a
client portal. The web office does not use this surface; it talks to the
database through server components/actions directly. This API exists so a
native/offline client has a stable, typed, JSON contract.
Built overnight 2026-06-02. All endpoints implemented + exercised end-to-end against the local stack (happy paths, idempotent replays, every error code).
Auth
Every request carries a Supabase user JWT:
Authorization: Bearer <jwt>
authenticateApiRequest() (src/lib/office/api/auth.ts) validates the token
against Supabase Auth (getUser(token) — signature + expiry checked
server-side), then resolves the office identity. Being a Supabase user is
necessary but not sufficient: the account must hold an office role (and, for
write endpoints, meet minRole), exactly as the web office's
requireStaff() / requireWrite() enforce.
401 unauthorized— missing / invalid / expired token. The app refreshes the session and retries once.403 forbidden— valid token, but the account holds no office role (or lacks the role for a write endpoint). The app signs out.
Dev-bypass mirrors the web rule (devBypassEnabled()): in non-prod with
ADMIN_DEV_BYPASS=1 (or a Vercel preview) the API grants a synthetic admin so
curl and the app work without minting a real token. It is impossible in
production — VERCEL_ENV=production hard-disables it. In bypass mode
actor_user_id is null (same as the web office).
The launch gate and i18n middleware never see these routes: proxy.ts's matcher
excludes /api.
Response envelope
Success is the raw payload with a 2xx status. Errors are always:
{ "error": { "code": "<stable_code>", "message": "<human readable>" } }
The app switches on error.code, so codes are a stable contract — add new
ones, never rename. Current codes (src/lib/office/api/response.ts):
| code | HTTP | meaning |
|---|---|---|
unauthorized | 401 | missing/invalid/expired token |
forbidden | 403 | valid token, no office role / insufficient role |
not_found | 404 | reservation / asset / item / code doesn't exist |
invalid_request | 400 | malformed body, missing field, bad id |
reservation_wrong_state | 409 | reservation isn't in a state that allows the op |
asset_not_on_reservation | 409 | scanned asset's item isn't on this reservation |
asset_on_other_reservation | 409 | asset is currently checked out elsewhere |
line_capacity_full | 409 | the matching line already has its full qty assigned |
internal | 500 | unexpected server error (logged to Sentry) |
Endpoints
All paths are under /api/office. All are force-dynamic (no caching).
| Method + path | Purpose | Body / query | Success |
|---|---|---|---|
GET /me | Validate session, return identity | — | { user: { id, email } } |
GET /reservations?window=today|week | Working list: pickups/returns in window + anything on the floor | window (default today) | { reservations: WireReservation[] } |
GET /reservations/{id} | One reservation in full | — | WireReservation |
GET /assets/resolve?code={code} | Scanned QR code → asset | code | { asset, code } |
GET /items/{itemId}/assets?location={loc} | All assets of an item (manual fallback) | optional location | { assets: WireAsset[] } |
POST /reservations/{id}/pickups | Mark a scanned asset picked up | { asset_id, scanned_code?, at? } | { reservation_status, asset, line_id } |
POST /reservations/{id}/returns | Mark a scanned asset returned | { asset_id, scanned_code?, at? } | { reservation_status, asset, line_id } |
WireReservation / WireAsset shapes are defined in
src/lib/office/api/rentals.ts. They are snake_case to match the wire
contract, deliberately decoupled from the web layer's camelCase types. Each
reservation line carries candidate_assets — the assets the device may scan for
that line — so the app can validate scans and offer a manual fallback offline,
without a per-scan round-trip.
Note on the asset QR
code: there is noasset_tag/codecolumn onassets. An asset's code is theshort_linksrow whoseasset_idpoints at it; the API joins that in. An asset with no sticker yet returnscode: null.
Check-out / check-in model
The mutations operate at the individual asset level (one scan = one call).
Gear being out is scan state, not a status: pickups require the reservation
to be confirmed and never change its status — the first asset out stamps
picked_up_at / actual_pickup_at. Returns require confirmed (returned is
tolerated so replays stay idempotent); partial returns leave the reservation
confirmed, and the last asset back flips it to returned. The API and the
web office write the same fields (assets.current_reservation_id,
reservation_items.assigned_asset_ids, reservation status), so they're
compatible — but staff should drive a given reservation from one surface at a
time to avoid double-assigning.
stateDiagram-v2
[*] --> confirmed
confirmed --> confirmed: pickup (status never changes)
confirmed --> confirmed: return (some assets still out)
confirmed --> returned: return (last asset back)
note right of confirmed
first pickup stamps picked_up_at
+ actual_pickup_at; the last
return stamps returned_at +
actual_return_at
end note
The single status change the API drives — confirmed → returned on the last
return — is a legal step of the canonical machine (RESERVATION_TRANSITIONS in
src/lib/office/reservations.ts), so the audit trail never records a forbidden
jump. A reservation still confirmed past its return_at is overdue.
Idempotency
Both mutations are naturally idempotent on (reservation, asset): replaying
a pickup whose asset is already bound here, or a return whose asset is already
free, is a success no-op. This covers the offline outbox's at-least-once retry
(docs/architecture/ios-staff-app.md §6) without a dedicated key store. The app still sends
an Idempotency-Key header per the contract; honoring it with a real key table
is a documented follow-up (below) — natural idempotency is sufficient for v1.
Side-effects
Every mutation:
- Writes a purposeful
audit_logrow withsource='api'andactor_user_id= the JWT's user (nullunder dev-bypass) —asset_picked_up/asset_returned, plus astatus_changedrow per transition. - Appends a
short_link_scansrow whenscanned_codeis present, giving a physical-handling trail alongside the audit log.
Deferred (documented, not built)
These are in the iOS doc's wishlist (§8) but intentionally not built tonight, each blocked on a decision rather than effort:
- Condition reports + signed photo upload (
POST /condition-reports,POST /condition-reports/uploads). Blocked: no storage bucket exists yet (storage.bucketsis empty). Provisioning needs decisions — bucket name, public vs. private, signed-URL TTL, RLS, and a path convention (reservation/{id}/asset/{id}/...). Thecondition_reportstable is ready (reservation_id,booking_line_id,direction,checklist,photos[],notes,signature_*,inspector_user_id); the endpoint lands with the bucket. Check-in works without it (you just can't attach photos yet). - Idempotency-Key store. A
request_idempotency(key, response)table so a replayed key returns the original response verbatim. Natural idempotency covers the common case now. - Device registration + APNs push (
POST /devices). Phase 2 per the iOS doc §9 — needs an Apple key + a server scheduler.
Files
src/lib/office/api/auth.ts— bearer-JWT auth + office-role resolve (minRank) + dev-bypass.src/lib/office/api/response.ts— error envelope, codes,isUuidguard.src/lib/office/api/rentals.ts— wire serializers + pickup/return logic.src/app/api/office/**/route.ts— the seven route handlers.writeAudit({ source: 'api' })—src/lib/office/audit.ts(extended this task).