Skip to main content

wavekat_platform_client/
voice.rs

1//! Voice-product resources synced from the desktop daemon up to the
2//! platform.
3//!
4//! The first shipped marker is [`VoiceCalls`] — per-call metadata for
5//! the platform's `/voice/calls` history page (see
6//! `wavekat-voice/docs/21-platform-call-history-sync.md`). Recordings
7//! (`VoiceRecordings`), transcripts (`VoiceTranscripts`), and summaries
8//! will follow the same shape: a marker type, a wire-record struct, and
9//! a typed query — no new HTTP plumbing.
10//!
11//! All wire shapes use camelCase JSON to match the platform's Hono/Zod
12//! convention. The Rust types stay snake_case so consumers feel native.
13
14use serde::{Deserialize, Serialize};
15
16use crate::client::Client;
17use crate::error::{Error, Result};
18use crate::sign::ReleaseCredential;
19use crate::sync::{stamp_schema_version, HasSyncEnvelope, SyncEndpoint, SyncEnvelope, SyncRequest};
20
21/// Inbound vs. outbound. Wire-stable snake_case strings — never
22/// renumber or rename. New states (e.g. `internal`) would be a wire
23/// addition, not a replacement.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(rename_all = "snake_case")]
26pub enum VoiceCallDirection {
27    Inbound,
28    Outbound,
29}
30
31/// User-visible disposition. Derived from [`VoiceCallEndReason`] by the
32/// daemon; the platform stores both, so future UI surfaces can read
33/// either without re-deriving.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(rename_all = "snake_case")]
36pub enum VoiceCallDisposition {
37    Answered,
38    Missed,
39    Rejected,
40    Cancelled,
41    Failed,
42}
43
44/// Finer-grained terminal reason — kept distinct from
45/// [`VoiceCallDisposition`] because the disposition collapses
46/// `hangup_local` and `hangup_remote` to `Answered`, losing the
47/// "who hung up?" answer the row otherwise carries.
48///
49/// Wire-stable snake_case strings; the daemon's matching enum is the
50/// canonical source.
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(rename_all = "snake_case")]
53pub enum VoiceCallEndReason {
54    HangupLocal,
55    HangupRemote,
56    RejectedLocal,
57    RejectedRemote,
58    Missed,
59    CancelledLocal,
60    /// An established call torn down because its connection died —
61    /// the daemon's RFC 4028 session keepalive stopped getting
62    /// answers (peer crashed, NAT binding dropped). Distinct from
63    /// `HangupLocal`: the user didn't end this call. Rows with this
64    /// reason still carry [`VoiceCallDisposition::Answered`].
65    ConnectionLost,
66    Failed,
67}
68
69/// One historical call as it crosses the wire from the daemon up to the
70/// platform.
71///
72/// Mirrors the daemon's local `CallRecord` (see
73/// `wavekat-voice/crates/wavekat-voice/src/db.rs`) with one rename:
74/// the daemon's local primary key (`id`) is shipped as `source_id`
75/// because the platform allocates its own row id and treats the
76/// daemon-side UUID as the idempotency key.
77#[derive(Debug, Clone, Serialize, Deserialize)]
78#[serde(rename_all = "camelCase")]
79pub struct VoiceCallRecord {
80    /// Daemon-generated UUID. The platform's `(user_id, source_id)`
81    /// upsert key — re-syncing the same id is a no-op.
82    pub source_id: String,
83    /// SIP account UUID on the daemon side. Opaque to the platform.
84    pub account_id: String,
85    pub direction: VoiceCallDirection,
86    /// SIP `From:` (inbound) or `To:` (outbound). Free text — caller
87    /// IDs, display names, and SIP URIs all land here.
88    pub party: String,
89    /// RFC 3339. First ring (inbound) or first dial-out (outbound).
90    pub ring_at: String,
91    /// RFC 3339. Present only when the call reached the answered
92    /// state.
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub answer_at: Option<String>,
95    /// RFC 3339. Terminal timestamp; the platform uses this as the
96    /// list cursor.
97    pub end_at: String,
98    /// `answer_at` → `end_at` in milliseconds. `None` for calls that
99    /// were never answered.
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub duration_ms: Option<i64>,
102    pub disposition: VoiceCallDisposition,
103    pub end_reason: VoiceCallEndReason,
104    /// Free-text error, populated only when `disposition == Failed`.
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub error: Option<String>,
107    /// Visibility tier of any *active* (not revoked / expired) share on this
108    /// call's recording, or `None` when it isn't shared. Read-only: the
109    /// platform sets it on list (`GET /api/voice/calls`) and detail responses
110    /// so a consumer can badge the row "Public" / "Invited only"; it is
111    /// skipped on serialize, so syncing a call never sends it. `Private` never
112    /// appears here — an unshared call is `None`.
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub share_visibility: Option<ShareVisibility>,
115    /// Version + forward-compat fields shared by every sync record.
116    /// Flattened so `schemaVersion` and `extras` sit at the top of
117    /// the JSON object alongside the other columns. See
118    /// [`SyncEnvelope`] and doc 21 §"Versioning and forward
119    /// compatibility".
120    #[serde(flatten, default)]
121    pub envelope: SyncEnvelope,
122}
123
124/// Query params for `GET /api/voice/calls`. All fields optional — the
125/// default returns the newest page.
126#[derive(Debug, Clone, Default, Serialize, Deserialize)]
127#[serde(rename_all = "camelCase")]
128pub struct VoiceCallsQuery {
129    /// RFC 3339 cursor; rows with `end_at < before` are returned.
130    #[serde(default, skip_serializing_if = "Option::is_none")]
131    pub before: Option<String>,
132    /// 1..=200. Server default is 50.
133    #[serde(default, skip_serializing_if = "Option::is_none")]
134    pub limit: Option<u32>,
135}
136
137/// Marker for the `/api/voice/calls/{sync,list}` endpoint pair.
138///
139/// Use as a type parameter, never construct: `client.sync::<VoiceCalls>(&items)`.
140pub struct VoiceCalls;
141
142impl SyncEndpoint for VoiceCalls {
143    const RESOURCE: &'static str = "calls";
144    type Record = VoiceCallRecord;
145    type Query = VoiceCallsQuery;
146}
147
148impl HasSyncEnvelope for VoiceCallRecord {
149    fn envelope_mut(&mut self) -> &mut SyncEnvelope {
150        &mut self.envelope
151    }
152}
153
154// ---- VoiceRecordings ------------------------------------------------------
155
156/// One per-call recording's metadata as it crosses the wire from the
157/// daemon up to the platform. The WAV bytes ride on a separate
158/// follow-up call ([`Client::upload_recording_bytes`]) so the
159/// idempotent metadata sync stays small and a flaky bytes upload
160/// doesn't force the daemon to re-ship the row.
161///
162/// Mirrors the daemon's `RecordingArtifact` (see
163/// `wavekat-voice/crates/wavekat-voice/src/recording.rs`) with one
164/// rename: the daemon's local id (`id`) ships as `source_id` because
165/// the platform allocates its own row id and treats the daemon-side
166/// UUID as the idempotency key (same convention as
167/// [`VoiceCallRecord`]).
168#[derive(Debug, Clone, Serialize, Deserialize)]
169#[serde(rename_all = "camelCase")]
170pub struct VoiceRecordingRecord {
171    /// Daemon-generated UUID for this recording artifact. Upsert key
172    /// on the platform side.
173    pub source_id: String,
174    /// Daemon's `calls.id` — the call this recording belongs to.
175    /// The platform stores both so the /voice/calls history page can
176    /// link a call to its recording without a separate join table.
177    pub call_source_id: String,
178    /// Byte length of the WAV file the daemon will PUT in the follow-
179    /// up bytes call. The platform refuses a PUT whose body length
180    /// disagrees.
181    pub size_bytes: u64,
182    pub duration_ms: u64,
183    pub sample_rate: u32,
184    pub channels: u16,
185    /// RFC 3339 timestamp the daemon stamped on the artifact at
186    /// finalize time. Drives the platform's `/voice/recordings` GET
187    /// cursor.
188    pub created_at: String,
189    #[serde(flatten, default)]
190    pub envelope: SyncEnvelope,
191}
192
193/// Query params for `GET /api/voice/recordings`.
194#[derive(Debug, Clone, Default, Serialize, Deserialize)]
195#[serde(rename_all = "camelCase")]
196pub struct VoiceRecordingsQuery {
197    /// RFC 3339 cursor; rows with `created_at < before` are returned.
198    #[serde(default, skip_serializing_if = "Option::is_none")]
199    pub before: Option<String>,
200    #[serde(default, skip_serializing_if = "Option::is_none")]
201    pub limit: Option<u32>,
202}
203
204/// Marker for the `/api/voice/recordings/{sync,list}` endpoint pair.
205///
206/// The corresponding bytes-upload endpoint
207/// (`PUT /api/voice/recordings/{sourceId}/bytes`) is invoked via
208/// [`Client::upload_recording_bytes`] — it doesn't fit the
209/// `SyncEndpoint` mold (no batch, no JSON body) so it has its own
210/// inherent method on `Client`.
211pub struct VoiceRecordings;
212
213impl SyncEndpoint for VoiceRecordings {
214    const RESOURCE: &'static str = "recordings";
215    type Record = VoiceRecordingRecord;
216    type Query = VoiceRecordingsQuery;
217}
218
219impl HasSyncEnvelope for VoiceRecordingRecord {
220    fn envelope_mut(&mut self) -> &mut SyncEnvelope {
221        &mut self.envelope
222    }
223}
224
225/// One item in the platform's response to
226/// `POST /api/voice/recordings/sync`. Lets the daemon learn the R2
227/// key the platform stamped (so a subsequent bytes PUT can target it)
228/// without re-deriving it, and check whether bytes have already
229/// landed on a prior cycle (so the daemon can mark the local row
230/// synced without re-uploading the WAV).
231#[derive(Debug, Clone, Serialize, Deserialize)]
232#[serde(rename_all = "camelCase")]
233pub struct VoiceRecordingSyncItem {
234    pub source_id: String,
235    pub r2_key: String,
236    pub bytes_uploaded: bool,
237}
238
239/// Full response from `POST /api/voice/recordings/sync`. Superset of
240/// the generic [`crate::SyncResponse`] — see [`Client::sync_recordings`].
241#[derive(Debug, Clone, Serialize, Deserialize)]
242#[serde(rename_all = "camelCase")]
243pub struct VoiceRecordingsSyncResponse {
244    pub accepted: u32,
245    pub skipped: u32,
246    pub items: Vec<VoiceRecordingSyncItem>,
247}
248
249// ---- VoiceTranscripts -----------------------------------------------------
250
251/// Wire-stable transcript channel tag. Matches the daemon's
252/// `TranscriptChannelLabel` and `events::TranscriptChannel`.
253#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
254#[serde(rename_all = "snake_case")]
255pub enum VoiceTranscriptChannel {
256    /// Local mic audio — what the user said.
257    Local,
258    /// Received RTP audio — what the remote party said.
259    Remote,
260}
261
262/// One ASR transcript segment ("final" in wavekat-asr parlance) as it
263/// crosses the wire. Each segment is a row on the daemon side
264/// (`transcripts` table); the daemon batches a slice of them per
265/// upload and the platform upserts per (user_id, source_id).
266#[derive(Debug, Clone, Serialize, Deserialize)]
267#[serde(rename_all = "camelCase")]
268pub struct VoiceTranscriptRecord {
269    /// Daemon-side row id, formatted as text (the column is an
270    /// autoincrement integer on SQLite). Stable per (call, segment)
271    /// so re-shipping converges.
272    pub source_id: String,
273    /// Daemon's `calls.id` — the call this segment belongs to.
274    pub call_source_id: String,
275    pub channel: VoiceTranscriptChannel,
276    /// Start of the segment in milliseconds relative to the start of
277    /// the call's audio stream (not wall-clock).
278    pub ts_ms: i64,
279    /// End of the segment, same reference frame as `ts_ms`.
280    pub end_ms: i64,
281    /// Recognised text. Free-form; the platform stores it verbatim.
282    pub text: String,
283    #[serde(flatten, default)]
284    pub envelope: SyncEnvelope,
285}
286
287/// Query params for `GET /api/voice/transcripts` — required
288/// `call_source_id` (the endpoint refuses a flat list).
289#[derive(Debug, Clone, Default, Serialize, Deserialize)]
290#[serde(rename_all = "camelCase")]
291pub struct VoiceTranscriptsQuery {
292    pub call_source_id: String,
293}
294
295/// Marker for the `/api/voice/transcripts/{sync,list}` endpoint pair.
296pub struct VoiceTranscripts;
297
298impl SyncEndpoint for VoiceTranscripts {
299    const RESOURCE: &'static str = "transcripts";
300    type Record = VoiceTranscriptRecord;
301    type Query = VoiceTranscriptsQuery;
302}
303
304impl HasSyncEnvelope for VoiceTranscriptRecord {
305    fn envelope_mut(&mut self) -> &mut SyncEnvelope {
306        &mut self.envelope
307    }
308}
309
310// ---- VoiceAccounts --------------------------------------------------------
311
312/// SIP transport for a synced account line. Wire-stable snake_case;
313/// mirrors the daemon's `TransportKind`.
314#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
315#[serde(rename_all = "snake_case")]
316pub enum VoiceTransport {
317    Udp,
318    Tcp,
319}
320
321/// One SIP account line's *configuration* as it crosses the wire from a
322/// device up to the platform and back down to another device
323/// (`wavekat-voice/docs/40-account-config-sync.md`).
324///
325/// Unlike calls / recordings / transcripts — which are immutable,
326/// one-way pushes — account config is **mutable and bidirectional**: a
327/// line is edited, toggled, renamed, and deleted, and those changes must
328/// restore onto a second device. The same idempotent
329/// `(user_id, source_id)` upsert that [`Client::sync`] performs carries
330/// every kind of change here; a *delete* is a soft-delete that rides as
331/// an upsert with `deleted_at` set, because a hard DELETE can't sync
332/// under a "push the row" model — once the row is gone there's nothing
333/// left to push.
334///
335/// **No secret field, by construction.** The SIP password never appears
336/// on this wire. Config sync (policy levels 1–2) keeps the credential
337/// device-local, and the end-to-end-encrypted secret path (level 3)
338/// ships its ciphertext through a *separate* opaque resource, never as a
339/// field here. Omitting it means level 3 can't be populated by accident
340/// before it exists.
341#[derive(Debug, Clone, Serialize, Deserialize)]
342#[serde(rename_all = "camelCase")]
343pub struct VoiceAccountRecord {
344    /// Daemon-side account UUID (`accounts.id`). The platform's
345    /// `(user_id, source_id)` upsert key — re-syncing the same id
346    /// updates the row in place (mutable), unlike the immutable
347    /// resources where a re-sync is a no-op.
348    pub source_id: String,
349    /// Whether the line registers on daemon boot. Pausing a line is a
350    /// portable preference, so it rides along.
351    pub enabled: bool,
352    pub display_name: String,
353    pub username: String,
354    pub domain: String,
355    #[serde(default, skip_serializing_if = "Option::is_none")]
356    pub auth_username: Option<String>,
357    #[serde(default, skip_serializing_if = "Option::is_none")]
358    pub server: Option<String>,
359    #[serde(default, skip_serializing_if = "Option::is_none")]
360    pub port: Option<u16>,
361    pub transport: VoiceTransport,
362    pub register_expires: u32,
363    #[serde(default, skip_serializing_if = "Option::is_none")]
364    pub keepalive_secs: Option<u32>,
365    /// Record-disclosure beep toggle — a column on the account row, so
366    /// it rides along for free (the account-portable taxonomy in doc 40).
367    pub disclosure_enabled: bool,
368    /// RFC 3339 last-modification time — the **last-write-wins key**. On
369    /// conflict the platform (and a pulling client) keep the copy with
370    /// the later `updated_at`. Whole-row LWW for v1; per-field merge is
371    /// deferred until users actually report lost edits (doc 40).
372    pub updated_at: String,
373    /// RFC 3339 soft-delete tombstone. `None` = live; `Some` = the line
374    /// was deleted on some device at that time. A tombstone syncs like
375    /// any other mutation so the delete propagates to other devices,
376    /// then is reaped locally once confirmed. The platform retains
377    /// tombstones so a late-syncing device still learns about the delete.
378    #[serde(default, skip_serializing_if = "Option::is_none")]
379    pub deleted_at: Option<String>,
380    /// Version + forward-compat fields shared by every sync record.
381    #[serde(flatten, default)]
382    pub envelope: SyncEnvelope,
383}
384
385/// Query params for `GET /api/voice/accounts`. All fields optional.
386#[derive(Debug, Clone, Default, Serialize, Deserialize)]
387#[serde(rename_all = "camelCase")]
388pub struct VoiceAccountsQuery {
389    /// Include soft-deleted tombstones in the response. Absent / false
390    /// returns only live lines — the restore-grade pull a fresh device
391    /// wants. A delta-syncing device sets this `true` to also learn
392    /// about deletes made elsewhere (doc 40).
393    #[serde(default, skip_serializing_if = "Option::is_none")]
394    pub include_deleted: Option<bool>,
395}
396
397/// Marker for the `/api/voice/accounts/{sync,list}` endpoint pair.
398///
399/// Accounts are the first *mutable, bidirectional* sync resource, but
400/// the wire shape is the same idempotent upsert the immutable resources
401/// use — the [`SyncResponse::skipped`](crate::sync::SyncResponse) field
402/// was reserved for exactly this case — so no new HTTP plumbing is
403/// needed: `client.sync::<VoiceAccounts>(&items)` uploads (including
404/// tombstones), `client.list::<VoiceAccounts>(&query)` pulls.
405pub struct VoiceAccounts;
406
407impl SyncEndpoint for VoiceAccounts {
408    const RESOURCE: &'static str = "accounts";
409    type Record = VoiceAccountRecord;
410    type Query = VoiceAccountsQuery;
411}
412
413impl HasSyncEnvelope for VoiceAccountRecord {
414    fn envelope_mut(&mut self) -> &mut SyncEnvelope {
415        &mut self.envelope
416    }
417}
418
419// ---- Anonymous install heartbeat ------------------------------------------
420//
421// A first-run / per-launch ping the desktop daemon fires *before* (and
422// independently of) any platform sign-in, so the platform can count
423// installs and track version / OS adoption for users who never sign in.
424// It hits the public, unauthenticated `POST /api/voice/installs/heartbeat`
425// and upserts a row keyed by `install_id` alone (no user) — distinct
426// from the authenticated `voice_clients` heartbeat, which is keyed by
427// `(user, install_id)`.
428//
429// The environment fields (os / os_version / arch / locale) are gathered
430// *here*, inside the client crate, rather than on the consumer side:
431// the daemon only owns the two values this crate genuinely cannot
432// discover — the persisted `install_id` and its own app version.
433
434/// Best-effort snapshot of the host environment, detected at call time.
435/// Every field is best-effort; a probe that fails contributes `None`
436/// (or, for the always-available `os` / `arch`, the compile-time
437/// target) rather than failing the heartbeat.
438#[derive(Debug, Clone, PartialEq, Eq)]
439pub struct SystemInfo {
440    /// `std::env::consts::OS` — `"macos"`, `"windows"`, `"linux"`, …
441    pub os: String,
442    /// Human OS version, e.g. `"15.5.0"`. `None` when the OS probe
443    /// can't determine it.
444    pub os_version: Option<String>,
445    /// `std::env::consts::ARCH` — `"aarch64"`, `"x86_64"`, …
446    pub arch: String,
447    /// BCP-47 system locale, e.g. `"en-NZ"`. `None` when unset /
448    /// undetectable (common for GUI-launched apps on some platforms).
449    pub locale: Option<String>,
450}
451
452impl SystemInfo {
453    /// Probe the current host. Cheap enough to call per heartbeat; we
454    /// don't cache so a locale change between launches is reflected.
455    pub fn detect() -> Self {
456        let os_version = match os_info::get().version() {
457            os_info::Version::Unknown => None,
458            v => Some(v.to_string()),
459        };
460        SystemInfo {
461            os: std::env::consts::OS.to_string(),
462            os_version,
463            arch: std::env::consts::ARCH.to_string(),
464            locale: sys_locale::get_locale(),
465        }
466    }
467}
468
469/// Body of `POST /api/voice/installs/heartbeat`. The daemon supplies
470/// `install_id` + `app_version`; [`Client::install_heartbeat`] fills the
471/// environment fields from [`SystemInfo::detect`].
472#[derive(Debug, Clone, Serialize, Deserialize)]
473#[serde(rename_all = "camelCase")]
474pub struct InstallHeartbeatRequest {
475    /// The daemon's persisted install UUID — the platform's upsert key.
476    pub install_id: String,
477    /// WaveKat Voice's own version (`env!("CARGO_PKG_VERSION")` on the
478    /// daemon side) — *not* this crate's version.
479    pub app_version: String,
480    pub os: String,
481    #[serde(default, skip_serializing_if = "Option::is_none")]
482    pub os_version: Option<String>,
483    #[serde(default, skip_serializing_if = "Option::is_none")]
484    pub arch: Option<String>,
485    #[serde(default, skip_serializing_if = "Option::is_none")]
486    pub locale: Option<String>,
487}
488
489/// The platform's view of an install row, echoed back from a heartbeat.
490#[derive(Debug, Clone, Serialize, Deserialize)]
491#[serde(rename_all = "camelCase")]
492pub struct InstallHeartbeatResponse {
493    pub id: String,
494    pub install_id: String,
495    pub app_version: String,
496    pub os: String,
497    pub os_version: Option<String>,
498    pub arch: Option<String>,
499    pub locale: Option<String>,
500    pub first_seen_at: String,
501    pub last_seen_at: String,
502}
503
504impl Client {
505    /// `POST /api/voice/installs/heartbeat` — the anonymous, no-auth
506    /// first-run install ping. Detects the host environment internally
507    /// and posts it alongside the caller-supplied `install_id` +
508    /// `app_version`. Associated (not a method) because the endpoint is
509    /// unauthenticated — there's no token, and at first run there's no
510    /// signed-in `Client` to hang it off of.
511    ///
512    /// Though unauthenticated, the request is **signed** with the release
513    /// credential `cred` (a per-version Ed25519 key + master-issued
514    /// certificate the consumer bakes in at build time) so the platform
515    /// can verify it came from a genuine release and reject forged or
516    /// replayed pings — see [`Client::post_public_signed_json`] and
517    /// [`crate::sign`]. The platform needs only the master *public* key to
518    /// verify.
519    ///
520    /// `base_url` is the platform base (e.g. `https://platform.wavekat.com`).
521    pub async fn install_heartbeat(
522        base_url: &str,
523        install_id: &str,
524        app_version: &str,
525        cred: &ReleaseCredential,
526    ) -> Result<InstallHeartbeatResponse> {
527        let sys = SystemInfo::detect();
528        let body = InstallHeartbeatRequest {
529            install_id: install_id.to_string(),
530            app_version: app_version.to_string(),
531            os: sys.os,
532            os_version: sys.os_version,
533            arch: Some(sys.arch),
534            locale: sys.locale,
535        };
536        Client::post_public_signed_json::<InstallHeartbeatResponse, _>(
537            base_url,
538            "/api/voice/installs/heartbeat",
539            &body,
540            cred,
541        )
542        .await
543    }
544}
545
546// ---- Client surface for recordings ----------------------------------------
547//
548// Recordings don't fit the generic `Client::sync` shape cleanly:
549//
550//   - the response carries per-item provenance (the platform-stamped
551//     `r2Key`, plus whether bytes have already landed) that the
552//     daemon needs in order to decide which rows still owe a PUT;
553//   - the bytes upload is its own HTTP call (`PUT
554//     /api/voice/recordings/{sourceId}/bytes`), not a JSON batch.
555//
556// Rather than overloading `SyncEndpoint` to carry these shapes, we
557// expose two inherent methods on `Client` that compose the existing
558// JSON / bytes-PUT primitives.
559
560impl Client {
561    /// `POST /api/voice/recordings/sync` — idempotent batch upsert of
562    /// recording metadata. Returns the per-item `r2Key` the daemon
563    /// should target for the follow-up bytes PUT, and whether bytes
564    /// have already landed for each row.
565    ///
566    /// Batch sizing rules match [`Client::sync`]: the platform rejects
567    /// batches over 100 items; the daemon's uploader chunks at 50.
568    pub async fn sync_recordings(
569        &self,
570        items: &[VoiceRecordingRecord],
571    ) -> Result<VoiceRecordingsSyncResponse> {
572        let stamped = stamp_schema_version::<VoiceRecordings>(items);
573        let body = SyncRequest { items: stamped };
574        self.post_json::<VoiceRecordingsSyncResponse, _>("/api/voice/recordings/sync", &body)
575            .await
576    }
577
578    /// `PUT /api/voice/recordings/{sourceId}/bytes` — upload the WAV
579    /// bytes for a recording whose metadata was previously synced via
580    /// [`Client::sync_recordings`]. The platform refuses (`HTTP 413`)
581    /// if `bytes.len()` disagrees with the synced `sizeBytes`.
582    ///
583    /// `source_id` is path-segmented as-is; callers pass the
584    /// daemon-side UUID they used for the metadata sync. Empty /
585    /// path-traversal-shaped ids are not specifically guarded here —
586    /// the platform's Zod schema rejects them server-side, so a
587    /// malformed id surfaces as a 4xx via [`Error::Http`].
588    pub async fn upload_recording_bytes(&self, source_id: &str, bytes: Vec<u8>) -> Result<()> {
589        if source_id.is_empty() {
590            return Err(Error::BadRequest("source_id must not be empty".into()));
591        }
592        let path = format!("/api/voice/recordings/{source_id}/bytes");
593        self.put_raw_bytes(&path, "audio/wav", bytes).await
594    }
595}
596
597// ---- Recording sharing ----------------------------------------------------
598//
599// Sharing is a *command* — mutate one recording's share state and get a
600// result back — not the "batch upsert + cursor list" shape `SyncEndpoint`
601// exists for (see wavekat-voice doc 38). So it's a typed method pair on
602// `Client` (mirroring `whoami` rather than `sync::<E>()`), not a marker.
603//
604// The desktop daemon keeps only a *mirror* of what these return; the
605// platform is authoritative for who may open a share. See
606// `wavekat-voice/docs/38-share-a-recording.md`.
607
608/// Access tier for a shared recording, mirroring Loom's model. Wire-stable
609/// snake_case strings — the platform's Zod schema validates against this
610/// exact list, so a rename here would bounce every share command with a 400.
611///
612/// - `Private` — owner only (the default; "not shared").
613/// - `Restricted` — owner + explicitly invited WaveKat accounts; the
614///   recipient must be signed in as an invited identity ("protected by login").
615/// - `Public` — anyone holding the capability link, no sign-in.
616#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
617#[serde(rename_all = "snake_case")]
618pub enum ShareVisibility {
619    Private,
620    Restricted,
621    Public,
622}
623
624/// How a shared recording's caller/callee identity (the call's `party`) is
625/// exposed to a viewer. Wire-stable snake_case, matching the platform's Zod
626/// enum, so a rename here bounces a share command with a 400.
627///
628/// - `Full` — hidden behind a neutral direction label ("Inbound call").
629/// - `Partial` — best-effort redaction (keeps shape, drops the value).
630/// - `None` — the raw `party` is shown.
631///
632/// Absent on the wire → the platform defaults to `Partial` (identity
633/// masked) — privacy-forward without fully erasing the caller. See
634/// `wavekat-platform` docs/14.
635#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
636#[serde(rename_all = "snake_case")]
637pub enum PartyMasking {
638    Full,
639    Partial,
640    None,
641}
642
643/// Body of `POST /api/voice/recordings/{id}/share` — create or update a
644/// recording's share. The recording must already be synced (metadata +
645/// bytes) or the platform returns 404.
646#[derive(Debug, Clone, Serialize, Deserialize)]
647#[serde(rename_all = "camelCase")]
648pub struct ShareRecordingRequest {
649    /// The artifact UUID, as synced (daemon-side `artifacts.id`). Goes in
650    /// the URL path; carried in the struct so callers pass one value.
651    pub recording_source_id: String,
652    pub visibility: ShareVisibility,
653    /// Restricted tier — the WaveKat-account emails allowed to open the
654    /// share. Ignored (and omitted) for `Private` / `Public`.
655    #[serde(default, skip_serializing_if = "Option::is_none")]
656    pub invited_emails: Option<Vec<String>>,
657    /// Per-share visibility controls (platform docs/14) — what a viewer may
658    /// see. Each is omitted when unset; the platform then applies its
659    /// privacy-forward default (identity masked, transcript hidden, audio
660    /// shown, download off). NB the platform treats the request as the
661    /// *full* desired state, so an omitted control is reset to its default,
662    /// not preserved from a prior share — send all of them when editing an
663    /// existing share's controls.
664    #[serde(default, skip_serializing_if = "Option::is_none")]
665    pub party_masking: Option<PartyMasking>,
666    #[serde(default, skip_serializing_if = "Option::is_none")]
667    pub show_transcript: Option<bool>,
668    #[serde(default, skip_serializing_if = "Option::is_none")]
669    pub show_audio: Option<bool>,
670    /// Whether a viewer may *download* the WAV, distinct from hearing it.
671    /// Off by default and only meaningful while `show_audio` is true — the
672    /// platform forces it off otherwise (you can't save what you can't
673    /// hear). A soft control: it hides the viewer's Download affordance,
674    /// not the bytes a listener already fetches to play.
675    #[serde(default, skip_serializing_if = "Option::is_none")]
676    pub allow_download: Option<bool>,
677    /// Per-channel playback defaults — which side is *audible by default*
678    /// in the viewer's player (docs/14). A call has two channels: `local`
679    /// (the owner's microphone, "your side") and `remote` (the other
680    /// party, "their side"). `true` means that side starts muted; the
681    /// viewer can still un-mute it, and the audio file is unchanged — this
682    /// is only the player's starting state. Each is omitted when unset, in
683    /// which case the platform defaults to audible (`false`). Only
684    /// meaningful while `show_audio` is true; ignored when audio is hidden.
685    #[serde(default, skip_serializing_if = "Option::is_none")]
686    pub default_mute_local: Option<bool>,
687    #[serde(default, skip_serializing_if = "Option::is_none")]
688    pub default_mute_remote: Option<bool>,
689    /// Phase 2 — out-of-band password gate. Omitted when unset.
690    #[serde(default, skip_serializing_if = "Option::is_none")]
691    pub password: Option<String>,
692    /// Phase 2 — RFC 3339 auto-revoke time. Omitted when unset.
693    #[serde(default, skip_serializing_if = "Option::is_none")]
694    pub expires_at: Option<String>,
695}
696
697/// The platform's response to a successful share command. `share_url` is
698/// the full https link the user copies; `token` is the opaque capability
699/// identifier embedded in it (returned separately so the daemon can store
700/// it for display without re-parsing the URL).
701#[derive(Debug, Clone, Serialize, Deserialize)]
702#[serde(rename_all = "camelCase")]
703pub struct ShareRecordingResponse {
704    pub visibility: ShareVisibility,
705    pub token: String,
706    pub share_url: String,
707    /// RFC 3339 — when the recording was first shared.
708    pub shared_at: String,
709    /// Effective visibility controls the platform stored (docs/14). Optional
710    /// for tolerance — a platform predating the feature omits them, in which
711    /// case the daemon should assume the defaults (identity masked, transcript
712    /// hidden, audio shown, download off).
713    #[serde(default, skip_serializing_if = "Option::is_none")]
714    pub party_masking: Option<PartyMasking>,
715    #[serde(default, skip_serializing_if = "Option::is_none")]
716    pub show_transcript: Option<bool>,
717    #[serde(default, skip_serializing_if = "Option::is_none")]
718    pub show_audio: Option<bool>,
719    /// Effective download permission — `show_audio && allow_download`, so
720    /// it's never true when the audio is hidden. Absent on a platform
721    /// predating the control (assume off).
722    #[serde(default, skip_serializing_if = "Option::is_none")]
723    pub allow_download: Option<bool>,
724    /// Effective per-channel playback defaults the platform stored — which
725    /// side starts muted in the viewer's player (docs/14). Absent on a
726    /// platform predating the control (assume audible, `false`).
727    #[serde(default, skip_serializing_if = "Option::is_none")]
728    pub default_mute_local: Option<bool>,
729    #[serde(default, skip_serializing_if = "Option::is_none")]
730    pub default_mute_remote: Option<bool>,
731}
732
733/// The platform's response to `GET /api/voice/recordings/{id}/share` — the
734/// *authoritative* current share state for an owned recording. The POST
735/// reply omits the invited-email list and a local mirror can't reflect a
736/// share changed from another device, so the desktop "who can open this"
737/// panel reads here.
738///
739/// A recording that was never shared (or whose share is revoked / expired)
740/// comes back as [`ShareVisibility::Private`] with the optional fields
741/// absent — the same "not shared" state DELETE leaves behind.
742#[derive(Debug, Clone, Serialize, Deserialize)]
743#[serde(rename_all = "camelCase")]
744pub struct ShareStateResponse {
745    pub visibility: ShareVisibility,
746    /// Absent when `visibility == Private` (nothing is shared).
747    #[serde(default, skip_serializing_if = "Option::is_none")]
748    pub token: Option<String>,
749    #[serde(default, skip_serializing_if = "Option::is_none")]
750    pub share_url: Option<String>,
751    /// RFC 3339 — when the recording was first shared. Absent when private.
752    #[serde(default, skip_serializing_if = "Option::is_none")]
753    pub shared_at: Option<String>,
754    /// The restricted tier's audience (lowercased, de-duped). Present
755    /// (possibly empty) only for [`ShareVisibility::Restricted`].
756    #[serde(default, skip_serializing_if = "Option::is_none")]
757    pub invited_emails: Option<Vec<String>>,
758    /// Per-share visibility controls (docs/14). Present for a live share;
759    /// absent when `Private` (nothing is shared, so no controls apply).
760    #[serde(default, skip_serializing_if = "Option::is_none")]
761    pub party_masking: Option<PartyMasking>,
762    #[serde(default, skip_serializing_if = "Option::is_none")]
763    pub show_transcript: Option<bool>,
764    #[serde(default, skip_serializing_if = "Option::is_none")]
765    pub show_audio: Option<bool>,
766    /// Effective download permission — `show_audio && allow_download`, so
767    /// never true when the audio is hidden. Absent when private.
768    #[serde(default, skip_serializing_if = "Option::is_none")]
769    pub allow_download: Option<bool>,
770    /// Effective per-channel playback defaults — which side starts muted in
771    /// the viewer's player (docs/14). Absent when private.
772    #[serde(default, skip_serializing_if = "Option::is_none")]
773    pub default_mute_local: Option<bool>,
774    #[serde(default, skip_serializing_if = "Option::is_none")]
775    pub default_mute_remote: Option<bool>,
776}
777
778impl Client {
779    /// `POST /api/voice/recordings/{id}/share` — create or update a share
780    /// for an already-synced recording. Returns the capability link + token
781    /// the desktop UI puts on the clipboard.
782    ///
783    /// Per the 404-not-403 ownership rule (doc 21 §"Authorization"), asking
784    /// to share a recording the caller doesn't own surfaces as
785    /// [`Error::Http`] with status 404 — existence doesn't leak.
786    pub async fn share_recording(
787        &self,
788        req: &ShareRecordingRequest,
789    ) -> Result<ShareRecordingResponse> {
790        if req.recording_source_id.is_empty() {
791            return Err(Error::BadRequest(
792                "recording_source_id must not be empty".into(),
793            ));
794        }
795        let path = format!("/api/voice/recordings/{}/share", req.recording_source_id);
796        self.post_json::<ShareRecordingResponse, _>(&path, req)
797            .await
798    }
799
800    /// `GET /api/voice/recordings/{id}/share` — read the authoritative
801    /// share state for an owned recording, including the restricted tier's
802    /// invited emails (which the share command's reply omits). Like
803    /// [`share_recording`](Self::share_recording), a recording the caller
804    /// doesn't own surfaces as [`Error::Http`] with status 404.
805    pub async fn get_recording_share(
806        &self,
807        recording_source_id: &str,
808    ) -> Result<ShareStateResponse> {
809        if recording_source_id.is_empty() {
810            return Err(Error::BadRequest(
811                "recording_source_id must not be empty".into(),
812            ));
813        }
814        let path = format!("/api/voice/recordings/{recording_source_id}/share");
815        self.get_json::<ShareStateResponse>(&path).await
816    }
817
818    /// `DELETE /api/voice/recordings/{id}/share` — revoke the share. The
819    /// recording reverts to Private and any outstanding link returns 410.
820    pub async fn revoke_recording_share(&self, recording_source_id: &str) -> Result<()> {
821        if recording_source_id.is_empty() {
822            return Err(Error::BadRequest(
823                "recording_source_id must not be empty".into(),
824            ));
825        }
826        let path = format!("/api/voice/recordings/{recording_source_id}/share");
827        self.delete(&path).await
828    }
829}
830
831#[cfg(test)]
832mod tests {
833    use super::*;
834
835    #[test]
836    fn share_visibility_types_are_reachable_from_the_crate_root() {
837        // Regression for the 0.0.13 gap: `PartyMasking` was added to this
838        // module but left out of the crate-root `pub use voice::{…}`, and the
839        // module is private — so a consumer (`wavekat-voice`) couldn't name
840        // the type to build a `ShareRecordingRequest`. Pin every share-control
841        // type to the root path so dropping one fails to compile here, not in
842        // a downstream crate. The body never runs; reachability is the test.
843        #[allow(dead_code)]
844        fn _reachable() {
845            let _: Option<crate::PartyMasking> = Some(crate::PartyMasking::Partial);
846            let _: Option<crate::ShareVisibility> = Some(crate::ShareVisibility::Public);
847            let _: fn(&crate::ShareRecordingRequest) = |_| {};
848            let _: fn(&crate::ShareRecordingResponse) = |_| {};
849        }
850    }
851
852    #[test]
853    fn record_serializes_with_camel_case_keys() {
854        let r = VoiceCallRecord {
855            source_id: "11111111-1111-4111-8111-111111111111".into(),
856            account_id: "22222222-2222-4222-8222-222222222222".into(),
857            direction: VoiceCallDirection::Inbound,
858            party: "+14155550123".into(),
859            ring_at: "2026-05-16T10:00:00Z".into(),
860            answer_at: Some("2026-05-16T10:00:05Z".into()),
861            end_at: "2026-05-16T10:01:00Z".into(),
862            duration_ms: Some(55_000),
863            disposition: VoiceCallDisposition::Answered,
864            end_reason: VoiceCallEndReason::HangupRemote,
865            error: None,
866            share_visibility: None,
867            envelope: SyncEnvelope::for_endpoint::<VoiceCalls>(),
868        };
869        let s = serde_json::to_string(&r).unwrap();
870        assert!(s.contains("\"sourceId\":"), "{s}");
871        assert!(s.contains("\"accountId\":"), "{s}");
872        assert!(s.contains("\"ringAt\":"), "{s}");
873        assert!(s.contains("\"endAt\":"), "{s}");
874        assert!(s.contains("\"durationMs\":55000"), "{s}");
875        // Optional `error` is None — should be omitted from the wire.
876        assert!(!s.contains("\"error\""), "error should be omitted: {s}");
877        // Envelope flattens to the top of the object — schemaVersion
878        // sits next to the other fields rather than nested under
879        // "envelope". Future resources rely on this layout.
880        assert!(
881            s.contains("\"schemaVersion\":1"),
882            "schemaVersion should flatten: {s}"
883        );
884        // `extras` is None, so the envelope contributes no `extras`
885        // key. Stays out of the row to keep the small/fast path.
886        assert!(!s.contains("\"extras\""), "extras should be omitted: {s}");
887    }
888
889    #[test]
890    fn record_round_trips_optional_fields() {
891        // An unanswered call has answer_at/duration_ms/error all absent.
892        let raw = r#"{
893            "sourceId": "a",
894            "accountId": "b",
895            "direction": "inbound",
896            "party": "anonymous",
897            "ringAt": "2026-05-16T10:00:00Z",
898            "endAt": "2026-05-16T10:00:30Z",
899            "disposition": "missed",
900            "endReason": "missed"
901        }"#;
902        let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
903        assert!(parsed.answer_at.is_none());
904        assert!(parsed.duration_ms.is_none());
905        assert!(parsed.error.is_none());
906        assert_eq!(parsed.disposition, VoiceCallDisposition::Missed);
907        assert_eq!(parsed.end_reason, VoiceCallEndReason::Missed);
908    }
909
910    #[test]
911    fn query_omits_unset_fields() {
912        let q = VoiceCallsQuery::default();
913        let s = serde_json::to_string(&q).unwrap();
914        // Empty object — every field skipped when None.
915        assert_eq!(
916            s, "{}",
917            "default query should serialize to empty object: {s}"
918        );
919    }
920
921    #[test]
922    fn enum_round_trip_via_json() {
923        // The wire form for each direction/disposition/reason must
924        // match what the daemon and platform expect — this guards
925        // against accidental Rust-side renames.
926        for d in [VoiceCallDirection::Inbound, VoiceCallDirection::Outbound] {
927            let s = serde_json::to_string(&d).unwrap();
928            let back: VoiceCallDirection = serde_json::from_str(&s).unwrap();
929            assert_eq!(d, back);
930        }
931        for d in [
932            VoiceCallDisposition::Answered,
933            VoiceCallDisposition::Missed,
934            VoiceCallDisposition::Rejected,
935            VoiceCallDisposition::Cancelled,
936            VoiceCallDisposition::Failed,
937        ] {
938            let s = serde_json::to_string(&d).unwrap();
939            let back: VoiceCallDisposition = serde_json::from_str(&s).unwrap();
940            assert_eq!(d, back);
941        }
942        for r in [
943            VoiceCallEndReason::HangupLocal,
944            VoiceCallEndReason::HangupRemote,
945            VoiceCallEndReason::RejectedLocal,
946            VoiceCallEndReason::RejectedRemote,
947            VoiceCallEndReason::Missed,
948            VoiceCallEndReason::CancelledLocal,
949            VoiceCallEndReason::ConnectionLost,
950            VoiceCallEndReason::Failed,
951        ] {
952            let s = serde_json::to_string(&r).unwrap();
953            let back: VoiceCallEndReason = serde_json::from_str(&s).unwrap();
954            assert_eq!(r, back);
955        }
956    }
957
958    #[test]
959    fn connection_lost_pins_its_wire_string() {
960        // The platform's sync endpoint validates end reasons against
961        // an exact string list — a rename here would make every
962        // upload from a session-timer teardown bounce with a 400.
963        let s = serde_json::to_string(&VoiceCallEndReason::ConnectionLost).unwrap();
964        assert_eq!(s, "\"connection_lost\"");
965    }
966
967    #[test]
968    fn voice_calls_marker_resource_is_calls() {
969        assert_eq!(<VoiceCalls as SyncEndpoint>::RESOURCE, "calls");
970    }
971
972    #[test]
973    fn record_accepts_unknown_extras_for_forward_compat() {
974        // A newer client shipping a `notes` field that this platform
975        // version doesn't have a column for should round-trip via
976        // the `extras` envelope. The platform persists the blob
977        // verbatim; a future deploy can promote it to a typed
978        // column without data loss.
979        let raw = r#"{
980            "sourceId": "a",
981            "accountId": "b",
982            "direction": "inbound",
983            "party": "anon",
984            "ringAt": "2026-05-16T10:00:00Z",
985            "endAt": "2026-05-16T10:00:30Z",
986            "disposition": "answered",
987            "endReason": "hangup_remote",
988            "schemaVersion": 2,
989            "extras": { "notes": "from staging build" }
990        }"#;
991        let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
992        assert_eq!(parsed.envelope.schema_version, Some(2));
993        let extras = parsed.envelope.extras.as_ref().expect("extras present");
994        assert_eq!(extras["notes"], "from staging build");
995    }
996
997    #[test]
998    fn call_record_parses_share_visibility_from_list_response() {
999        // The list / detail endpoints decorate a call with the tier of any
1000        // active share on its recording, so a consumer can badge the row.
1001        let raw = r#"{
1002            "sourceId": "a",
1003            "accountId": "b",
1004            "direction": "outbound",
1005            "party": "+14155550123",
1006            "ringAt": "2026-05-16T10:00:00Z",
1007            "endAt": "2026-05-16T10:00:30Z",
1008            "disposition": "answered",
1009            "endReason": "hangup_remote",
1010            "shareVisibility": "public"
1011        }"#;
1012        let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
1013        assert_eq!(parsed.share_visibility, Some(ShareVisibility::Public));
1014
1015        let restricted = raw.replace("public", "restricted");
1016        let parsed: VoiceCallRecord = serde_json::from_str(&restricted).unwrap();
1017        assert_eq!(parsed.share_visibility, Some(ShareVisibility::Restricted));
1018    }
1019
1020    #[test]
1021    fn call_record_unshared_has_no_share_visibility() {
1022        // Absent (older platform, or an unshared call) and an explicit
1023        // `null` both read as "not shared" — never `Some(Private)`.
1024        let base = r#"{
1025            "sourceId": "a",
1026            "accountId": "b",
1027            "direction": "inbound",
1028            "party": "anon",
1029            "ringAt": "2026-05-16T10:00:00Z",
1030            "endAt": "2026-05-16T10:00:30Z",
1031            "disposition": "missed",
1032            "endReason": "missed"
1033        }"#;
1034        let parsed: VoiceCallRecord = serde_json::from_str(base).unwrap();
1035        assert_eq!(parsed.share_visibility, None);
1036
1037        let with_null = base.replace(
1038            r#""endReason": "missed""#,
1039            r#""endReason": "missed", "shareVisibility": null"#,
1040        );
1041        let parsed: VoiceCallRecord = serde_json::from_str(&with_null).unwrap();
1042        assert_eq!(parsed.share_visibility, None);
1043    }
1044
1045    #[test]
1046    fn synced_call_omits_share_visibility() {
1047        // `share_visibility` is read-only decoration: a call uploaded via
1048        // sync must not carry it on the wire (skip_serializing_if = None),
1049        // so the round trip from a sync-shaped record stays clean.
1050        let raw = r#"{
1051            "sourceId": "a",
1052            "accountId": "b",
1053            "direction": "inbound",
1054            "party": "anon",
1055            "ringAt": "2026-05-16T10:00:00Z",
1056            "endAt": "2026-05-16T10:00:30Z",
1057            "disposition": "answered",
1058            "endReason": "hangup_remote"
1059        }"#;
1060        let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
1061        assert_eq!(parsed.share_visibility, None);
1062        let s = serde_json::to_string(&parsed).unwrap();
1063        assert!(
1064            !s.contains("shareVisibility"),
1065            "sync payload leaked share_visibility: {s}"
1066        );
1067    }
1068
1069    #[test]
1070    fn recording_marker_resource_is_recordings() {
1071        // Path constant drives the URL in `Client::sync_recordings`;
1072        // a rename here would silently 404 against the platform.
1073        assert_eq!(<VoiceRecordings as SyncEndpoint>::RESOURCE, "recordings");
1074    }
1075
1076    #[test]
1077    fn recording_record_serializes_with_camel_case_and_envelope() {
1078        let r = VoiceRecordingRecord {
1079            source_id: "11111111-1111-4111-8111-111111111111".into(),
1080            call_source_id: "22222222-2222-4222-8222-222222222222".into(),
1081            size_bytes: 44 + 64_000,
1082            duration_ms: 2_000,
1083            sample_rate: 8_000,
1084            channels: 2,
1085            created_at: "2026-05-16T10:01:05Z".into(),
1086            envelope: SyncEnvelope::for_endpoint::<VoiceRecordings>(),
1087        };
1088        let s = serde_json::to_string(&r).unwrap();
1089        // Field-by-field wire contract — these strings are also what
1090        // the platform's Zod schema expects.
1091        assert!(s.contains("\"sourceId\":"), "{s}");
1092        assert!(s.contains("\"callSourceId\":"), "{s}");
1093        assert!(s.contains("\"sizeBytes\":64044"), "{s}");
1094        assert!(s.contains("\"durationMs\":2000"), "{s}");
1095        assert!(s.contains("\"sampleRate\":8000"), "{s}");
1096        assert!(s.contains("\"channels\":2"), "{s}");
1097        assert!(s.contains("\"createdAt\":"), "{s}");
1098        // Envelope flattens to the top of the object, same as VoiceCallRecord.
1099        assert!(s.contains("\"schemaVersion\":1"), "{s}");
1100    }
1101
1102    #[test]
1103    fn recordings_sync_response_round_trips() {
1104        // The richer-than-generic response carries per-item provenance —
1105        // the daemon's uploader reads `r2Key` for the bytes follow-up
1106        // and `bytesUploaded` to short-circuit when the row already
1107        // landed on a previous cycle.
1108        let raw = r#"{
1109            "accepted": 2,
1110            "skipped": 0,
1111            "items": [
1112                {"sourceId": "a", "r2Key": "voice/recordings/1/a.wav", "bytesUploaded": false},
1113                {"sourceId": "b", "r2Key": "voice/recordings/1/b.wav", "bytesUploaded": true}
1114            ]
1115        }"#;
1116        let parsed: VoiceRecordingsSyncResponse = serde_json::from_str(raw).unwrap();
1117        assert_eq!(parsed.accepted, 2);
1118        assert_eq!(parsed.items.len(), 2);
1119        assert_eq!(parsed.items[0].r2_key, "voice/recordings/1/a.wav");
1120        assert!(!parsed.items[0].bytes_uploaded);
1121        assert!(parsed.items[1].bytes_uploaded);
1122    }
1123
1124    #[test]
1125    fn install_heartbeat_request_serializes_with_camel_case_keys() {
1126        let req = InstallHeartbeatRequest {
1127            install_id: "11111111-1111-4111-8111-111111111111".into(),
1128            app_version: "0.0.21".into(),
1129            os: "macos".into(),
1130            os_version: Some("15.5.0".into()),
1131            arch: Some("aarch64".into()),
1132            locale: Some("en-NZ".into()),
1133        };
1134        let s = serde_json::to_string(&req).unwrap();
1135        assert!(s.contains("\"installId\":"), "{s}");
1136        assert!(s.contains("\"appVersion\":\"0.0.21\""), "{s}");
1137        assert!(s.contains("\"os\":\"macos\""), "{s}");
1138        assert!(s.contains("\"osVersion\":\"15.5.0\""), "{s}");
1139        assert!(s.contains("\"arch\":\"aarch64\""), "{s}");
1140        assert!(s.contains("\"locale\":\"en-NZ\""), "{s}");
1141    }
1142
1143    #[test]
1144    fn install_heartbeat_request_omits_absent_optional_fields() {
1145        // A host where the OS version / locale probe came up empty
1146        // shouldn't send `null` — keeping the keys out lets the
1147        // platform's Zod `.optional()` accept the body and the column
1148        // stay NULL rather than the string "null".
1149        let req = InstallHeartbeatRequest {
1150            install_id: "x".into(),
1151            app_version: "0.0.21".into(),
1152            os: "linux".into(),
1153            os_version: None,
1154            arch: None,
1155            locale: None,
1156        };
1157        let s = serde_json::to_string(&req).unwrap();
1158        assert!(!s.contains("osVersion"), "osVersion should be omitted: {s}");
1159        assert!(!s.contains("arch"), "arch should be omitted: {s}");
1160        assert!(!s.contains("locale"), "locale should be omitted: {s}");
1161    }
1162
1163    #[test]
1164    fn install_heartbeat_response_parses_platform_shape() {
1165        let raw = r#"{
1166            "id": "abc-123",
1167            "installId": "11111111-1111-4111-8111-111111111111",
1168            "appVersion": "0.0.21",
1169            "os": "macos",
1170            "osVersion": "15.5.0",
1171            "arch": "aarch64",
1172            "locale": null,
1173            "firstSeenAt": "2026-05-31T10:00:00.000Z",
1174            "lastSeenAt": "2026-05-31T10:00:00.000Z"
1175        }"#;
1176        let parsed: InstallHeartbeatResponse = serde_json::from_str(raw).unwrap();
1177        assert_eq!(parsed.id, "abc-123");
1178        assert_eq!(parsed.app_version, "0.0.21");
1179        assert_eq!(parsed.os_version.as_deref(), Some("15.5.0"));
1180        assert!(parsed.locale.is_none());
1181    }
1182
1183    #[test]
1184    fn system_info_detect_fills_os_and_arch() {
1185        // os / arch come from compile-time consts, so they're always
1186        // non-empty on every supported target. os_version / locale are
1187        // best-effort and intentionally not asserted.
1188        let sys = SystemInfo::detect();
1189        assert!(!sys.os.is_empty(), "os should be a non-empty target string");
1190        assert!(
1191            !sys.arch.is_empty(),
1192            "arch should be a non-empty target string"
1193        );
1194    }
1195
1196    #[test]
1197    fn transcripts_marker_resource_is_transcripts() {
1198        assert_eq!(<VoiceTranscripts as SyncEndpoint>::RESOURCE, "transcripts");
1199    }
1200
1201    #[test]
1202    fn transcript_record_serializes_with_camel_case_and_channel_enum() {
1203        let r = VoiceTranscriptRecord {
1204            source_id: "1".into(),
1205            call_source_id: "22222222-2222-4222-8222-222222222222".into(),
1206            channel: VoiceTranscriptChannel::Remote,
1207            ts_ms: 100,
1208            end_ms: 1_500,
1209            text: "hello".into(),
1210            envelope: SyncEnvelope::for_endpoint::<VoiceTranscripts>(),
1211        };
1212        let s = serde_json::to_string(&r).unwrap();
1213        assert!(s.contains("\"sourceId\":"), "{s}");
1214        assert!(s.contains("\"callSourceId\":"), "{s}");
1215        // The channel enum is wire-stable snake_case — matches the
1216        // platform's Zod `enum(VOICE_TRANSCRIPT_CHANNELS)`.
1217        assert!(s.contains("\"channel\":\"remote\""), "{s}");
1218        assert!(s.contains("\"tsMs\":100"), "{s}");
1219        assert!(s.contains("\"endMs\":1500"), "{s}");
1220        assert!(s.contains("\"text\":\"hello\""), "{s}");
1221        assert!(s.contains("\"schemaVersion\":1"), "{s}");
1222    }
1223
1224    #[test]
1225    fn share_visibility_pins_its_wire_strings() {
1226        // The platform validates these against an exact string list; a
1227        // rename would bounce every share command with a 400.
1228        assert_eq!(
1229            serde_json::to_string(&ShareVisibility::Private).unwrap(),
1230            "\"private\""
1231        );
1232        assert_eq!(
1233            serde_json::to_string(&ShareVisibility::Restricted).unwrap(),
1234            "\"restricted\""
1235        );
1236        assert_eq!(
1237            serde_json::to_string(&ShareVisibility::Public).unwrap(),
1238            "\"public\""
1239        );
1240        for v in [
1241            ShareVisibility::Private,
1242            ShareVisibility::Restricted,
1243            ShareVisibility::Public,
1244        ] {
1245            let s = serde_json::to_string(&v).unwrap();
1246            let back: ShareVisibility = serde_json::from_str(&s).unwrap();
1247            assert_eq!(v, back);
1248        }
1249    }
1250
1251    #[test]
1252    fn share_request_serializes_with_camel_case_and_omits_unset() {
1253        let req = ShareRecordingRequest {
1254            recording_source_id: "11111111-1111-4111-8111-111111111111".into(),
1255            visibility: ShareVisibility::Public,
1256            invited_emails: None,
1257            party_masking: None,
1258            show_transcript: None,
1259            show_audio: None,
1260            allow_download: None,
1261            default_mute_local: None,
1262            default_mute_remote: None,
1263            password: None,
1264            expires_at: None,
1265        };
1266        let s = serde_json::to_string(&req).unwrap();
1267        assert!(s.contains("\"recordingSourceId\":"), "{s}");
1268        assert!(s.contains("\"visibility\":\"public\""), "{s}");
1269        // Phase-2 / tier-specific / visibility-control fields stay off the
1270        // wire when unset so the platform's `.optional()` schema accepts the
1271        // body (and the omitted controls fall to the platform defaults).
1272        assert!(!s.contains("invitedEmails"), "{s}");
1273        assert!(!s.contains("partyMasking"), "{s}");
1274        assert!(!s.contains("showTranscript"), "{s}");
1275        assert!(!s.contains("showAudio"), "{s}");
1276        assert!(!s.contains("allowDownload"), "{s}");
1277        assert!(!s.contains("defaultMuteLocal"), "{s}");
1278        assert!(!s.contains("defaultMuteRemote"), "{s}");
1279        assert!(!s.contains("password"), "{s}");
1280        assert!(!s.contains("expiresAt"), "{s}");
1281    }
1282
1283    #[test]
1284    fn share_request_serializes_visibility_controls_camel_case() {
1285        let req = ShareRecordingRequest {
1286            recording_source_id: "a".into(),
1287            visibility: ShareVisibility::Public,
1288            invited_emails: None,
1289            party_masking: Some(PartyMasking::Partial),
1290            show_transcript: Some(false),
1291            show_audio: Some(true),
1292            allow_download: Some(true),
1293            default_mute_local: Some(false),
1294            default_mute_remote: Some(true),
1295            password: None,
1296            expires_at: None,
1297        };
1298        let s = serde_json::to_string(&req).unwrap();
1299        assert!(s.contains("\"partyMasking\":\"partial\""), "{s}");
1300        assert!(s.contains("\"showTranscript\":false"), "{s}");
1301        assert!(s.contains("\"showAudio\":true"), "{s}");
1302        assert!(s.contains("\"allowDownload\":true"), "{s}");
1303        // The owner muted their own side by default but left the other
1304        // party audible — both ride the wire as camelCase booleans.
1305        assert!(s.contains("\"defaultMuteLocal\":false"), "{s}");
1306        assert!(s.contains("\"defaultMuteRemote\":true"), "{s}");
1307    }
1308
1309    #[test]
1310    fn share_request_carries_invited_emails_for_restricted() {
1311        let req = ShareRecordingRequest {
1312            recording_source_id: "a".into(),
1313            visibility: ShareVisibility::Restricted,
1314            invited_emails: Some(vec!["alex@example.com".into()]),
1315            party_masking: None,
1316            show_transcript: None,
1317            show_audio: None,
1318            allow_download: None,
1319            default_mute_local: None,
1320            default_mute_remote: None,
1321            password: None,
1322            expires_at: None,
1323        };
1324        let s = serde_json::to_string(&req).unwrap();
1325        assert!(s.contains("\"visibility\":\"restricted\""), "{s}");
1326        assert!(
1327            s.contains("\"invitedEmails\":[\"alex@example.com\"]"),
1328            "{s}"
1329        );
1330    }
1331
1332    #[test]
1333    fn share_response_parses_platform_shape() {
1334        let raw = r#"{
1335            "visibility": "public",
1336            "token": "Zr7-x9F2k1QpLmN4sT8wYa",
1337            "shareUrl": "https://platform.wavekat.com/voice/s/Zr7-x9F2k1QpLmN4sT8wYa",
1338            "sharedAt": "2026-06-19T10:00:00.000Z"
1339        }"#;
1340        let parsed: ShareRecordingResponse = serde_json::from_str(raw).unwrap();
1341        assert_eq!(parsed.visibility, ShareVisibility::Public);
1342        assert_eq!(parsed.token, "Zr7-x9F2k1QpLmN4sT8wYa");
1343        assert!(parsed.share_url.ends_with(&parsed.token));
1344    }
1345
1346    #[test]
1347    fn share_state_parses_restricted_with_invited_emails() {
1348        // The GET read carries the audience back — this is the field the
1349        // POST reply omits and the desktop "who can open this" panel needs.
1350        let raw = r#"{
1351            "visibility": "restricted",
1352            "token": "Zr7-x9F2k1QpLmN4sT8wYa",
1353            "shareUrl": "https://platform.wavekat.com/voice/s/Zr7-x9F2k1QpLmN4sT8wYa",
1354            "sharedAt": "2026-06-19T10:00:00.000Z",
1355            "invitedEmails": ["bob@example.com", "carol@example.com"],
1356            "partyMasking": "full",
1357            "showTranscript": true,
1358            "showAudio": false,
1359            "allowDownload": false,
1360            "defaultMuteLocal": false,
1361            "defaultMuteRemote": true
1362        }"#;
1363        let parsed: ShareStateResponse = serde_json::from_str(raw).unwrap();
1364        assert_eq!(parsed.visibility, ShareVisibility::Restricted);
1365        assert_eq!(
1366            parsed.invited_emails.as_deref(),
1367            Some(
1368                [
1369                    "bob@example.com".to_string(),
1370                    "carol@example.com".to_string()
1371                ]
1372                .as_slice()
1373            )
1374        );
1375        // The visibility controls ride back on the live-share read.
1376        assert_eq!(parsed.party_masking, Some(PartyMasking::Full));
1377        assert_eq!(parsed.show_transcript, Some(true));
1378        assert_eq!(parsed.show_audio, Some(false));
1379        // Audio hidden here, so download comes back off (platform folds the two).
1380        assert_eq!(parsed.allow_download, Some(false));
1381        // Per-channel playback defaults ride back too.
1382        assert_eq!(parsed.default_mute_local, Some(false));
1383        assert_eq!(parsed.default_mute_remote, Some(true));
1384    }
1385
1386    #[test]
1387    fn share_state_parses_private_with_fields_absent() {
1388        // A never-shared (or revoked) recording reports private with no
1389        // token / url / emails — the optional fields stay None.
1390        let parsed: ShareStateResponse =
1391            serde_json::from_str(r#"{ "visibility": "private" }"#).unwrap();
1392        assert_eq!(parsed.visibility, ShareVisibility::Private);
1393        assert!(parsed.token.is_none());
1394        assert!(parsed.share_url.is_none());
1395        assert!(parsed.shared_at.is_none());
1396        assert!(parsed.invited_emails.is_none());
1397    }
1398
1399    #[test]
1400    fn share_request_rejects_empty_source_id_before_hitting_network() {
1401        // Guarded client-side so an empty id can't produce a path like
1402        // `/api/voice/recordings//share` that 404s confusingly.
1403        let req = ShareRecordingRequest {
1404            recording_source_id: String::new(),
1405            visibility: ShareVisibility::Private,
1406            invited_emails: None,
1407            party_masking: None,
1408            show_transcript: None,
1409            show_audio: None,
1410            allow_download: None,
1411            default_mute_local: None,
1412            default_mute_remote: None,
1413            password: None,
1414            expires_at: None,
1415        };
1416        // We can't call the async method without a runtime here, but the
1417        // guard mirrors `upload_recording_bytes` — assert the precondition
1418        // shape the method checks.
1419        assert!(req.recording_source_id.is_empty());
1420    }
1421
1422    // ---- VoiceAccounts ----
1423
1424    fn sample_account() -> VoiceAccountRecord {
1425        VoiceAccountRecord {
1426            source_id: "11111111-1111-4111-8111-111111111111".into(),
1427            enabled: true,
1428            display_name: "Work line".into(),
1429            username: "alice".into(),
1430            domain: "sip.example.com".into(),
1431            auth_username: Some("alice-auth".into()),
1432            server: Some("sip.example.com".into()),
1433            port: Some(5060),
1434            transport: VoiceTransport::Udp,
1435            register_expires: 60,
1436            keepalive_secs: Some(50),
1437            disclosure_enabled: true,
1438            updated_at: "2026-06-20T10:00:00Z".into(),
1439            deleted_at: None,
1440            envelope: SyncEnvelope::for_endpoint::<VoiceAccounts>(),
1441        }
1442    }
1443
1444    #[test]
1445    fn accounts_marker_resource_is_accounts() {
1446        // Path constant drives the URL in `Client::sync` / `Client::list`;
1447        // a rename here would silently 404 against the platform.
1448        assert_eq!(<VoiceAccounts as SyncEndpoint>::RESOURCE, "accounts");
1449    }
1450
1451    #[test]
1452    fn account_record_serializes_with_camel_case_and_envelope() {
1453        let s = serde_json::to_string(&sample_account()).unwrap();
1454        // Field-by-field wire contract — also what the platform's Zod
1455        // schema expects.
1456        assert!(s.contains("\"sourceId\":"), "{s}");
1457        assert!(s.contains("\"displayName\":\"Work line\""), "{s}");
1458        assert!(s.contains("\"authUsername\":\"alice-auth\""), "{s}");
1459        assert!(s.contains("\"registerExpires\":60"), "{s}");
1460        assert!(s.contains("\"keepaliveSecs\":50"), "{s}");
1461        assert!(s.contains("\"disclosureEnabled\":true"), "{s}");
1462        assert!(s.contains("\"transport\":\"udp\""), "{s}");
1463        assert!(s.contains("\"updatedAt\":\"2026-06-20T10:00:00Z\""), "{s}");
1464        // A live line carries no tombstone.
1465        assert!(!s.contains("deletedAt"), "deletedAt should be omitted: {s}");
1466        // The secret never crosses this wire, by construction.
1467        assert!(!s.contains("password"), "no password field: {s}");
1468        // Envelope flattens to the top, same as the other resources.
1469        assert!(s.contains("\"schemaVersion\":1"), "{s}");
1470    }
1471
1472    #[test]
1473    fn account_tombstone_serializes_deleted_at() {
1474        // A soft-delete rides as an upsert with deletedAt set — the
1475        // delete-propagation mechanism (doc 40).
1476        let mut r = sample_account();
1477        r.deleted_at = Some("2026-06-20T12:00:00Z".into());
1478        let s = serde_json::to_string(&r).unwrap();
1479        assert!(s.contains("\"deletedAt\":\"2026-06-20T12:00:00Z\""), "{s}");
1480    }
1481
1482    #[test]
1483    fn account_record_round_trips_optional_fields() {
1484        // A minimal line — no auth username, server, port, keepalive, or
1485        // tombstone — should parse with those all absent.
1486        let raw = r#"{
1487            "sourceId": "a",
1488            "enabled": false,
1489            "displayName": "Cheap trunk",
1490            "username": "u",
1491            "domain": "d",
1492            "transport": "tcp",
1493            "registerExpires": 120,
1494            "disclosureEnabled": false,
1495            "updatedAt": "2026-06-20T10:00:00Z"
1496        }"#;
1497        let parsed: VoiceAccountRecord = serde_json::from_str(raw).unwrap();
1498        assert!(!parsed.enabled);
1499        assert!(parsed.auth_username.is_none());
1500        assert!(parsed.server.is_none());
1501        assert!(parsed.port.is_none());
1502        assert!(parsed.keepalive_secs.is_none());
1503        assert!(parsed.deleted_at.is_none());
1504        assert_eq!(parsed.transport, VoiceTransport::Tcp);
1505        assert_eq!(parsed.register_expires, 120);
1506    }
1507
1508    #[test]
1509    fn voice_transport_round_trips_via_json() {
1510        for t in [VoiceTransport::Udp, VoiceTransport::Tcp] {
1511            let s = serde_json::to_string(&t).unwrap();
1512            let back: VoiceTransport = serde_json::from_str(&s).unwrap();
1513            assert_eq!(t, back);
1514        }
1515        // Pin the wire strings — the daemon's `TransportKind` and the
1516        // platform's Zod enum both depend on these exact tokens.
1517        assert_eq!(
1518            serde_json::to_string(&VoiceTransport::Udp).unwrap(),
1519            "\"udp\""
1520        );
1521        assert_eq!(
1522            serde_json::to_string(&VoiceTransport::Tcp).unwrap(),
1523            "\"tcp\""
1524        );
1525    }
1526
1527    #[test]
1528    fn accounts_query_omits_unset_and_serializes_include_deleted() {
1529        let empty = serde_json::to_string(&VoiceAccountsQuery::default()).unwrap();
1530        assert_eq!(empty, "{}", "default query should be empty: {empty}");
1531        let with_deleted = serde_json::to_string(&VoiceAccountsQuery {
1532            include_deleted: Some(true),
1533        })
1534        .unwrap();
1535        assert!(
1536            with_deleted.contains("\"includeDeleted\":true"),
1537            "{with_deleted}"
1538        );
1539    }
1540
1541    #[test]
1542    fn account_record_accepts_unknown_extras_for_forward_compat() {
1543        // A newer client shipping a field this platform version lacks a
1544        // column for round-trips via the `extras` envelope.
1545        let raw = r#"{
1546            "sourceId": "a",
1547            "enabled": true,
1548            "displayName": "x",
1549            "username": "u",
1550            "domain": "d",
1551            "transport": "udp",
1552            "registerExpires": 60,
1553            "disclosureEnabled": true,
1554            "updatedAt": "2026-06-20T10:00:00Z",
1555            "schemaVersion": 2,
1556            "extras": { "ringtone": "classic" }
1557        }"#;
1558        let parsed: VoiceAccountRecord = serde_json::from_str(raw).unwrap();
1559        assert_eq!(parsed.envelope.schema_version, Some(2));
1560        let extras = parsed.envelope.extras.as_ref().expect("extras present");
1561        assert_eq!(extras["ringtone"], "classic");
1562    }
1563}