Extract API, migrations and portable NAS deployment

This commit is contained in:
tomchukk committed 2026-09-07 19:34:49 +03:00
commit 673255fa4f
57 files changed
+3799

No files matched your search

+48
View File
@@ -0,0 +1,48 @@
# Initial architecture
## Decisions
Use a pnpm monorepo, React 19 + Vite 8 + TypeScript 5.9, Dexie 4 and dexie-react-hooks for local reactivity, vite-plugin-pwa/Workbox for the static app shell, Fastify 5 and the official MariaDB Node driver. Shared Zod 4 contracts validate both user operations and server responses. Vitest, fake-indexeddb and Playwright cover consistency and the actual offline browser flow. The lockfile pins the complete resolved dependency graph. No ORM, NestJS, router or state framework is needed for this slice.
The selected Vite release supports the installed Node 22.16 runtime; see the [Vite requirements](https://vite.dev/guide/). Network calls stay outside Dexie transactions, following [Dexie's transaction guidance](https://dexie.org/docs/Tutorial/Best-Practices). The service worker caches the app shell; IndexedDB owns operational data and the outbox.
## Data model
| Table | Identity and contents |
| --- | --- |
| customers | UUID; name, contact, phone, comments, three preferred delivery windows, version and timestamps/tombstone |
| customer_addresses | UUID; customer FK, label, formatted address plus city/street/building number, version and timestamps |
| delivery_types | UUID; unique code, Hebrew label, configurable unit hundredths, version and timestamps |
| deliveries | UUID; unique server business number, customer/address/type FKs, planned date, type version, immutable unit and price snapshots, notes, status, entity version and timestamps |
| sync_state | Singleton serialization lock, committed sequence, next delivery number |
| sync_operations | Globally unique operation UUID, device UUID, canonical payload hash, immutable acknowledgment and timestamp |
| sync_changes | Ordered sequence, entity type/UUID and complete versioned record (including tombstone) |
| schema_migrations | Ordered migration filename, SHA-256 checksum and application time |
| app_users | UUID, display name, role, active flag and last verified OIDC group snapshot; recovery password hash or unique issuer/subject identity hash |
| auth_sessions | Hashed session identifier, user FK, absolute expiry, provider and encrypted token bundle |
| oidc_settings | Encrypted singleton SSO configuration and administrator audit metadata |
| oidc_transactions | Hashed browser binding, encrypted state/nonce/PKCE verifier and ten-minute expiry |
The composite delivery FK `(address_id, customer_id)` references the assigned address/customer pair. JSON wire fields use camelCase; SQL uses snake_case. UUID identity exists before sync. Versions start at 0 locally and 1 after server creation. Creates require baseVersion 0. Customer and delivery changes require the exact current positive version and increment it. Customer removal writes a tombstone and preserves addresses/deliveries; archived customers cannot receive new deliveries. Delivery updates change customer/address/planned date/timing/notes. A named timing choice snapshots the customer's current delivery window; a custom choice stores one exact time. Cancellation stores a reason and server timestamp, retains the number/price and prevents further edits. Older records without a planned date fall back to their creation date in local summaries, and pre-v5 records display an unset timing until edited. There is no permanent-deletion API. Sync receipts include the authenticated actor's user UUID; legacy anonymous receipts remain readable for cache migration.
Money is stored as integer agorot (one hundredth of an ILS); shipment units as integer hundredths. `total = floor((unitPriceAgorot * shipmentUnitHundredths + 50) / 100)`. Input bounds keep multiplication within JavaScript safe-integer precision. The server checks the selected delivery type version and multiplier, then persists the supplied unit-price snapshot and calculated total. The first prototype allows explicit manual entry of the unit price, including zero; it has no pricing policy engine. Catalog version changes reject stale offline drafts for review instead of silently repricing them.
## Consistency
An IndexedDB transaction writes the optimistic entity and FIFO outbox entry together. A persisted device ID is generated atomically. Browser Web Locks serialize sync across tabs where supported; server idempotency provides duplicate protection independently.
The API locks sync_state before checking receipts and applying writes. Entity creation, number allocation, change log and receipt commit atomically. Serializing writes is a deliberate small-scale tradeoff: it guarantees sequence order follows commit order. An auto-increment change log without commit ordering can cause a client to skip an earlier, still-uncommitted change. At larger scale, replace this lock with a proven commit-ordered feed, not an uncoordinated sequence.
Migrations use a MariaDB advisory lock and checksum checks. DDL implicitly commits; initial CREATE statements are rerunnable after interruption. The small runner supports semicolon-separated SQL statements, not stored routines/DELIMITER syntax. Later migrations must account for interrupted DDL and use new files instead of altering applied ones.
OIDC login always requests Synology's `groups` scope. When group access is enabled, the verified ID-token/UserInfo group claim must contain at least one configured allowlist entry. Matching is exact after Unicode normalization and case folding. The validated group snapshot is stored on the user and checked on every online app-session read, so saving changed access settings invalidates or rejects ineligible SSO sessions. The local recovery administrator is outside this rule and remains able to repair configuration. Group membership changes at the provider are learned on the next SSO login; fully offline clients cannot learn revocation.
## Limits that remain
- One lab workspace, group-admitted users, separate device caches and role scaffolding; no tenant, role-specific action or record-level permission isolation. Keep both servers on loopback pending deployment work. See [authentication](authentication.md).
- Explicit customer and delivery conflict review is implemented. No automatic resolution, address editing, permanent-deletion API, compaction or arbitrary outbox editing. One pending operation per editable entity; finish synchronization before its next edit.
- Full delta history is retained. New devices start at cursor 0. A cursor ahead of the server is rejected; database replacement with a new history/epoch needs an explicit future bootstrap protocol. Do not reset a server database while reusing device stores.
- IndexedDB durability does not protect against browser data clearing or storage eviction. Persistence permission is best effort.
- Background OS execution is not required. Active tabs sync on reconnect, visibility, launch, manual request and every 30 seconds while visible. Transport failures keep entries pending; validation conflicts become explicit errors.
- The PWA must be loaded online once before offline launch. Service-worker updates prompt for refresh; unsent outbox data remains in IndexedDB, but unfinished form text is not a saved draft.
- Physical iPad/Samsung and Safari validation is separate from Chromium viewport checks.
+113
View File
@@ -0,0 +1,113 @@
# Synology SSO and local recovery
## First setup on this workstation
1. Start the local stack and apply migrations. The existing ignored `.env` already contains random `TOKEN_ENCRYPTION_KEY` and `LOCAL_ADMIN_SETUP_TOKEN` values; preserve them.
2. Open `http://localhost:5173/local-admin`. This URL is intentionally absent from normal navigation.
3. Read `LOCAL_ADMIN_SETUP_TOKEN` locally from `.env` and enter it in the setup form. Choose your own username, display name and password (12–128 characters). No account or default password is shipped. Only one password-based account can be created; setup closes atomically after it exists. The setup token can then be removed from `.env` and the API restarted.
4. Save the recovery credentials in your password manager. This account can configure SSO and open the app even when the identity provider is unavailable, provided the Model Delivery API is reachable.
5. Complete the SSO form using your actual Synology values. Save, then use **בדיקת ההגדרות השמורות** to test discovery. This checks metadata connectivity and issuer matching, not the full interactive sign-in. Log out of the app and use its normal SSO button to verify the real login.
For a fresh installation elsewhere, generate a 32-byte random base64 encryption key and a separate long random setup token as described in `.env.example`. Preserve the encryption key with backups; losing it prevents decrypting saved SSO settings and provider tokens. No secret is returned by the settings API after saving.
## Synology settings
In Synology SSO Server, enable OIDC and register an OIDC application for Model Delivery. Record its Application ID, Application Secret and exact Redirect URI. Synology's [application documentation](https://kb.synology.com/en-global/DSM/help/SSOServer/sso_server_application_list?version=7) describes these fields. Obtain the Well-known URL from your installed SSO Server's OIDC service settings; copy the `issuer` value from that discovery document without altering its path or trailing slash.
| Model Delivery setting | Value to use |
| --- | --- |
| Issuer | Exact `issuer` from your server's discovery document |
| Well-known URL | Synology's full discovery URL; optional if discovery from the issuer works |
| Application ID / Secret | Values from the OIDC application registered in Synology |
| Redirect URI | `http://localhost:5173/api/auth/callback` for current local development; `http://localhost:4173/api/auth/callback` for built preview; your HTTPS application URL plus `/api/auth/callback` after deployment |
| Client authentication | Match the provider's advertised method: `client_secret_post` or `client_secret_basic`; `none` is available for explicitly registered public clients |
| Scopes | Always `openid`; Model Delivery adds `groups` to each authorization request. Add only other scopes supported by the installed server |
| Display-name claim | Default `name`; configure `username` if preferred. Fallbacks include given name, preferred username, Synology's username, email and subject |
| Group claim | Default `groups`, matching Synology's documented OIDC group claim |
| Allowed groups | One exact Synology/AD group name per line; a user may belong to any one of them |
| Restrict by group | Leave off until the allowlist is ready and a test SSO login has shown the expected group in **החשבון שלי** |
| offline_access | Optional, off by default. Enable only if your Synology version/application supports refresh tokens and this scope |
| prompt=consent | Optional explicit consent request if required by the provider |
The server validates issuer, metadata endpoints and signatures using `openid-client`, with Authorization Code + PKCE S256, random state and nonce. Do not weaken verification to accommodate mismatched Synology URLs. Correct the server's externally advertised URLs and trusted certificate. This workstation's Synology sign-in and UserInfo lookup are now working; installed-version refresh-token support remains to be confirmed.
`APP_ORIGINS` is an allowlist in the server's `.env`, independent of the settings form. Callback origins must be in it. On deployment set the real HTTPS app origin, `COOKIE_SECURE=true`, and leave `OIDC_ALLOW_HTTP=false`. The latter is only for the loopback mock provider in automated tests, never a workaround for NAS certificates. Browser origins must stay consistent between app entry and callback.
## Sessions and offline work
The browser receives an opaque HttpOnly SameSite=Strict session cookie. A separate short-lived SameSite=Lax cookie binds the cross-site callback to the browser that initiated sign-in. Pending state/nonce/PKCE verifier are encrypted in one-use server transactions with a ten-minute expiry. The callback is excluded from service-worker navigation fallback and request logs omit its query string.
Access and refresh tokens stay AES-256-GCM encrypted in MariaDB, using the server-only encryption key. App session identifiers are stored as hashes. OIDC users are identified by a hash of `(issuer, subject)`, never linked to the recovery account by a matching username/email. Successful first SSO login provisions a staff profile only after group authorization succeeds; subsequent logins update the greeting and group snapshot from validated claims/UserInfo. Admitted users share the lab's operational data in this slice.
## Synology group access
[Synology's SSO Server documentation](https://kb.synology.com/en-af/PAS/help/SSOServer/sso_server_service?version=1_0) describes `groups` as the ID-token claim containing the groups to which the user belongs. Model Delivery always requests the `groups` scope and reads the configured claim from the validated ID token plus UserInfo response. In `/local-admin`, enter one group per line under **קבוצות מורשות**, then enable **הגבלת הכניסה לפי קבוצות Synology / AD**. A user is admitted when at least one returned group exactly matches an allowlist entry; comparison is Unicode-normalized and case-insensitive. Substrings and partial domain names do not match.
Keep the restriction disabled for the first diagnostic login. Open **החשבון שלי** after SSO login to see the group names actually returned by this Synology installation, copy the intended name into the allowlist, and then enable the restriction from the local recovery account. If Synology returns no group claim, group-restricted login is denied. The local recovery administrator is always exempt and can disable or repair the settings.
An unmatched user is rejected before a new app account or session is created. Saving changed SSO/group settings causes existing SSO sessions to fail their next online session check and require login. The server also checks each existing session against its last verified group snapshot. Provider-side membership changes with otherwise unchanged settings are learned during the next SSO login; an already-open app session can last up to seven days, and a fully offline device cannot learn a revocation. For immediate removal, change the allowlist (which invalidates existing SSO sessions) or disable the app user in the database until a dedicated user-management screen is built.
The app session has its own absolute seven-day lifetime from login, with no shorter idle timeout. If the provider issues no refresh token, expiration of its access token does not end this app session; no expired token is sent to a provider API. This supports Synology SSO configurations that return short-lived access tokens without refresh tokens. A fresh login is still required after the app session expires.
When an access token approaches expiry and a refresh token is present, the API renews it. A database lock prevents simultaneous requests from reusing a rotated refresh token. Network/provider errors keep the session for retry. An invalid refresh grant, disabled account, disabled SSO, changed SSO configuration or rejected stored group membership requires a fresh sign-in. Sync receives 401 and leaves its outbox untouched. Provider-side account/group changes are not polled for sessions without refresh tokens: the verified last-login group snapshot is checked against the current app allowlist on each authenticated request; changed provider membership is learned at a fresh login.
The local profile cache contains only user information and an absolute seven-day app-session expiry, never provider tokens or passwords. After an online login and app-shell installation, that unexpired profile permits offline work in the user's IndexedDB store. `offline_access` does not grant offline server access and is not needed for these saved local edits. After the local allowance expires, sign-in requires connectivity; pending data remains. Revocation cannot be learned while completely offline. Cached data is not encrypted or erased at logout; this is a trusted-device prototype, not a shared-device security boundary.
Logging out revokes this Model Delivery session, clears its cookie and cached profile, and retains account-specific outbox/data. It does not sign out of Synology or other SSO applications; the next SSO login may therefore be immediate. Provider-wide logout/revocation and recovery-password management UI are future work.
### Personal display name
Some Synology installations return only `sub`, `username` and `email` from UserInfo, with no full-name claim. Requesting `profile` cannot supply a field the provider does not support. In that case the initial greeting falls back to the username.
On 2026-09-04, a fresh live sign-in explicitly requested `name` (essential), `given_name` and `family_name` in both `id_token` and `userinfo` through the standard OIDC `claims` parameter. The validated ID token still contained only `aud`, `auth_time`, `email`, `exp`, `iat`, `iss`, `nonce`, `sub` and `username`; UserInfo still returned only `email`, `sub` and `username`. No name fields or Hebrew name were returned. The temporary request/capture code and encrypted snapshot were removed afterwards. This confirms the installed service did not supply those fields in that experiment; the protocol's claims parameter is optional ([OIDC Core](https://openid.net/specs/openid-connect-core-1_0.html#ClaimsParameter)). Synology's documented custom attribute mappings are under SAML, rather than its OIDC application setup ([Synology application documentation](https://kb.synology.com/en-global/DSM/help/SSOServer/sso_server_application_list?version=7)).
Open **החשבון שלי** and set **שם לתצוגה** to choose the name used in the greeting. This online account setting is stored separately in `app_users.display_name_override`; the provider's original name remains in `display_name`. The preference takes precedence on session reads and future SSO logins. The username, issuer/subject identity, roles and operational records do not change. Successful saves update the cached profile immediately, including its next offline launch. `POST /auth/profile` accepts only `{displayName}`, requires a valid session, an allowed request origin/custom header and an `x-model-user` matching the session.
Choose **שימוש בשם הארגוני** to clear the personal override (`POST /auth/profile` with `{displayName:null}`) and use the directory/provider name again. This also refreshes the offline profile cache. It never edits the directory.
### Optional names from REZNIK Active Directory
The deployment target is the same NAS that hosts Synology SSO and Synology Directory Server (AD). Since the live OIDC responses omit name fields, the API can optionally read AD's `displayName` after a verified SSO login. Authentication, user identity and roles continue to come from the existing OIDC flow/app account; LDAP is used only for the greeting.
In `/local-admin`, open **שמות מתוך Active Directory** and configure:
| Setting | Value to supply |
| --- | --- |
| LDAPS URL | An internal NAS/domain-controller hostname reachable by the API, such as `ldaps://nas.example.internal:636`; the hostname must match the directory TLS certificate |
| NetBIOS domain | `REZNIK`, matching the verified SSO subject prefix |
| Base DN | The actual AD search root, for example `DC=example,DC=internal`; this example is not the discovered REZNIK domain |
| Bind DN / UPN | A dedicated account permitted to read the relevant AD user attributes |
| Password | Enter locally in the masked field; do not put it in source control or chat |
| CA certificate | Optional public PEM certificate chain if the directory uses an internal CA |
Save, enable when the connection is ready, and test a full SSO subject such as `REZNIK\tom.ruff`. The test reads the saved configuration and returns only the resolved name or a safe failure code. It does not update accounts. Sign in through SSO again to apply the directory name. A personal override still takes priority; use **שימוש בשם הארגוני** to clear it when desired.
Co-location alone does not establish connectivity: a container's `localhost` points to the container, not the NAS. Choose a reachable internal NAS address and verify that **Synology Directory Server's** LDAPS service and TLS certificate are configured. The DSM web certificate/SSO HTTPS endpoint is not evidence that LDAP TLS is configured. No directory ports were opened, NAS services changed, or production deployment performed. The development PC did not discover the REZNIK domain and the earlier public-host LDAPS connection timed out. The full AD DNS domain, internal connection details and read credentials have not been supplied; the working feature remains disabled.
Implementation uses [ldapts](https://github.com/ldapts/ldapts) 9.0.0 with direct LDAPS, TLS 1.2 minimum, certificate/hostname verification, and two-second connection and operation timeouts. Plain LDAP and StartTLS are not offered in this slice. A successful bind precedes one bounded search for an exact `sAMAccountName` under the configured base DN, with literal equality filters, user/person constraints and a two-entry limit. The verified issuer must match the one bound to the directory configuration and the subject must have the configured `DOMAIN\username` form. Bare usernames, other domains, duplicate entries and referrals are not accepted. Only `displayName`, `givenName`, `sn` and `sAMAccountName` are requested; first name plus surname is a fallback if displayName is empty. No groups, roles or directory writes are involved.
Migration 005 stores encrypted directory settings separately from OIDC settings and caches the resolved name with its configuration fingerprint. The precedence is personal override, cached name from the active matching directory configuration, then OIDC name/username. A directory failure does not prevent a valid OIDC sign-in; the last known name remains available. Changing or disabling directory settings makes old cached directory names ineligible on the next online session read. Offline devices retain their last saved profile until online refresh. Saving directory settings does not invalidate SSO sessions. Blank password preserves the existing credential only while the server URL and bind account remain unchanged. LDAP library errors and credentials are never returned or logged.
The first local administrator claims a copy of the pre-authentication IndexedDB cache once, preserving existing UUIDs/outbox entries. The original database remains as a backup. Open the app with that recovery account and synchronize any earlier unsent work before switching to SSO. Other accounts get separate device caches and pull the common server records. An account-ID request header prevents one account's queued mutations being sent under another account's cookie.
## Hidden page protection and API
The hidden path is only a convenience. All recovery endpoints reject non-loopback connections; settings/discovery additionally require an authenticated local administrator. Initial setup requires the random setup token. Login/setup use rate limits and passwords use salted scrypt. State-changing endpoints require a custom request header and reject disallowed browser origins.
At a future public reverse proxy, **deny `/local-admin` and `/api/local-admin/*` on the public listener** and provide a separate local/admin access path. The API sees the proxy's loopback address, so its loopback check alone cannot distinguish public clients behind that proxy. Do not expose this development proxy as a production access boundary.
| API path (browser adds `/api`) | Method / purpose |
| --- | --- |
| `/auth/session` | GET current session or SSO availability |
| `/auth/login` | POST begin OIDC; returns authorization URL |
| `/auth/callback` | GET process one-use code callback and redirect to app |
| `/auth/logout` | POST revoke current app session |
| `/local-admin/status` | GET whether recovery account setup remains open |
| `/local-admin/setup` | POST `{username,password,displayName,setupToken}` once |
| `/local-admin/login` | POST `{username,password}` for recovery account |
| `/local-admin/settings` | GET masked config; POST shared `SsoSettings` contract |
| `/local-admin/discover` | POST validate saved issuer/discovery metadata |
| `/local-admin/directory` | GET masked directory config; POST shared `DirectorySettings` contract |
| `/local-admin/directory/test` | POST `{subject}` to read one name using saved settings |
Blank client-secret input preserves the saved secret; choosing public-client authentication clears it. Saving changed SSO configuration invalidates existing SSO sessions on their next server request while leaving the recovery account usable.
+14
View File
@@ -0,0 +1,14 @@
# Model Delivery repositories
| Repository | Responsibility |
| --- | --- |
| delivery-app-web | React/Vite/Dexie PWA, browser tests |
| delivery-app-api | Fastify, Synology SSO, MariaDB migrations, deployment |
| delivery-app-mobile | React Native/Expo for iOS and Android |
| delivery-app-shared | Versioned TypeScript/Zod contracts and pricing rules |
Clone from https://git.ruff.co.il/model-dlab/ using your own Git credential helper. Never put credentials in URLs or package manifests. Sibling checkouts are convenient, but ordinary installs/builds need no sibling source. Browser E2E and the combined NAS image explicitly use the web and API checkouts.
The shared dependency is pinned to an immutable Git commit, including its compiled dist files. There is no public npm publication or requirement for a private npm registry. Build, test and commit shared source AND dist together, tag releases, then explicitly update the Git SHA and pnpm lockfile in each consumer. TypeScript code can be reused on mobile; DOM, IndexedDB and Node-specific code cannot.
All clients use one API and the same operational data. SQLite on mobile will implement the existing sync protocol. Do not change deployed database identities, credentials, encryption keys, or OIDC configuration during this source split. The original model-app checkout remains available.
+18
View File
@@ -0,0 +1,18 @@
# Repository split verification — 2026-09-07
The original model-app checkout is retained. Web source/assets, API business/auth/sync code, shared contracts and all nine migrations were compared byte-for-byte against it. Only API root/env path resolution and frontend Vite config paths changed to accommodate flat repositories. No working database was reset. No NAS deployment was performed.
Verified locally:
- Shared: strict typecheck/build and 3 published-contract tests.
- Web: typecheck/build and 16 unit tests.
- API: typecheck/build, 6 unit tests and 19 integration tests using model_delivery_test.
- Cross-repository Chromium: all 5 existing browser tests passed, covering SSO/recovery, offline entry, offline customer/delivery creation, reconnect, archival, conflicts and cancellation.
- Mobile: strict typecheck, 3 URL-configuration tests, iOS and Android Metro bundle exports, and all 21 Expo doctor checks passed.
- Separate web/API checkouts staged and built a Linux/amd64 standalone image with Podman in Docker image format.
- The actual image passed isolated MariaDB migration/seed/readiness, public/admin routing, secure recovery-cookie and healthcheck tests. A gateway failure stopped the container; restarting preserved the account, and graceful shutdown exited successfully.
- Publish candidates were checked against local environment secret values. Environment files, database state, signing materials, dependency directories and generated build outputs are excluded. Shared dist is intentionally tracked for immutable Git dependencies.
Mobile verification does not include Xcode/Gradle compilation, signing, a device installation, visual inspection of Liquid Glass, or native SSO/offline behavior. Those remain required before mobile distribution. The mobile repository is a foundation with a read-only readiness check, not a replacement for the operational PWA yet.
The shared release is pinned to Git revision 9133b921a713b35c46a138f9e83d1ccfbf56417e (v0.1.0) in all three consumers. See each repository's README for reproducible commands and the API README for single-image staging.
+69
View File
@@ -0,0 +1,69 @@
# Sync protocol v5
All push/pull requests require the app session cookie and `x-model-user` equal to that session's user ID. POST requests additionally require `x-model-request: 1` and an allowed Origin when present. Missing/expired/mismatched authentication returns 401, pauses synchronization and preserves the entire outbox. Provider tokens never travel in sync requests. Each user has a separate IndexedDB database. See [authentication](authentication.md).
## Local write
1. Generate `entityId` and `operationId` as UUIDs. Load/create the durable device UUID.
2. Validate using the shared schema.
3. In one IndexedDB transaction, save the local record (`version: 0`, `syncState: pending`, deliveryNumber: null) and its outbox entry.
4. Only after commit, report success and attempt background synchronization.
Outbox queue order follows an IndexedDB auto-increment key so same-millisecond writes remain ordered. Create a customer before its address, and the address before a delivery. The UI permits only assigned addresses. Pending and error records survive a reload and are not overwritten by the delta feed.
## Push
`POST /sync/push`, JSON body:
```json
{
"operationId": "e209ee4f-22e8-4f82-a7b6-1ac092d9ec47",
"deviceId": "a8380f2a-478c-4c68-93ac-b2f2e7d22080",
"entityId": "1d9d9740-58bd-4f88-a102-d6d1231e04c5",
"baseVersion": 0,
"kind": "delivery.create",
"data": {
"customerId": "20000000-0000-4000-8000-000000000001",
"addressId": "20000000-0000-4000-8000-000000000002",
"deliveryTypeId": "10000000-0000-4000-8000-000000000003",
"deliveryTypeVersion": 1,
"shipmentUnitHundredths": 150,
"unitPriceAgorot": 1001,
"notes": ""
}
}
```
Other creates: `customer.create` takes `{name,contactName,phone,comments,deliveryWindows}`. `deliveryWindows` contains nullable `morning`, `afternoon` and `lateNight` entries, each `{start,end}` in 24-hour `HH:mm` form with end after start. `address.create` takes `{customerId,label,address,city,street,buildingNumber}`; `address` is the formatted compatibility/display value. Unrecognized fields and nonzero create base versions are rejected. A successful response is `{change: {entityType, record}, replayed: boolean}`. The complete record has its server version, timestamp and (for delivery) number and price total. Returning a receipt does not imply earlier changes have been pulled.
`customer.update` replaces those editable profile fields and requires the current positive `baseVersion`. `customer.remove` takes `{}`, writes `deletedAt`, and preserves the customer's existing addresses and deliveries. Both increment the customer version and set `updatedAt`. Removed customers and their addresses are excluded from new delivery validation. Customer conflicts return the current complete customer record and use the same explicit server/local review flow as deliveries.
`delivery.create` includes nullable `scheduledDate` as an ISO calendar date (`YYYY-MM-DD`); current clients always set it. It also selects `deliverySlot` as `morning`, `afternoon`, `lateNight` or `custom`. Named slots carry a nullable `{start,end}` `deliveryWindow` snapshot copied from the selected customer's current settings and have `specificTime: null`; `custom` requires an exact 24-hour `specificTime` and has `deliveryWindow: null`. `delivery.update` can replace the customer, address, date, timing and notes, while an older update that omits all timing fields preserves the current timing. `delivery.cancel` takes `{reason}`. Both require the current positive `baseVersion`. They preserve the delivery number, type/version, shipment units, unit price, total and creation timestamp. Each successful operation increments the version and sets `updatedAt`; cancellation also sets `status: cancelled`, `cancelledAt` and `cancellationReason`. Cancelled/deleted deliveries reject further changes with `409 DELIVERY_CANCELLED`. Price/type fields in update payloads are rejected rather than silently ignored.
The server hashes the validated, canonical operation object. Replaying an identical operation ID returns its immutable acknowledgment without changing records or numbers. Compatibility checks accept only exact earlier canonical representations for customer/address defaults, a null planned date and operations created before delivery timing; ownership checks remain mandatory. Reusing the ID with different content returns `409 OPERATION_REUSED`; replay from a different authenticated actor returns `409 OPERATION_OWNER_CHANGED`. A different operation creating an existing entity ID or updating a stale version returns `409 VERSION_CONFLICT` with the current server change as `{current: {entityType,record}}`. Neither silently overwrites data.
The local change and its outbox entry commit atomically. Editing requires a synchronized customer or delivery with no other pending operation for that entity. The version captured when the form opens is checked again at save, so a background refresh cannot silently overwrite a newer record. Optimistic edits keep the last acknowledged version until the server accepts them.
On a customer or delivery conflict, preserve both the local draft and server record in IndexedDB. The user explicitly chooses either (a) accept the reviewed server version and remove the queued change, or (b) submit the saved local change using a new operation ID and the reviewed server base version. Choice (b) is prohibited when the server archived/cancelled/deleted the entity. The next push can conflict again. A server version received during review invalidates that review until the new version is considered. Ordinary Retry never bypasses a stored conflict.
`422 ADDRESS_MISMATCH` or `CUSTOMER_MISSING` rejects invalid references. `409 CATALOG_CONFLICT` rejects changed delivery-type versions/multipliers. These remain explicit local errors. Manual retry preserves the exact operation and snapshots; it does not fabricate a new operation ID or silently change prices. Catalog-conflict and invalid-reference draft editing remain deferred.
For transport failures, timeouts, 429 or 5xx, leave the operation pending. While the browser reports online, retry with a 1-second exponential backoff capped at 30 seconds, in addition to the normal sync triggers. An online event may arrive before the server is reachable. If the server committed but its response was lost, retry the same operation. Only delete an outbox entry in the local transaction that also applies a validated acknowledgment. The API retains receipts indefinitely in this slice.
## Pull
`GET /sync/pull?cursor=0&limit=100` returns:
```text
{
changes: [{ sequence: "1", change: { entityType, record } }, ...],
nextCursor: "100",
hasMore: true
}
```
Sequences/cursors are decimal strings, preserving BIGINT precision. Limits are 1–200. The page includes only committed changes in sequence order. Repeat while `hasMore`; atomically apply a page and save its `nextCursor` in IndexedDB. Never derive or advance the pull cursor from a push receipt, or remote records may be skipped. Never replace a newer local acknowledged version with older historical feed entries/receipts. Pending local edits survive pulls; later server versions update their stored conflict details for review.
Records contain `deletedAt: null` while active. Customer archive appends a versioned tombstone through the same commit-ordered write mechanism. Clients retain tombstones locally and hide archived customers from operational selectors while allowing an archive/history view. Delivery numbers on deleted/cancelled records remain reserved.
A cursor ahead of server history returns `409 CURSOR_AHEAD`, preserving local data and requiring reconciliation. This protocol does not yet support server history replacement, epoch changes or log retention/compaction; add a bootstrap protocol before those operations.