SYNC_CONTRACT — bff-backoffice-service
Sibling: API_CONTRACTS · DEPLOYMENT_TOPOLOGY · SECURITY_MODEL
Cross-cutting: ADR-0003 Electron offline-first desktop · 02 Enterprise Architecture · §8 Sync & Offline · services/sync-service/
This BFF participates in the desktop sync flow as the handshake broker, cursor cache, and online-mode session orchestrator. The bulk pull/push protocol itself is owned by sync-service; the BFF is not on the bulk transfer path. The desktop talks to sync-service directly for batched binary streams, and to this BFF for everything else (handshake, cursor cache, session lifecycle, lock-action proxy, AI fetches, alert acks, telemetry).
This document is also the canonical reference for the Electron-specific integration: device-bound JWT refresh with proof-of-possession, the narrow contextBridge API surface served, the sync cursor handshake protocol, and the polling-vs-SSE channel for AI suggestions and alerts.
1. Roles
| Surface | Role | Responsibility |
|---|---|---|
Electron desktop (@ghasi/app-desktop-backoffice) | Sync client | Owns local SQLite truth when offline; runs sync worker; renders UI |
bff-backoffice-service (this) | Handshake broker + cursor cache + session orchestrator | Mints sync-session-token; caches cursor; routes lock/AI/alert/mutation calls |
sync-service | Bulk sync engine | Owns pull/push protocol; durable cursor authority; conflict-policy enforcement |
iam-service | Auth + device registry | Issues device-bound JWT; refreshes with proof-of-possession; revocation broadcast |
| Domain services (reservation, billing, lock, etc.) | Source of truth | Receive proxied mutations; emit canonical events; produce sync stream payloads |
2. Online-mode flow (BFF on the path)
Electron renderer ── window.melmastoon.* ──► Electron main ── HTTPS ──► bff-backoffice-service ──► domain service
│
└──► sync-service.handshake (cursor read)
The desktop's renderer never makes network calls. The contextBridge surface in the main process handles auth, idempotency, retry, and offline detection. When online, the main process calls this BFF for:
- Read APIs (dashboard, workbench, AI suggestions, alerts, preferences).
- Write APIs (reservations, folios, housekeeping, maintenance, lock actions).
- Sync handshake + cursor reporting.
- SSE stream subscription.
When offline, the desktop bypasses the BFF entirely. It serves reads from local SQLite and queues writes to its local outbox. The BFF sees no traffic until reconnect.
3. Reconnect-mode flow (BFF coordinates handshake; bulk goes direct)
[T0] desktop detects network up
│
▼
[T1] POST /auth/refresh (BFF) ── refresh device-bound JWT
│
▼
[T2] POST /sync/handshake (BFF) ── BFF proxies to sync-service.handshake
│ ── returns syncSessionToken + pull/push endpoints + cursor
▼
[T3] desktop drains local outbox to sync-service.push (DIRECT; not via BFF)
│
▼
[T4] desktop pulls deltas from sync-service.pull (DIRECT; not via BFF)
│
▼
[T5] POST /sync/cursor (BFF) ── cursor cache update + telemetry
│
▼
[T6] GET /dashboard (BFF) ── normal online operation resumes
Why bulk goes direct to sync-service:
- Egress cost: BFF forwarding multi-megabyte deltas adds GCP-to-GCP transit cost twice.
- Backpressure:
sync-servicealready has a chunk/resume protocol; the BFF would just be a passthrough with no value-add. - Authority: cursor advance must be reflected in the source-of-truth store (
sync-service) atomically with the pull commit; a BFF intermediate adds another consistency boundary.
The BFF's role is therefore: handshake, cursor cache (best-effort mirror), and post-pull telemetry.
4. POST /sync/handshake protocol
4.1 Request
{
"deviceId": "dev_01H8Y...",
"appVersion": "1.4.2",
"appPlatform": "win32|darwin|linux",
"capabilities": ["bulk-pull-v1","push-batched-v1","resumable-v1","compression-zstd"],
"lastKnownCursor": "ck_v1_..."
}
4.2 Response
{
"syncSessionToken": "sst_...",
"expiresAt": "2026-04-23T09:44:22Z",
"pullEndpoint": "https://sync.melmastoon.ghasi.io/sync/v1/pull",
"pushEndpoint": "https://sync.melmastoon.ghasi.io/sync/v1/push",
"cursor": "ck_v1_...",
"maxBatchSize": 500,
"maxBatchBytes": 4194304,
"compressionAccepted": ["zstd"],
"policyHash": "sha256:..."
}
syncSessionToken is HMAC-signed; sync-service verifies on each pull/push. TTL 30 min; sync worker refreshes proactively at T-5 min.
4.3 Error envelope
| Code | When |
|---|---|
MELMASTOON.IAM.SESSION_REQUIRED | No or invalid auth |
MELMASTOON.BFF.BACKOFFICE.DEVICE_MISMATCH | deviceId ≠ JWT cnf |
MELMASTOON.SYNC.VERSION_BLOCKED | Desktop version below floor |
MELMASTOON.SYNC.REAUTH_REQUIRED | Sync state requires fresh auth (e.g., after schema migration) |
MELMASTOON.BFF.UPSTREAM_UNAVAILABLE | sync-service circuit open |
5. Cursor cache
The BFF mirrors the device's last-known cursor in device_sync_status. The cache is best-effort:
- Writes happen on every
POST /sync/cursor. - Reads serve
/sync/cursorand feed the dashboard'ssyncStatuswidget. - The canonical authority remains
sync-service; if the BFF cache and sync-service disagree, the desktop trusts sync-service.
Cursor format is opaque to the BFF (a versioned base64-encoded structure understood only by sync-service). The BFF never parses it.
6. Per-aggregate sync policy (informational)
The BFF doesn't enforce sync policy, but operators see policy outcomes through dashboard hints. Per 02 §8.4:
| Aggregate | Pull cadence (desktop default) | Conflict policy (server-side) |
|---|---|---|
Reservation | every 30 s online; full delta on reconnect | Server-authoritative on stay state; desktop may write check-in/check-out queued offline |
Folio | every 30 s | Append-only; conflicts impossible by design |
RoomStatus | every 60 s | Max-of with worst-status-wins |
HousekeepingTask | every 60 s | Server-authoritative on assignment; desktop writes status flips queued |
MaintenanceWorkOrder | every 60 s | Same |
KeyCredential | every 30 s | Server-authoritative |
RatePlan | every 5 min | Read-only on desktop |
Inventory | every 5 min on reconnect; minute on online | Server-authoritative |
OperatorPreferences | on focus | LWW with version |
KeyboardShortcutMap | on bootstrap | Read-only |
Theme | on bootstrap | Read-only |
These are desktop defaults read from theme-config-service.syncPolicies; the BFF surfaces them in /sync/handshake response so the desktop can override per device profile.
7. Electron-specific integration
7.1 Device-bound JWT refresh (DPoP)
Every request from the desktop carries Authorization: Bearer <jwt> and DPoP: <jwt-signed-with-device-key>. The BFF verifies:
- JWT signature against
iam-serviceJWKS. cnf.jkt(JWK thumbprint) matches the SHA-256 of the public key embedded in the DPoP proof.- DPoP
htu(HTTP target URI) andhtm(HTTP method) match the request. - DPoP
iatis within ±60 s of server time. - DPoP
jti(nonce) is single-use within 5 min (Memorystore tracking).
Refresh flow:
desktop main process
│
├── load refresh token from keytar (OS keychain)
├── load device key from keytar; sign DPoP for /auth/refresh
├── POST /auth/refresh with body { refreshToken, devicePoP }
│
▼
BFF
├── verify DPoP against device key thumbprint
├── proxy to iam-service.refresh with DPoP attached
├── on success, return new access + refresh tokens
├── audit log: auth.refresh.success | auth.refresh.failed
▼
desktop main process
├── persist new refresh token to keytar
├── access token kept in memory only; never to disk
└── notify renderer via window.melmastoon.auth.onTokenRotated
7.2 Narrow contextBridge API surface served by the BFF
Per ADR-0003 §3, the renderer accesses only window.melmastoon.*. The BFF is the cloud-side counterpart; the bridge maps as follows:
window.melmastoon.* | BFF endpoint(s) |
|---|---|
auth.refresh() | POST /auth/refresh |
auth.signOut() | POST /auth/sign-out |
auth.mfaStepUp(scope, factor, code) | POST /auth/mfa/step-up |
dashboard.fetch({propertyId}) | GET /dashboard |
workbench.today({propertyId}) | GET /today |
workbench.arrivals(...) | GET /arrivals |
workbench.departures(...) | GET /departures |
workbench.inHouse(...) | GET /in-house |
workbench.housekeepingBoard(...) | GET /housekeeping/board |
workbench.maintenanceBoard(...) | GET /maintenance/board |
ai.list(...) | GET /ai/suggestions |
ai.decide(id, decision) | POST /ai/suggestions/{id}/decide |
alerts.list(...) | GET /alerts |
alerts.acknowledge(id, body) | POST /alerts/{id}/acknowledge |
preferences.read() | GET /preferences |
preferences.write(patch) | PUT /preferences |
telemetry.heartbeat(payload) | POST /devices/{deviceId}/heartbeat |
sync.handshake(input) | POST /sync/handshake |
sync.cursorRead() | GET /sync/cursor |
sync.cursorAdvance(payload) | POST /sync/cursor |
locks.issueKey(rsv, body) | POST /locks/{rsv}/issue-key |
locks.revokeKey(rsv, body) | POST /locks/{rsv}/revoke-key |
reservations.checkIn(id, body) | POST /reservations/{id}/check-in |
reservations.checkOut(id, body) | POST /reservations/{id}/check-out |
folios.addCharge(id, body) | POST /folios/{id}/charges |
housekeeping.transition(id, body) | POST /housekeeping/tasks/{id}/transition |
maintenance.transition(id, body) | POST /maintenance/work-orders/{id}/transition |
sse.subscribe(channels, onEvent) | GET /sse/stream |
Out-of-bridge (not BFF concerns):
window.melmastoon.db.*— local SQLite reads, served by Electron main process.window.melmastoon.ai.infer(...)— local ONNX Runtime inference.window.melmastoon.locks.*BLE/serial drivers — vendor SDKs in main process (the BFF carries only the cloud-side proxy).window.melmastoon.printer.*— local USB/serial printers.window.melmastoon.fs.*— narrowly typed file paths only.
7.3 Sync cursor handshake protocol detail
The handshake is the only place where the BFF and sync-service exchange information about a device's sync state. Sequence:
desktop BFF sync-service
│ │ │
│── POST /handshake ─►│ │
│ │── handshake req ──────────►│
│ │ │── verify device + tenant
│ │ │── verify version >= floor
│ │ │── compute starting cursor
│ │◄─ syncSessionToken + ──────│
│ │ pull/push URL + cursor │
│ │── update device_sync_status│
│ │── emit handshake_completed │
│◄────────────────────│ │
│ │
│── DIRECT pull ──────────────────────────────────►│
│◄────── delta batch ──────────────────────────────│
│── DIRECT push ──────────────────────────────────►│
│◄────── push ack + new cursor ────────────────────│
│ │
│── POST /sync/cursor ────────────────────────────►│ (BFF only; sync-service already authoritative)
7.4 AI suggestion polling vs SSE
The desktop's AI inbox is hydrated by either:
- SSE preferred —
GET /sse/stream?channels=ai. Server pushesai.newandai.removedevents with the full inbox entry payload. Connection holds open with 25 s keep-alive comments. - Polling fallback — when SSE is blocked by a corporate proxy or the network profile is
flaky, the desktop pollsGET /ai/suggestions?propertyId=<id>&limit=20every 60 s. The desktop trackslastSeenAtand dedupes onid.
The choice is per-device and switches automatically:
- On startup, the desktop attempts SSE.
- If the SSE connection drops more than 3 times in 5 min, the desktop falls back to polling for 30 min before retrying SSE.
- Operators can force polling mode in preferences (
notifications.transport: 'polling').
Equivalent fallback exists for alerts (alerts.new, alerts.removed channels; polling GET /alerts every 30 s).
7.5 Force-logout broadcast
When melmastoon.iam.session.revoked.v1 is received in the inbox, the BFF:
- Drops the session blob from Memorystore.
- Publishes
forceLogoutto the SSE bus for the affecteddeviceId. - The desktop receives
event: session\ndata: {"kind":"forceLogout"}\n\n. - The desktop main process clears in-memory tokens, retains queued offline writes (the user can sign back in to drain), and reroutes the renderer to the sign-in screen.
- If the desktop is offline at the time, the SSE event is missed. On reconnect, the next
/auth/refreshreturns401 SESSION_EXPIRED, which has the same effect.
7.6 Auto-update interplay
The desktop checks for updates on every cold start and every 6 h. Update flow is out of scope for this BFF (handled by electron-updater against a signed update server). However, the BFF carries X-App-Version-Floor in /auth/refresh and /sync/handshake responses; if the desktop's appVersion is below the floor, the BFF returns MELMASTOON.SYNC.VERSION_BLOCKED and the desktop shows the upgrade dialog.
8. Conflict resolution surfaces
The BFF does not resolve conflicts (sync-service does). It surfaces resolved conflicts to operators by:
- Including
conflictsResolved: truein the dashboardsyncStatuswidget whendevice_sync_status.cursor_versionhas advanced more than expected. - Pushing
dashboard-refresh-hintsSSE events when reservation, folio, or housekeeping status events arrive. - Logging operator-visible conflict toasts via
melmastoon.bff.backoffice.operator.activity.v1withcategory=conflict_observed.
9. Mobile / web backoffice (out of scope here)
The Electron desktop is the only consumer of this BFF. Mobile/web backoffice surfaces are explicitly out of scope; if introduced in Phase 2, they will likely use a separate BFF (bff-backoffice-mobile-service) or a constrained subset of this BFF.
10. Security envelope
See SECURITY_MODEL for full detail. Key bullets relevant to sync:
- DPoP on every request → token theft alone insufficient.
- Sync session token signed; expiry 30 min.
- MFA step-up required for lock revocation, large folio adjustments, refunds.
- Activity, AI decisions, lock-actions all audit-logged with full envelope (operator + device + session + tenant + property + traceId).
- Cross-tenant attempts blocked at JWT subject + RLS.
11. Failure semantics
| Scenario | Behaviour |
|---|---|
| BFF down, sync-service up | Desktop online-mode reads/writes fail with banner; sync still works (it's direct to sync-service); renderer disables most surfaces; reconnect on BFF recovery |
| sync-service down, BFF up | Handshake returns 503; desktop continues offline mode; cursor cache stale until recovery |
| Both down | Pure offline mode; SQLite serves all reads; outbox grows; user notified with persistent banner |
| iam-service down | Refresh fails; sessions hold until access token expires (15 min); after expiry, online mode unavailable; offline mode unaffected |
| Memorystore session tier down | Online mutations fail; reads degrade to upstream-direct (skipping cache); SSE bus unavailable; fallback to polling |
| Postgres down | Mutating endpoints 503 (idempotency unwritable); reads still work from cache; outbox writes pause |
12. Tests
- Contract: Pact verification of
/sync/handshakewith sync-service. - Integration: handshake → cursor cache write →
/sync/cursorGET returns the same cursor. - Chaos: sync-service circuit open during handshake → 503 surfaces; client falls back gracefully.
- E2E: full reconnect simulation — desktop offline → online → handshake → drain push → pull deltas → cursor advance → BFF cache mirror confirmed.