5.7 KiB
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)andlogin(email, password)POST to/v1/auth/registerand/v1/auth/loginwith{email,password}.- They return
{accessToken,refreshToken,expiresAt,user:{id,email}}. refresh(refreshToken)POSTs{refreshToken}to/v1/auth/refreshand rotates the refresh token. Serialize refresh operations and save the full returned session atomically in the separate secure session store.me(accessToken)GETs/v1/meusingAuthorization: Bearer ….logout(refreshToken)POSTs to/v1/auth/logout(204). Clear the local store infinally; 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:
{"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.