Android CI / Build and Test (push) Failing after 21m18s
iOS CI / Build and Test SwiftUI App (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build Unsigned iOS IPA (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build macOS ZIP (push) Canceled after 0s
95 lines
5.7 KiB
Markdown
95 lines
5.7 KiB
Markdown
# Balance sync wire protocol
|
||
|
||
`lib/services/sync_api.dart` implements the existing Go `/v1` API using
|
||
`package:http`. It uses JSON maps independently of local model classes. Add
|
||
`http` to the app's dependencies. No server modifications are required.
|
||
|
||
## Authentication
|
||
|
||
- `register(email, password)` and `login(email, password)` POST to
|
||
`/v1/auth/register` and `/v1/auth/login` with `{email,password}`.
|
||
- They return `{accessToken,refreshToken,expiresAt,user:{id,email}}`.
|
||
- `refresh(refreshToken)` POSTs `{refreshToken}` to `/v1/auth/refresh` and
|
||
**rotates** the refresh token. Serialize refresh operations and save the full
|
||
returned session atomically in the separate secure session store.
|
||
- `me(accessToken)` GETs `/v1/me` using `Authorization: Bearer …`.
|
||
- `logout(refreshToken)` POSTs to `/v1/auth/logout` (204). Clear the local store
|
||
in `finally`; offline logout cannot revoke the server session.
|
||
|
||
The client is stateless and never persists tokens. Its caller should refresh
|
||
60 seconds before `expiresAt`, retain sessions on transient network/5xx errors,
|
||
clear them on invalid refresh credentials, and retry an authorized request at
|
||
most once following a successful refresh on 401. Concurrent refreshes must share
|
||
one in-flight operation because refresh tokens are single-use. `synchronize`
|
||
accepts an async `accessToken` provider for this external session manager; it
|
||
does not automatically replay failed HTTP requests. HTTP errors expose status,
|
||
server error code and message through `SyncApiException`; network, timeout and
|
||
format errors remain distinct exceptions. Never log session maps or passwords.
|
||
|
||
## Sync and payloads
|
||
|
||
POST `/v1/sync` with:
|
||
|
||
```json
|
||
{"cursor":0,"deviceId":"stable-device-id","changes":[{"entity":"category","id":"UUID","deleted":false,"updatedAt":"2026-10-28T12:00:00Z","payload":{"name":"Food","icon":"cart","emoji":"","colorName":"indigo","kindRawValue":"expense","createdAt":"2026-10-28T12:00:00Z"}}],"pull":false}
|
||
```
|
||
|
||
The server returns `{cursor,changes,serverTime,hasMore}`. Changes include server
|
||
`version` and global `sequence`; neither is an optimistic concurrency precondition.
|
||
Timestamps use RFC3339 UTC (Dart: `date.toUtc().toIso8601String()`). Preserve
|
||
record IDs exactly, including UUID case; Swift uses UUID IDs. The current server
|
||
does not use `deviceId` for conflict resolution.
|
||
|
||
| Entity | Active payload fields |
|
||
| --- | --- |
|
||
| `transaction` | `amount` number, `date` timestamp, `note`, `categoryName`, `categoryIcon`, `categoryEmoji`, `categoryColorName`, `isBalanceAdjustment` boolean, `kindRawValue` (`income` / `expense`) |
|
||
| `category` | `name`, `icon`, `emoji`, `colorName`, `kindRawValue`, `createdAt` timestamp |
|
||
| `budget` | `categoryName`, `categoryIcon`, `categoryEmoji`, `limit` number, `monthStart` timestamp |
|
||
|
||
Deletion uses the same envelope with `deleted:true`, an updated timestamp and
|
||
no payload. Keep tombstones until confirmed by a pulled change. The server limits
|
||
requests to 2 MiB, 5000 changes and each payload to 256 KiB; it rejects timestamps
|
||
over 24 hours into its future. `synchronize` uploads immutable snapshots in batches
|
||
of 100, splitting batches beyond 1.5 MB, then downloads 500-record pages. Push-only
|
||
requests do not advance the cursor. `sync` exposes a single page for custom flows.
|
||
|
||
## Durable merge contract
|
||
|
||
Scope cursor and push watermark by normalized server URL **and user ID**. Never
|
||
reuse another account's cursor. Serialize full sync runs. Capture a cutoff and a
|
||
stable snapshot of local edits up to that cutoff before starting. Native clients
|
||
query edits after `lastPush - 5 seconds` (or from zero after a clock rollback),
|
||
plus pending tombstones; overlapping uploads are safe.
|
||
|
||
The server keeps a change only when `incoming.updatedAt > existing.updatedAt`:
|
||
last timestamp wins, and equal timestamps leave the existing server value. Pull
|
||
in sequence order. Apply an active record only when it is newer than the local
|
||
record; apply a deletion when its timestamp is at least the local timestamp.
|
||
A pending local tombstone must also participate in this comparison, preventing
|
||
an older remote active record from resurrecting a newer local deletion. Remove
|
||
a pending tombstone only when a confirmed remote change is at least as new.
|
||
|
||
`synchronize(..., applyPage: (changes, cursor) async { ... })` requires the caller
|
||
to merge a complete page and store its cursor in one durable transaction. Throw
|
||
on malformed/unapplied records; advancing the cursor would otherwise lose data.
|
||
It validates page order and cursor progress before invoking this callback. Record
|
||
the push cutoff only after the entire method succeeds. A failed run can replay
|
||
its snapshot safely; do not remove edits made concurrently after the cutoff.
|
||
|
||
## Browser considerations
|
||
|
||
Use HTTPS. Local HTTP development requires explicit `allowInsecureHttp:true`;
|
||
browsers still block HTTPS-to-HTTP mixed content. Cross-origin Flutter Web needs
|
||
the exact frontend origin in the existing server CORS configuration. This client
|
||
uses bearer headers and refresh tokens in JSON, not credentialed browser cookies;
|
||
the server currently has no `Access-Control-Allow-Credentials`, and its cookies
|
||
are `SameSite=Strict`. A cross-site cookie session cannot be assumed to work.
|
||
Browser token persistence is a separate security decision: do not use
|
||
localStorage/SharedPreferences for tokens; memory-only sessions require login
|
||
after reload. JavaScript execution can access any tokens returned to the app,
|
||
so XSS remains a risk even with encrypted browser persistence. Native platforms
|
||
should use their OS secure storage. Cursors above 2^53−1 are rejected to avoid
|
||
silent precision loss in Flutter Web. The transport disables redirect following
|
||
where supported; browsers may manage redirects themselves, so configure the
|
||
final HTTPS API URL directly.
|