Office REST API (/api/office/)

architecture/rest-api.md

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 productionVERCEL_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):

codeHTTPmeaning
unauthorized401missing/invalid/expired token
forbidden403valid token, no office role / insufficient role
not_found404reservation / asset / item / code doesn't exist
invalid_request400malformed body, missing field, bad id
reservation_wrong_state409reservation isn't in a state that allows the op
asset_not_on_reservation409scanned asset's item isn't on this reservation
asset_on_other_reservation409asset is currently checked out elsewhere
line_capacity_full409the matching line already has its full qty assigned
internal500unexpected server error (logged to Sentry)

Endpoints

All paths are under /api/office. All are force-dynamic (no caching).

Method + pathPurposeBody / querySuccess
GET /meValidate session, return identity{ user: { id, email } }
GET /reservations?window=today|weekWorking list: pickups/returns in window + anything on the floorwindow (default today){ reservations: WireReservation[] }
GET /reservations/{id}One reservation in fullWireReservation
GET /assets/resolve?code={code}Scanned QR code → assetcode{ asset, code }
GET /items/{itemId}/assets?location={loc}All assets of an item (manual fallback)optional location{ assets: WireAsset[] }
POST /reservations/{id}/pickupsMark a scanned asset picked up{ asset_id, scanned_code?, at? }{ reservation_status, asset, line_id }
POST /reservations/{id}/returnsMark 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 no asset_tag/code column on assets. An asset's code is the short_links row whose asset_id points at it; the API joins that in. An asset with no sticker yet returns code: 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:

  1. Writes a purposeful audit_log row with source='api' and actor_user_id = the JWT's user (null under dev-bypass) — asset_picked_up / asset_returned, plus a status_changed row per transition.
  2. Appends a short_link_scans row when scanned_code is 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.buckets is empty). Provisioning needs decisions — bucket name, public vs. private, signed-URL TTL, RLS, and a path convention (reservation/{id}/asset/{id}/...). The condition_reports table 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, isUuid guard.
  • 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).