delete CloudKit and added BalanceFlutter
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
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
This commit is contained in:
1 parent
a715eca947
commit
2c278044bb
214 files changed
+13411
-2290
No files matched your search
@@ -0,0 +1,94 @@
|
||||
# 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.
|
||||
Reference in new issue
Block a user