Skip to content

feat(device-client): sync per zone, zone keys, notices stream and status - #127

Merged
kinqsradio merged 3 commits into
mainfrom
feat/device-client-sync
Nov 11, 2026
Merged

kinqsradio merged 3 commits into
mainfrom
feat/device-client-sync

Conversation

@kinqsradio

@kinqsradio kinqsradio commented Nov 11, 2026 •

Copy link
Copy Markdown
Member

Summary

Second of three orbyn-device-client PRs: sync. Design: docs/device-client.md ("Sync").

  • open_engine(snapshot) opens orbyn_client::Engine for this device (a snapshot of another account or device is refused). The host stages and reads records with the engine as plain Fields; sealing, opening and zone keys are inside it. The host seals the snapshot under the vault key.
  • sync_all(&mut engine): one pass. Device list, zone keys other devices sent (accepted, then quarantined records retried), the zone list compared with local tokens, then every changed or pending zone pulled and pushed. A new zone is created on the server with a new key sealed to the account's other trusted devices. sync_zone and send_keys are public (the approver calls send_keys per zone after an approval).
  • Pull follows the change pages and starts the zone again on a 410 token_expired (also when an upload gets it); push merges a 409 record_changed and goes again. A zone with no key yet is waiting_for_key, not an error. A network failure is Transport, changes nothing and leaves everything staged.
  • status(): a tokio::sync::watch of SyncStatus { state, last_synced, last_error } with Idle, Syncing, Offline, SignedOut, ReauthenticationRequired, SessionExpired, AccountGone, Failed.
  • notices(config): the /v1/sync WebSocket (ws for a loopback http, wss with rustls, ring and the web roots; still no aws-lc or OpenSSL). Signs in first with the session token; Connected, ZoneChanged, Resync, Disconnected, Rejected; answers pings; a silent connection is dropped after two minutes; reconnects with a doubling, jittered delay (1 s to 60 s); stops on unauthorized.
  • New errors: ZoneDeleted, NoZoneKey, Engine(message); DeviceClient::api() accessor; DeviceApi::base_url() (default none).
  • Review round 1 (7a5f71d): a pass stops after 8 conflicts (Server { 409, record_changed }) and after 3 restarts on a 410 (Server { 410, token_expired }), keeping the work staged; the notices stream allows ws only to a loopback IP literal (any other plain-text base gets no stream), caps frames and messages at 16 KiB, has a connect timeout, and writes the sign-in message with the token into a wiped buffer. The TooLarge comment now says what happens (the batch is cleared, the records stay dirty, the next pass hits it again).
  • Not in this PR (the next one): approval digits, approve and deny, remove_device and the device app label (engine#73). The integration test does the approval over HTTP with the engine's own pairing calls until then. Protection-chain signing is not done by the client.

Closes #

Testing

  • CI covers the rest.
  • crates/orbyn-server/tests/device_client.rs (real server, Postgres, real loopback socket): two devices with the zone key, a sealed record the server holds only as ciphertext, a pending device refused, waiting for a key then sending once it arrives, a merge of both devices' edits, a real conflict (a rival upload lands just before ours) merged and sent again, history purged and the zone started again, the snapshot round trip and refusal, offline work kept and sent later, a second zone's key handed out in the same pass, and the notices stream hearing another device and being rejected after the session is revoked.
  • tests/notices.rs: a hand-made WebSocket server checks the sign-in message, the pong, the reconnect and the stop. Unit tests for the percent-encoding, the state per error and the backoff.
  • 26 mutations (410 and the plain apply call, conflict not merged, upload not applied, keys not accepted or not retried or not handed out, no waiting for key, snapshot check, status states, notices sign-in, reconnect, backoff cap, unauthorized retry, plus the review's: restarts unbounded on a pull and an upload, conflict rounds unbounded, backoff never reset, plain ws to any host, no frame limits, no connect timeout): all killed. tests/limits.rs scripts a transport that always answers 409 or 410 and checks the pass stops after the limit with an error and the work staged; tests/notices.rs checks a connection that reaches ready starts the delay over, the connect timeout and the oversized frame. One equivalent removed: the explicit verify_devices call, since the engine verifies when it takes keys in or hands them out.
  • Gate: fmt, clippy, structure, cargo test --workspace -- --include-ignored with Postgres.
  • Not tested: a real OS keychain; a pass against a second app; the approval client API (next PR).

Co-authors

none

AI usage

  • Written by hand, with no AI
  • Harness and version: Claude Code 2.1.317
  • Models: claude-sonnet-5-5 (implementation, tests, docs)
  • Effort: medium
  • Human role: agreed the trait and scope with the app owner; reviews the PR at the exact head
  • Sources: none

Checklist

  • Works on Windows, macOS and Linux
  • docs/architecture.md with any rule change; generated wire types with any contract change
  • No copied source, secrets, credentials or personal data; no AI attribution in commits, title or branch

@kinqsradio
kinqsradio merged commit b01598b into main Nov 11, 2026
16 of 17 checks passed
@kinqsradio
kinqsradio deleted the feat/device-client-sync branch November 11, 2026 12:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant