Skip to main content

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

SurfaceRoleResponsibility
Electron desktop (@ghasi/app-desktop-backoffice)Sync clientOwns local SQLite truth when offline; runs sync worker; renders UI
bff-backoffice-service (this)Handshake broker + cursor cache + session orchestratorMints sync-session-token; caches cursor; routes lock/AI/alert/mutation calls
sync-serviceBulk sync engineOwns pull/push protocol; durable cursor authority; conflict-policy enforcement
iam-serviceAuth + device registryIssues device-bound JWT; refreshes with proof-of-possession; revocation broadcast
Domain services (reservation, billing, lock, etc.)Source of truthReceive 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-service already 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

CodeWhen
MELMASTOON.IAM.SESSION_REQUIREDNo or invalid auth
MELMASTOON.BFF.BACKOFFICE.DEVICE_MISMATCHdeviceId ≠ JWT cnf
MELMASTOON.SYNC.VERSION_BLOCKEDDesktop version below floor
MELMASTOON.SYNC.REAUTH_REQUIREDSync state requires fresh auth (e.g., after schema migration)
MELMASTOON.BFF.UPSTREAM_UNAVAILABLEsync-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/cursor and feed the dashboard's syncStatus widget.
  • 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:

AggregatePull cadence (desktop default)Conflict policy (server-side)
Reservationevery 30 s online; full delta on reconnectServer-authoritative on stay state; desktop may write check-in/check-out queued offline
Folioevery 30 sAppend-only; conflicts impossible by design
RoomStatusevery 60 sMax-of with worst-status-wins
HousekeepingTaskevery 60 sServer-authoritative on assignment; desktop writes status flips queued
MaintenanceWorkOrderevery 60 sSame
KeyCredentialevery 30 sServer-authoritative
RatePlanevery 5 minRead-only on desktop
Inventoryevery 5 min on reconnect; minute on onlineServer-authoritative
OperatorPreferenceson focusLWW with version
KeyboardShortcutMapon bootstrapRead-only
Themeon bootstrapRead-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:

  1. JWT signature against iam-service JWKS.
  2. cnf.jkt (JWK thumbprint) matches the SHA-256 of the public key embedded in the DPoP proof.
  3. DPoP htu (HTTP target URI) and htm (HTTP method) match the request.
  4. DPoP iat is within ±60 s of server time.
  5. 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 preferredGET /sse/stream?channels=ai. Server pushes ai.new and ai.removed events 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 polls GET /ai/suggestions?propertyId=<id>&limit=20 every 60 s. The desktop tracks lastSeenAt and dedupes on id.

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:

  1. Drops the session blob from Memorystore.
  2. Publishes forceLogout to the SSE bus for the affected deviceId.
  3. The desktop receives event: session\ndata: {"kind":"forceLogout"}\n\n.
  4. 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.
  5. If the desktop is offline at the time, the SSE event is missed. On reconnect, the next /auth/refresh returns 401 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: true in the dashboard syncStatus widget when device_sync_status.cursor_version has advanced more than expected.
  • Pushing dashboard-refresh-hints SSE events when reservation, folio, or housekeeping status events arrive.
  • Logging operator-visible conflict toasts via melmastoon.bff.backoffice.operator.activity.v1 with category=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

ScenarioBehaviour
BFF down, sync-service upDesktop 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 upHandshake returns 503; desktop continues offline mode; cursor cache stale until recovery
Both downPure offline mode; SQLite serves all reads; outbox grows; user notified with persistent banner
iam-service downRefresh fails; sessions hold until access token expires (15 min); after expiry, online mode unavailable; offline mode unaffected
Memorystore session tier downOnline mutations fail; reads degrade to upstream-direct (skipping cache); SSE bus unavailable; fallback to polling
Postgres downMutating endpoints 503 (idempotency unwritable); reads still work from cache; outbox writes pause

12. Tests

  • Contract: Pact verification of /sync/handshake with sync-service.
  • Integration: handshake → cursor cache write → /sync/cursor GET 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.