| 1 |
# SyncKit Cloud Sync |
| 2 |
|
| 3 |
SyncKit provides cloud sync and encrypted data storage for desktop applications: device registration, changelog-based sync, E2E encryption, and content-addressed blob storage, all through a REST API backed by your Makenot.work account. |
| 4 |
|
| 5 |
For the Rust client SDK, see the [API reference](/rustdoc/synckit_client/). |
| 6 |
|
| 7 |
## Concepts |
| 8 |
|
| 9 |
- **Sync App**: A registered application on Makenot.work. Each app has its own API key, data namespace, and device list. |
| 10 |
- **Device**: A named installation of your app (e.g., "Alice's MacBook"). Each device syncs independently. |
| 11 |
- **Changelog**: An append-only log of changes. Each entry records a table name, operation, row ID, timestamp, and encrypted data blob. |
| 12 |
- **Cursor**: An opaque position in the changelog. Pull from cursor to get only new changes. |
| 13 |
- **Blob**: A content-addressed encrypted file stored in S3. Referenced by SHA-256 hash. |
| 14 |
|
| 15 |
## Creating a Sync App |
| 16 |
|
| 17 |
From the dashboard, go to Settings and create a new SyncKit app. You receive an API key (shown only once; regenerating it invalidates existing clients). Optionally link the app to a project or item. |
| 18 |
|
| 19 |
## Authentication |
| 20 |
|
| 21 |
### Direct Authentication |
| 22 |
|
| 23 |
For desktop apps where the user enters their Makenot.work credentials: |
| 24 |
|
| 25 |
``` |
| 26 |
POST /api/sync/auth |
| 27 |
Content-Type: application/json |
| 28 |
|
| 29 |
{ |
| 30 |
"email": "user@example.com", |
| 31 |
"password": "their-password", |
| 32 |
"api_key": "your-app-api-key" |
| 33 |
} |
| 34 |
``` |
| 35 |
|
| 36 |
Response: |
| 37 |
|
| 38 |
```json |
| 39 |
{ |
| 40 |
"token": "eyJ...", |
| 41 |
"user_id": "550e8400-...", |
| 42 |
"app_id": "660f9500-..." |
| 43 |
} |
| 44 |
``` |
| 45 |
|
| 46 |
Direct auth does not work for accounts with 2FA enabled. [OAuth2 PKCE](./oauth.md) is recommended for all apps (browser-based login, no credential handling). The resulting access token works with all SyncKit endpoints. |
| 47 |
|
| 48 |
## Device Registration |
| 49 |
|
| 50 |
Register a device before pushing or pulling data: |
| 51 |
|
| 52 |
``` |
| 53 |
POST /api/sync/devices |
| 54 |
Authorization: Bearer <token> |
| 55 |
Content-Type: application/json |
| 56 |
|
| 57 |
{ |
| 58 |
"device_name": "Alice's MacBook", |
| 59 |
"platform": "macos" |
| 60 |
} |
| 61 |
``` |
| 62 |
|
| 63 |
Platform values: `macos`, `windows`, `linux`, `ios`, `android`, `web`. |
| 64 |
|
| 65 |
Response: |
| 66 |
|
| 67 |
```json |
| 68 |
{ |
| 69 |
"id": "770a0600-...", |
| 70 |
"device_name": "Alice's MacBook", |
| 71 |
"created_at": "2026-03-13T10:00:00Z" |
| 72 |
} |
| 73 |
``` |
| 74 |
|
| 75 |
If the same app + user + device_name combination already exists, the existing device is returned (upsert behavior). |
| 76 |
|
| 77 |
### Listing and Removing Devices |
| 78 |
|
| 79 |
``` |
| 80 |
GET /api/sync/devices |
| 81 |
Authorization: Bearer <token> |
| 82 |
``` |
| 83 |
|
| 84 |
``` |
| 85 |
DELETE /api/sync/devices/{device_id} |
| 86 |
Authorization: Bearer <token> |
| 87 |
``` |
| 88 |
|
| 89 |
## Push/Pull Sync |
| 90 |
|
| 91 |
### Pushing Changes |
| 92 |
|
| 93 |
Send local changes to the server: |
| 94 |
|
| 95 |
``` |
| 96 |
POST /api/sync/push |
| 97 |
Authorization: Bearer <token> |
| 98 |
Content-Type: application/json |
| 99 |
|
| 100 |
{ |
| 101 |
"device_id": "770a0600-...", |
| 102 |
"batch_id": "a1b2c3d4-...", |
| 103 |
"changes": [ |
| 104 |
{ |
| 105 |
"table": "tasks", |
| 106 |
"op": "insert", |
| 107 |
"row_id": "task-001", |
| 108 |
"timestamp": "2026-03-13T10:05:00Z", |
| 109 |
"data": "<encrypted-blob>" |
| 110 |
} |
| 111 |
] |
| 112 |
} |
| 113 |
``` |
| 114 |
|
| 115 |
Response: |
| 116 |
|
| 117 |
```json |
| 118 |
{ |
| 119 |
"cursor": "abc123..." |
| 120 |
} |
| 121 |
``` |
| 122 |
|
| 123 |
The `batch_id` ensures idempotent pushes. If the same ID is submitted twice, the server returns the existing cursor without re-inserting. Generate a unique `batch_id` per push and retry with the same ID on network failure. |
| 124 |
|
| 125 |
Changes per push are capped at 500 (the server returns an error if exceeded). Table names are limited to 100 characters (alphanumeric and underscores only). Row IDs are limited to 255 characters. The server validates device ownership. |
| 126 |
|
| 127 |
### Pulling Changes |
| 128 |
|
| 129 |
Fetch changes from other devices: |
| 130 |
|
| 131 |
``` |
| 132 |
POST /api/sync/pull |
| 133 |
Authorization: Bearer <token> |
| 134 |
Content-Type: application/json |
| 135 |
|
| 136 |
{ |
| 137 |
"device_id": "770a0600-...", |
| 138 |
"cursor": "abc123..." |
| 139 |
} |
| 140 |
``` |
| 141 |
|
| 142 |
Response: |
| 143 |
|
| 144 |
```json |
| 145 |
{ |
| 146 |
"changes": [ |
| 147 |
{ |
| 148 |
"table": "tasks", |
| 149 |
"op": "update", |
| 150 |
"row_id": "task-001", |
| 151 |
"timestamp": "2026-03-13T10:10:00Z", |
| 152 |
"data": "<encrypted-blob>" |
| 153 |
} |
| 154 |
], |
| 155 |
"cursor": "def456...", |
| 156 |
"has_more": false |
| 157 |
} |
| 158 |
``` |
| 159 |
|
| 160 |
Results are paginated. Keep pulling while `has_more` is `true`. |
| 161 |
|
| 162 |
### Checking Sync Status |
| 163 |
|
| 164 |
``` |
| 165 |
GET /api/sync/status |
| 166 |
Authorization: Bearer <token> |
| 167 |
``` |
| 168 |
|
| 169 |
Response: |
| 170 |
|
| 171 |
```json |
| 172 |
{ |
| 173 |
"total_changes": 1523, |
| 174 |
"latest_cursor": "ghi789..." |
| 175 |
} |
| 176 |
``` |
| 177 |
|
| 178 |
## End-to-End Encryption |
| 179 |
|
| 180 |
The server stores only encrypted blobs in the `data` field; it never sees plaintext user data. The client SDK uses ChaCha20-Poly1305 for encryption and Argon2 for key derivation. The encrypted master key envelope is stored server-side so users can set up new devices without re-entering a passphrase. |
| 181 |
|
| 182 |
### Key Storage |
| 183 |
|
| 184 |
Store and retrieve the encrypted master key: |
| 185 |
|
| 186 |
``` |
| 187 |
PUT /api/sync/keys |
| 188 |
Authorization: Bearer <token> |
| 189 |
Content-Type: application/json |
| 190 |
|
| 191 |
{ |
| 192 |
"encrypted_key": "<base64-encoded-encrypted-master-key>" |
| 193 |
} |
| 194 |
``` |
| 195 |
|
| 196 |
``` |
| 197 |
GET /api/sync/keys |
| 198 |
Authorization: Bearer <token> |
| 199 |
``` |
| 200 |
|
| 201 |
Response: |
| 202 |
|
| 203 |
```json |
| 204 |
{ |
| 205 |
"encrypted_key": "<base64-encoded-encrypted-master-key>", |
| 206 |
"key_version": 1 |
| 207 |
} |
| 208 |
``` |
| 209 |
|
| 210 |
Maximum key size: 4KB. Returns 404 if no key has been stored yet. |
| 211 |
|
| 212 |
## Blob Storage |
| 213 |
|
| 214 |
Content-addressed blob storage in S3, deduplicated by hash. |
| 215 |
|
| 216 |
### Upload Flow |
| 217 |
|
| 218 |
1. Request a presigned upload URL: |
| 219 |
|
| 220 |
``` |
| 221 |
POST /api/sync/blobs/upload |
| 222 |
Authorization: Bearer <token> |
| 223 |
Content-Type: application/json |
| 224 |
|
| 225 |
{ |
| 226 |
"hash": "sha256-hex-string", |
| 227 |
"size_bytes": 1048576 |
| 228 |
} |
| 229 |
``` |
| 230 |
|
| 231 |
Response: |
| 232 |
|
| 233 |
```json |
| 234 |
{ |
| 235 |
"upload_url": "https://s3.example.com/...", |
| 236 |
"already_exists": false |
| 237 |
} |
| 238 |
``` |
| 239 |
|
| 240 |
If `already_exists` is `true`, the blob is already stored. Skip the upload. |
| 241 |
|
| 242 |
2. Upload the file directly to the presigned URL (PUT request to S3). |
| 243 |
|
| 244 |
3. Confirm the upload: |
| 245 |
|
| 246 |
``` |
| 247 |
POST /api/sync/blobs/confirm |
| 248 |
Authorization: Bearer <token> |
| 249 |
Content-Type: application/json |
| 250 |
|
| 251 |
{ |
| 252 |
"hash": "sha256-hex-string", |
| 253 |
"size_bytes": 1048576 |
| 254 |
} |
| 255 |
``` |
| 256 |
|
| 257 |
Blob size is capped per tier. The server rejects oversized uploads. |
| 258 |
|
| 259 |
### Downloading Blobs |
| 260 |
|
| 261 |
``` |
| 262 |
POST /api/sync/blobs/download |
| 263 |
Authorization: Bearer <token> |
| 264 |
Content-Type: application/json |
| 265 |
|
| 266 |
{ |
| 267 |
"hash": "sha256-hex-string" |
| 268 |
} |
| 269 |
``` |
| 270 |
|
| 271 |
Response: |
| 272 |
|
| 273 |
```json |
| 274 |
{ |
| 275 |
"download_url": "https://s3.example.com/..." |
| 276 |
} |
| 277 |
``` |
| 278 |
|
| 279 |
The download URL is a presigned S3 URL valid for a limited time. |
| 280 |
|
| 281 |
## Real-Time Notifications (SSE) |
| 282 |
|
| 283 |
Subscribe to Server-Sent Events instead of polling. The server pushes a notification when another device pushes changes. |
| 284 |
|
| 285 |
``` |
| 286 |
GET /api/sync/subscribe?app_id={app_id} |
| 287 |
Authorization: Bearer <token> |
| 288 |
``` |
| 289 |
|
| 290 |
This is a long-lived SSE connection. Events: |
| 291 |
|
| 292 |
|
| 293 |
|
| 294 |
| `changed` | `{}` | Another device pushed changes. Call pull to catch up. | |
| 295 |
|
| 296 |
Recommended pattern: |
| 297 |
|
| 298 |
1. Open SSE connection on app launch |
| 299 |
2. On `changed` event, call pull to fetch new changes |
| 300 |
3. Reconnect on connection drop (with exponential backoff) |
| 301 |
4. Fall back to periodic polling if SSE is unavailable |
| 302 |
|
| 303 |
## Key Rotation |
| 304 |
|
| 305 |
Multi-step key rotation (e.g., after a suspected compromise): |
| 306 |
|
| 307 |
1. **Begin rotation**: Store the new encrypted key alongside the old one: |
| 308 |
|
| 309 |
``` |
| 310 |
POST /api/sync/keys/rotate/begin |
| 311 |
Authorization: Bearer <token> |
| 312 |
Content-Type: application/json |
| 313 |
|
| 314 |
{ "new_encrypted_key": "<base64>" } |
| 315 |
``` |
| 316 |
|
| 317 |
2. **Fetch entries to re-encrypt**: Pull changelog entries encrypted with the old key: |
| 318 |
|
| 319 |
``` |
| 320 |
POST /api/sync/keys/rotate/entries |
| 321 |
Authorization: Bearer <token> |
| 322 |
Content-Type: application/json |
| 323 |
|
| 324 |
{ "cursor": "...", "limit": 100 } |
| 325 |
``` |
| 326 |
|
| 327 |
3. **Submit re-encrypted batch**: Upload entries re-encrypted with the new key: |
| 328 |
|
| 329 |
``` |
| 330 |
POST /api/sync/keys/rotate/batch |
| 331 |
Authorization: Bearer <token> |
| 332 |
Content-Type: application/json |
| 333 |
|
| 334 |
{ "entries": [...] } |
| 335 |
``` |
| 336 |
|
| 337 |
4. **Complete rotation**: Finalize once all entries are re-encrypted: |
| 338 |
|
| 339 |
``` |
| 340 |
POST /api/sync/keys/rotate/complete |
| 341 |
Authorization: Bearer <token> |
| 342 |
``` |
| 343 |
|
| 344 |
During rotation, clients may receive a mix of old-key and new-key entries. Be prepared to decrypt with both keys until rotation completes. |
| 345 |
|
| 346 |
## Membership Gating |
| 347 |
|
| 348 |
Configured per app. Apps linked to a free item (or no item) have unrestricted sync access. Apps linked to a paid item or membership tier require an active purchase or membership. If the membership lapses, push/pull return `403`. Device registration and key storage remain accessible. |
| 349 |
|
| 350 |
## See Also |
| 351 |
|
| 352 |
- [OTA Updates](./ota.md): auto-update your app through SyncKit |
| 353 |
- [OAuth2 PKCE](./oauth.md): browser-based login for SyncKit apps |
| 354 |
- [SyncKit Client SDK](/rustdoc/synckit_client/): Rust client library documentation |
| 355 |
|