From e4f7de3c2c3f00fe5c3b6b08960d5de2f24659ef Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Sat, 3 Oct 2026 19:01:40 -0400 Subject: [PATCH 01/12] Core: one ServerRequest effect and one ServerResponse command own the request table The six request-specific effects become ServerRequest { id, method, path, bearerToken?, body? } and the seven settle commands become ServerResponse { id, status, body }. The request table (endpoints, bodies, response parsing, outcome routing) lives in cascade-core's new server module. Requests get ids from a non-persisted counter; the pending account request and the in-flight sync remember theirs and responses are routed by id. DeleteListeningData and DeleteAccount carry newDeviceId, adopted only on a confirmed delete; RequestSignInLink takes an optional platform. The account and listening-sync suites settle with ServerResponse, with new tests for the table, status 0, unparseable 2xx, a negative total and unknown ids. ADR 0001 decision 5 is amended. Co-Authored-By: Claude Opus 5.5 --- crates/cascade-core/src/account.rs | 43 +- crates/cascade-core/src/command.rs | 96 +-- crates/cascade-core/src/effect.rs | 52 +- crates/cascade-core/src/lib.rs | 2 + crates/cascade-core/src/listening.rs | 71 +- crates/cascade-core/src/server.rs | 172 ++++ crates/cascade-core/src/state.rs | 220 ++--- crates/cascade-core/tests/account.rs | 880 +++++++++++++------- crates/cascade-core/tests/listening_sync.rs | 334 ++++++-- crates/cascade-core/tests/property_tests.rs | 73 +- crates/cascade-core/tests/restore.rs | 8 +- crates/cascade-core/tests/support/mod.rs | 44 + crates/cascade-core/tests/timer_options.rs | 165 ++-- docs/adr/0001-listening-time-crdt.md | 12 +- 14 files changed, 1460 insertions(+), 712 deletions(-) create mode 100644 crates/cascade-core/src/server.rs create mode 100644 crates/cascade-core/tests/support/mod.rs diff --git a/crates/cascade-core/src/account.rs b/crates/cascade-core/src/account.rs index 8c3795c..6497203 100644 --- a/crates/cascade-core/src/account.rs +++ b/crates/cascade-core/src/account.rs @@ -3,8 +3,8 @@ //! //! The core holds the session and every account rule: what to send, when a //! request may start, what the user is told, and what success, failure and a -//! 401 do. A shell only carries the requests it is handed (one effect per -//! request, settled with one outcome command) and stores the blob. +//! 401 do. A shell only carries the requests it is handed (each one generic +//! request, settled with one generic response) and stores the blob. use serde::{Deserialize, Serialize}; @@ -19,14 +19,28 @@ pub struct Session { pub email: String, } -/// The one account request the shell is carrying, if any. Only one runs at a -/// time, so its outcome command needs no request identity. +/// The one account request the shell is carrying, if any, and the id its +/// response will carry. Only one runs at a time. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Pending { + pub id: u64, + pub request: PendingRequest, +} + +/// What a pending account request is for, and what its success needs. #[derive(Debug, Clone, PartialEq, Eq)] pub enum PendingRequest { - SignInLink { email: String }, + SignInLink { + email: String, + }, Verify, - DeleteListening, - DeleteAccount, + /// Holds the fresh id to rotate to once the server confirms the delete. + DeleteListening { + new_device_id: String, + }, + DeleteAccount { + new_device_id: String, + }, } /// What the user was last told about their account. [`Self::label`] holds the @@ -70,7 +84,7 @@ impl AccountStatus { #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct Account { pub session: Option, - pub pending: Option, + pub pending: Option, pub status: Option, } @@ -80,14 +94,13 @@ impl Account { self.pending.is_some() } - /// Settle the pending request if it is `expected`. An outcome with - /// nothing pending, or for another request, changes nothing. - pub fn settle(&mut self, expected: PendingRequest) -> bool { - let settles = self.pending.as_ref() == Some(&expected); - if settles { - self.pending = None; + /// Take the pending request if response `id` settles it. A response for + /// anything else (nothing pending, or a dropped request) changes nothing. + pub fn settle(&mut self, id: u64) -> Option { + if self.pending.as_ref()?.id != id { + return None; } - settles + self.pending.take().map(|p| p.request) } pub fn session_token(&self) -> Option { diff --git a/crates/cascade-core/src/command.rs b/crates/cascade-core/src/command.rs index 3396b80..2e31cde 100644 --- a/crates/cascade-core/src/command.rs +++ b/crates/cascade-core/src/command.rs @@ -47,7 +47,8 @@ pub enum Command { /// Wall-clock tick from the platform. `elapsed_ms` is the delta since the /// previous tick. The core never reads the system clock; the UI ticks it. /// A tick that counts listening also offers the routine sync, so its - /// update may carry a [`crate::Effect::PushListening`] to settle. + /// update may carry a listening push ([`crate::Effect::ServerRequest`]) + /// to settle. Tick { elapsed_ms: u64 }, /// Platform reports that audio actually started playing (e.g. the browser @@ -96,20 +97,11 @@ pub enum Command { }, /// The shell is able to talk to the server (online) and asks whether /// there is anything to send. If so, the update carries one - /// [`crate::Effect::PushListening`]; the shell PUTs exactly that and - /// reports back with [`Command::ListeningSyncSucceeded`] or - /// [`Command::ListeningSyncFailed`]. No effect means nothing to send, a - /// sync is already in flight, or the core holds the account and is signed - /// out. + /// [`crate::Effect::ServerRequest`]: the listening push, PUT `/listening`. + /// The shell carries it and settles it with [`Command::ServerResponse`]. + /// No effect means nothing to send, a sync is already in flight, or the + /// core holds the account and is signed out. BeginListeningSync { reason: SyncReason }, - /// The in-flight listening PUT succeeded; `server_total_ms` is the - /// cross-device aggregate it returned. The core marks exactly what it sent - /// as synced. Moves the display baseline only; never lowers the device slot. - ListeningSyncSucceeded { server_total_ms: u64 }, - /// The in-flight listening PUT failed. `unauthorized` is true for an HTTP - /// 401: the session is gone, and the core signs out (see - /// [`Command::AccountRequestFailed`]). Listening stays local. - ListeningSyncFailed { unauthorized: bool }, /// "Delete my listening data": zero this device's slot, forget the server /// aggregate, and rotate to `new_device_id` (a fresh random id from the /// shell) — all in one persisted write, so a crash can't leave a fresh id @@ -118,51 +110,53 @@ pub enum Command { ResetListeningData { new_device_id: String }, /// User asked for a sign-in link. The email is trimmed; an empty one is - /// refused with a status and no effect. Answered with - /// [`crate::Effect::SendSignInLink`], settled with - /// [`Command::SignInLinkSent`] or [`Command::AccountRequestFailed`]. + /// refused with a status and no effect. Answered with a + /// [`crate::Effect::ServerRequest`] (POST `/auth/request`). `platform` + /// names the shell's link hand-off (Windows sends `"windows"`) and is + /// added to the request body only when given. /// /// Like every account request, it is ignored while another account /// request is pending (`account.busy`). - RequestSignInLink { email: String }, + RequestSignInLink { + email: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + platform: Option, + }, /// User pasted or opened a sign-in link: either the whole link or the bare - /// token. Answered with [`crate::Effect::VerifySignInToken`], settled with - /// [`Command::SignInVerified`] or [`Command::AccountRequestFailed`]. + /// token. Answered with a [`crate::Effect::ServerRequest`] (POST + /// `/auth/verify`). Once verified, the core persists the account and + /// starts a refresh sync for the cross-device total. SubmitSignInLink { input: String }, /// Sign out locally. Always works, even while a request is pending: that - /// request is dropped and its settle ignored. Forgets the cross-device - /// total and hands the server a fire-and-forget - /// [`crate::Effect::RevokeSession`]. + /// request is dropped and its response ignored. Forgets the cross-device + /// total and asks the server to revoke the session (POST `/auth/logout`), + /// whose response changes nothing. SignOut, - /// User asked to delete their synced listening data. Answered with - /// [`crate::Effect::DeleteServerListening`], settled with - /// [`Command::ListeningDataDeleted`] or [`Command::AccountRequestFailed`]. - DeleteListeningData, - /// User asked to delete their account. Answered with - /// [`crate::Effect::DeleteServerAccount`], settled with - /// [`Command::AccountDeleted`] or [`Command::AccountRequestFailed`]. - DeleteAccount, - /// The sign-in link request succeeded. - SignInLinkSent, - /// The sign-in token was redeemed for a session. The core persists the - /// account and starts a refresh sync for the cross-device total. - SignInVerified { - session_token: String, - email: String, + /// User asked to delete their synced listening data. Answered with a + /// [`crate::Effect::ServerRequest`] (DELETE `/listening`). Once the server + /// confirms, the core zeroes the slot and rotates to `new_device_id` (a + /// fresh random id from the shell) in one persisted write, like + /// [`Command::ResetListeningData`]. Held until then, so the slot only + /// rotates once the server has actually deleted. + DeleteListeningData { new_device_id: String }, + /// User asked to delete their account. Answered with a + /// [`crate::Effect::ServerRequest`] (DELETE `/account`). Once the server + /// confirms, the core resets listening like [`Command::DeleteListeningData`] + /// and signs out. + DeleteAccount { new_device_id: String }, + /// The shell's answer to a [`crate::Effect::ServerRequest`]: its `id`, the + /// HTTP `status` (`0` when it was not sent or got no response) and the + /// response `body` verbatim (empty when there is none). Every request is + /// settled this way. The core routes it by `id` and reads it: any 2xx is + /// success, a 401 while signed in signs out and tells the user to sign in + /// again, and anything else, or a 2xx whose body does not parse, is a + /// plain failure. A response the core is not waiting for changes nothing. + ServerResponse { + id: u64, + status: u16, + #[serde(default)] + body: String, }, - /// The server deleted the listening data. Like - /// [`Command::ResetListeningData`], zeroes the slot and rotates to - /// `new_device_id` (a fresh random id from the shell) in one persisted - /// write; supplied at settle time, so the slot only rotates once the - /// server has actually deleted. - ListeningDataDeleted { new_device_id: String }, - /// The server deleted the account. Resets listening like - /// [`Command::ListeningDataDeleted`], and signs out. - AccountDeleted { new_device_id: String }, - /// The pending account request failed. `unauthorized` is true for an HTTP - /// 401: while signed in, the core then signs out, forgets the cross-device - /// total and tells the user to sign in again. - AccountRequestFailed { unauthorized: bool }, } #[cfg(test)] diff --git a/crates/cascade-core/src/effect.rs b/crates/cascade-core/src/effect.rs index c9b1c38..39ec9d9 100644 --- a/crates/cascade-core/src/effect.rs +++ b/crates/cascade-core/src/effect.rs @@ -5,6 +5,8 @@ use serde::{Deserialize, Serialize}; +use crate::server::HttpMethod; + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde( tag = "type", @@ -29,34 +31,30 @@ pub enum Effect { /// fail independently. The platform stores the string verbatim and hands it /// back via [`crate::Command::Restore`] on the next launch. PersistListening { json: String }, - /// PUT this device's slot to the listening endpoint, exactly as given. - /// Emitted in answer to [`crate::Command::BeginListeningSync`], a - /// [`crate::Command::Tick`] that crosses the sync threshold, or a sign-in's - /// refresh ([`crate::Command::SignInVerified`]); the shell must settle it - /// with `ListeningSyncSucceeded` or `ListeningSyncFailed`, or no further - /// sync will start. `session_token` is the bearer token to send; there is - /// never a push without one. - PushListening { - device_id: String, - device_total_ms: u64, - session_token: String, + /// Send one request to the sync server, exactly as described: `method` + /// to `path` (relative to the shell's sync-server base URL), with + /// `Authorization: Bearer {bearer_token}` when there is one, and `body` + /// verbatim as `application/json` when there is one. The core owns the + /// request table (see [`crate::server`]); the shell decides nothing per + /// request. + /// + /// The shell settles every request, with no exceptions, by dispatching + /// [`crate::Command::ServerResponse`] with the same `id`: the HTTP status + /// and the response body, or status `0` when it was not sent or got no + /// response (no sync server configured, a network error, a timeout). + /// Until it is settled, the core's in-flight guard holds the next account + /// request or listening sync. A response the core is no longer waiting + /// for (a sign-out revoke, or a request sign-out or a reset superseded) + /// changes nothing. + ServerRequest { + id: u64, + method: HttpMethod, + path: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + bearer_token: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + body: Option, }, - - /// POST a sign-in link request for `email`. Settle with - /// `SignInLinkSent` or `AccountRequestFailed`. - SendSignInLink { email: String }, - /// POST `token` for verification. Settle with `SignInVerified` or - /// `AccountRequestFailed`. - VerifySignInToken { token: String }, - /// POST a sign-out for `session_token`. Fire-and-forget: there is no settle - /// command, and its result changes nothing — sign-out is local. - RevokeSession { session_token: String }, - /// DELETE the server's listening data. Settle with `ListeningDataDeleted` - /// or `AccountRequestFailed`. - DeleteServerListening { session_token: String }, - /// DELETE the account. Settle with `AccountDeleted` or - /// `AccountRequestFailed`. - DeleteServerAccount { session_token: String }, /// Persist the account blob, verbatim, and hand it back as `accountJson` /// in [`crate::Command::Restore`] on the next launch. An empty `json` /// means "delete the stored account". diff --git a/crates/cascade-core/src/lib.rs b/crates/cascade-core/src/lib.rs index 51936dd..775f7f6 100644 --- a/crates/cascade-core/src/lib.rs +++ b/crates/cascade-core/src/lib.rs @@ -13,6 +13,7 @@ pub mod account; pub mod command; pub mod effect; pub mod listening; +pub mod server; pub mod settings; pub mod snapshot; pub mod state; @@ -25,6 +26,7 @@ pub use listening::{ InFlight, ListeningLedger, PersistedListening, SyncReason, LISTENING_SYNC_THRESHOLD_MS, LISTENING_VERSION, MAX_TICK_ACCRUAL_MS, }; +pub use server::HttpMethod; pub use settings::{PersistedSettings, SETTINGS_VERSION}; pub use snapshot::{ AccountSnapshot, ListeningSnapshot, Snapshot, TimerOptions, TimerSnapshot, TimerSnapshotKind, diff --git a/crates/cascade-core/src/listening.rs b/crates/cascade-core/src/listening.rs index 302cfc1..4bbffde 100644 --- a/crates/cascade-core/src/listening.rs +++ b/crates/cascade-core/src/listening.rs @@ -79,16 +79,29 @@ pub struct ListeningLedger { pub failed_through_ms: Option, } -/// A listening PUT the shell has been told to send and hasn't settled yet. +/// A listening PUT the shell has been told to send and hasn't settled yet, +/// with the request id its response will carry. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum InFlight { /// Sent for the current slot, carrying this `device_total_ms`. - Sent { device_total_ms: u64 }, - /// Sent for a slot a reset has since replaced. Its ack says nothing about - /// the current slot and is dropped — but it still blocks the next sync, - /// because acks carry no request identity and it could otherwise settle - /// a newer PUT. - Superseded, + Sent { id: u64, device_total_ms: u64 }, + /// Sent for a slot or an account that a reset or sign-out has since + /// replaced. Its response says nothing about the current slot and is + /// dropped, but it still blocks the next sync until it is settled, so + /// only one PUT is ever out at a time. + Superseded { id: u64 }, +} + +impl InFlight { + pub fn id(self) -> u64 { + match self { + Self::Sent { id, .. } | Self::Superseded { id } => id, + } + } + + fn superseded(self) -> Self { + Self::Superseded { id: self.id() } + } } impl Default for ListeningLedger { @@ -142,28 +155,28 @@ impl ListeningLedger { } /// Decide whether to sync for a shell's `reason`, and if so what to send: - /// `(device_id, device_total_ms)`. Marks that total as in flight, so a - /// second call returns `None` until [`Self::sync_succeeded`] or - /// [`Self::sync_failed`] settles it — the one re-entrancy guard every - /// shell shares. - pub fn begin_sync(&mut self, reason: SyncReason) -> Option<(String, u64)> { + /// `(device_id, device_total_ms)`. Marks that total as in flight as + /// request `id`, so a second call returns `None` until + /// [`Self::sync_succeeded`] or [`Self::sync_failed`] settles it — the one + /// re-entrancy guard every shell shares. + pub fn begin_sync(&mut self, reason: SyncReason, id: u64) -> Option<(String, u64)> { let worth_sending = match reason { SyncReason::Flush => self.unsynced_ms() > 0, SyncReason::Refresh => true, }; - self.begin_sync_if(worth_sending) + self.begin_sync_if(worth_sending, id) } /// The core's own routine sync, checked on every tick that counts /// listening: send only once at least [`LISTENING_SYNC_THRESHOLD_MS`] is /// unsynced, counted past any failed PUT. Same result and in-flight guard /// as [`Self::begin_sync`]. - pub fn begin_threshold_sync(&mut self) -> Option<(String, u64)> { + pub fn begin_threshold_sync(&mut self, id: u64) -> Option<(String, u64)> { let worth_sending = self.unsent_ms() >= LISTENING_SYNC_THRESHOLD_MS; - self.begin_sync_if(worth_sending) + self.begin_sync_if(worth_sending, id) } - fn begin_sync_if(&mut self, worth_sending: bool) -> Option<(String, u64)> { + fn begin_sync_if(&mut self, worth_sending: bool, id: u64) -> Option<(String, u64)> { if self.in_flight.is_some() { return None; } @@ -172,6 +185,7 @@ impl ListeningLedger { return None; } self.in_flight = Some(InFlight::Sent { + id, device_total_ms: self.device_total_ms, }); Some((device_id, self.device_total_ms)) @@ -183,10 +197,12 @@ impl ListeningLedger { /// display baseline forward; it never touches `device_total_ms`, and is /// monotonic so an out-of-order ack can't rewind the high-water mark. /// Returns `false` (and changes nothing else) if no sync for the current - /// slot was in flight. + /// slot was in flight. The caller has already matched the response's id + /// to [`Self::in_flight`]. pub fn sync_succeeded(&mut self, server_total_ms: u64) -> bool { let Some(InFlight::Sent { device_total_ms: sent, + .. }) = self.in_flight.take() else { return false; @@ -204,6 +220,7 @@ impl ListeningLedger { pub fn sync_failed(&mut self) -> bool { let Some(InFlight::Sent { device_total_ms: sent, + .. }) = self.in_flight.take() else { return false; @@ -236,9 +253,7 @@ impl ListeningLedger { self.server_total_ms = None; self.device_id = Some(new_device_id); self.failed_through_ms = None; - if self.in_flight.is_some() { - self.in_flight = Some(InFlight::Superseded); - } + self.in_flight = self.in_flight.map(InFlight::superseded); } /// Forget the cross-device total when the account goes away (sign-out, a @@ -250,9 +265,7 @@ impl ListeningLedger { pub fn forget_server(&mut self) { self.server_total_ms = None; self.failed_through_ms = None; - if self.in_flight.is_some() { - self.in_flight = Some(InFlight::Superseded); - } + self.in_flight = self.in_flight.map(InFlight::superseded); } /// Adopt a restored ledger without ever *lowering* the live counters. A @@ -336,7 +349,7 @@ mod tests { /// Send whatever is unsynced and have the server accept it. fn sync(l: &mut ListeningLedger, server_total_ms: u64) { l.device_id.get_or_insert_with(|| "device".into()); - l.begin_sync(SyncReason::Refresh) + l.begin_sync(SyncReason::Refresh, 1) .expect("nothing in flight"); assert!(l.sync_succeeded(server_total_ms)); } @@ -406,10 +419,11 @@ mod tests { l.accrue(4_000); sync(&mut l, 9_000); l.accrue(1_000); - l.begin_sync(SyncReason::Flush).expect("nothing in flight"); + l.begin_sync(SyncReason::Flush, 2) + .expect("nothing in flight"); l.forget_server(); assert_eq!(l.server_total_ms, None); - assert_eq!(l.in_flight, Some(InFlight::Superseded)); + assert_eq!(l.in_flight, Some(InFlight::Superseded { id: 2 })); assert_eq!(l.device_total_ms, 5_000); assert_eq!(l.synced_through_ms, 4_000); assert_eq!(l.displayed_total_ms(), 5_000); @@ -457,7 +471,10 @@ mod tests { server_total_ms: Some(9), tracking_enabled: true, device_id: Some("d".into()), - in_flight: Some(InFlight::Sent { device_total_ms: 7 }), + in_flight: Some(InFlight::Sent { + id: 1, + device_total_ms: 7, + }), failed_through_ms: Some(7), }; let json = serde_json::to_string(&l.to_persisted()).unwrap(); diff --git a/crates/cascade-core/src/server.rs b/crates/cascade-core/src/server.rs new file mode 100644 index 0000000..6529ef6 --- /dev/null +++ b/crates/cascade-core/src/server.rs @@ -0,0 +1,172 @@ +//! The request table — every call the core makes to the sync server, and what +//! each response means. +//! +//! The core describes a request as one generic [`crate::Effect::ServerRequest`] +//! (method, path, bearer token, JSON body) and hears back through one generic +//! [`crate::Command::ServerResponse`] (HTTP status and body). Endpoints, bodies +//! and response parsing live here, once; a shell only sends what it is given +//! to its configured server and reports the status and body verbatim. + +use serde::{Deserialize, Serialize}; + +use crate::effect::Effect; + +/// The HTTP verbs the request table uses, sent uppercase. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "UPPERCASE")] +pub enum HttpMethod { + Post, + Put, + Delete, +} + +/// One row of the request table, before the core gives it an id. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Request { + method: HttpMethod, + path: &'static str, + bearer_token: Option, + body: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct SignInLinkBody<'a> { + email: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + platform: Option<&'a str>, +} + +#[derive(Serialize)] +struct VerifyBody<'a> { + token: &'a str, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct PushListeningBody<'a> { + device_id: &'a str, + device_total_ms: u64, +} + +impl Request { + /// POST `/auth/request` `{email[, platform]}`. `platform` names the + /// shell's link hand-off, when it has one. + pub fn sign_in_link(email: &str, platform: Option<&str>) -> Self { + Self::post_json("/auth/request", &SignInLinkBody { email, platform }) + } + + /// POST `/auth/verify` `{token}`; read with [`parse_verified`]. + pub fn verify(token: &str) -> Self { + Self::post_json("/auth/verify", &VerifyBody { token }) + } + + /// POST `/auth/logout` with the bearer token. Nothing waits for its + /// response. + pub fn revoke(session_token: String) -> Self { + Self::bearer(HttpMethod::Post, "/auth/logout", session_token) + } + + /// PUT `/listening` `{deviceId, deviceTotalMs}`; read with + /// [`parse_server_total`]. + pub fn push_listening(device_id: &str, device_total_ms: u64, session_token: String) -> Self { + Self { + body: to_json(&PushListeningBody { + device_id, + device_total_ms, + }), + ..Self::bearer(HttpMethod::Put, "/listening", session_token) + } + } + + /// DELETE `/listening` with the bearer token. + pub fn delete_listening(session_token: String) -> Self { + Self::bearer(HttpMethod::Delete, "/listening", session_token) + } + + /// DELETE `/account` with the bearer token. + pub fn delete_account(session_token: String) -> Self { + Self::bearer(HttpMethod::Delete, "/account", session_token) + } + + fn post_json(path: &'static str, body: &impl Serialize) -> Self { + Self { + method: HttpMethod::Post, + path, + bearer_token: None, + body: to_json(body), + } + } + + fn bearer(method: HttpMethod, path: &'static str, session_token: String) -> Self { + Self { + method, + path, + bearer_token: Some(session_token), + body: None, + } + } + + /// The effect that asks the shell to carry this request as `id`. + pub fn into_effect(self, id: u64) -> Effect { + Effect::ServerRequest { + id, + method: self.method, + path: self.path.into(), + bearer_token: self.bearer_token, + body: self.body, + } + } +} + +fn to_json(body: &impl Serialize) -> Option { + serde_json::to_string(body).ok() +} + +/// What a response's status means, before its body is read. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Status { + /// Any 2xx. + Success, + /// 401: the session is gone. + Unauthorized, + /// Anything else, including `0` (not sent, or no response). + Failed, +} + +impl Status { + pub fn of(status: u16) -> Self { + match status { + 200..=299 => Self::Success, + 401 => Self::Unauthorized, + _ => Self::Failed, + } + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct VerifiedBody { + session_token: String, + email: String, +} + +/// The `(session_token, email)` a successful verify returned. `None` when the +/// body does not parse or carries an empty token, which is no session. +pub fn parse_verified(body: &str) -> Option<(String, String)> { + let verified: VerifiedBody = serde_json::from_str(body).ok()?; + (!verified.session_token.is_empty()).then_some((verified.session_token, verified.email)) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct PushedBody { + server_total_ms: i64, +} + +/// The cross-device total a successful push returned, read as a signed +/// integer and clamped at 0. `None` when the body does not parse. +pub fn parse_server_total(body: &str) -> Option { + let pushed: PushedBody = serde_json::from_str(body).ok()?; + Some(pushed.server_total_ms.max(0) as u64) +} diff --git a/crates/cascade-core/src/state.rs b/crates/cascade-core/src/state.rs index 1b8ddf6..95e9137 100644 --- a/crates/cascade-core/src/state.rs +++ b/crates/cascade-core/src/state.rs @@ -6,10 +6,13 @@ use serde::{Deserialize, Serialize}; -use crate::account::{parse_sign_in_token, Account, AccountStatus, PendingRequest, Session}; +use crate::account::{ + parse_sign_in_token, Account, AccountStatus, Pending, PendingRequest, Session, +}; use crate::command::Command; use crate::effect::Effect; use crate::listening::{ListeningLedger, PersistedListening, SyncReason}; +use crate::server::{parse_server_total, parse_verified, Request, Status}; use crate::settings::{PersistedSettings, SETTINGS_VERSION}; use crate::timer::{clamp_minutes, ActiveTimer, TimerKind, DEFAULT_TIMER_MINUTES}; @@ -63,6 +66,10 @@ pub struct State { /// The optional sign-in. Restored from its own blob via /// [`Command::Restore`]'s `account_json`. pub account: Account, + /// The id the next [`Effect::ServerRequest`] gets. Starts at 1 and only + /// grows; never persisted, since a restart forgets every request in + /// flight along with the shell process that was carrying it. + next_request_id: u64, /// Whether [`Command::Restore`] has already run. Only the first restore /// takes effect, so a replayed stale blob can't undo a later reset. restored: bool, @@ -82,6 +89,7 @@ impl Default for State { audio_confirmed_playing: false, listening: ListeningLedger::default(), account: Account::default(), + next_request_id: 1, restored: false, } } @@ -151,22 +159,29 @@ fn push_persist_account(state: &State, effects: &mut Vec) { }); } +/// Hand the shell `request` under a fresh id, and return that id. +fn send(state: &mut State, request: Request, effects: &mut Vec) -> u64 { + let id = state.next_request_id; + state.next_request_id += 1; + effects.push(request.into_effect(id)); + id +} + /// Start a listening sync if `begin` finds anything to send and the account -/// allows it, answering with one `PushListening`. +/// allows it, answering with one push. `begin` is handed the id the push +/// will carry, so the ledger can remember it. fn begin_listening_sync( state: &mut State, effects: &mut Vec, - begin: impl FnOnce(&mut ListeningLedger) -> Option<(String, u64)>, + begin: impl FnOnce(&mut ListeningLedger, u64) -> Option<(String, u64)>, ) { let Some(session_token) = state.account.session_token() else { return; }; - if let Some((device_id, device_total_ms)) = begin(&mut state.listening) { - effects.push(Effect::PushListening { - device_id, - device_total_ms, - session_token, - }); + let id = state.next_request_id; + if let Some((device_id, device_total_ms)) = begin(&mut state.listening, id) { + let request = Request::push_listening(&device_id, device_total_ms, session_token); + send(state, request, effects); } } @@ -176,12 +191,15 @@ fn begin_account_request( state: &mut State, pending: PendingRequest, status: Option, - request: Effect, + request: Request, effects: &mut Vec, ) { - state.account.pending = Some(pending); + let id = send(state, request, effects); + state.account.pending = Some(Pending { + id, + request: pending, + }); state.account.status = status; - effects.push(request); } /// Drop the session and everything learned through it: the stored blob, the @@ -222,19 +240,13 @@ fn clears_timer_completion(command: &Command) -> bool { | Command::SetListeningTracking { .. } | Command::Restore { .. } | Command::BeginListeningSync { .. } - | Command::ListeningSyncSucceeded { .. } - | Command::ListeningSyncFailed { .. } | Command::ResetListeningData { .. } | Command::RequestSignInLink { .. } | Command::SubmitSignInLink { .. } | Command::SignOut - | Command::DeleteListeningData - | Command::DeleteAccount - | Command::SignInLinkSent - | Command::SignInVerified { .. } - | Command::ListeningDataDeleted { .. } - | Command::AccountDeleted { .. } - | Command::AccountRequestFailed { .. } => false, + | Command::DeleteListeningData { .. } + | Command::DeleteAccount { .. } + | Command::ServerResponse { .. } => false, } } @@ -384,18 +396,7 @@ pub fn reduce(state: &mut State, command: Command, effects: &mut Vec) { } } Command::BeginListeningSync { reason } => { - begin_listening_sync(state, effects, |l| l.begin_sync(reason)) - } - Command::ListeningSyncSucceeded { server_total_ms } => { - if state.listening.sync_succeeded(server_total_ms) { - push_persist_listening(state, effects); - } - } - Command::ListeningSyncFailed { unauthorized } => { - // A superseded sync's 401 refers to a session already dropped. - if state.listening.sync_failed() && unauthorized { - session_expired(state, effects); - } + begin_listening_sync(state, effects, |l, id| l.begin_sync(reason, id)) } Command::ResetListeningData { new_device_id } => { state.listening.reset(new_device_id); @@ -406,18 +407,16 @@ pub fn reduce(state: &mut State, command: Command, effects: &mut Vec) { // cuts in. Command::RequestSignInLink { .. } | Command::SubmitSignInLink { .. } - | Command::DeleteListeningData - | Command::DeleteAccount + | Command::DeleteListeningData { .. } + | Command::DeleteAccount { .. } if state.account.busy() => {} - Command::RequestSignInLink { email } => { + Command::RequestSignInLink { email, platform } => { let email = email.trim().to_string(); if email.is_empty() { state.account.status = Some(AccountStatus::EnterEmail); return; } - let request = Effect::SendSignInLink { - email: email.clone(), - }; + let request = Request::sign_in_link(&email, platform.as_deref()); begin_account_request( state, PendingRequest::SignInLink { email }, @@ -431,29 +430,29 @@ pub fn reduce(state: &mut State, command: Command, effects: &mut Vec) { state, PendingRequest::Verify, Some(AccountStatus::SigningIn), - Effect::VerifySignInToken { token }, + Request::verify(&token), effects, ), None => state.account.status = Some(AccountStatus::PasteFullLink), }, - Command::DeleteListeningData => { + Command::DeleteListeningData { new_device_id } => { if let Some(session_token) = state.account.session_token() { begin_account_request( state, - PendingRequest::DeleteListening, + PendingRequest::DeleteListening { new_device_id }, None, - Effect::DeleteServerListening { session_token }, + Request::delete_listening(session_token), effects, ); } } - Command::DeleteAccount => { + Command::DeleteAccount { new_device_id } => { if let Some(session_token) = state.account.session_token() { begin_account_request( state, - PendingRequest::DeleteAccount, + PendingRequest::DeleteAccount { new_device_id }, None, - Effect::DeleteServerAccount { session_token }, + Request::delete_account(session_token), effects, ); } @@ -462,52 +461,67 @@ pub fn reduce(state: &mut State, command: Command, effects: &mut Vec) { state.account.pending = None; state.account.status = None; if let Some(session_token) = state.account.session_token() { - effects.push(Effect::RevokeSession { session_token }); + // Remembered nowhere, so its response is ignored. + send(state, Request::revoke(session_token), effects); forget_session(state, effects); } } - Command::SignInLinkSent => { - if let Some(PendingRequest::SignInLink { email }) = &state.account.pending { - state.account.status = Some(AccountStatus::LinkSent { - email: email.clone(), - }); - state.account.pending = None; + Command::ServerResponse { id, status, body } => { + let status = Status::of(status); + if let Some(request) = state.account.settle(id) { + account_response(state, request, status, &body, effects); + } else if state.listening.in_flight.is_some_and(|f| f.id() == id) { + sync_response(state, status, &body, effects); } } - Command::SignInVerified { - session_token, - email, - } => { - if !state.account.settle(PendingRequest::Verify) { - return; - } - // Signing in over another account: nothing of its total may show. - if state.account.session.is_some() { - state.listening.forget_server(); - push_persist_listening(state, effects); - } - state.account.status = Some(AccountStatus::SignedIn { - email: email.clone(), - }); - state.account.session = Some(Session { - session_token, - email, - }); - push_persist_account(state, effects); - begin_listening_sync(state, effects, |l| l.begin_sync(SyncReason::Refresh)); + } +} + +/// Settle the in-flight listening push. A superseded push only frees the +/// slot: its success or 401 refers to a slot or session already replaced. +fn sync_response(state: &mut State, status: Status, body: &str, effects: &mut Vec) { + let server_total_ms = match status { + Status::Success => parse_server_total(body), + Status::Unauthorized | Status::Failed => None, + }; + if let Some(server_total_ms) = server_total_ms { + if state.listening.sync_succeeded(server_total_ms) { + push_persist_listening(state, effects); } - Command::ListeningDataDeleted { new_device_id } => { - if !state.account.settle(PendingRequest::DeleteListening) { - return; + } else if state.listening.sync_failed() && status == Status::Unauthorized { + session_expired(state, effects); + } +} + +/// Settle the pending account `request`, already taken off the account. +fn account_response( + state: &mut State, + request: PendingRequest, + status: Status, + body: &str, + effects: &mut Vec, +) { + if status == Status::Unauthorized && state.account.session.is_some() { + session_expired(state, effects); + return; + } + let succeeded = status == Status::Success; + match request { + PendingRequest::SignInLink { email } if succeeded => { + state.account.status = Some(AccountStatus::LinkSent { email }); + } + PendingRequest::Verify if succeeded => match parse_verified(body) { + Some((session_token, email)) => { + signed_in(state, session_token, email, effects); } + None => state.account.status = Some(AccountStatus::LinkInvalid), + }, + PendingRequest::DeleteListening { new_device_id } if succeeded => { state.account.status = Some(AccountStatus::ListeningDeleted); state.listening.reset(new_device_id); push_persist_listening(state, effects); } - Command::AccountDeleted { new_device_id } => { - if !state.account.settle(PendingRequest::DeleteAccount) { - return; - } + PendingRequest::DeleteAccount { new_device_id } if succeeded => { state.account.status = Some(AccountStatus::AccountDeleted); // The reset forgets the server total and supersedes any in-flight // sync too, so the slot and the sign-out share one listening write. @@ -516,22 +530,38 @@ pub fn reduce(state: &mut State, command: Command, effects: &mut Vec) { state.account.session = None; push_persist_account(state, effects); } - Command::AccountRequestFailed { unauthorized } => { - let Some(pending) = state.account.pending.take() else { - return; - }; - if unauthorized && state.account.session.is_some() { - session_expired(state, effects); - return; - } - state.account.status = Some(match pending { - PendingRequest::SignInLink { .. } => AccountStatus::LinkFailed, - PendingRequest::Verify => AccountStatus::LinkInvalid, - PendingRequest::DeleteListening => AccountStatus::ListeningDeleteFailed, - PendingRequest::DeleteAccount => AccountStatus::AccountDeleteFailed, - }); + PendingRequest::SignInLink { .. } => { + state.account.status = Some(AccountStatus::LinkFailed); } + PendingRequest::Verify => state.account.status = Some(AccountStatus::LinkInvalid), + PendingRequest::DeleteListening { .. } => { + state.account.status = Some(AccountStatus::ListeningDeleteFailed); + } + PendingRequest::DeleteAccount { .. } => { + state.account.status = Some(AccountStatus::AccountDeleteFailed); + } + } +} + +/// The sign-in token was redeemed for a session: persist the account and +/// start a refresh sync for the cross-device total. +fn signed_in(state: &mut State, session_token: String, email: String, effects: &mut Vec) { + // Signing in over another account: nothing of its total may show. + if state.account.session.is_some() { + state.listening.forget_server(); + push_persist_listening(state, effects); } + state.account.status = Some(AccountStatus::SignedIn { + email: email.clone(), + }); + state.account.session = Some(Session { + session_token, + email, + }); + push_persist_account(state, effects); + begin_listening_sync(state, effects, |l, id| { + l.begin_sync(SyncReason::Refresh, id) + }); } /// The server rejected the session (a 401 on any request): sign out and tell diff --git a/crates/cascade-core/tests/account.rs b/crates/cascade-core/tests/account.rs index 0efb576..bfe7cc1 100644 --- a/crates/cascade-core/tests/account.rs +++ b/crates/cascade-core/tests/account.rs @@ -1,11 +1,16 @@ //! The account flow, driven only through `Core::dispatch` — no network. //! -//! The core holds the session and every account rule; the shell carries each -//! request effect and settles it with an outcome command. These tests pin the -//! core half: what is sent, when a request may start, what the user is told, -//! and what success, failure and a 401 do to the account and to listening. +//! The core holds the session and every account rule, including the request +//! table; the shell carries each `ServerRequest` and settles it with a +//! `ServerResponse`. These tests pin the core half: what is sent, when a +//! request may start, what the user is told, and what success, failure and a +//! 401 do to the account and to listening. -use cascade_core::{AccountSnapshot, Command, Core, Effect, SyncReason}; +mod support; + +use cascade_core::{AccountSnapshot, Command, Core, Effect, HttpMethod, SyncReason}; +use serde_json::json; +use support::{bare_response, request_id, requests, response}; const DEVICE_A: &str = "device-a"; const DEVICE_B: &str = "device-b"; @@ -52,11 +57,15 @@ fn listen(core: &mut Core, ms: u64) { core.dispatch(Command::Pause); } +/// Dispatch `command` and return the id of the one request it sent. +fn start(core: &mut Core, command: Command) -> u64 { + request_id(&core.dispatch(command).effects) +} + /// Start a sync and have the server report `server_total_ms`. fn sync(core: &mut Core, server_total_ms: u64) { - let effects = begin(core, SyncReason::Refresh); - assert!(pushed(&effects).is_some(), "{effects:?}"); - core.dispatch(Command::ListeningSyncSucceeded { server_total_ms }); + let id = request_id(&begin(core, SyncReason::Refresh)); + core.dispatch(pushed_ok(id, server_total_ms)); } fn begin(core: &mut Core, reason: SyncReason) -> Vec { @@ -64,13 +73,64 @@ fn begin(core: &mut Core, reason: SyncReason) -> Vec { .effects } +/// The listening push in `effects`, if any. fn pushed(effects: &[Effect]) -> Option { effects .iter() - .find(|e| matches!(e, Effect::PushListening { .. })) + .find(|e| matches!(e, Effect::ServerRequest { path, .. } if path == "/listening")) .cloned() } +/// The push request the core should send, per the request table. +fn push_request(id: u64, device_id: &str, device_total_ms: u64, token: &str) -> Effect { + Effect::ServerRequest { + id, + method: HttpMethod::Put, + path: "/listening".into(), + bearer_token: Some(token.into()), + body: Some(format!( + r#"{{"deviceId":"{device_id}","deviceTotalMs":{device_total_ms}}}"# + )), + } +} + +fn pushed_ok(id: u64, server_total_ms: u64) -> Command { + response(id, 200, json!({ "serverTotalMs": server_total_ms })) +} + +fn verified_ok(id: u64, session_token: &str, email: &str) -> Command { + response( + id, + 200, + json!({ "sessionToken": session_token, "email": email }), + ) +} + +fn request_link(email: &str) -> Command { + Command::RequestSignInLink { + email: email.into(), + platform: None, + } +} + +fn submit(input: &str) -> Command { + Command::SubmitSignInLink { + input: input.into(), + } +} + +fn delete_listening() -> Command { + Command::DeleteListeningData { + new_device_id: DEVICE_B.into(), + } +} + +fn delete_account() -> Command { + Command::DeleteAccount { + new_device_id: DEVICE_B.into(), + } +} + fn status(core: &Core) -> Option { core.snapshot().account.status_label } @@ -98,50 +158,158 @@ fn persisted_listening(effects: &[Effect]) -> Vec { /// Every account request command, so busy rules can be checked across all. fn account_requests() -> Vec { vec![ - Command::RequestSignInLink { - email: "b@example.com".into(), - }, - Command::SubmitSignInLink { - input: "raw-token".into(), - }, - Command::DeleteListeningData, - Command::DeleteAccount, + request_link("b@example.com"), + submit("raw-token"), + delete_listening(), + delete_account(), ] } -/// Every outcome command. -fn outcomes() -> Vec { +/// A response of every kind the shell can send, for request `id`. +fn every_response(id: u64) -> Vec { vec![ - Command::SignInLinkSent, - Command::SignInVerified { - session_token: "late".into(), - email: "late@example.com".into(), - }, - Command::ListeningDataDeleted { - new_device_id: DEVICE_B.into(), - }, - Command::AccountDeleted { - new_device_id: DEVICE_B.into(), - }, - Command::AccountRequestFailed { - unauthorized: false, - }, - Command::AccountRequestFailed { unauthorized: true }, + bare_response(id, 204), + verified_ok(id, "late", "late@example.com"), + pushed_ok(id, 9_000_000), + bare_response(id, 0), + bare_response(id, 500), + bare_response(id, 401), ] } +// ---- request table --------------------------------------------------------- + +#[test] +fn requests_are_numbered_from_one_and_never_reused() { + let mut core = signed_in(); + assert_eq!(start(&mut core, delete_listening()), 1); + core.dispatch(bare_response(1, 500)); + assert_eq!(request_id(&begin(&mut core, SyncReason::Refresh)), 2); + assert_eq!(start(&mut core, submit("raw-token")), 3); + assert_eq!(request_id(&core.dispatch(Command::SignOut).effects), 4); +} + +#[test] +fn each_request_matches_the_servers_table() { + let cases = [ + ( + signed_out(), + request_link(EMAIL), + Effect::ServerRequest { + id: 1, + method: HttpMethod::Post, + path: "/auth/request".into(), + bearer_token: None, + body: Some(r#"{"email":"a@example.com"}"#.into()), + }, + ), + ( + signed_out(), + Command::RequestSignInLink { + email: EMAIL.into(), + platform: Some("windows".into()), + }, + Effect::ServerRequest { + id: 1, + method: HttpMethod::Post, + path: "/auth/request".into(), + bearer_token: None, + body: Some(r#"{"email":"a@example.com","platform":"windows"}"#.into()), + }, + ), + ( + signed_out(), + submit("raw-token"), + Effect::ServerRequest { + id: 1, + method: HttpMethod::Post, + path: "/auth/verify".into(), + bearer_token: None, + body: Some(r#"{"token":"raw-token"}"#.into()), + }, + ), + ( + signed_in(), + Command::SignOut, + Effect::ServerRequest { + id: 1, + method: HttpMethod::Post, + path: "/auth/logout".into(), + bearer_token: Some(TOKEN.into()), + body: None, + }, + ), + ( + signed_in(), + Command::BeginListeningSync { + reason: SyncReason::Refresh, + }, + push_request(1, DEVICE_A, 0, TOKEN), + ), + ( + signed_in(), + delete_listening(), + Effect::ServerRequest { + id: 1, + method: HttpMethod::Delete, + path: "/listening".into(), + bearer_token: Some(TOKEN.into()), + body: None, + }, + ), + ( + signed_in(), + delete_account(), + Effect::ServerRequest { + id: 1, + method: HttpMethod::Delete, + path: "/account".into(), + bearer_token: Some(TOKEN.into()), + body: None, + }, + ), + ]; + for (mut core, command, expected) in cases { + let effects = core.dispatch(command.clone()).effects; + assert_eq!(requests(&effects), vec![&expected], "{command:?}"); + } +} + +#[test] +fn a_platform_is_escaped_into_the_link_request_body() { + let mut core = signed_out(); + let effects = core + .dispatch(Command::RequestSignInLink { + email: r#"a"b@example.com"#.into(), + platform: Some("win\"dows".into()), + }) + .effects; + let Some(Effect::ServerRequest { + body: Some(body), .. + }) = requests(&effects).pop() + else { + panic!("{effects:?}"); + }; + assert_eq!( + serde_json::from_str::(body).unwrap(), + json!({ "email": r#"a"b@example.com"#, "platform": "win\"dows" }) + ); +} + // ---- sign-in link ---------------------------------------------------------- #[test] fn requesting_a_link_sends_the_trimmed_email_and_goes_busy() { let mut core = signed_out(); - let update = core.dispatch(Command::RequestSignInLink { - email: " a@example.com\n".into(), - }); + let update = core.dispatch(request_link(" a@example.com\n")); assert_eq!( update.effects, - vec![Effect::SendSignInLink { - email: EMAIL.into() + vec![Effect::ServerRequest { + id: 1, + method: HttpMethod::Post, + path: "/auth/request".into(), + bearer_token: None, + body: Some(r#"{"email":"a@example.com"}"#.into()), }] ); assert!(update.snapshot.account.busy); @@ -152,9 +320,7 @@ fn requesting_a_link_sends_the_trimmed_email_and_goes_busy() { fn an_empty_or_blank_email_is_refused() { for email in ["", " ", "\n\t"] { let mut core = signed_out(); - let update = core.dispatch(Command::RequestSignInLink { - email: email.into(), - }); + let update = core.dispatch(request_link(email)); assert!(update.effects.is_empty(), "{email:?}"); assert!(!update.snapshot.account.busy, "{email:?}"); assert_eq!( @@ -167,46 +333,47 @@ fn an_empty_or_blank_email_is_refused() { #[test] fn a_sent_link_says_where_to_look() { - let mut core = signed_out(); - core.dispatch(Command::RequestSignInLink { - email: EMAIL.into(), - }); - let update = core.dispatch(Command::SignInLinkSent); - assert!(update.effects.is_empty()); - assert!(!update.snapshot.account.busy); - assert_eq!( - update.snapshot.account.status_label.as_deref(), - Some("Check a@example.com for a sign-in link.") - ); + for success in [200, 202, 204, 299] { + let mut core = signed_out(); + let id = start(&mut core, request_link(EMAIL)); + let update = core.dispatch(bare_response(id, success)); + assert!(update.effects.is_empty(), "{success}"); + assert!(!update.snapshot.account.busy, "{success}"); + assert_eq!( + update.snapshot.account.status_label.as_deref(), + Some("Check a@example.com for a sign-in link."), + "{success}" + ); + } } #[test] fn a_failed_link_request_says_try_again() { - let mut core = signed_out(); - core.dispatch(Command::RequestSignInLink { - email: EMAIL.into(), - }); - core.dispatch(Command::AccountRequestFailed { - unauthorized: false, - }); - assert_eq!( - status(&core).as_deref(), - Some("Couldn't send the sign-in link. Try again.") - ); - assert!(!core.snapshot().account.busy); + for failure in [0, 199, 300, 400, 403, 500] { + let mut core = signed_out(); + let id = start(&mut core, request_link(EMAIL)); + core.dispatch(bare_response(id, failure)); + assert_eq!( + status(&core).as_deref(), + Some("Couldn't send the sign-in link. Try again."), + "{failure}" + ); + assert!(!core.snapshot().account.busy, "{failure}"); + } } // ---- token parser ---------------------------------------------------------- fn submitted_token(input: &str) -> Option { let mut core = signed_out(); - let effects = core - .dispatch(Command::SubmitSignInLink { - input: input.into(), - }) - .effects; + let effects = core.dispatch(submit(input)).effects; effects.into_iter().find_map(|e| match e { - Effect::VerifySignInToken { token } => Some(token), + Effect::ServerRequest { + body: Some(body), .. + } => { + let body: serde_json::Value = serde_json::from_str(&body).unwrap(); + body["token"].as_str().map(String::from) + } _ => None, }) } @@ -250,9 +417,7 @@ fn the_token_is_found_in_whatever_was_pasted() { #[test] fn a_link_without_a_token_asks_for_the_full_link() { let mut core = signed_out(); - let update = core.dispatch(Command::SubmitSignInLink { - input: "https://cascade.example/".into(), - }); + let update = core.dispatch(submit("https://cascade.example/")); assert!(update.effects.is_empty()); assert!(!update.snapshot.account.busy); assert_eq!( @@ -266,15 +431,8 @@ fn a_link_without_a_token_asks_for_the_full_link() { #[test] fn submitting_a_link_says_signing_in_while_it_is_checked() { let mut core = signed_out(); - let update = core.dispatch(Command::SubmitSignInLink { - input: "raw-token".into(), - }); - assert_eq!( - update.effects, - vec![Effect::VerifySignInToken { - token: "raw-token".into() - }] - ); + let update = core.dispatch(submit("raw-token")); + assert_eq!(requests(&update.effects).len(), 1); assert!(update.snapshot.account.busy); assert_eq!( update.snapshot.account.status_label.as_deref(), @@ -286,24 +444,15 @@ fn submitting_a_link_says_signing_in_while_it_is_checked() { fn a_verified_sign_in_persists_the_account_and_refreshes_the_total() { let mut core = signed_out(); listen(&mut core, 3_000); - core.dispatch(Command::SubmitSignInLink { - input: "raw-token".into(), - }); - let update = core.dispatch(Command::SignInVerified { - session_token: TOKEN.into(), - email: EMAIL.into(), - }); + let id = start(&mut core, submit("raw-token")); + let update = core.dispatch(verified_ok(id, TOKEN, EMAIL)); assert_eq!( update.effects, vec![ Effect::PersistAccount { json: blob(TOKEN, EMAIL) }, - Effect::PushListening { - device_id: DEVICE_A.into(), - device_total_ms: 3_000, - session_token: TOKEN.into(), - }, + push_request(id + 1, DEVICE_A, 3_000, TOKEN), ] ); assert_eq!( @@ -317,19 +466,28 @@ fn a_verified_sign_in_persists_the_account_and_refreshes_the_total() { ); } +#[test] +fn a_verify_response_ignores_unknown_fields() { + let mut core = signed_out(); + let id = start(&mut core, submit("raw-token")); + let snap = core + .dispatch(response( + id, + 200, + json!({ "sessionToken": TOKEN, "email": EMAIL, "expiresAt": 1 }), + )) + .snapshot; + assert_eq!(snap.account.email, Some(EMAIL.into())); +} + #[test] fn a_verified_sign_in_skips_the_refresh_while_a_sync_is_in_flight() { // Signed in, a sync goes out, then the user signs in again from a link. let mut core = signed_in(); assert!(pushed(&begin(&mut core, SyncReason::Refresh)).is_some()); - core.dispatch(Command::SubmitSignInLink { - input: "raw-token".into(), - }); + let id = start(&mut core, submit("raw-token")); let effects = core - .dispatch(Command::SignInVerified { - session_token: "session-2".into(), - email: "b@example.com".into(), - }) + .dispatch(verified_ok(id, "session-2", "b@example.com")) .effects; assert!(pushed(&effects).is_none(), "{effects:?}"); assert_eq!( @@ -340,19 +498,47 @@ fn a_verified_sign_in_skips_the_refresh_while_a_sync_is_in_flight() { #[test] fn an_invalid_link_says_so() { - let mut core = signed_out(); - core.dispatch(Command::SubmitSignInLink { - input: "raw-token".into(), - }); - let update = core.dispatch(Command::AccountRequestFailed { - unauthorized: false, - }); - assert!(update.effects.is_empty()); - assert_eq!( - update.snapshot.account.status_label.as_deref(), - Some("That sign-in link was invalid or expired.") - ); - assert_eq!(update.snapshot.account.email, None); + for failure in [0, 400, 410, 500] { + let mut core = signed_out(); + let id = start(&mut core, submit("raw-token")); + let update = core.dispatch(bare_response(id, failure)); + assert!(update.effects.is_empty(), "{failure}"); + assert_eq!( + update.snapshot.account.status_label.as_deref(), + Some("That sign-in link was invalid or expired."), + "{failure}" + ); + assert_eq!(update.snapshot.account.email, None, "{failure}"); + } +} + +#[test] +fn a_verify_success_that_does_not_parse_is_an_invalid_link() { + let bodies = [ + String::new(), + "not json".into(), + json!({ "email": EMAIL }).to_string(), + json!({ "sessionToken": TOKEN }).to_string(), + json!({ "sessionToken": "", "email": EMAIL }).to_string(), + json!({ "sessionToken": 7, "email": EMAIL }).to_string(), + ]; + for body in bodies { + let mut core = signed_out(); + let id = start(&mut core, submit("raw-token")); + let update = core.dispatch(Command::ServerResponse { + id, + status: 200, + body: body.clone(), + }); + assert!(update.effects.is_empty(), "{body:?}"); + assert!(!update.snapshot.account.busy, "{body:?}"); + assert_eq!(update.snapshot.account.email, None, "{body:?}"); + assert_eq!( + update.snapshot.account.status_label.as_deref(), + Some("That sign-in link was invalid or expired."), + "{body:?}" + ); + } } #[test] @@ -391,14 +577,55 @@ fn a_push_carries_the_session_token() { let mut core = signed_in(); assert_eq!( pushed(&begin(&mut core, SyncReason::Refresh)), - Some(Effect::PushListening { - device_id: DEVICE_A.into(), - device_total_ms: 0, - session_token: TOKEN.into(), - }) + Some(push_request(1, DEVICE_A, 0, TOKEN)) ); } +#[test] +fn an_account_request_and_a_sync_settle_in_either_order() { + for sync_first in [true, false] { + let mut core = signed_in(); + listen(&mut core, 2_000); + let sync_id = request_id(&begin(&mut core, SyncReason::Refresh)); + let delete_id = start(&mut core, delete_listening()); + assert_ne!(sync_id, delete_id); + + let settle_sync = pushed_ok(sync_id, 1_000_000); + let settle_delete = bare_response(delete_id, 500); + let (first, second) = if sync_first { + (settle_sync, settle_delete) + } else { + (settle_delete, settle_sync) + }; + core.dispatch(first); + core.dispatch(second); + + let snap = core.snapshot(); + assert_eq!(snap.listening.unsynced_ms, 0, "sync first: {sync_first}"); + assert_eq!( + snap.listening.displayed_total_ms, 1_000_000, + "sync first: {sync_first}" + ); + assert!(!snap.account.busy, "sync first: {sync_first}"); + assert_eq!( + snap.account.status_label.as_deref(), + Some("Couldn't delete listening data. Try again."), + "sync first: {sync_first}" + ); + } +} + +#[test] +fn a_sync_response_does_not_settle_the_account_request() { + let mut core = signed_in(); + let sync_id = request_id(&begin(&mut core, SyncReason::Refresh)); + start(&mut core, delete_account()); + core.dispatch(bare_response(sync_id, 204)); + let snap = core.snapshot(); + assert!(snap.account.busy); + assert_eq!(snap.account.email, Some(EMAIL.into())); +} + // ---- sign-out -------------------------------------------------------------- #[test] @@ -411,8 +638,12 @@ fn sign_out_revokes_forgets_and_persists() { let update = core.dispatch(Command::SignOut); assert_eq!( update.effects.first(), - Some(&Effect::RevokeSession { - session_token: TOKEN.into() + Some(&Effect::ServerRequest { + id: 2, + method: HttpMethod::Post, + path: "/auth/logout".into(), + bearer_token: Some(TOKEN.into()), + body: None, }) ); assert_eq!(persisted_accounts(&update.effects), vec![String::new()]); @@ -428,18 +659,29 @@ fn sign_out_revokes_forgets_and_persists() { assert_eq!(update.snapshot.account.status_label, None); } +#[test] +fn the_revokes_response_changes_nothing() { + for revoke_answer in every_response(2) { + let mut core = signed_in(); + listen(&mut core, 2_000); + sync(&mut core, 1_000_000); + let revoke_id = request_id(&core.dispatch(Command::SignOut).effects); + assert_eq!(revoke_id, 2); + let before = core.snapshot(); + let update = core.dispatch(revoke_answer.clone()); + assert!(update.effects.is_empty(), "{revoke_answer:?}"); + assert_eq!(update.snapshot, before, "{revoke_answer:?}"); + } +} + #[test] fn a_sync_ack_that_lands_after_sign_out_is_dropped() { let mut core = signed_in(); listen(&mut core, 2_000); - assert!(pushed(&begin(&mut core, SyncReason::Refresh)).is_some()); + let id = request_id(&begin(&mut core, SyncReason::Refresh)); core.dispatch(Command::SignOut); - let snap = core - .dispatch(Command::ListeningSyncSucceeded { - server_total_ms: 1_000_000, - }) - .snapshot; + let snap = core.dispatch(pushed_ok(id, 1_000_000)).snapshot; assert_eq!(snap.listening.displayed_total_ms, 2_000); } @@ -450,14 +692,9 @@ fn a_second_account_shows_none_of_the_first_accounts_total() { sync(&mut core, 1_000_000); core.dispatch(Command::SignOut); - core.dispatch(Command::SubmitSignInLink { - input: "raw-token".into(), - }); + let id = start(&mut core, submit("raw-token")); let snap = core - .dispatch(Command::SignInVerified { - session_token: "session-2".into(), - email: "b@example.com".into(), - }) + .dispatch(verified_ok(id, "session-2", "b@example.com")) .snapshot; assert_eq!(snap.listening.displayed_total_ms, 2_000); } @@ -468,14 +705,9 @@ fn signing_in_over_an_account_forgets_its_total() { listen(&mut core, 2_000); sync(&mut core, 1_000_000); - core.dispatch(Command::SubmitSignInLink { - input: "raw-token".into(), - }); + let id = start(&mut core, submit("raw-token")); let snap = core - .dispatch(Command::SignInVerified { - session_token: "session-2".into(), - email: "b@example.com".into(), - }) + .dispatch(verified_ok(id, "session-2", "b@example.com")) .snapshot; assert_eq!(snap.listening.displayed_total_ms, 2_000); } @@ -483,7 +715,7 @@ fn signing_in_over_an_account_forgets_its_total() { #[test] fn sign_out_while_signed_out_emits_nothing_but_clears_the_status() { let mut core = signed_out(); - core.dispatch(Command::RequestSignInLink { email: "".into() }); + core.dispatch(request_link("")); let update = core.dispatch(Command::SignOut); assert!(update.effects.is_empty()); assert_eq!(update.snapshot.account.status_label, None); @@ -494,18 +726,10 @@ fn sign_out_while_signed_out_emits_nothing_but_clears_the_status() { #[test] fn every_account_request_is_ignored_while_one_is_pending() { let pending_starts = [ - ( - signed_out(), - Command::RequestSignInLink { - email: EMAIL.into(), - }, - ), - ( - signed_out(), - Command::SubmitSignInLink { input: "t".into() }, - ), - (signed_in(), Command::DeleteListeningData), - (signed_in(), Command::DeleteAccount), + (signed_out(), request_link(EMAIL)), + (signed_out(), submit("t")), + (signed_in(), delete_listening()), + (signed_in(), delete_account()), ]; for (mut core, start) in pending_starts { core.dispatch(start.clone()); @@ -520,55 +744,57 @@ fn every_account_request_is_ignored_while_one_is_pending() { } #[test] -fn an_outcome_with_nothing_pending_changes_nothing() { - for outcome in outcomes() { - let mut core = signed_in(); - listen(&mut core, 2_000); - sync(&mut core, 1_000_000); - let before = core.snapshot(); - let update = core.dispatch(outcome.clone()); - assert!(update.effects.is_empty(), "{outcome:?}"); - assert_eq!(update.snapshot, before, "{outcome:?}"); +fn a_response_with_an_unknown_id_changes_nothing() { + // Nothing pending, nothing in flight: no id is known. + for unknown in [0, 1, 2, 3, u64::MAX] { + for answer in every_response(unknown) { + let mut core = signed_in(); + listen(&mut core, 2_000); + sync(&mut core, 1_000_000); + let before = core.snapshot(); + let update = core.dispatch(answer.clone()); + assert!(update.effects.is_empty(), "{answer:?}"); + assert_eq!(update.snapshot, before, "{answer:?}"); + } } } #[test] -fn an_outcome_for_another_request_changes_nothing() { - // A delete is pending; a sign-in outcome can't be its answer. +fn a_response_for_another_id_leaves_the_pending_request_alone() { let mut core = signed_in(); - core.dispatch(Command::DeleteListeningData); + let id = start(&mut core, delete_listening()); let before = core.snapshot(); - for outcome in [ - Command::SignInLinkSent, - Command::SignInVerified { - session_token: "x".into(), - email: "x@example.com".into(), - }, - Command::AccountDeleted { - new_device_id: DEVICE_B.into(), - }, - ] { - let update = core.dispatch(outcome.clone()); - assert!(update.effects.is_empty(), "{outcome:?}"); - assert_eq!(update.snapshot, before, "{outcome:?}"); + for other in [id - 1, id + 1] { + for answer in every_response(other) { + let update = core.dispatch(answer.clone()); + assert!(update.effects.is_empty(), "{answer:?}"); + assert_eq!(update.snapshot, before, "{answer:?}"); + } } } +#[test] +fn a_request_is_settled_only_once() { + let mut core = signed_in(); + let id = start(&mut core, delete_listening()); + core.dispatch(bare_response(id, 500)); + let before = core.snapshot(); + let update = core.dispatch(bare_response(id, 204)); + assert!(update.effects.is_empty()); + assert_eq!(update.snapshot, before); +} + #[test] fn sign_out_cuts_in_and_drops_the_pending_request() { let mut core = signed_in(); - core.dispatch(Command::DeleteAccount); + let id = start(&mut core, delete_account()); let update = core.dispatch(Command::SignOut); assert!(!update.snapshot.account.busy); - assert!(update.effects.contains(&Effect::RevokeSession { - session_token: TOKEN.into() - })); + assert_eq!(requests(&update.effects).len(), 1, "the revoke"); - // The dropped request's late settle is ignored. + // The dropped request's late response is ignored. let before = core.snapshot(); - let late = core.dispatch(Command::AccountDeleted { - new_device_id: DEVICE_B.into(), - }); + let late = core.dispatch(bare_response(id, 204)); assert!(late.effects.is_empty()); assert_eq!(late.snapshot, before); } @@ -576,14 +802,9 @@ fn sign_out_cuts_in_and_drops_the_pending_request() { #[test] fn sign_out_drops_a_pending_sign_in() { let mut core = signed_out(); - core.dispatch(Command::SubmitSignInLink { - input: "raw-token".into(), - }); + let id = start(&mut core, submit("raw-token")); core.dispatch(Command::SignOut); - let update = core.dispatch(Command::SignInVerified { - session_token: TOKEN.into(), - email: EMAIL.into(), - }); + let update = core.dispatch(verified_ok(id, TOKEN, EMAIL)); assert!(update.effects.is_empty()); assert_eq!(update.snapshot.account.email, None); assert!(!update.snapshot.account.busy); @@ -599,31 +820,18 @@ fn revoke_session_does_not_set_busy() { // ---- deletes --------------------------------------------------------------- #[test] -fn deletes_carry_the_session_token() { - let mut core = signed_in(); - let update = core.dispatch(Command::DeleteListeningData); - assert_eq!( - update.effects, - vec![Effect::DeleteServerListening { - session_token: TOKEN.into() - }] - ); - assert!(update.snapshot.account.busy); - - let mut core = signed_in(); - let update = core.dispatch(Command::DeleteAccount); - assert_eq!( - update.effects, - vec![Effect::DeleteServerAccount { - session_token: TOKEN.into() - }] - ); - assert!(update.snapshot.account.busy); +fn deletes_go_busy() { + for request in [delete_listening(), delete_account()] { + let mut core = signed_in(); + let update = core.dispatch(request.clone()); + assert_eq!(requests(&update.effects).len(), 1, "{request:?}"); + assert!(update.snapshot.account.busy, "{request:?}"); + } } #[test] fn deletes_do_nothing_while_signed_out() { - for request in [Command::DeleteListeningData, Command::DeleteAccount] { + for request in [delete_listening(), delete_account()] { let mut core = signed_out(); let update = core.dispatch(request.clone()); assert!(update.effects.is_empty(), "{request:?}"); @@ -631,15 +839,22 @@ fn deletes_do_nothing_while_signed_out() { } } +#[test] +fn a_delete_keeps_the_old_id_until_the_server_confirms() { + let mut core = signed_in(); + listen(&mut core, 2_000); + let update = core.dispatch(delete_listening()); + assert!(persisted_listening(&update.effects).is_empty()); + assert_eq!(update.snapshot.listening.device_total_ms, 2_000); +} + #[test] fn deleted_listening_data_rotates_the_id_and_zeroes_the_slot_in_one_write() { let mut core = signed_in(); listen(&mut core, 2_000); sync(&mut core, 1_000_000); - core.dispatch(Command::DeleteListeningData); - let update = core.dispatch(Command::ListeningDataDeleted { - new_device_id: DEVICE_B.into(), - }); + let id = start(&mut core, delete_listening()); + let update = core.dispatch(bare_response(id, 204)); let writes = persisted_listening(&update.effects); assert_eq!(writes.len(), 1, "{:?}", update.effects); @@ -658,29 +873,30 @@ fn deleted_listening_data_rotates_the_id_and_zeroes_the_slot_in_one_write() { ); assert_eq!( pushed(&begin(&mut core, SyncReason::Refresh)), - Some(Effect::PushListening { - device_id: DEVICE_B.into(), - device_total_ms: 0, - session_token: TOKEN.into(), - }) + Some(push_request(id + 1, DEVICE_B, 0, TOKEN)) ); } #[test] fn a_failed_listening_delete_leaves_the_data_untouched() { - let mut core = signed_in(); - listen(&mut core, 2_000); - core.dispatch(Command::DeleteListeningData); - let update = core.dispatch(Command::AccountRequestFailed { - unauthorized: false, - }); - assert!(update.effects.is_empty()); - assert_eq!(update.snapshot.listening.device_total_ms, 2_000); - assert_eq!( - update.snapshot.account.status_label.as_deref(), - Some("Couldn't delete listening data. Try again.") - ); - assert!(!update.snapshot.account.busy); + for failure in [0, 500] { + let mut core = signed_in(); + listen(&mut core, 2_000); + let id = start(&mut core, delete_listening()); + let update = core.dispatch(bare_response(id, failure)); + assert!(update.effects.is_empty(), "{failure}"); + assert_eq!(update.snapshot.listening.device_total_ms, 2_000); + assert_eq!( + update.snapshot.account.status_label.as_deref(), + Some("Couldn't delete listening data. Try again.") + ); + assert!(!update.snapshot.account.busy); + assert_eq!( + pushed(&begin(&mut core, SyncReason::Refresh)), + Some(push_request(id + 1, DEVICE_A, 2_000, TOKEN)), + "the old slot stays" + ); + } } #[test] @@ -688,10 +904,8 @@ fn a_deleted_account_rotates_the_slot_and_signs_out() { let mut core = signed_in(); listen(&mut core, 2_000); sync(&mut core, 1_000_000); - core.dispatch(Command::DeleteAccount); - let update = core.dispatch(Command::AccountDeleted { - new_device_id: DEVICE_B.into(), - }); + let id = start(&mut core, delete_account()); + let update = core.dispatch(bare_response(id, 200)); let writes = persisted_listening(&update.effects); assert_eq!(writes.len(), 1, "{:?}", update.effects); @@ -703,11 +917,8 @@ fn a_deleted_account_rotates_the_slot_and_signs_out() { assert!(writes[0].contains(r#""deviceTotalMs":0"#), "{}", writes[0]); assert_eq!(persisted_accounts(&update.effects), vec![String::new()]); assert!( - !update - .effects - .iter() - .any(|e| matches!(e, Effect::RevokeSession { .. })), - "the account is already gone server-side" + requests(&update.effects).is_empty(), + "the account is already gone server-side: no revoke" ); assert_eq!(update.snapshot.listening.displayed_total_ms, 0); assert_eq!(update.snapshot.account.email, None); @@ -722,26 +933,18 @@ fn a_deleted_account_rotates_the_slot_and_signs_out() { fn a_deleted_account_drops_a_sync_ack_still_in_flight() { let mut core = signed_in(); listen(&mut core, 2_000); - assert!(pushed(&begin(&mut core, SyncReason::Refresh)).is_some()); - core.dispatch(Command::DeleteAccount); - core.dispatch(Command::AccountDeleted { - new_device_id: DEVICE_B.into(), - }); - let snap = core - .dispatch(Command::ListeningSyncSucceeded { - server_total_ms: 1_000_000, - }) - .snapshot; + let sync_id = request_id(&begin(&mut core, SyncReason::Refresh)); + let id = start(&mut core, delete_account()); + core.dispatch(bare_response(id, 204)); + let snap = core.dispatch(pushed_ok(sync_id, 1_000_000)).snapshot; assert_eq!(snap.listening.displayed_total_ms, 0); } #[test] fn a_failed_account_delete_stays_signed_in() { let mut core = signed_in(); - core.dispatch(Command::DeleteAccount); - let update = core.dispatch(Command::AccountRequestFailed { - unauthorized: false, - }); + let id = start(&mut core, delete_account()); + let update = core.dispatch(bare_response(id, 0)); assert!(update.effects.is_empty()); assert_eq!(update.snapshot.account.email, Some(EMAIL.into())); assert_eq!( @@ -756,9 +959,7 @@ fn a_failed_account_delete_stays_signed_in() { fn assert_signed_out_by_401(core: &mut Core, effects: &[Effect]) { assert_eq!(persisted_accounts(effects), vec![String::new()]); assert_eq!(persisted_listening(effects).len(), 1, "{effects:?}"); - assert!(!effects - .iter() - .any(|e| matches!(e, Effect::RevokeSession { .. }))); + assert!(requests(effects).is_empty(), "no revoke: {effects:?}"); let snap = core.snapshot(); assert_eq!(snap.account.email, None); assert!(!snap.account.busy); @@ -775,31 +976,25 @@ fn a_401_from_a_listening_sync_signs_out() { let mut core = signed_in(); listen(&mut core, 2_000); sync(&mut core, 1_000_000); - assert!(pushed(&begin(&mut core, SyncReason::Refresh)).is_some()); - let effects = core - .dispatch(Command::ListeningSyncFailed { unauthorized: true }) - .effects; + let id = request_id(&begin(&mut core, SyncReason::Refresh)); + let effects = core.dispatch(bare_response(id, 401)).effects; assert_signed_out_by_401(&mut core, &effects); } #[test] fn a_401_from_any_account_request_signs_out() { let starts = [ - Command::RequestSignInLink { - email: EMAIL.into(), - }, - Command::SubmitSignInLink { input: "t".into() }, - Command::DeleteListeningData, - Command::DeleteAccount, + request_link(EMAIL), + submit("t"), + delete_listening(), + delete_account(), ]; - for start in starts { + for start_command in starts { let mut core = signed_in(); listen(&mut core, 2_000); sync(&mut core, 1_000_000); - core.dispatch(start); - let effects = core - .dispatch(Command::AccountRequestFailed { unauthorized: true }) - .effects; + let id = start(&mut core, start_command); + let effects = core.dispatch(bare_response(id, 401)).effects; assert_signed_out_by_401(&mut core, &effects); } } @@ -807,10 +1002,8 @@ fn a_401_from_any_account_request_signs_out() { #[test] fn a_401_while_signed_out_is_an_ordinary_failure() { let mut core = signed_out(); - core.dispatch(Command::SubmitSignInLink { - input: "raw-token".into(), - }); - let update = core.dispatch(Command::AccountRequestFailed { unauthorized: true }); + let id = start(&mut core, submit("raw-token")); + let update = core.dispatch(bare_response(id, 401)); assert!(update.effects.is_empty()); assert_eq!( update.snapshot.account.status_label.as_deref(), @@ -827,7 +1020,17 @@ fn account_commands_accept_the_shells_camel_case_json() { let cases = [ ( r#"{"type":"requestSignInLink","email":"e"}"#, - Command::RequestSignInLink { email: "e".into() }, + Command::RequestSignInLink { + email: "e".into(), + platform: None, + }, + ), + ( + r#"{"type":"requestSignInLink","email":"e","platform":"windows"}"#, + Command::RequestSignInLink { + email: "e".into(), + platform: Some("windows".into()), + }, ), ( r#"{"type":"submitSignInLink","input":"i"}"#, @@ -835,69 +1038,110 @@ fn account_commands_accept_the_shells_camel_case_json() { ), (r#"{"type":"signOut"}"#, Command::SignOut), ( - r#"{"type":"deleteListeningData"}"#, - Command::DeleteListeningData, + r#"{"type":"deleteListeningData","newDeviceId":"d"}"#, + Command::DeleteListeningData { + new_device_id: "d".into(), + }, ), - (r#"{"type":"deleteAccount"}"#, Command::DeleteAccount), - (r#"{"type":"signInLinkSent"}"#, Command::SignInLinkSent), ( - r#"{"type":"signInVerified","sessionToken":"t","email":"e"}"#, - Command::SignInVerified { - session_token: "t".into(), - email: "e".into(), + r#"{"type":"deleteAccount","newDeviceId":"d"}"#, + Command::DeleteAccount { + new_device_id: "d".into(), }, ), + ]; + for (json, expected) in cases { + let parsed: Command = serde_json::from_str(json).expect(json); + assert_eq!(parsed, expected, "{json}"); + } +} + +#[test] +fn the_removed_settle_commands_no_longer_parse() { + for json in [ + r#"{"type":"signInLinkSent"}"#, + r#"{"type":"signInVerified","sessionToken":"t","email":"e"}"#, + r#"{"type":"listeningDataDeleted","newDeviceId":"d"}"#, + r#"{"type":"accountDeleted","newDeviceId":"d"}"#, + r#"{"type":"accountRequestFailed","unauthorized":true}"#, + r#"{"type":"listeningSyncSucceeded","serverTotalMs":5}"#, + r#"{"type":"listeningSyncFailed","unauthorized":true}"#, + r#"{"type":"deleteAccount"}"#, + ] { + assert!(serde_json::from_str::(json).is_err(), "{json}"); + } +} + +#[test] +fn server_response_accepts_the_shells_camel_case_json() { + let cases = [ ( - r#"{"type":"listeningDataDeleted","newDeviceId":"d"}"#, - Command::ListeningDataDeleted { - new_device_id: "d".into(), + r#"{"type":"serverResponse","id":3,"status":200,"body":"{\"serverTotalMs\":5}"}"#, + Command::ServerResponse { + id: 3, + status: 200, + body: r#"{"serverTotalMs":5}"#.into(), }, ), ( - r#"{"type":"accountDeleted","newDeviceId":"d"}"#, - Command::AccountDeleted { - new_device_id: "d".into(), + r#"{"type":"serverResponse","id":4,"status":0,"body":""}"#, + Command::ServerResponse { + id: 4, + status: 0, + body: String::new(), }, ), ( - r#"{"type":"accountRequestFailed","unauthorized":true}"#, - Command::AccountRequestFailed { unauthorized: true }, + r#"{"type":"serverResponse","id":4,"status":0}"#, + Command::ServerResponse { + id: 4, + status: 0, + body: String::new(), + }, ), ]; for (json, expected) in cases { let parsed: Command = serde_json::from_str(json).expect(json); assert_eq!(parsed, expected, "{json}"); + assert_eq!( + serde_json::from_str::(&serde_json::to_string(&parsed).unwrap()).unwrap(), + expected + ); } } #[test] -fn account_effects_serialize_camel_case() { +fn server_request_serializes_camel_case() { let cases = [ ( - Effect::SendSignInLink { email: "e".into() }, - r#"{"type":"sendSignInLink","email":"e"}"#, - ), - ( - Effect::VerifySignInToken { token: "t".into() }, - r#"{"type":"verifySignInToken","token":"t"}"#, - ), - ( - Effect::RevokeSession { - session_token: "s".into(), + Effect::ServerRequest { + id: 7, + method: HttpMethod::Put, + path: "/listening".into(), + bearer_token: Some("s".into()), + body: Some(r#"{"deviceId":"d","deviceTotalMs":7}"#.into()), }, - r#"{"type":"revokeSession","sessionToken":"s"}"#, + r#"{"type":"serverRequest","id":7,"method":"PUT","path":"/listening","bearerToken":"s","body":"{\"deviceId\":\"d\",\"deviceTotalMs\":7}"}"#, ), ( - Effect::DeleteServerListening { - session_token: "s".into(), + Effect::ServerRequest { + id: 1, + method: HttpMethod::Post, + path: "/auth/verify".into(), + bearer_token: None, + body: Some(r#"{"token":"t"}"#.into()), }, - r#"{"type":"deleteServerListening","sessionToken":"s"}"#, + r#"{"type":"serverRequest","id":1,"method":"POST","path":"/auth/verify","body":"{\"token\":\"t\"}"}"#, ), ( - Effect::DeleteServerAccount { - session_token: "s".into(), + Effect::ServerRequest { + id: 2, + method: HttpMethod::Delete, + path: "/account".into(), + bearer_token: Some("s".into()), + body: None, }, - r#"{"type":"deleteServerAccount","sessionToken":"s"}"#, + r#"{"type":"serverRequest","id":2,"method":"DELETE","path":"/account","bearerToken":"s"}"#, ), ( Effect::PersistAccount { json: "j".into() }, diff --git a/crates/cascade-core/tests/listening_sync.rs b/crates/cascade-core/tests/listening_sync.rs index bf49858..75c692e 100644 --- a/crates/cascade-core/tests/listening_sync.rs +++ b/crates/cascade-core/tests/listening_sync.rs @@ -6,7 +6,74 @@ //! the threshold and its failure pacing, the payload, the synced high-water //! mark, the 401 rule, and the device-id lifecycle. -use cascade_core::{Command, Core, Effect, SyncReason, LISTENING_SYNC_THRESHOLD_MS}; +mod support; + +use std::collections::VecDeque; +use std::ops::Deref; + +use cascade_core::{ + Command, Core, Effect, HttpMethod, SyncReason, Update, LISTENING_SYNC_THRESHOLD_MS, +}; +use serde_json::json; +use support::{bare_response, request_id, response}; + +/// A core plus what a shell would remember: the listening pushes it is +/// carrying, oldest first, so a test can settle them by id. +struct Shell { + core: Core, + pushes: VecDeque, +} + +impl Shell { + fn new() -> Self { + Self { + core: Core::new(), + pushes: VecDeque::new(), + } + } + + fn dispatch(&mut self, command: Command) -> Update { + let update = self.core.dispatch(command); + for effect in &update.effects { + if let Effect::ServerRequest { + id, + method: HttpMethod::Put, + .. + } = effect + { + self.pushes.push_back(*id); + } + } + update + } + + fn oldest_push(&mut self) -> u64 { + self.pushes.pop_front().expect("a push to settle") + } + + /// Settle the oldest push with a success reporting `server_total_ms`. + fn ack(&mut self, server_total_ms: i64) -> Update { + let id = self.oldest_push(); + self.dispatch(response( + id, + 200, + json!({ "serverTotalMs": server_total_ms }), + )) + } + + /// Settle the oldest push with `status` and no body. + fn settle(&mut self, status: u16) -> Update { + let id = self.oldest_push(); + self.dispatch(bare_response(id, status)) + } +} + +impl Deref for Shell { + type Target = Core; + fn deref(&self) -> &Core { + &self.core + } +} const DEVICE_A: &str = "device-a"; const DEVICE_B: &str = "device-b"; @@ -15,8 +82,8 @@ const SIGNED_IN: &str = r#"{"version":1,"sessionToken":"session","email":"a@exam /// A signed-in core that has restored no listening, with `DEVICE_A` as its /// fallback id, and audio confirmed playing. -fn signed_in_and_playing() -> Core { - let mut core = Core::new(); +fn signed_in_and_playing() -> Shell { + let mut core = Shell::new(); core.dispatch(Command::Restore { settings_json: String::new(), listening_json: String::new(), @@ -30,7 +97,7 @@ fn signed_in_and_playing() -> Core { /// Tick `listened_ms` of confirmed audio in one-second steps, returning every /// effect the ticks emitted. -fn listen(core: &mut Core, listened_ms: u64) -> Vec { +fn listen(core: &mut Shell, listened_ms: u64) -> Vec { let mut effects = Vec::new(); let mut left = listened_ms; while left > 0 { @@ -43,7 +110,7 @@ fn listen(core: &mut Core, listened_ms: u64) -> Vec { /// A signed-in core that has accrued `listened_ms` of confirmed audio. From /// 30 seconds on, the tick that crossed the threshold left a push in flight. -fn core_with_listening(listened_ms: u64) -> Core { +fn core_with_listening(listened_ms: u64) -> Shell { let mut core = signed_in_and_playing(); listen(&mut core, listened_ms); core @@ -52,8 +119,8 @@ fn core_with_listening(listened_ms: u64) -> Core { /// A signed-in core restored from a blob holding `unsynced_ms` of listening /// the server has not seen, with `tracking` as the tracking flag. Audio is /// confirmed playing, and no tick has run yet. -fn restored_with_unsynced(unsynced_ms: u64, tracking: bool) -> Core { - let mut core = Core::new(); +fn restored_with_unsynced(unsynced_ms: u64, tracking: bool) -> Shell { + let mut core = Shell::new(); core.dispatch(Command::Restore { settings_json: String::new(), listening_json: format!( @@ -67,14 +134,19 @@ fn restored_with_unsynced(unsynced_ms: u64, tracking: bool) -> Core { core } -/// The `PushListening` payload in `effects`, if any. +/// The `(deviceId, deviceTotalMs)` a listening push in `effects` carries, if +/// any. fn pushed(effects: &[Effect]) -> Option<(String, u64)> { - effects.iter().find_map(|e| match e { - Effect::PushListening { - device_id, - device_total_ms, - .. - } => Some((device_id.clone(), *device_total_ms)), + pushes(effects).into_iter().find_map(|e| match e { + Effect::ServerRequest { + body: Some(body), .. + } => { + let body: serde_json::Value = serde_json::from_str(body).unwrap(); + Some(( + body["deviceId"].as_str()?.to_string(), + body["deviceTotalMs"].as_u64()?, + )) + } _ => None, }) } @@ -82,11 +154,11 @@ fn pushed(effects: &[Effect]) -> Option<(String, u64)> { fn pushes(effects: &[Effect]) -> Vec<&Effect> { effects .iter() - .filter(|e| matches!(e, Effect::PushListening { .. })) + .filter(|e| matches!(e, Effect::ServerRequest { path, .. } if path == "/listening")) .collect() } -fn begin(core: &mut Core, reason: SyncReason) -> Option<(String, u64)> { +fn begin(core: &mut Shell, reason: SyncReason) -> Option<(String, u64)> { pushed( &core .dispatch(Command::BeginListeningSync { reason }) @@ -94,14 +166,12 @@ fn begin(core: &mut Core, reason: SyncReason) -> Option<(String, u64)> { ) } -fn tick(core: &mut Core, elapsed_ms: u64) -> Option<(String, u64)> { +fn tick(core: &mut Shell, elapsed_ms: u64) -> Option<(String, u64)> { pushed(&core.dispatch(Command::Tick { elapsed_ms }).effects) } -fn fail(core: &mut Core) { - core.dispatch(Command::ListeningSyncFailed { - unauthorized: false, - }); +fn fail(core: &mut Shell) { + core.settle(500); } /// The `PersistListening` JSON in `effects`, if any. @@ -123,17 +193,19 @@ fn the_tick_that_reaches_thirty_seconds_pushes_once_with_the_session_token() { let effects = core.dispatch(Command::Tick { elapsed_ms: 1_000 }).effects; assert_eq!( pushes(&effects), - vec![&Effect::PushListening { - device_id: DEVICE_A.into(), - device_total_ms: LISTENING_SYNC_THRESHOLD_MS, - session_token: "session".into(), + vec![&Effect::ServerRequest { + id: 1, + method: HttpMethod::Put, + path: "/listening".into(), + bearer_token: Some("session".into()), + body: Some(r#"{"deviceId":"device-a","deviceTotalMs":30000}"#.into()), }] ); } #[test] fn a_signed_out_core_never_pushes_from_a_tick() { - let mut core = Core::new(); + let mut core = Shell::new(); core.dispatch(Command::Restore { settings_json: String::new(), listening_json: String::new(), @@ -165,9 +237,7 @@ fn a_push_in_flight_blocks_every_tick_until_it_settles() { let during = listen(&mut core, LISTENING_SYNC_THRESHOLD_MS); assert!(pushes(&during).is_empty(), "{during:?}"); - core.dispatch(Command::ListeningSyncSucceeded { - server_total_ms: 1_000_000, - }); + core.ack(1_000_000); // 30s unsynced was waiting behind the push; the next counted tick sends it. assert_eq!( tick(&mut core, 1_000), @@ -209,9 +279,7 @@ fn a_successful_settle_rearms_the_normal_threshold() { fail(&mut core); listen(&mut core, 5_000); assert!(begin(&mut core, SyncReason::Flush).is_some()); - core.dispatch(Command::ListeningSyncSucceeded { - server_total_ms: 1_000_000, - }); + core.ack(1_000_000); // Synced through 35s: the next push comes 30s after that. let below = listen(&mut core, LISTENING_SYNC_THRESHOLD_MS - 1_000); @@ -249,13 +317,18 @@ fn signing_out_clears_the_failure_pacing() { listen(&mut core, 1_000); assert!(begin(&mut core, SyncReason::Flush).is_some()); core.dispatch(Command::SignOut); - core.dispatch(Command::SubmitSignInLink { - input: "raw-token".into(), - }); - let signed_in = core.dispatch(Command::SignInVerified { - session_token: "session-2".into(), - email: "a@example.com".into(), - }); + let verify = request_id( + &core + .dispatch(Command::SubmitSignInLink { + input: "raw-token".into(), + }) + .effects, + ); + let signed_in = core.dispatch(response( + verify, + 200, + json!({ "sessionToken": "session-2", "email": "a@example.com" }), + )); assert_eq!(pushed(&signed_in.effects), None); fail(&mut core); @@ -273,7 +346,7 @@ fn the_failure_pacing_is_never_persisted() { let json = persisted_json(&core.dispatch(Command::Tick { elapsed_ms: 1_000 }).effects) .expect("a counted tick persists the ledger"); - let mut restarted = Core::new(); + let mut restarted = Shell::new(); restarted.dispatch(Command::Restore { settings_json: String::new(), listening_json: json, @@ -313,7 +386,7 @@ fn refresh_sends_even_with_nothing_unsynced_to_fetch_the_aggregate() { #[test] fn nothing_is_sent_before_the_core_has_a_device_id() { - let mut core = Core::new(); + let mut core = Shell::new(); assert_eq!(begin(&mut core, SyncReason::Refresh), None); } @@ -330,11 +403,7 @@ fn synced_through_is_exactly_what_was_sent() { // More listening lands while the PUT is in flight. core.dispatch(Command::Tick { elapsed_ms: 2_000 }); - let snap = core - .dispatch(Command::ListeningSyncSucceeded { - server_total_ms: 1_000_000, - }) - .snapshot; + let snap = core.ack(1_000_000).snapshot; // Only what was sent is acknowledged; the 2s accrued mid-flight stays // unsynced rather than being silently marked as delivered. assert_eq!(snap.listening.unsynced_ms, 2_000); @@ -360,9 +429,7 @@ fn a_sync_is_not_reentrant_until_the_first_settles() { #[test] fn a_transient_failure_acknowledges_nothing() { let mut core = core_with_listening(LISTENING_SYNC_THRESHOLD_MS); - let update = core.dispatch(Command::ListeningSyncFailed { - unauthorized: false, - }); + let update = core.settle(500); assert_eq!( update.snapshot.listening.unsynced_ms, LISTENING_SYNC_THRESHOLD_MS @@ -373,23 +440,124 @@ fn a_transient_failure_acknowledges_nothing() { #[test] fn an_ack_with_nothing_in_flight_is_ignored() { let mut core = core_with_listening(20_000); + let update = core.dispatch(response(1, 200, json!({ "serverTotalMs": 9_000_000 }))); + assert!(update.effects.is_empty(), "{:?}", update.effects); + assert_eq!(update.snapshot.listening.unsynced_ms, 20_000); + assert_eq!(update.snapshot.listening.displayed_total_ms, 20_000); +} + +#[test] +fn an_ack_with_another_id_leaves_the_push_in_flight() { + let mut core = core_with_listening(LISTENING_SYNC_THRESHOLD_MS); + let in_flight = core.pushes[0]; + for other in [0, in_flight + 1, u64::MAX] { + let update = core.dispatch(response(other, 200, json!({ "serverTotalMs": 9 }))); + assert!(update.effects.is_empty(), "{other}"); + assert_eq!( + update.snapshot.listening.unsynced_ms, LISTENING_SYNC_THRESHOLD_MS, + "{other}" + ); + } + assert_eq!( + begin(&mut core, SyncReason::Refresh), + None, + "still in flight" + ); + core.ack(9); + assert_eq!(core.snapshot().listening.unsynced_ms, 0); +} + +#[test] +fn any_2xx_with_a_server_total_is_a_success() { + for success in [200, 201, 299] { + let mut core = core_with_listening(LISTENING_SYNC_THRESHOLD_MS); + let id = core.oldest_push(); + let snap = core + .dispatch(response(id, success, json!({ "serverTotalMs": 50_000 }))) + .snapshot; + assert_eq!(snap.listening.unsynced_ms, 0, "{success}"); + assert_eq!(snap.listening.displayed_total_ms, 50_000, "{success}"); + } +} + +#[test] +fn unknown_fields_in_a_push_response_are_ignored() { + let mut core = core_with_listening(LISTENING_SYNC_THRESHOLD_MS); + let id = core.oldest_push(); let snap = core - .dispatch(Command::ListeningSyncSucceeded { - server_total_ms: 9_000_000, - }) + .dispatch(response( + id, + 200, + json!({ "serverTotalMs": 50_000, "devices": 3 }), + )) .snapshot; - assert_eq!(snap.listening.unsynced_ms, 20_000); - assert_eq!(snap.listening.displayed_total_ms, 20_000); + assert_eq!(snap.listening.displayed_total_ms, 50_000); +} + +#[test] +fn a_negative_server_total_clamps_to_zero() { + let mut core = core_with_listening(LISTENING_SYNC_THRESHOLD_MS); + let update = core.ack(-5); + assert_eq!(update.snapshot.listening.unsynced_ms, 0); + assert_eq!(update.snapshot.listening.displayed_total_ms, 0); + let json = persisted_json(&update.effects).expect("ack persists the ledger"); + assert!(json.contains(r#""serverTotalMs":0"#), "{json}"); +} + +/// Assert `core`'s push in flight was settled as a plain failure: nothing +/// acknowledged, still signed in, and the threshold paced from what it sent. +fn assert_plain_failure(core: &mut Shell, update: Update, case: &str) { + assert!(update.effects.is_empty(), "{case}: {:?}", update.effects); + assert_eq!( + update.snapshot.listening.unsynced_ms, LISTENING_SYNC_THRESHOLD_MS, + "{case}" + ); + assert_eq!( + update.snapshot.account.email.as_deref(), + Some("a@example.com"), + "{case}" + ); + assert_eq!(update.snapshot.account.status_label, None, "{case}"); + let paced = listen(core, LISTENING_SYNC_THRESHOLD_MS - 1_000); + assert!(pushes(&paced).is_empty(), "{case}: {paced:?}"); + assert!(tick(core, 1_000).is_some(), "{case}"); +} + +#[test] +fn status_zero_and_non_401_errors_are_plain_failures() { + for status in [0, 100, 302, 400, 403, 404, 500, 503] { + let mut core = core_with_listening(LISTENING_SYNC_THRESHOLD_MS); + let update = core.settle(status); + assert_plain_failure(&mut core, update, &status.to_string()); + } +} + +#[test] +fn a_2xx_whose_body_does_not_parse_is_a_plain_failure() { + let bodies = [ + String::new(), + "not json".into(), + "{}".into(), + json!({ "serverTotalMs": "50000" }).to_string(), + json!({ "serverTotalMs": 1.5 }).to_string(), + json!({ "serverTotalMs": null }).to_string(), + ]; + for body in bodies { + let mut core = core_with_listening(LISTENING_SYNC_THRESHOLD_MS); + let id = core.oldest_push(); + let update = core.dispatch(Command::ServerResponse { + id, + status: 200, + body: body.clone(), + }); + assert_plain_failure(&mut core, update, &body); + } } #[test] fn a_successful_sync_persists_the_new_high_water_mark() { let mut core = core_with_listening(LISTENING_SYNC_THRESHOLD_MS); - let effects = core - .dispatch(Command::ListeningSyncSucceeded { - server_total_ms: 50_000, - }) - .effects; + let effects = core.ack(50_000).effects; let json = persisted_json(&effects).expect("ack persists the ledger"); assert!(json.contains(r#""syncedThroughMs":30000"#), "{json}"); } @@ -399,7 +567,7 @@ fn a_successful_sync_persists_the_new_high_water_mark() { #[test] fn unauthorized_signs_out_and_keeps_local_listening() { let mut core = core_with_listening(LISTENING_SYNC_THRESHOLD_MS); - let update = core.dispatch(Command::ListeningSyncFailed { unauthorized: true }); + let update = core.settle(401); assert_eq!(update.snapshot.account.email, None); assert_eq!( update.snapshot.listening.device_total_ms, @@ -416,7 +584,7 @@ fn a_401_for_a_sync_superseded_by_sign_out_is_dropped() { let mut core = core_with_listening(LISTENING_SYNC_THRESHOLD_MS); core.dispatch(Command::SignOut); let status = core.snapshot().account.status_label; - let update = core.dispatch(Command::ListeningSyncFailed { unauthorized: true }); + let update = core.settle(401); assert!(update.effects.is_empty(), "{:?}", update.effects); assert_eq!(update.snapshot.account.status_label, status); assert!( @@ -429,7 +597,7 @@ fn a_401_for_a_sync_superseded_by_sign_out_is_dropped() { #[test] fn restore_without_a_stored_id_adopts_and_persists_the_fallback() { - let mut core = Core::new(); + let mut core = Shell::new(); let effects = core .dispatch(Command::Restore { settings_json: String::new(), @@ -447,7 +615,7 @@ fn a_stored_id_wins_over_the_fallback_across_restarts() { let first = core_with_listening(3_000); let blob = serde_json::to_string(&first.state().listening.to_persisted()).unwrap(); - let mut second = Core::new(); + let mut second = Shell::new(); let effects = second .dispatch(Command::Restore { settings_json: String::new(), @@ -471,7 +639,7 @@ fn a_legacy_blob_without_an_id_keeps_its_total_and_adopts_the_fallback() { // Blobs written before the core owned the id. Shells pass the id they used // to store themselves as the fallback, so the server slot is preserved. let legacy = r#"{"version":1,"deviceTotalMs":100,"syncedThroughMs":50,"trackingEnabled":true}"#; - let mut core = Core::new(); + let mut core = Shell::new(); core.dispatch(Command::Restore { settings_json: String::new(), listening_json: legacy.into(), @@ -504,7 +672,7 @@ fn reset_rotates_the_id_and_zeroes_the_slot_in_one_persisted_write() { // A crash right after that write restores to exactly the rotated, zeroed // slot — never the fresh id holding the old total. - let mut restarted = Core::new(); + let mut restarted = Shell::new(); restarted.dispatch(Command::Restore { settings_json: String::new(), listening_json: json, @@ -525,11 +693,7 @@ fn an_ack_for_the_old_slot_is_dropped_after_a_reset() { }); core.dispatch(Command::Tick { elapsed_ms: 1_000 }); - let snap = core - .dispatch(Command::ListeningSyncSucceeded { - server_total_ms: 1_000_000, - }) - .snapshot; + let snap = core.ack(1_000_000).snapshot; assert_eq!( snap.listening.unsynced_ms, 1_000, "the old slot's 30s must not be credited to the new one" @@ -549,11 +713,7 @@ fn a_new_sync_waits_for_the_old_slots_put_to_settle_after_a_reset() { // to settle a new one: nothing starts until it comes back. assert_eq!(begin(&mut core, SyncReason::Refresh), None); - let snap = core - .dispatch(Command::ListeningSyncSucceeded { - server_total_ms: 1_000_000, - }) - .snapshot; + let snap = core.ack(1_000_000).snapshot; assert_eq!(snap.listening.unsynced_ms, 1_000); assert_eq!( begin(&mut core, SyncReason::Refresh), @@ -580,14 +740,6 @@ fn sync_commands_accept_the_shells_camel_case_json() { reason: SyncReason::Refresh, }, ), - ( - r#"{"type":"listeningSyncSucceeded","serverTotalMs":5}"#, - Command::ListeningSyncSucceeded { server_total_ms: 5 }, - ), - ( - r#"{"type":"listeningSyncFailed","unauthorized":true}"#, - Command::ListeningSyncFailed { unauthorized: true }, - ), ( r#"{"type":"resetListeningData","newDeviceId":"d"}"#, Command::ResetListeningData { @@ -608,14 +760,14 @@ fn a_shell_cannot_ask_for_a_threshold_sync() { } #[test] -fn sync_effects_serialize_camel_case() { - let push = Effect::PushListening { - device_id: "d".into(), - device_total_ms: 7, - session_token: "s".into(), - }; +fn the_push_serializes_camel_case() { + let effects = signed_in_and_playing() + .dispatch(Command::BeginListeningSync { + reason: SyncReason::Refresh, + }) + .effects; assert_eq!( - serde_json::to_string(&push).unwrap(), - r#"{"type":"pushListening","deviceId":"d","deviceTotalMs":7,"sessionToken":"s"}"# + serde_json::to_string(&effects).unwrap(), + r#"[{"type":"serverRequest","id":1,"method":"PUT","path":"/listening","bearerToken":"session","body":"{\"deviceId\":\"device-a\",\"deviceTotalMs\":0}"}]"# ); } diff --git a/crates/cascade-core/tests/property_tests.rs b/crates/cascade-core/tests/property_tests.rs index 50d9e16..0fc201e 100644 --- a/crates/cascade-core/tests/property_tests.rs +++ b/crates/cascade-core/tests/property_tests.rs @@ -232,34 +232,54 @@ proptest! { } } +/// One step a shell (or the network) could produce: a command, or a response +/// to a recent request — the latest, or one `back` requests before it, so +/// stale and superseded ids are answered too. +#[derive(Debug, Clone)] +enum SyncStep { + Dispatch(Command), + Answer { + back: u64, + status: u16, + server_total_ms: i64, + }, +} + /// Listening accrual interleaved with every step of the sync protocol, in any /// order a shell (or the network) could produce. -fn sync_command_strategy() -> impl Strategy { +fn sync_step_strategy() -> impl Strategy { prop_oneof![ - (0u64..=10_000).prop_map(|elapsed_ms| Command::Tick { elapsed_ms }), - Just(Command::BeginListeningSync { + (0u64..=10_000).prop_map(|elapsed_ms| SyncStep::Dispatch(Command::Tick { elapsed_ms })), + Just(SyncStep::Dispatch(Command::BeginListeningSync { reason: SyncReason::Flush - }), - Just(Command::BeginListeningSync { + })), + Just(SyncStep::Dispatch(Command::BeginListeningSync { reason: SyncReason::Refresh - }), - any::() - .prop_map(|server_total_ms| Command::ListeningSyncSucceeded { server_total_ms }), - any::().prop_map(|unauthorized| Command::ListeningSyncFailed { unauthorized }), - Just(Command::ResetListeningData { + })), + ( + 0u64..3, + prop::sample::select(vec![0u16, 200, 204, 401, 500]), + any::(), + ) + .prop_map(|(back, status, server_total_ms)| SyncStep::Answer { + back, + status, + server_total_ms, + }), + Just(SyncStep::Dispatch(Command::ResetListeningData { new_device_id: "rotated".into() - }), + })), ] } proptest! { - /// Whatever the order of ticks, syncs, acks, failures and resets, the - /// synced high-water mark never runs past what the device has actually - /// counted — so the server is never told a slot was delivered that wasn't - /// — and every push carries the live device id and total. + /// Whatever the order of ticks, syncs, responses, failures and resets, + /// the synced high-water mark never runs past what the device has + /// actually counted — so the server is never told a slot was delivered + /// that wasn't — and every push carries the live device id and total. #[test] fn synced_mark_never_exceeds_device_total( - cmds in prop::collection::vec(sync_command_strategy(), 1..80), + steps in prop::collection::vec(sync_step_strategy(), 1..80), ) { let mut core = Core::new(); core.dispatch(Command::Restore { @@ -271,14 +291,25 @@ proptest! { }); core.dispatch(Command::Play); core.dispatch(Command::PlatformPlaybackStarted); - for cmd in cmds { - let update = core.dispatch(cmd); + let mut last_request: u64 = 0; + for step in steps { + let command = match step { + SyncStep::Dispatch(command) => command, + SyncStep::Answer { back, status, server_total_ms } => Command::ServerResponse { + id: last_request.saturating_sub(back), + status, + body: format!(r#"{{"serverTotalMs":{server_total_ms}}}"#), + }, + }; + let update = core.dispatch(command); let l = &core.state().listening; prop_assert!(l.synced_through_ms <= l.device_total_ms); for e in &update.effects { - if let Effect::PushListening { device_id, device_total_ms, .. } = e { - prop_assert_eq!(Some(device_id), l.device_id.as_ref()); - prop_assert_eq!(*device_total_ms, l.device_total_ms); + if let Effect::ServerRequest { id, body: Some(body), .. } = e { + last_request = *id; + let body: serde_json::Value = serde_json::from_str(body).unwrap(); + prop_assert_eq!(body["deviceId"].as_str(), l.device_id.as_deref()); + prop_assert_eq!(body["deviceTotalMs"].as_u64(), Some(l.device_total_ms)); } } } diff --git a/crates/cascade-core/tests/restore.rs b/crates/cascade-core/tests/restore.rs index e40aa84..d0082ca 100644 --- a/crates/cascade-core/tests/restore.rs +++ b/crates/cascade-core/tests/restore.rs @@ -45,7 +45,11 @@ fn pushed_device_id(core: &mut Core) -> Option { .effects .into_iter() .find_map(|e| match e { - Effect::PushListening { device_id, .. } => Some(device_id), + Effect::ServerRequest { + body: Some(body), .. + } => serde_json::from_str::(&body).unwrap()["deviceId"] + .as_str() + .map(String::from), _ => None, }) } @@ -197,7 +201,7 @@ fn pushed_session_token(core: &mut Core) -> Option { .effects .into_iter() .find_map(|e| match e { - Effect::PushListening { session_token, .. } => Some(session_token), + Effect::ServerRequest { bearer_token, .. } => bearer_token, _ => None, }) } diff --git a/crates/cascade-core/tests/support/mod.rs b/crates/cascade-core/tests/support/mod.rs new file mode 100644 index 0000000..a749683 --- /dev/null +++ b/crates/cascade-core/tests/support/mod.rs @@ -0,0 +1,44 @@ +//! Shared helpers for suites that carry the core's server requests: find the +//! `ServerRequest`s an update emitted, and build the `ServerResponse` a shell +//! would settle one with. + +#![allow(dead_code)] + +use cascade_core::{Command, Effect}; + +/// Every `ServerRequest` in `effects`, in order. +pub fn requests(effects: &[Effect]) -> Vec<&Effect> { + effects + .iter() + .filter(|e| matches!(e, Effect::ServerRequest { .. })) + .collect() +} + +/// The id of the one `ServerRequest` in `effects`. Panics unless there is +/// exactly one. +pub fn request_id(effects: &[Effect]) -> u64 { + match requests(effects).as_slice() { + [Effect::ServerRequest { id, .. }] => *id, + other => panic!("expected one server request, got {other:?}"), + } +} + +/// The response a shell dispatches for request `id`: the HTTP `status` and a +/// JSON `body`. +pub fn response(id: u64, status: u16, body: serde_json::Value) -> Command { + Command::ServerResponse { + id, + status, + body: body.to_string(), + } +} + +/// A response with no body: an empty 2xx, a failure status, or `0` for a +/// request that was never sent or got no answer. +pub fn bare_response(id: u64, status: u16) -> Command { + Command::ServerResponse { + id, + status, + body: String::new(), + } +} diff --git a/crates/cascade-core/tests/timer_options.rs b/crates/cascade-core/tests/timer_options.rs index 7a909d0..cd225a5 100644 --- a/crates/cascade-core/tests/timer_options.rs +++ b/crates/cascade-core/tests/timer_options.rs @@ -7,8 +7,8 @@ //! rules on the snapshot. use cascade_core::{ - Command, Core, Effect, PersistedSettings, Snapshot, SyncReason, TimerSnapshotKind, Update, - LISTENING_SYNC_THRESHOLD_MS, + Command, Core, Effect, HttpMethod, PersistedSettings, Snapshot, SyncReason, TimerSnapshotKind, + Update, LISTENING_SYNC_THRESHOLD_MS, }; fn core_after(commands: impl IntoIterator) -> Core { @@ -286,26 +286,22 @@ fn the_completion_message_survives_everything_the_user_did_not_ask_for() { Command::BeginListeningSync { reason: SyncReason::Refresh, }, - Command::ListeningSyncSucceeded { server_total_ms: 0 }, - Command::ListeningSyncFailed { - unauthorized: false, + // Responses the core is not waiting for. + Command::ServerResponse { + id: 1, + status: 200, + body: r#"{"serverTotalMs":0}"#.into(), }, - Command::ListeningSyncFailed { unauthorized: true }, - Command::SignInLinkSent, - Command::SignInVerified { - session_token: "t".into(), - email: "a@example.com".into(), - }, - Command::ListeningDataDeleted { - new_device_id: "d".into(), + Command::ServerResponse { + id: 2, + status: 0, + body: String::new(), }, - Command::AccountDeleted { - new_device_id: "d".into(), + Command::ServerResponse { + id: 3, + status: 401, + body: String::new(), }, - Command::AccountRequestFailed { - unauthorized: false, - }, - Command::AccountRequestFailed { unauthorized: true }, Command::PlatformPlaybackStarted, Command::PlatformPlaybackPaused, Command::SetVolume { percent: 30 }, @@ -322,10 +318,17 @@ fn the_completion_message_survives_everything_the_user_did_not_ask_for() { } } +/// One step of a shell's traffic: a command, or the response to the last +/// request the core sent. +enum Step { + Send(Command), + Answer(u16, &'static str), +} + #[test] fn the_completion_message_survives_sync_and_account_traffic_that_lands() { - // Signed in, so each request really goes out and each settle really - // lands on a pending request. + // Signed in, so each request really goes out and each response really + // lands on a request the core is waiting for. let mut core = Core::new(); core.dispatch(Command::Restore { settings_json: String::new(), @@ -336,51 +339,81 @@ fn the_completion_message_survives_sync_and_account_traffic_that_lands() { core.dispatch(Command::Play); core.dispatch(Command::StartSleepTimer { minutes: 1 }); core.dispatch(Command::Tick { elapsed_ms: 60_000 }); - let refresh = Command::BeginListeningSync { - reason: SyncReason::Refresh, + let refresh = || { + Step::Send(Command::BeginListeningSync { + reason: SyncReason::Refresh, + }) + }; + let pushed = r#"{"serverTotalMs":0}"#; + let verified = r#"{"sessionToken":"t2","email":"a@example.com"}"#; + let sign_in = || { + [ + Step::Send(Command::SubmitSignInLink { + input: "raw-token".into(), + }), + Step::Answer(200, verified), + ] + }; + let delete_listening = |id: &str| { + Step::Send(Command::DeleteListeningData { + new_device_id: id.into(), + }) }; - let sign_in = [ - Command::SubmitSignInLink { - input: "raw-token".into(), - }, - Command::SignInVerified { - session_token: "t2".into(), - email: "a@example.com".into(), - }, - ]; let traffic = [ - refresh.clone(), - Command::ListeningSyncSucceeded { server_total_ms: 0 }, - refresh, - Command::ListeningSyncFailed { - unauthorized: false, - }, - Command::DeleteListeningData, - Command::ListeningDataDeleted { - new_device_id: "d2".into(), - }, - Command::DeleteListeningData, - Command::AccountRequestFailed { - unauthorized: false, - }, - Command::DeleteAccount, - Command::AccountDeleted { - new_device_id: "d3".into(), - }, + refresh(), + Step::Answer(200, pushed), + refresh(), + Step::Answer(500, ""), + delete_listening("d2"), + Step::Answer(204, ""), + delete_listening("d3"), + Step::Answer(0, ""), + Step::Send(Command::DeleteAccount { + new_device_id: "d4".into(), + }), + Step::Answer(204, ""), + Step::Send(Command::RequestSignInLink { + email: "a@example.com".into(), + platform: None, + }), + Step::Answer(204, ""), ] .into_iter() - .chain(sign_in.clone()) - .chain([Command::ListeningSyncFailed { unauthorized: true }]) - .chain(sign_in) + // The sign-in's refresh push is the last request: a 401 settles it. + .chain(sign_in()) + .chain([Step::Answer(401, "")]) + .chain(sign_in()) .chain([ - Command::ListeningSyncSucceeded { server_total_ms: 0 }, - Command::DeleteListeningData, - Command::AccountRequestFailed { unauthorized: true }, - ]); - for command in traffic { - let snap = core.dispatch(command.clone()).snapshot; - assert_eq!(snap.timer.status_label, SLEEP_TIMER_ENDED, "{command:?}"); + Step::Answer(200, pushed), + delete_listening("d5"), + Step::Answer(401, ""), + ]) + .chain(sign_in()) + .chain([Step::Send(Command::SignOut), Step::Answer(204, "")]); + + let mut last_request = None; + for step in traffic { + let command = match step { + Step::Send(command) => command, + Step::Answer(status, body) => Command::ServerResponse { + id: last_request.expect("a request to answer"), + status, + body: body.into(), + }, + }; + let update = core.dispatch(command.clone()); + for effect in &update.effects { + if let Effect::ServerRequest { id, .. } = effect { + last_request = Some(*id); + } + } + assert_eq!( + update.snapshot.timer.status_label, SLEEP_TIMER_ENDED, + "{command:?}" + ); } + // The traffic really landed: the last sign-in, then the sign-out. + assert_eq!(core.snapshot().account.email, None); } #[test] @@ -431,10 +464,14 @@ fn the_tick_that_ends_a_timer_and_crosses_the_threshold_does_both() { assert_eq!(update.snapshot.timer.kind, TimerSnapshotKind::JustCompleted); assert_eq!(update.snapshot.timer.status_label, SLEEP_TIMER_ENDED); assert!( - update.effects.contains(&Effect::PushListening { - device_id: "device".into(), - device_total_ms: LISTENING_SYNC_THRESHOLD_MS, - session_token: "t".into(), + update.effects.contains(&Effect::ServerRequest { + id: 1, + method: HttpMethod::Put, + path: "/listening".into(), + bearer_token: Some("t".into()), + body: Some(format!( + r#"{{"deviceId":"device","deviceTotalMs":{LISTENING_SYNC_THRESHOLD_MS}}}"# + )), }), "{:?}", update.effects diff --git a/docs/adr/0001-listening-time-crdt.md b/docs/adr/0001-listening-time-crdt.md index 1fef0b0..db74b6d 100644 --- a/docs/adr/0001-listening-time-crdt.md +++ b/docs/adr/0001-listening-time-crdt.md @@ -1,6 +1,6 @@ # ADR 0001 — Cross-platform listening-time tracking: a pure-core G-Counter with data-minimizing sync -- **Status:** Accepted; decisions 5 and 6 amended 2026-09-27 (issue #18); decision 5 amended again 2026-10-03 (issues #31, #44 and #49) +- **Status:** Accepted; decisions 5 and 6 amended 2026-09-27 (issue #18); decision 5 amended again 2026-10-03 (issues #31, #44 and #49, and issue #58: the request table moved into the core) - **Date:** 2026-06-05 - **Context:** Cascade is one headless Rust core (`cascade-core`) driving six native shells (web, Android, macOS, Windows, iOS, watchOS). We want to show a user the total time they've spent listening, aggregated across every device they use, with an optional account to centralize it — without turning a white-noise app into a surveillance liability. @@ -55,6 +55,16 @@ The rules are tested once in `crates/cascade-core/tests/account.rs`. The rules are tested in `crates/cascade-core/tests/listening_sync.rs`. +*Amended 2026-10-03 (issue #58).* The **request table** moves into the core as well. The effects above each named one request, so every shell held its own copy of the table — endpoint, method, JSON body, which response fields to read, which settle command to build, what a 401 means — about 650 untested lines across four languages, and the copies had drifted (a negative server total clamped on two shells and rejected on two; a dropped request on Windows that never cleared the in-flight guard). The core now describes every call as one generic request and hears back through one generic response: + +- **One request, one response.** `PushListening`, `SendSignInLink`, `VerifySignInToken`, `RevokeSession`, `DeleteServerListening` and `DeleteServerAccount` become `ServerRequest { id, method, path, bearerToken?, body? }`; the seven settle commands become `ServerResponse { id, status, body }`. `status` is the HTTP status, or `0` when the request was not sent or got no response. Ids come from a counter in core state, starting at 1 and never persisted; the pending account request and the in-flight listening sync (superseded or not) each remember theirs, and a response is routed by id. +- **The core owns the table.** Endpoints, bodies, response parsing and outcome routing live in one module (`crates/cascade-core/src/server.rs`): POST `/auth/request`, POST `/auth/verify`, POST `/auth/logout`, PUT `/listening`, DELETE `/listening`, DELETE `/account`. Any 2xx is success; a 401 follows the 401 rule above; status 0, any other status, and a 2xx whose body does not parse are plain failures. `serverTotalMs` is read as a signed integer and clamped at 0. +- **The revoke is settled like any other request.** Every `ServerRequest` gets a `ServerResponse`, with no fire-and-forget exception. A response the core is not waiting for — the revoke's, or one for a request sign-out or a reset superseded — changes nothing. +- **Fresh device ids arrive with the delete intent.** `DeleteListeningData` and `DeleteAccount` carry `newDeviceId`, held with the pending request and adopted only when the server confirms the delete, in one persisted listening write (decision 6). The response stays generic. Likewise `RequestSignInLink` carries an optional `platform` (Windows's link hand-off), added to the request body only when given. +- **Transport stays in the shells.** Base URL, the HTTP stack, timeouts, threading, reachability and lifecycle (including the web's `keepalive` flush) are still each shell's; a shell with no sync server settles every request with status 0. Each shell's request carrier becomes one HTTP adapter with no per-request branching. This keeps the rejection of transport in the core below: the core describes the request, the shell sends it. + +The table and its routing are tested through `Core::dispatch` in `crates/cascade-core/tests/account.rs` and `crates/cascade-core/tests/listening_sync.rs`. + ### 6. Opaque tokens + magic-link, and `device_id` rotation on delete Auth is email magic-link (no passwords) with **opaque server-side session tokens** (not JWT), so logout / delete-account revoke instantly with one `DELETE`. Tokens are stored only as SHA-256 hashes. "Delete my data" rotates the client's `device_id`, closing the one loophole inherent to grow-only counters: a forgotten offline device can't later resurrect a deleted total by pushing a stale higher counter — it lands in a fresh slot. From ac64b1d1d48f38573e757028de9e9d7243fbfd2c Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Sat, 3 Oct 2026 19:06:25 -0400 Subject: [PATCH 02/12] Address review: ADR superseded markers, StatusClass, ledger awaits Mark the ADR's earlier decision-5 bullets as superseded by the issue #58 amendment, word RequestSignInLink's platform per the glossary, rename the response classification to StatusClass and Request::bearer to bearer_only, give the ledger an awaits(id) check, and share the listening-push predicate (PUT only) in the test support module. Co-Authored-By: Claude Opus 5.5 --- crates/cascade-core/src/command.rs | 5 ++-- crates/cascade-core/src/listening.rs | 5 ++++ crates/cascade-core/src/server.rs | 16 ++++++------- crates/cascade-core/src/state.rs | 26 ++++++++++----------- crates/cascade-core/tests/account.rs | 7 ++---- crates/cascade-core/tests/listening_sync.rs | 9 +------ crates/cascade-core/tests/support/mod.rs | 15 +++++++++++- docs/adr/0001-listening-time-crdt.md | 6 ++--- 8 files changed, 49 insertions(+), 40 deletions(-) diff --git a/crates/cascade-core/src/command.rs b/crates/cascade-core/src/command.rs index 2e31cde..783d5f9 100644 --- a/crates/cascade-core/src/command.rs +++ b/crates/cascade-core/src/command.rs @@ -112,8 +112,9 @@ pub enum Command { /// User asked for a sign-in link. The email is trimmed; an empty one is /// refused with a status and no effect. Answered with a /// [`crate::Effect::ServerRequest`] (POST `/auth/request`). `platform` - /// names the shell's link hand-off (Windows sends `"windows"`) and is - /// added to the request body only when given. + /// names the platform whose app the emailed link should hand off to + /// (the Windows shell sends `"windows"`), and is added to the request + /// body only when given. /// /// Like every account request, it is ignored while another account /// request is pending (`account.busy`). diff --git a/crates/cascade-core/src/listening.rs b/crates/cascade-core/src/listening.rs index 4bbffde..43d3a12 100644 --- a/crates/cascade-core/src/listening.rs +++ b/crates/cascade-core/src/listening.rs @@ -119,6 +119,11 @@ impl Default for ListeningLedger { } impl ListeningLedger { + /// Whether the PUT in flight (superseded or not) is request `id`. + pub fn awaits(&self, id: u64) -> bool { + self.in_flight.is_some_and(|f| f.id() == id) + } + /// Milliseconds accrued locally that the server has not yet acknowledged. /// Saturating so a hand-edited or partially-written blob can never /// underflow. diff --git a/crates/cascade-core/src/server.rs b/crates/cascade-core/src/server.rs index 6529ef6..00cd1fa 100644 --- a/crates/cascade-core/src/server.rs +++ b/crates/cascade-core/src/server.rs @@ -64,7 +64,7 @@ impl Request { /// POST `/auth/logout` with the bearer token. Nothing waits for its /// response. pub fn revoke(session_token: String) -> Self { - Self::bearer(HttpMethod::Post, "/auth/logout", session_token) + Self::bearer_only(HttpMethod::Post, "/auth/logout", session_token) } /// PUT `/listening` `{deviceId, deviceTotalMs}`; read with @@ -75,18 +75,18 @@ impl Request { device_id, device_total_ms, }), - ..Self::bearer(HttpMethod::Put, "/listening", session_token) + ..Self::bearer_only(HttpMethod::Put, "/listening", session_token) } } /// DELETE `/listening` with the bearer token. pub fn delete_listening(session_token: String) -> Self { - Self::bearer(HttpMethod::Delete, "/listening", session_token) + Self::bearer_only(HttpMethod::Delete, "/listening", session_token) } /// DELETE `/account` with the bearer token. pub fn delete_account(session_token: String) -> Self { - Self::bearer(HttpMethod::Delete, "/account", session_token) + Self::bearer_only(HttpMethod::Delete, "/account", session_token) } fn post_json(path: &'static str, body: &impl Serialize) -> Self { @@ -98,7 +98,7 @@ impl Request { } } - fn bearer(method: HttpMethod, path: &'static str, session_token: String) -> Self { + fn bearer_only(method: HttpMethod, path: &'static str, session_token: String) -> Self { Self { method, path, @@ -123,9 +123,9 @@ fn to_json(body: &impl Serialize) -> Option { serde_json::to_string(body).ok() } -/// What a response's status means, before its body is read. +/// What a response's HTTP status means, before its body is read. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Status { +pub enum StatusClass { /// Any 2xx. Success, /// 401: the session is gone. @@ -134,7 +134,7 @@ pub enum Status { Failed, } -impl Status { +impl StatusClass { pub fn of(status: u16) -> Self { match status { 200..=299 => Self::Success, diff --git a/crates/cascade-core/src/state.rs b/crates/cascade-core/src/state.rs index 95e9137..72964bf 100644 --- a/crates/cascade-core/src/state.rs +++ b/crates/cascade-core/src/state.rs @@ -12,7 +12,7 @@ use crate::account::{ use crate::command::Command; use crate::effect::Effect; use crate::listening::{ListeningLedger, PersistedListening, SyncReason}; -use crate::server::{parse_server_total, parse_verified, Request, Status}; +use crate::server::{parse_server_total, parse_verified, Request, StatusClass}; use crate::settings::{PersistedSettings, SETTINGS_VERSION}; use crate::timer::{clamp_minutes, ActiveTimer, TimerKind, DEFAULT_TIMER_MINUTES}; @@ -467,11 +467,11 @@ pub fn reduce(state: &mut State, command: Command, effects: &mut Vec) { } } Command::ServerResponse { id, status, body } => { - let status = Status::of(status); + let class = StatusClass::of(status); if let Some(request) = state.account.settle(id) { - account_response(state, request, status, &body, effects); - } else if state.listening.in_flight.is_some_and(|f| f.id() == id) { - sync_response(state, status, &body, effects); + account_response(state, request, class, &body, effects); + } else if state.listening.awaits(id) { + sync_response(state, class, &body, effects); } } } @@ -479,16 +479,16 @@ pub fn reduce(state: &mut State, command: Command, effects: &mut Vec) { /// Settle the in-flight listening push. A superseded push only frees the /// slot: its success or 401 refers to a slot or session already replaced. -fn sync_response(state: &mut State, status: Status, body: &str, effects: &mut Vec) { - let server_total_ms = match status { - Status::Success => parse_server_total(body), - Status::Unauthorized | Status::Failed => None, +fn sync_response(state: &mut State, class: StatusClass, body: &str, effects: &mut Vec) { + let server_total_ms = match class { + StatusClass::Success => parse_server_total(body), + StatusClass::Unauthorized | StatusClass::Failed => None, }; if let Some(server_total_ms) = server_total_ms { if state.listening.sync_succeeded(server_total_ms) { push_persist_listening(state, effects); } - } else if state.listening.sync_failed() && status == Status::Unauthorized { + } else if state.listening.sync_failed() && class == StatusClass::Unauthorized { session_expired(state, effects); } } @@ -497,15 +497,15 @@ fn sync_response(state: &mut State, status: Status, body: &str, effects: &mut Ve fn account_response( state: &mut State, request: PendingRequest, - status: Status, + class: StatusClass, body: &str, effects: &mut Vec, ) { - if status == Status::Unauthorized && state.account.session.is_some() { + if class == StatusClass::Unauthorized && state.account.session.is_some() { session_expired(state, effects); return; } - let succeeded = status == Status::Success; + let succeeded = class == StatusClass::Success; match request { PendingRequest::SignInLink { email } if succeeded => { state.account.status = Some(AccountStatus::LinkSent { email }); diff --git a/crates/cascade-core/tests/account.rs b/crates/cascade-core/tests/account.rs index bfe7cc1..73570a9 100644 --- a/crates/cascade-core/tests/account.rs +++ b/crates/cascade-core/tests/account.rs @@ -10,7 +10,7 @@ mod support; use cascade_core::{AccountSnapshot, Command, Core, Effect, HttpMethod, SyncReason}; use serde_json::json; -use support::{bare_response, request_id, requests, response}; +use support::{bare_response, pushes, request_id, requests, response}; const DEVICE_A: &str = "device-a"; const DEVICE_B: &str = "device-b"; @@ -75,10 +75,7 @@ fn begin(core: &mut Core, reason: SyncReason) -> Vec { /// The listening push in `effects`, if any. fn pushed(effects: &[Effect]) -> Option { - effects - .iter() - .find(|e| matches!(e, Effect::ServerRequest { path, .. } if path == "/listening")) - .cloned() + pushes(effects).first().map(|e| (*e).clone()) } /// The push request the core should send, per the request table. diff --git a/crates/cascade-core/tests/listening_sync.rs b/crates/cascade-core/tests/listening_sync.rs index 75c692e..63a2221 100644 --- a/crates/cascade-core/tests/listening_sync.rs +++ b/crates/cascade-core/tests/listening_sync.rs @@ -15,7 +15,7 @@ use cascade_core::{ Command, Core, Effect, HttpMethod, SyncReason, Update, LISTENING_SYNC_THRESHOLD_MS, }; use serde_json::json; -use support::{bare_response, request_id, response}; +use support::{bare_response, pushes, request_id, response}; /// A core plus what a shell would remember: the listening pushes it is /// carrying, oldest first, so a test can settle them by id. @@ -151,13 +151,6 @@ fn pushed(effects: &[Effect]) -> Option<(String, u64)> { }) } -fn pushes(effects: &[Effect]) -> Vec<&Effect> { - effects - .iter() - .filter(|e| matches!(e, Effect::ServerRequest { path, .. } if path == "/listening")) - .collect() -} - fn begin(core: &mut Shell, reason: SyncReason) -> Option<(String, u64)> { pushed( &core diff --git a/crates/cascade-core/tests/support/mod.rs b/crates/cascade-core/tests/support/mod.rs index a749683..7671863 100644 --- a/crates/cascade-core/tests/support/mod.rs +++ b/crates/cascade-core/tests/support/mod.rs @@ -4,7 +4,7 @@ #![allow(dead_code)] -use cascade_core::{Command, Effect}; +use cascade_core::{Command, Effect, HttpMethod}; /// Every `ServerRequest` in `effects`, in order. pub fn requests(effects: &[Effect]) -> Vec<&Effect> { @@ -14,6 +14,19 @@ pub fn requests(effects: &[Effect]) -> Vec<&Effect> { .collect() } +/// Every listening push (PUT `/listening`) in `effects`, in order. +pub fn pushes(effects: &[Effect]) -> Vec<&Effect> { + effects + .iter() + .filter(|e| { + matches!( + e, + Effect::ServerRequest { method: HttpMethod::Put, path, .. } if path == "/listening" + ) + }) + .collect() +} + /// The id of the one `ServerRequest` in `effects`. Panics unless there is /// exactly one. pub fn request_id(effects: &[Effect]) -> u64 { diff --git a/docs/adr/0001-listening-time-crdt.md b/docs/adr/0001-listening-time-crdt.md index db74b6d..b7b088e 100644 --- a/docs/adr/0001-listening-time-crdt.md +++ b/docs/adr/0001-listening-time-crdt.md @@ -30,8 +30,8 @@ The account stores an email and **one integer per device** — no timestamps, no *Amended 2026-09-27 (issue #18).* Originally "sync cadence lives in the shells, not the core", which conflated two things. Knowledge only a shell has — lifecycle, reachability, auth state (web `pagehide`, Android `onStop`, etc.) — stays in the shell, as does the HTTP transport. Protocol rules the core already has every input for now live in the core, defined once: -- **Threshold and payload.** The shell dispatches `BeginListeningSync { reason }` (`threshold` / `flush` / `refresh`). If there is something worth sending (≥ 30 s unsynced for `threshold`, anything for `flush`, always for `refresh`) the core answers with one `PushListening { deviceId, deviceTotalMs }`, and the shell PUTs exactly that. *(The `threshold` timing is superseded by the issue #44 amendment below: the core's tick checks it.)* -- **High-water mark.** The shell reports `ListeningSyncSucceeded { serverTotalMs }` or `ListeningSyncFailed { unauthorized }`. The core marks as synced exactly the total it sent — never a shell-reconstructed value — so accrual during the request stays unsynced. +- **Threshold and payload.** The shell dispatches `BeginListeningSync { reason }` (`threshold` / `flush` / `refresh`). If there is something worth sending (≥ 30 s unsynced for `threshold`, anything for `flush`, always for `refresh`) the core answers with one `PushListening { deviceId, deviceTotalMs }`, and the shell PUTs exactly that. *(The `threshold` timing is superseded by the issue #44 amendment below: the core's tick checks it. The `PushListening` effect is superseded by the issue #58 amendment below: it is now a generic `ServerRequest`.)* +- **High-water mark.** The shell reports `ListeningSyncSucceeded { serverTotalMs }` or `ListeningSyncFailed { unauthorized }`. The core marks as synced exactly the total it sent — never a shell-reconstructed value — so accrual during the request stays unsynced. *(The settle commands are superseded by the issue #58 amendment below: every request is settled with `ServerResponse`.)* - **Re-entrancy.** A sync in flight blocks the next `BeginListeningSync` until it is settled — including one sent for a slot a reset has since replaced, whose ack is then dropped. - **401 rule.** `unauthorized: true` makes the core emit `ClearSession`; the shell drops its token. *(Superseded by the issue #31 amendment below.)* @@ -39,7 +39,7 @@ Six shells running decision 5 as first written produced ~20 copies of these four *Amended 2026-10-03 (issue #31).* "Auth state" is no longer on the list of things only a shell knows. Whether the user is signed in, and as whom, is data the core can hold, and four hand-written account flows had drifted with no test on any of them. The core now holds the **Account** — the session and its rules — while reachability, lifecycle, the HTTP transport and storage stay in the shell: -- **Request and settle, as above.** User intents (`RequestSignInLink`, `SubmitSignInLink`, `SignOut`, `DeleteListeningData`, `DeleteAccount`) are answered with one effect describing an HTTP request (`SendSignInLink`, `VerifySignInToken`, `DeleteServerListening`, `DeleteServerAccount`, plus the fire-and-forget `RevokeSession`). The shell settles each with a success command or `AccountRequestFailed { unauthorized }`. One account request runs at a time; `SignOut` always works and drops it. +- **Request and settle, as above.** User intents (`RequestSignInLink`, `SubmitSignInLink`, `SignOut`, `DeleteListeningData`, `DeleteAccount`) are answered with one effect describing an HTTP request (`SendSignInLink`, `VerifySignInToken`, `DeleteServerListening`, `DeleteServerAccount`, plus the fire-and-forget `RevokeSession`). The shell settles each with a success command or `AccountRequestFailed { unauthorized }`. One account request runs at a time; `SignOut` always works and drops it. *(Superseded by the issue #58 amendment below: these effects and settle commands are now one `ServerRequest` and one `ServerResponse`, and the revoke is no longer fire-and-forget.)* - **The session rides on effects.** `PushListening` carries `sessionToken`, and `BeginListeningSync` sends nothing while signed out. The snapshot's `account` section never carries the token. - **Signing out forgets the server.** Sign-out, a 401 on any request, and a deleted account forget the cross-device total and supersede any in-flight sync, so neither a stale total nor a late ack outlives the account. - **401 rule, revised.** `unauthorized: true` signs out and emits an empty `PersistAccount`. Only a sync that sign-out or a reset has already superseded is ignored: its 401 may belong to a session already dropped. *(Issue #36: `ClearSession` is gone, since every shell now takes the account from the core.)* From 10acf7568d2fea63ab617d51263acd8f3aef59c4 Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Sat, 3 Oct 2026 19:15:54 -0400 Subject: [PATCH 03/12] Apple shell: carry every server request through one HTTP adapter The DTOs mirror the core's new wire: one serverRequest effect in place of the six request effects, one serverResponse command in place of the seven settles, newDeviceId on deleteListeningData / deleteAccount (a fresh UUID made when the user asks) and the optional platform on requestSignInLink. ServerCarrier replaces the typed SyncApi client and its response types: it sends any request to the sync-server base URL as described and returns the serverResponse that settles it, status 0 with no sync server or on a network error, timeout or cancellation. AppStore carries every request, the sign-out revoke included, and dispatches the settle on the main actor. Co-Authored-By: Claude Opus 5.5 --- apps/apple/CascadeShared/App/AppStore.swift | 84 ++--------- apps/apple/CascadeShared/App/Dto.swift | 134 ++++++++---------- .../Sync/AccountControlsView.swift | 4 +- .../CascadeShared/Sync/ServerCarrier.swift | 46 ++++++ apps/apple/CascadeShared/Sync/SyncApi.swift | 75 ---------- 5 files changed, 120 insertions(+), 223 deletions(-) create mode 100644 apps/apple/CascadeShared/Sync/ServerCarrier.swift delete mode 100644 apps/apple/CascadeShared/Sync/SyncApi.swift diff --git a/apps/apple/CascadeShared/App/AppStore.swift b/apps/apple/CascadeShared/App/AppStore.swift index f94941d..3e02706 100644 --- a/apps/apple/CascadeShared/App/AppStore.swift +++ b/apps/apple/CascadeShared/App/AppStore.swift @@ -22,7 +22,7 @@ final class AppStore { var syncAvailable: Bool { SyncConfig.available } private let accountStore = AccountStore() - private let syncApi = SyncApi() + private let serverCarrier = ServerCarrier() /// Side-channel observer for non-SwiftUI consumers (the iPhone's /// `PhoneConnectivityService` uses this to push every snapshot down to @@ -119,9 +119,8 @@ final class AppStore { settings.writeListeningSafely(json) case .persistAccount(let json): accountStore.writeAccountJson(json) - case .pushListening, .sendSignInLink, .verifySignInToken, .revokeSession, - .deleteServerListening, .deleteServerAccount: - carry(effect) + case .serverRequest(let request): + carry(request) } } @@ -138,7 +137,7 @@ final class AppStore { // MARK: - Sync - /// Offer the core a listening sync. It answers with a `pushListening` only + /// Offer the core a listening sync. It answers with a listening push only /// if there is something to send and it holds a session; `apply` carries it. func sync(reason: SyncReason) { guard syncAvailable else { return } @@ -151,75 +150,18 @@ final class AppStore { dispatch(.submitSignInLink(input: url.absoluteString)) } - /// Carry one request effect over HTTP and settle it with the core. Every - /// request but `revokeSession` must be settled, or the core won't start - /// another; the settle's own effects come back through `apply`. A request + /// Carry one request over HTTP and settle it with the core. Every request + /// is settled, the sign-out revoke included, or the core won't start + /// another; with no sync server, or on a network error, timeout or + /// cancellation, the settle carries status 0. The settle is dispatched on + /// the main actor, and its own effects come back through `apply`. A request /// can come from any dispatch, including a tick, so carry everything /// `apply` hands over. - private func carry(_ effect: Effect) { - let api = syncApi - let accountFailed = { (unauthorized: Bool) in Command.accountRequestFailed(unauthorized: unauthorized) } + private func carry(_ request: ServerRequest) { + let carrier = serverCarrier Task { @MainActor in - switch effect { - case .pushListening(let deviceId, let deviceTotalMs, let sessionToken): - await settle( - { - let res = try await api.putListening( - token: sessionToken, - deviceId: deviceId, - deviceTotalMs: Int64(clamping: deviceTotalMs)) - return .listeningSyncSucceeded(serverTotalMs: UInt64(max(0, res.serverTotalMs))) - }, - failed: { .listeningSyncFailed(unauthorized: $0) }) - case .sendSignInLink(let email): - await settle( - { - try await api.requestLink(email: email) - return .signInLinkSent - }, - failed: accountFailed) - case .verifySignInToken(let token): - await settle( - { - let res = try await api.verify(token: token) - return .signInVerified(sessionToken: res.sessionToken, email: res.email) - }, - failed: accountFailed) - case .revokeSession(let sessionToken): - // Already gone server-side or offline — local sign-out stands. - try? await api.logout(token: sessionToken) - case .deleteServerListening(let sessionToken): - await settle( - { - try await api.deleteListening(token: sessionToken) - return .listeningDataDeleted(newDeviceId: UUID().uuidString) - }, - failed: accountFailed) - case .deleteServerAccount(let sessionToken): - await settle( - { - try await api.deleteAccount(token: sessionToken) - return .accountDeleted(newDeviceId: UUID().uuidString) - }, - failed: accountFailed) - case .startPlayback, .pausePlayback, .setPlatformVolume, .persistSettings, - .persistListening, .persistAccount: - // Not requests: `apply` handles these itself. - break - } - } - } - - /// Run one request and dispatch the command that settles it; `failed` is - /// told whether the failure was an HTTP 401. - private func settle(_ request: () async throws -> Command, failed: (Bool) -> Command) async { - let outcome: Command - do { - outcome = try await request() - } catch { - outcome = failed((error as? SyncError)?.status == 401) + dispatch(await carrier.send(request)) } - dispatch(outcome) } // MARK: - Tick loop @@ -240,7 +182,7 @@ final class AppStore { let elapsedMs = UInt64(now.timeIntervalSince(last) * 1000) last = now // A tick that brings unsynced listening to the threshold - // comes back with a `pushListening`; `apply` carries it. + // comes back with a listening push; `apply` carries it. self.dispatch(.tick(elapsedMs: elapsedMs)) } } diff --git a/apps/apple/CascadeShared/App/Dto.swift b/apps/apple/CascadeShared/App/Dto.swift index e30a255..967fb08 100644 --- a/apps/apple/CascadeShared/App/Dto.swift +++ b/apps/apple/CascadeShared/App/Dto.swift @@ -23,26 +23,25 @@ enum Command: Codable { /// Always sent, so the core holds the account. case restore(settingsJson: String, listeningJson: String, fallbackDeviceId: String, accountJson: String) case beginListeningSync(reason: SyncReason) - case listeningSyncSucceeded(serverTotalMs: UInt64) - case listeningSyncFailed(unauthorized: Bool) case resetListeningData(newDeviceId: String) - case requestSignInLink(email: String) + /// `platform` names the platform whose app the emailed link hands off to; + /// nil leaves it out of the request. + case requestSignInLink(email: String, platform: String? = nil) /// The whole pasted or opened sign-in link, or the bare token. case submitSignInLink(input: String) case signOut - case deleteListeningData - case deleteAccount - case signInLinkSent - case signInVerified(sessionToken: String, email: String) - case listeningDataDeleted(newDeviceId: String) - case accountDeleted(newDeviceId: String) - /// `unauthorized` is true for an HTTP 401. - case accountRequestFailed(unauthorized: Bool) + /// `newDeviceId` is a fresh UUID made when the user asks; the core adopts + /// it only once the server confirms the delete. + case deleteListeningData(newDeviceId: String) + case deleteAccount(newDeviceId: String) + /// Settles the `serverRequest` with the same `id`: the HTTP status (0 when + /// it was not sent or got no response) and the response body verbatim. + case serverResponse(id: UInt64, status: UInt16, body: String) private enum CodingKeys: String, CodingKey { case type, percent, minutes, elapsedMs, message, enabled, settingsJson, listeningJson, fallbackDeviceId, accountJson, reason, - serverTotalMs, unauthorized, newDeviceId, email, input, sessionToken + newDeviceId, email, platform, input, id, status, body } func encode(to encoder: Encoder) throws { @@ -83,38 +82,28 @@ enum Command: Codable { case .beginListeningSync(let reason): try c.encode("beginListeningSync", forKey: .type) try c.encode(reason, forKey: .reason) - case .listeningSyncSucceeded(let serverTotalMs): - try c.encode("listeningSyncSucceeded", forKey: .type) - try c.encode(serverTotalMs, forKey: .serverTotalMs) - case .listeningSyncFailed(let unauthorized): - try c.encode("listeningSyncFailed", forKey: .type) - try c.encode(unauthorized, forKey: .unauthorized) case .resetListeningData(let newDeviceId): try c.encode("resetListeningData", forKey: .type) try c.encode(newDeviceId, forKey: .newDeviceId) - case .requestSignInLink(let email): + case .requestSignInLink(let email, let platform): try c.encode("requestSignInLink", forKey: .type) try c.encode(email, forKey: .email) + try c.encodeIfPresent(platform, forKey: .platform) case .submitSignInLink(let input): try c.encode("submitSignInLink", forKey: .type) try c.encode(input, forKey: .input) case .signOut: try c.encode("signOut", forKey: .type) - case .deleteListeningData: try c.encode("deleteListeningData", forKey: .type) - case .deleteAccount: try c.encode("deleteAccount", forKey: .type) - case .signInLinkSent: try c.encode("signInLinkSent", forKey: .type) - case .signInVerified(let sessionToken, let email): - try c.encode("signInVerified", forKey: .type) - try c.encode(sessionToken, forKey: .sessionToken) - try c.encode(email, forKey: .email) - case .listeningDataDeleted(let newDeviceId): - try c.encode("listeningDataDeleted", forKey: .type) + case .deleteListeningData(let newDeviceId): + try c.encode("deleteListeningData", forKey: .type) try c.encode(newDeviceId, forKey: .newDeviceId) - case .accountDeleted(let newDeviceId): - try c.encode("accountDeleted", forKey: .type) + case .deleteAccount(let newDeviceId): + try c.encode("deleteAccount", forKey: .type) try c.encode(newDeviceId, forKey: .newDeviceId) - case .accountRequestFailed(let unauthorized): - try c.encode("accountRequestFailed", forKey: .type) - try c.encode(unauthorized, forKey: .unauthorized) + case .serverResponse(let id, let status, let body): + try c.encode("serverResponse", forKey: .type) + try c.encode(id, forKey: .id) + try c.encode(status, forKey: .status) + try c.encode(body, forKey: .body) } } @@ -145,30 +134,24 @@ enum Command: Codable { accountJson: try c.decodeIfPresent(String.self, forKey: .accountJson) ?? "") case "beginListeningSync": self = .beginListeningSync(reason: try c.decode(SyncReason.self, forKey: .reason)) - case "listeningSyncSucceeded": - self = .listeningSyncSucceeded(serverTotalMs: try c.decode(UInt64.self, forKey: .serverTotalMs)) - case "listeningSyncFailed": - self = .listeningSyncFailed(unauthorized: try c.decode(Bool.self, forKey: .unauthorized)) case "resetListeningData": self = .resetListeningData(newDeviceId: try c.decode(String.self, forKey: .newDeviceId)) case "requestSignInLink": - self = .requestSignInLink(email: try c.decode(String.self, forKey: .email)) + self = .requestSignInLink( + email: try c.decode(String.self, forKey: .email), + platform: try c.decodeIfPresent(String.self, forKey: .platform)) case "submitSignInLink": self = .submitSignInLink(input: try c.decode(String.self, forKey: .input)) case "signOut": self = .signOut - case "deleteListeningData": self = .deleteListeningData - case "deleteAccount": self = .deleteAccount - case "signInLinkSent": self = .signInLinkSent - case "signInVerified": - self = .signInVerified( - sessionToken: try c.decode(String.self, forKey: .sessionToken), - email: try c.decode(String.self, forKey: .email)) - case "listeningDataDeleted": - self = .listeningDataDeleted(newDeviceId: try c.decode(String.self, forKey: .newDeviceId)) - case "accountDeleted": - self = .accountDeleted(newDeviceId: try c.decode(String.self, forKey: .newDeviceId)) - case "accountRequestFailed": - self = .accountRequestFailed(unauthorized: try c.decode(Bool.self, forKey: .unauthorized)) + case "deleteListeningData": + self = .deleteListeningData(newDeviceId: try c.decode(String.self, forKey: .newDeviceId)) + case "deleteAccount": + self = .deleteAccount(newDeviceId: try c.decode(String.self, forKey: .newDeviceId)) + case "serverResponse": + self = .serverResponse( + id: try c.decode(UInt64.self, forKey: .id), + status: try c.decode(UInt16.self, forKey: .status), + body: try c.decodeIfPresent(String.self, forKey: .body) ?? "") default: throw DecodingError.dataCorruptedError(forKey: .type, in: c, debugDescription: "unknown command type: \(type)") } @@ -194,19 +177,14 @@ enum Effect: Decodable { case setPlatformVolume(gain: Float) case persistSettings(json: String) case persistListening(json: String) - /// `sessionToken` is the bearer token to send. - case pushListening(deviceId: String, deviceTotalMs: UInt64, sessionToken: String) - case sendSignInLink(email: String) - case verifySignInToken(token: String) - /// Fire-and-forget: no settle command. - case revokeSession(sessionToken: String) - case deleteServerListening(sessionToken: String) - case deleteServerAccount(sessionToken: String) + /// One request to carry to the sync server and settle with + /// `.serverResponse`, whichever dispatch produced it. + case serverRequest(ServerRequest) /// Store verbatim; `""` means delete the stored account. case persistAccount(json: String) private enum CodingKeys: String, CodingKey { - case type, gain, json, deviceId, deviceTotalMs, sessionToken, email, token + case type, gain, json } init(from decoder: Decoder) throws { @@ -223,21 +201,8 @@ enum Effect: Decodable { self = .persistSettings(json: try c.decode(String.self, forKey: .json)) case "persistListening": self = .persistListening(json: try c.decode(String.self, forKey: .json)) - case "pushListening": - self = .pushListening( - deviceId: try c.decode(String.self, forKey: .deviceId), - deviceTotalMs: try c.decode(UInt64.self, forKey: .deviceTotalMs), - sessionToken: try c.decode(String.self, forKey: .sessionToken)) - case "sendSignInLink": - self = .sendSignInLink(email: try c.decode(String.self, forKey: .email)) - case "verifySignInToken": - self = .verifySignInToken(token: try c.decode(String.self, forKey: .token)) - case "revokeSession": - self = .revokeSession(sessionToken: try c.decode(String.self, forKey: .sessionToken)) - case "deleteServerListening": - self = .deleteServerListening(sessionToken: try c.decode(String.self, forKey: .sessionToken)) - case "deleteServerAccount": - self = .deleteServerAccount(sessionToken: try c.decode(String.self, forKey: .sessionToken)) + case "serverRequest": + self = .serverRequest(try ServerRequest(from: decoder)) case "persistAccount": self = .persistAccount(json: try c.decode(String.self, forKey: .json)) default: @@ -246,6 +211,25 @@ enum Effect: Decodable { } } +/// The HTTP verbs the core's request table uses, sent uppercase. +enum HttpMethod: String, Decodable { + case post = "POST" + case put = "PUT" + case delete = "DELETE" +} + +/// One request to the sync server, exactly as the core describes it: `method` +/// to `path` (relative to the sync-server base URL), with a bearer token and +/// a JSON `body` when there are any. The core owns what each one means. +struct ServerRequest: Decodable { + let id: UInt64 + let method: HttpMethod + let path: String + let bearerToken: String? + /// Sent verbatim as the JSON request body. + let body: String? +} + enum TimerKind: String, Codable { case off, sleep, pomodoro, stopwatch, justCompleted } diff --git a/apps/apple/CascadeShared/Sync/AccountControlsView.swift b/apps/apple/CascadeShared/Sync/AccountControlsView.swift index 1ed3fd0..82ca1d0 100644 --- a/apps/apple/CascadeShared/Sync/AccountControlsView.swift +++ b/apps/apple/CascadeShared/Sync/AccountControlsView.swift @@ -21,9 +21,9 @@ struct AccountControlsView: View { Text(signedInLabel).font(.callout) HStack(spacing: 12) { Button("Sign out") { store.dispatch(.signOut) } - Button("Delete data") { store.dispatch(.deleteListeningData) } + Button("Delete data") { store.dispatch(.deleteListeningData(newDeviceId: UUID().uuidString)) } .disabled(account.busy) - Button("Delete account", role: .destructive) { store.dispatch(.deleteAccount) } + Button("Delete account", role: .destructive) { store.dispatch(.deleteAccount(newDeviceId: UUID().uuidString)) } .disabled(account.busy) } .buttonStyle(.borderless) diff --git a/apps/apple/CascadeShared/Sync/ServerCarrier.swift b/apps/apple/CascadeShared/Sync/ServerCarrier.swift new file mode 100644 index 0000000..4095739 --- /dev/null +++ b/apps/apple/CascadeShared/Sync/ServerCarrier.swift @@ -0,0 +1,46 @@ +import Foundation + +/// Base URL of cascade-sync-server. Empty disables the sync feature. +enum SyncConfig { + static let apiBase = "https://sync.cascade.stephens.page" + static var available: Bool { !apiBase.isEmpty } +} + +/// Carries the core's `serverRequest` effects to the sync server (URLSession). +/// It decides nothing per request: it sends what it is given and answers with +/// the `serverResponse` that settles it, holding the status and body verbatim. +/// The core reads them. +struct ServerCarrier { + private let session = URLSession.shared + + /// Send one request and return its settle. Status 0 when it was not sent + /// or got no response: no sync server configured, a network error, a + /// timeout or a cancelled task. + func send(_ request: ServerRequest) async -> Command { + let unsent = Command.serverResponse(id: request.id, status: 0, body: "") + guard SyncConfig.available, let url = URL(string: SyncConfig.apiBase + request.path) else { + return unsent + } + var req = URLRequest(url: url) + req.httpMethod = request.method.rawValue + req.timeoutInterval = 15 + if let token = request.bearerToken { + req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + if let body = request.body { + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.httpBody = Data(body.utf8) + } + + do { + let (data, response) = try await session.data(for: req) + guard let http = response as? HTTPURLResponse else { return unsent } + return .serverResponse( + id: request.id, + status: UInt16(clamping: http.statusCode), + body: String(decoding: data, as: UTF8.self)) + } catch { + return unsent + } + } +} diff --git a/apps/apple/CascadeShared/Sync/SyncApi.swift b/apps/apple/CascadeShared/Sync/SyncApi.swift deleted file mode 100644 index d118796..0000000 --- a/apps/apple/CascadeShared/Sync/SyncApi.swift +++ /dev/null @@ -1,75 +0,0 @@ -import Foundation - -/// Base URL of cascade-sync-server. Empty disables the sync feature. -enum SyncConfig { - static let apiBase = "https://sync.cascade.stephens.page" - static var available: Bool { !apiBase.isEmpty } -} - -struct SyncError: Error { - let status: Int - let message: String -} - -struct VerifyResponse: Decodable { - let sessionToken: String - let email: String -} - -struct ListeningResponse: Decodable { - let serverTotalMs: Int64 -} - -/// Typed async client for the sync endpoints (URLSession). -struct SyncApi { - private let base = SyncConfig.apiBase - private let session = URLSession.shared - - func requestLink(email: String) async throws { - _ = try await send("POST", "/auth/request", body: ["email": email], token: nil) - } - - func verify(token: String) async throws -> VerifyResponse { - let data = try await send("POST", "/auth/verify", body: ["token": token], token: nil) - return try JSONDecoder().decode(VerifyResponse.self, from: data) - } - - func logout(token: String) async throws { - _ = try await send("POST", "/auth/logout", body: nil, token: token) - } - - func putListening(token: String, deviceId: String, deviceTotalMs: Int64) async throws -> ListeningResponse { - let data = try await send( - "PUT", "/listening", - body: ["deviceId": deviceId, "deviceTotalMs": deviceTotalMs], - token: token) - return try JSONDecoder().decode(ListeningResponse.self, from: data) - } - - func deleteListening(token: String) async throws { - _ = try await send("DELETE", "/listening", body: nil, token: token) - } - - func deleteAccount(token: String) async throws { - _ = try await send("DELETE", "/account", body: nil, token: token) - } - - private func send(_ method: String, _ path: String, body: [String: Any]?, token: String?) async throws -> Data { - guard let url = URL(string: base + path) else { - throw SyncError(status: 0, message: "bad url") - } - var req = URLRequest(url: url) - req.httpMethod = method - req.timeoutInterval = 15 - req.setValue("application/json", forHTTPHeaderField: "Content-Type") - if let token { req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } - if let body { req.httpBody = try JSONSerialization.data(withJSONObject: body) } - - let (data, response) = try await session.data(for: req) - let code = (response as? HTTPURLResponse)?.statusCode ?? 0 - guard (200...299).contains(code) else { - throw SyncError(status: code, message: String(data: data, encoding: .utf8) ?? "request failed (\(code))") - } - return data - } -} From cd8be8cad38284339ae3de6ca374aa3f974e0f45 Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Sat, 3 Oct 2026 19:23:50 -0400 Subject: [PATCH 04/12] Address review: SyncConfig in its own file, status-0 cases said once Co-Authored-By: Claude Opus 5.5 --- apps/apple/CascadeShared/App/AppStore.swift | 12 +++++------- apps/apple/CascadeShared/Sync/ServerCarrier.swift | 6 ------ apps/apple/CascadeShared/Sync/SyncConfig.swift | 7 +++++++ 3 files changed, 12 insertions(+), 13 deletions(-) create mode 100644 apps/apple/CascadeShared/Sync/SyncConfig.swift diff --git a/apps/apple/CascadeShared/App/AppStore.swift b/apps/apple/CascadeShared/App/AppStore.swift index 3e02706..fc24c86 100644 --- a/apps/apple/CascadeShared/App/AppStore.swift +++ b/apps/apple/CascadeShared/App/AppStore.swift @@ -150,13 +150,11 @@ final class AppStore { dispatch(.submitSignInLink(input: url.absoluteString)) } - /// Carry one request over HTTP and settle it with the core. Every request - /// is settled, the sign-out revoke included, or the core won't start - /// another; with no sync server, or on a network error, timeout or - /// cancellation, the settle carries status 0. The settle is dispatched on - /// the main actor, and its own effects come back through `apply`. A request - /// can come from any dispatch, including a tick, so carry everything - /// `apply` hands over. + /// Carry one request over HTTP and settle it with the core, on the main + /// actor. Every request is settled, the sign-out revoke included, or the + /// core won't start another; the settle's own effects come back through + /// `apply`. A request can come from any dispatch, including a tick, so + /// carry everything `apply` hands over. private func carry(_ request: ServerRequest) { let carrier = serverCarrier Task { @MainActor in diff --git a/apps/apple/CascadeShared/Sync/ServerCarrier.swift b/apps/apple/CascadeShared/Sync/ServerCarrier.swift index 4095739..3307797 100644 --- a/apps/apple/CascadeShared/Sync/ServerCarrier.swift +++ b/apps/apple/CascadeShared/Sync/ServerCarrier.swift @@ -1,11 +1,5 @@ import Foundation -/// Base URL of cascade-sync-server. Empty disables the sync feature. -enum SyncConfig { - static let apiBase = "https://sync.cascade.stephens.page" - static var available: Bool { !apiBase.isEmpty } -} - /// Carries the core's `serverRequest` effects to the sync server (URLSession). /// It decides nothing per request: it sends what it is given and answers with /// the `serverResponse` that settles it, holding the status and body verbatim. diff --git a/apps/apple/CascadeShared/Sync/SyncConfig.swift b/apps/apple/CascadeShared/Sync/SyncConfig.swift new file mode 100644 index 0000000..b6625f2 --- /dev/null +++ b/apps/apple/CascadeShared/Sync/SyncConfig.swift @@ -0,0 +1,7 @@ +import Foundation + +/// Base URL of cascade-sync-server. Empty disables the sync feature. +enum SyncConfig { + static let apiBase = "https://sync.cascade.stephens.page" + static var available: Bool { !apiBase.isEmpty } +} From 413819abcca5be3ae72bfe376f2c36ef05b13bbb Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Sat, 3 Oct 2026 19:27:48 -0400 Subject: [PATCH 05/12] Android: carry every server request through one HTTP adapter The shell's DTOs mirror the core's new wire: one ServerRequest effect in place of the six request effects, one ServerResponse command in place of the seven settles, newDeviceId on DeleteListeningData / DeleteAccount (a fresh UUID when the user asks) and the optional platform on RequestSignInLink. The RequestEffect sealed interface is gone. SyncApi and its response types are replaced by ServerHttp, which sends any ServerRequest verbatim (method, base URL + path, bearer, JSON body) and settles it with the status and body, or status 0 for no sync server, a network error, a timeout or a cancel mid-flight. SyncManager hands every request to it with no per-request branching; the revoke is settled too. JVM unit tests cover the adapter against a local HTTP server and the request wire; Android CI now runs them. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/android.yml | 4 +- apps/android/app/build.gradle.kts | 3 + .../cascade/core/CascadeBridgeHolder.kt | 10 +- .../java/page/stephens/cascade/core/Dto.kt | 52 ++--- .../page/stephens/cascade/sync/ServerHttp.kt | 87 ++++++++ .../page/stephens/cascade/sync/SyncApi.kt | 106 ---------- .../page/stephens/cascade/sync/SyncManager.kt | 101 ++-------- .../stephens/cascade/core/ServerWireTest.kt | 55 ++++++ .../stephens/cascade/sync/ServerHttpTest.kt | 185 ++++++++++++++++++ 9 files changed, 382 insertions(+), 221 deletions(-) create mode 100644 apps/android/app/src/main/java/page/stephens/cascade/sync/ServerHttp.kt delete mode 100644 apps/android/app/src/main/java/page/stephens/cascade/sync/SyncApi.kt create mode 100644 apps/android/app/src/test/java/page/stephens/cascade/core/ServerWireTest.kt create mode 100644 apps/android/app/src/test/java/page/stephens/cascade/sync/ServerHttpTest.kt diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index b69833a..076c190 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -51,6 +51,6 @@ jobs: - uses: Swatinem/rust-cache@v2 - name: Install cargo-ndk run: cargo install cargo-ndk --locked - - name: Build debug APK + - name: Build debug APK and run unit tests working-directory: apps/android - run: ./gradlew :app:assembleDebug --no-daemon + run: ./gradlew :app:assembleDebug :app:testDebugUnitTest --no-daemon diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts index 901c227..4f1ca59 100644 --- a/apps/android/app/build.gradle.kts +++ b/apps/android/app/build.gradle.kts @@ -98,6 +98,9 @@ dependencies { // UniFFI bindings rely on JNA at runtime. implementation("net.java.dev.jna:jna:5.15.0@aar") + // JVM unit tests (the sync-server adapter and the request wire). + testImplementation("junit:junit:4.13.2") + debugImplementation("androidx.compose.ui:ui-tooling") debugImplementation("androidx.compose.ui:ui-test-manifest") } diff --git a/apps/android/app/src/main/java/page/stephens/cascade/core/CascadeBridgeHolder.kt b/apps/android/app/src/main/java/page/stephens/cascade/core/CascadeBridgeHolder.kt index 4c20989..795824d 100644 --- a/apps/android/app/src/main/java/page/stephens/cascade/core/CascadeBridgeHolder.kt +++ b/apps/android/app/src/main/java/page/stephens/cascade/core/CascadeBridgeHolder.kt @@ -41,13 +41,13 @@ class CascadeBridgeHolder( } private val dispatchLock = Any() - /** Request effects from every dispatch, in the order the core produced + /** Server requests from every dispatch, in the order the core produced * them, for the one request carrier (SyncManager). A channel rather than a * StateFlow, so none is overwritten or dropped: each is received, and so * settled, exactly once — whether a tick, a playback report, a UI command * or a settle produced it. */ - private val _requests = Channel(Channel.UNLIMITED) - val requests: ReceiveChannel = _requests + private val _requests = Channel(Channel.UNLIMITED) + val requests: ReceiveChannel = _requests private val bridge = CascadeBridge() @@ -100,7 +100,7 @@ class CascadeBridgeHolder( is Effect.PersistSettings -> writes.trySend { settingsStore.write(effect.json) } is Effect.PersistListening -> writes.trySend { settingsStore.writeListening(effect.json) } is Effect.PersistAccount -> writes.trySend { accountStore.write(effect.json) } - is RequestEffect -> _requests.trySend(effect) + is Effect.ServerRequest -> _requests.trySend(effect) else -> {} } } @@ -115,7 +115,7 @@ class CascadeBridgeHolder( private fun forHandlers(effects: List): List = effects.filter { when (it) { is Effect.PersistSettings, is Effect.PersistListening, is Effect.PersistAccount -> false - is RequestEffect -> false + is Effect.ServerRequest -> false else -> true } } diff --git a/apps/android/app/src/main/java/page/stephens/cascade/core/Dto.kt b/apps/android/app/src/main/java/page/stephens/cascade/core/Dto.kt index 58567e1..5729fa7 100644 --- a/apps/android/app/src/main/java/page/stephens/cascade/core/Dto.kt +++ b/apps/android/app/src/main/java/page/stephens/cascade/core/Dto.kt @@ -2,6 +2,7 @@ package page.stephens.cascade.core +import kotlinx.serialization.EncodeDefault import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonClassDiscriminator @@ -43,19 +44,25 @@ sealed class Command { val accountJson: String, ) : Command() @Serializable @SerialName("beginListeningSync") data class BeginListeningSync(val reason: SyncReason) : Command() - @Serializable @SerialName("listeningSyncSucceeded") data class ListeningSyncSucceeded(val serverTotalMs: Long) : Command() - @Serializable @SerialName("listeningSyncFailed") data class ListeningSyncFailed(val unauthorized: Boolean) : Command() @Serializable @SerialName("resetListeningData") data class ResetListeningData(val newDeviceId: String) : Command() - @Serializable @SerialName("requestSignInLink") data class RequestSignInLink(val email: String) : Command() + /** [platform] names the shell's link hand-off; omitted from the wire when null. */ + @Serializable @SerialName("requestSignInLink") data class RequestSignInLink( + val email: String, + @EncodeDefault(EncodeDefault.Mode.NEVER) val platform: String? = null, + ) : Command() @Serializable @SerialName("submitSignInLink") data class SubmitSignInLink(val input: String) : Command() @Serializable @SerialName("signOut") data object SignOut : Command() - @Serializable @SerialName("deleteListeningData") data object DeleteListeningData : Command() - @Serializable @SerialName("deleteAccount") data object DeleteAccount : Command() - @Serializable @SerialName("signInLinkSent") data object SignInLinkSent : Command() - @Serializable @SerialName("signInVerified") data class SignInVerified(val sessionToken: String, val email: String) : Command() - @Serializable @SerialName("listeningDataDeleted") data class ListeningDataDeleted(val newDeviceId: String) : Command() - @Serializable @SerialName("accountDeleted") data class AccountDeleted(val newDeviceId: String) : Command() - @Serializable @SerialName("accountRequestFailed") data class AccountRequestFailed(val unauthorized: Boolean) : Command() + /** [newDeviceId] is a fresh random id, adopted only once the server confirms. */ + @Serializable @SerialName("deleteListeningData") data class DeleteListeningData(val newDeviceId: String) : Command() + /** [newDeviceId] is a fresh random id, adopted only once the server confirms. */ + @Serializable @SerialName("deleteAccount") data class DeleteAccount(val newDeviceId: String) : Command() + /** Settles the [Effect.ServerRequest] with this [id]: the HTTP [status] (0 + * when not sent or no response) and the response [body] verbatim. */ + @Serializable @SerialName("serverResponse") data class ServerResponse( + val id: Long, + val status: Int, + val body: String, + ) : Command() } /** Why the shell is asking to sync; the core decides whether it's worth a PUT. @@ -66,10 +73,6 @@ enum class SyncReason { @SerialName("refresh") REFRESH, } -/** An HTTP request the shell's one carrier runs and settles. Sealed, so the - * carrier's `when` is exhaustive: a new request can't be silently dropped. */ -sealed interface RequestEffect - @Serializable @JsonClassDiscriminator("type") sealed class Effect { @@ -79,17 +82,16 @@ sealed class Effect { @Serializable @SerialName("setPlatformVolume") data class SetPlatformVolume(val gain: Float) : Effect() @Serializable @SerialName("persistSettings") data class PersistSettings(val json: String) : Effect() @Serializable @SerialName("persistListening") data class PersistListening(val json: String) : Effect() - @Serializable @SerialName("pushListening") data class PushListening( - val deviceId: String, - val deviceTotalMs: Long, - val sessionToken: String, - ) : Effect(), RequestEffect - @Serializable @SerialName("sendSignInLink") data class SendSignInLink(val email: String) : Effect(), RequestEffect - @Serializable @SerialName("verifySignInToken") data class VerifySignInToken(val token: String) : Effect(), RequestEffect - /** Fire-and-forget: no settle command. */ - @Serializable @SerialName("revokeSession") data class RevokeSession(val sessionToken: String) : Effect(), RequestEffect - @Serializable @SerialName("deleteServerListening") data class DeleteServerListening(val sessionToken: String) : Effect(), RequestEffect - @Serializable @SerialName("deleteServerAccount") data class DeleteServerAccount(val sessionToken: String) : Effect(), RequestEffect + /** One request to the sync server: [method] to the base URL plus [path], + * with `Authorization: Bearer` when [bearerToken] is set and [body] sent + * verbatim as JSON when set. Always settled with [Command.ServerResponse]. */ + @Serializable @SerialName("serverRequest") data class ServerRequest( + val id: Long, + val method: String, + val path: String, + val bearerToken: String? = null, + val body: String? = null, + ) : Effect() /** Store verbatim; `""` means delete the stored account. */ @Serializable @SerialName("persistAccount") data class PersistAccount(val json: String) : Effect() } diff --git a/apps/android/app/src/main/java/page/stephens/cascade/sync/ServerHttp.kt b/apps/android/app/src/main/java/page/stephens/cascade/sync/ServerHttp.kt new file mode 100644 index 0000000..2022d58 --- /dev/null +++ b/apps/android/app/src/main/java/page/stephens/cascade/sync/ServerHttp.kt @@ -0,0 +1,87 @@ +package page.stephens.cascade.sync + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import page.stephens.cascade.core.Command +import page.stephens.cascade.core.Effect +import java.net.HttpURLConnection +import java.net.URL +import kotlin.coroutines.cancellation.CancellationException + +/** Base URL of cascade-sync-server. Empty disables the account/sync feature. */ +const val SYNC_API_BASE = "https://sync.cascade.stephens.page" + +val syncAvailable: Boolean get() = SYNC_API_BASE.isNotEmpty() + +/** + * The one HTTP adapter for the core's sync-server requests, dependency-free + * (HttpURLConnection on the IO dispatcher). It sends any + * [Effect.ServerRequest] exactly as described and reports the status and body + * verbatim; the core owns the request table and reads the response. Status 0 + * means no response: no sync server ([baseUrl] empty, nothing sent), a network + * error or a timeout. + */ +class ServerHttp( + private val baseUrl: String, + private val timeoutMs: Int = 10_000, +) { + /** + * Send [request] in [scope] and hand its [Command.ServerResponse] to + * [settle], exactly once — including when the request is cancelled + * mid-flight, which settles with status 0. Until it is settled the core + * holds the next account request or listening sync. + */ + fun carry( + request: Effect.ServerRequest, + scope: CoroutineScope, + settle: (Command.ServerResponse) -> Unit, + ): Job = scope.launch { + var response = unanswered(request) + try { + response = send(request) + } finally { + settle(response) + } + } + + private suspend fun send(request: Effect.ServerRequest): Command.ServerResponse { + if (baseUrl.isEmpty()) return unanswered(request) + return withContext(Dispatchers.IO) { + try { + exchange(request) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + unanswered(request) + } + } + } + + private fun exchange(request: Effect.ServerRequest): Command.ServerResponse { + val conn = (URL(baseUrl + request.path).openConnection() as HttpURLConnection).apply { + requestMethod = request.method + connectTimeout = timeoutMs + readTimeout = timeoutMs + request.bearerToken?.let { setRequestProperty("Authorization", "Bearer $it") } + } + try { + request.body?.let { body -> + conn.setRequestProperty("Content-Type", "application/json") + conn.doOutput = true + conn.outputStream.use { it.write(body.toByteArray()) } + } + val status = conn.responseCode + val stream = if (status in 200..299) conn.inputStream else conn.errorStream + val body = stream?.bufferedReader()?.use { it.readText() }.orEmpty() + return Command.ServerResponse(request.id, status, body) + } finally { + conn.disconnect() + } + } + + private fun unanswered(request: Effect.ServerRequest) = + Command.ServerResponse(request.id, status = 0, body = "") +} diff --git a/apps/android/app/src/main/java/page/stephens/cascade/sync/SyncApi.kt b/apps/android/app/src/main/java/page/stephens/cascade/sync/SyncApi.kt deleted file mode 100644 index 0294cac..0000000 --- a/apps/android/app/src/main/java/page/stephens/cascade/sync/SyncApi.kt +++ /dev/null @@ -1,106 +0,0 @@ -package page.stephens.cascade.sync - -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json -import java.io.BufferedReader -import java.net.HttpURLConnection -import java.net.URL - -/** Base URL of cascade-sync-server. Empty disables the account/sync feature. */ -const val SYNC_API_BASE = "https://sync.cascade.stephens.page" - -val syncAvailable: Boolean get() = SYNC_API_BASE.isNotEmpty() - -class SyncException(val status: Int, message: String) : Exception(message) - -@Serializable -data class VerifyResponse(val sessionToken: String, val email: String) - -@Serializable -data class ListeningResponse(val serverTotalMs: Long, val syncedThroughMs: Long = 0) - -@Serializable -private data class EmailBody(val email: String) - -@Serializable -private data class TokenBody(val token: String) - -@Serializable -private data class ListeningBody(val deviceId: String, val deviceTotalMs: Long) - -/** - * Thin HTTP client for the sync service, dependency-free (HttpURLConnection on - * the IO dispatcher). Throws [SyncException] with the status on non-2xx. - */ -object SyncApi { - private val json = Json { ignoreUnknownKeys = true } - - suspend fun requestLink(email: String) { - post("/auth/request", json.encodeToString(EmailBody.serializer(), EmailBody(email)), null) - } - - suspend fun verify(token: String): VerifyResponse { - val body = post("/auth/verify", json.encodeToString(TokenBody.serializer(), TokenBody(token)), null) - return json.decodeFromString(VerifyResponse.serializer(), body) - } - - suspend fun logout(sessionToken: String) { - post("/auth/logout", "", sessionToken) - } - - suspend fun putListening( - sessionToken: String, - deviceId: String, - deviceTotalMs: Long, - ): ListeningResponse { - val payload = json.encodeToString( - ListeningBody.serializer(), - ListeningBody(deviceId, deviceTotalMs), - ) - val body = request("PUT", "/listening", payload, sessionToken) - return json.decodeFromString(ListeningResponse.serializer(), body) - } - - suspend fun deleteListening(sessionToken: String) { - request("DELETE", "/listening", null, sessionToken) - } - - suspend fun deleteAccount(sessionToken: String) { - request("DELETE", "/account", null, sessionToken) - } - - private suspend fun post(path: String, body: String, token: String?): String = - request("POST", path, body, token) - - private suspend fun request( - method: String, - path: String, - body: String?, - token: String?, - ): String = withContext(Dispatchers.IO) { - val conn = (URL("$SYNC_API_BASE$path").openConnection() as HttpURLConnection).apply { - requestMethod = method - connectTimeout = 10_000 - readTimeout = 10_000 - setRequestProperty("Content-Type", "application/json") - token?.let { setRequestProperty("Authorization", "Bearer $it") } - if (body != null) { - doOutput = true - outputStream.use { it.write(body.toByteArray()) } - } - } - try { - val code = conn.responseCode - if (code in 200..299) { - conn.inputStream.bufferedReader().use(BufferedReader::readText) - } else { - val err = conn.errorStream?.bufferedReader()?.use(BufferedReader::readText).orEmpty() - throw SyncException(code, err.ifEmpty { "request failed ($code)" }) - } - } finally { - conn.disconnect() - } - } -} diff --git a/apps/android/app/src/main/java/page/stephens/cascade/sync/SyncManager.kt b/apps/android/app/src/main/java/page/stephens/cascade/sync/SyncManager.kt index 83a82dc..aac0367 100644 --- a/apps/android/app/src/main/java/page/stephens/cascade/sync/SyncManager.kt +++ b/apps/android/app/src/main/java/page/stephens/cascade/sync/SyncManager.kt @@ -6,30 +6,32 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import page.stephens.cascade.core.CascadeBridgeHolder import page.stephens.cascade.core.Command -import page.stephens.cascade.core.Effect -import page.stephens.cascade.core.RequestEffect import page.stephens.cascade.core.SyncReason import java.util.UUID -import kotlin.coroutines.cancellation.CancellationException /** * Carries the account's and listening sync's requests on Android. The core - * holds the account and decides what to send — the threshold, the in-flight guard, the - * device id, the session, the status copy and the 401 rule all live there. - * This is the one request carrier: it takes every request effect any dispatch - * produced (a tick's threshold push included) from the bridge, carries it over - * HTTP, and settles it with its outcome command. The shell decides only when - * it can talk (lifecycle triggers) and what the user asked for. + * holds the account and decides what to send — the request table, the + * threshold, the in-flight guard, the device id, the session, the status copy + * and the 401 rule all live there. This is the one request carrier: it takes + * every [page.stephens.cascade.core.Effect.ServerRequest] any dispatch + * produced (a tick's threshold push included) from the bridge, sends it + * through [ServerHttp] and settles it with its [Command.ServerResponse]. The + * shell decides only when it can talk (lifecycle triggers) and what the user + * asked for. */ class SyncManager(private val bridge: CascadeBridgeHolder) { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val server = ServerHttp(SYNC_API_BASE) init { // Always drained, even without a sync server, so a request (say, a - // tick's push for a stored session) is still settled and never leaves - // the core's in-flight guard set. + // tick's push for a stored session) is still settled — with status 0, + // unsent — and never leaves the core's in-flight guard set. A + // settle's own answer comes back through the same channel (a sign-in + // answers with a refresh push). scope.launch { - for (request in bridge.requests) carry(request) + for (request in bridge.requests) server.carry(request, scope, bridge::dispatch) } if (syncAvailable) { // On launch, fetch the cross-device total straight away. The core @@ -45,8 +47,8 @@ class SyncManager(private val bridge: CascadeBridgeHolder) { fun submitSignInLink(input: String) = dispatch(Command.SubmitSignInLink(input)) fun signOut() = dispatch(Command.SignOut) - fun deleteListeningData() = dispatch(Command.DeleteListeningData) - fun deleteAccount() = dispatch(Command.DeleteAccount) + fun deleteListeningData() = dispatch(Command.DeleteListeningData(newDeviceId())) + fun deleteAccount() = dispatch(Command.DeleteAccount(newDeviceId())) /** Flush recent listening, e.g. when the activity is going to the background. */ fun flush() = dispatch(Command.BeginListeningSync(SyncReason.FLUSH)) @@ -55,73 +57,6 @@ class SyncManager(private val bridge: CascadeBridgeHolder) { if (syncAvailable) bridge.dispatch(command) } - /** - * Carry one request [effect] over HTTP and settle it with the core. A - * settle's own answer comes back through [CascadeBridgeHolder.requests] - * like any other (a sign-in answers with a refresh push). Every request - * but `RevokeSession` must be settled, or the core won't start another. - */ - private fun carry(effect: RequestEffect) { - when (effect) { - is Effect.PushListening -> carryRequest( - call = { - SyncApi.putListening(effect.sessionToken, effect.deviceId, effect.deviceTotalMs) - }, - succeeded = { Command.ListeningSyncSucceeded(serverTotalMs = it.serverTotalMs) }, - failed = { Command.ListeningSyncFailed(unauthorized = it) }, - ) - is Effect.SendSignInLink -> carryRequest( - call = { SyncApi.requestLink(effect.email) }, - succeeded = { Command.SignInLinkSent }, - failed = { Command.AccountRequestFailed(unauthorized = it) }, - ) - is Effect.VerifySignInToken -> carryRequest( - call = { SyncApi.verify(effect.token) }, - succeeded = { Command.SignInVerified(sessionToken = it.sessionToken, email = it.email) }, - failed = { Command.AccountRequestFailed(unauthorized = it) }, - ) - // Already gone server-side or offline — local sign-out stands. - is Effect.RevokeSession -> if (syncAvailable) { - scope.launch { runCatching { SyncApi.logout(effect.sessionToken) } } - } - is Effect.DeleteServerListening -> carryRequest( - call = { SyncApi.deleteListening(effect.sessionToken) }, - succeeded = { Command.ListeningDataDeleted(newDeviceId = UUID.randomUUID().toString()) }, - failed = { Command.AccountRequestFailed(unauthorized = it) }, - ) - is Effect.DeleteServerAccount -> carryRequest( - call = { SyncApi.deleteAccount(effect.sessionToken) }, - succeeded = { Command.AccountDeleted(newDeviceId = UUID.randomUUID().toString()) }, - failed = { Command.AccountRequestFailed(unauthorized = it) }, - ) - } - } - - /** - * Run one request and settle it with the command its result maps to (an - * HTTP 401 is `unauthorized`). Without a sync server the request fails - * unsent, so it is still settled. - */ - private fun carryRequest( - call: suspend () -> T, - succeeded: (T) -> Command, - failed: (unauthorized: Boolean) -> Command, - ) { - if (!syncAvailable) { - bridge.dispatch(failed(false)) - return - } - scope.launch { - val outcome = try { - succeeded(call()) - } catch (e: Throwable) { - // Every request must be settled, or the core never starts - // another — including on cancellation, which is rethrown after. - bridge.dispatch(failed(e is SyncException && e.status == 401)) - if (e is CancellationException) throw e - return@launch - } - bridge.dispatch(outcome) - } - } + /** The id the core rotates to once a delete is confirmed. */ + private fun newDeviceId() = UUID.randomUUID().toString() } diff --git a/apps/android/app/src/test/java/page/stephens/cascade/core/ServerWireTest.kt b/apps/android/app/src/test/java/page/stephens/cascade/core/ServerWireTest.kt new file mode 100644 index 0000000..d17aad9 --- /dev/null +++ b/apps/android/app/src/test/java/page/stephens/cascade/core/ServerWireTest.kt @@ -0,0 +1,55 @@ +package page.stephens.cascade.core + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** The request wire, as `cascade-core` writes and reads it. */ +class ServerWireTest { + @Test + fun aServerRequestDecodesFromTheCoresJson() { + val effect = cascadeJson.decodeFromString( + """{"type":"serverRequest","id":3,"method":"PUT","path":"/listening","bearerToken":"tok","body":"{\"deviceId\":\"d\"}"}""", + ) + assertEquals(Effect.ServerRequest(3, "PUT", "/listening", "tok", """{"deviceId":"d"}"""), effect) + } + + @Test + fun aServerRequestWithoutBearerOrBodyDecodes() { + val effect = cascadeJson.decodeFromString( + """{"type":"serverRequest","id":4,"method":"DELETE","path":"/account"}""", + ) + assertEquals(Effect.ServerRequest(4, "DELETE", "/account", null, null), effect) + } + + @Test + fun aServerResponseEncodesForTheCore() { + assertEquals( + """{"type":"serverResponse","id":3,"status":401,"body":""}""", + cascadeJson.encodeToString(Command.serializer(), Command.ServerResponse(3, 401, "")), + ) + } + + @Test + fun deletesCarryTheNewDeviceId() { + assertEquals( + """{"type":"deleteListeningData","newDeviceId":"n"}""", + cascadeJson.encodeToString(Command.serializer(), Command.DeleteListeningData("n")), + ) + assertEquals( + """{"type":"deleteAccount","newDeviceId":"n"}""", + cascadeJson.encodeToString(Command.serializer(), Command.DeleteAccount("n")), + ) + } + + @Test + fun aSignInLinkRequestNamesItsPlatformOnlyWhenGiven() { + assertEquals( + """{"type":"requestSignInLink","email":"a@b.c"}""", + cascadeJson.encodeToString(Command.serializer(), Command.RequestSignInLink("a@b.c")), + ) + assertEquals( + """{"type":"requestSignInLink","email":"a@b.c","platform":"android"}""", + cascadeJson.encodeToString(Command.serializer(), Command.RequestSignInLink("a@b.c", "android")), + ) + } +} diff --git a/apps/android/app/src/test/java/page/stephens/cascade/sync/ServerHttpTest.kt b/apps/android/app/src/test/java/page/stephens/cascade/sync/ServerHttpTest.kt new file mode 100644 index 0000000..d0ab750 --- /dev/null +++ b/apps/android/app/src/test/java/page/stephens/cascade/sync/ServerHttpTest.kt @@ -0,0 +1,185 @@ +package page.stephens.cascade.sync + +import com.sun.net.httpserver.HttpExchange +import com.sun.net.httpserver.HttpServer +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import page.stephens.cascade.core.Command +import page.stephens.cascade.core.Effect +import java.net.InetSocketAddress +import java.net.ServerSocket +import java.util.concurrent.CountDownLatch +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.TimeUnit + +class ServerHttpTest { + /** What the fake sync server saw of one request. */ + private data class Seen( + val method: String, + val path: String, + val authorization: String?, + val contentType: String?, + val body: String, + ) + + private val seen = LinkedBlockingQueue() + private var server: HttpServer? = null + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + @After + fun tearDown() { + scope.cancel() + server?.stop(0) + } + + /** A local sync server that records each request and answers with [answer]. */ + private fun serve(answer: (HttpExchange) -> Pair): String { + val http = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0) + http.createContext("/") { exchange -> + seen.add( + Seen( + method = exchange.requestMethod, + path = exchange.requestURI.path, + authorization = exchange.requestHeaders.getFirst("Authorization"), + contentType = exchange.requestHeaders.getFirst("Content-Type"), + body = exchange.requestBody.bufferedReader().use { it.readText() }, + ), + ) + val (status, body) = answer(exchange) + val bytes = body.toByteArray() + exchange.sendResponseHeaders(status, if (bytes.isEmpty()) -1 else bytes.size.toLong()) + if (bytes.isNotEmpty()) exchange.responseBody.use { it.write(bytes) } + exchange.close() + } + http.start() + server = http + return "http://127.0.0.1:${http.address.port}" + } + + private fun request( + id: Long = 7, + method: String = "POST", + path: String = "/auth/request", + bearerToken: String? = null, + body: String? = null, + ) = Effect.ServerRequest(id, method, path, bearerToken, body) + + /** Carry [request] and wait for its one settle. */ + private fun settle(http: ServerHttp, request: Effect.ServerRequest): Command.ServerResponse { + val settles = LinkedBlockingQueue() + runBlocking { http.carry(request, scope) { settles.add(it) }.join() } + assertEquals("settled exactly once", 1, settles.size) + return settles.single() + } + + @Test + fun sendsTheRequestVerbatimAndSettlesWithStatusAndBody() { + val base = serve { 200 to """{"serverTotalMs":42}""" } + val response = settle( + ServerHttp(base), + request( + id = 11, + method = "PUT", + path = "/listening", + bearerToken = "tok", + body = """{"deviceId":"d","deviceTotalMs":5}""", + ), + ) + + assertEquals(Command.ServerResponse(11, 200, """{"serverTotalMs":42}"""), response) + val sent = seen.single() + assertEquals("PUT", sent.method) + assertEquals("/listening", sent.path) + assertEquals("Bearer tok", sent.authorization) + assertEquals("application/json", sent.contentType) + assertEquals("""{"deviceId":"d","deviceTotalMs":5}""", sent.body) + } + + @Test + fun aRequestWithNoBearerOrBodySendsNeither() { + val base = serve { 204 to "" } + val response = settle(ServerHttp(base), request(method = "DELETE", path = "/account")) + + assertEquals(Command.ServerResponse(7, 204, ""), response) + val sent = seen.single() + assertEquals("DELETE", sent.method) + assertNull(sent.authorization) + assertEquals("", sent.body) + } + + @Test + fun aBodilessPostIsSent() { + // The sign-out revoke: POST /auth/logout with only the bearer token. + val base = serve { 204 to "" } + val response = settle(ServerHttp(base), request(path = "/auth/logout", bearerToken = "tok")) + + assertEquals(204, response.status) + assertEquals("POST", seen.single().method) + assertEquals("Bearer tok", seen.single().authorization) + } + + @Test + fun anErrorStatusIsSettledWithItsBody() { + val base = serve { 401 to "unauthorized" } + val response = settle(ServerHttp(base), request(bearerToken = "stale")) + + assertEquals(Command.ServerResponse(7, 401, "unauthorized"), response) + } + + @Test + fun anErrorStatusWithNoBodySettlesWithAnEmptyBody() { + val base = serve { 500 to "" } + assertEquals(Command.ServerResponse(7, 500, ""), settle(ServerHttp(base), request())) + } + + @Test + fun noSyncServerSettlesWithStatusZeroAndSendsNothing() { + serve { 200 to "" } + val response = settle(ServerHttp(""), request(body = "{}")) + + assertEquals(Command.ServerResponse(7, 0, ""), response) + assertTrue(seen.isEmpty()) + } + + @Test + fun aNetworkErrorSettlesWithStatusZero() { + val closedPort = ServerSocket(0).use { it.localPort } + val response = settle(ServerHttp("http://127.0.0.1:$closedPort"), request(body = "{}")) + + assertEquals(Command.ServerResponse(7, 0, ""), response) + } + + @Test + fun aTimeoutSettlesWithStatusZero() { + val base = serve { Thread.sleep(1_000); 200 to "late" } + val response = settle(ServerHttp(base, timeoutMs = 100), request()) + + assertEquals(Command.ServerResponse(7, 0, ""), response) + } + + @Test + fun aRequestCancelledMidFlightIsStillSettledWithStatusZero() { + val arrived = CountDownLatch(1) + val base = serve { arrived.countDown(); Thread.sleep(300); 200 to "too late" } + val settles = LinkedBlockingQueue() + + runBlocking { + val job = ServerHttp(base).carry(request(), scope) { settles.add(it) } + assertTrue(arrived.await(5, TimeUnit.SECONDS)) + delay(20) + job.cancel() + job.join() + } + + assertEquals(listOf(Command.ServerResponse(7, 0, "")), settles.toList()) + } +} From b779cc89646fa72bae0e18536527bb64e52205c4 Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Sat, 3 Oct 2026 19:28:32 -0400 Subject: [PATCH 06/12] Web: carry every server request through one HTTP adapter The web shell's types mirror the core's new wire: one serverRequest effect in place of the six request effects, one serverResponse command in place of the seven settles, newDeviceId on deleteListeningData and deleteAccount (a fresh UUID made when the user asks), and the optional platform on requestSignInLink. The carrier sends any serverRequest as described (method, base URL plus path, bearer token, JSON body verbatim, keepalive on the pagehide flush) and settles it with the HTTP status and body, or status 0 with no sync server, on a network error or after a 30 s timeout. The sign-out revoke is settled too. The typed sync client is deleted; only the base URL and syncAvailable remain, in sync/config.ts. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/core/types.ts | 46 +++++++------- apps/web/src/sync/api.ts | 105 ------------------------------- apps/web/src/sync/carrier.ts | 118 ++++++++++++----------------------- apps/web/src/sync/config.ts | 12 ++++ apps/web/src/sync/useSync.ts | 25 +++++--- 5 files changed, 95 insertions(+), 211 deletions(-) delete mode 100644 apps/web/src/sync/api.ts create mode 100644 apps/web/src/sync/config.ts diff --git a/apps/web/src/core/types.ts b/apps/web/src/core/types.ts index 772e521..fec70b3 100644 --- a/apps/web/src/core/types.ts +++ b/apps/web/src/core/types.ts @@ -28,19 +28,17 @@ export type Command = accountJson: string; } | { type: "beginListeningSync"; reason: SyncReason } - | { type: "listeningSyncSucceeded"; serverTotalMs: number } - | { type: "listeningSyncFailed"; unauthorized: boolean } | { type: "resetListeningData"; newDeviceId: string } - | { type: "requestSignInLink"; email: string } + /** `platform` names the app the emailed link hands off to, if any. */ + | { type: "requestSignInLink"; email: string; platform?: string } | { type: "submitSignInLink"; input: string } | { type: "signOut" } - | { type: "deleteListeningData" } - | { type: "deleteAccount" } - | { type: "signInLinkSent" } - | { type: "signInVerified"; sessionToken: string; email: string } - | { type: "listeningDataDeleted"; newDeviceId: string } - | { type: "accountDeleted"; newDeviceId: string } - | { type: "accountRequestFailed"; unauthorized: boolean }; + /** `newDeviceId` is a fresh UUID, adopted once the server confirms. */ + | { type: "deleteListeningData"; newDeviceId: string } + | { type: "deleteAccount"; newDeviceId: string } + /** Settles the `serverRequest` with the same `id`: the HTTP status (0 when + * not sent or no response) and the response body verbatim. */ + | { type: "serverResponse"; id: number; status: number; body: string }; /** Why the shell is asking to sync; the core decides whether to send. The * routine threshold sync is the core's own, answered from a tick. */ @@ -53,21 +51,25 @@ export type Effect = | { type: "setPlatformVolume"; gain: number } | { type: "persistSettings"; json: string } | { type: "persistListening"; json: string } - | { - type: "pushListening"; - deviceId: string; - deviceTotalMs: number; - sessionToken: string; - } - | { type: "sendSignInLink"; email: string } - | { type: "verifySignInToken"; token: string } - /** Fire-and-forget: no settle command. */ - | { type: "revokeSession"; sessionToken: string } - | { type: "deleteServerListening"; sessionToken: string } - | { type: "deleteServerAccount"; sessionToken: string } + /** Send to the sync server exactly as described; settle with + * `serverResponse`, always. */ + | ServerRequest /** Store verbatim; `""` means delete the stored account. */ | { type: "persistAccount"; json: string }; +/** One request to the sync server. `path` is relative to the base URL; + * `body`, when present, is JSON to send verbatim. */ +export interface ServerRequest { + type: "serverRequest"; + id: number; + method: HttpMethod; + path: string; + bearerToken?: string; + body?: string; +} + +export type HttpMethod = "POST" | "PUT" | "DELETE"; + export type TimerKind = | "off" | "sleep" diff --git a/apps/web/src/sync/api.ts b/apps/web/src/sync/api.ts deleted file mode 100644 index 354316e..0000000 --- a/apps/web/src/sync/api.ts +++ /dev/null @@ -1,105 +0,0 @@ -/** - * Thin client for the cascade-sync-server. The base URL comes from - * `VITE_SYNC_API` at build time; when it's unset the whole account/sync feature - * is considered unavailable and the UI hides it (the app still tracks listening - * time locally). - */ - -const API_BASE = (import.meta.env.VITE_SYNC_API ?? "").replace(/\/$/, ""); - -export const syncAvailable = API_BASE.length > 0; - -export interface VerifyResponse { - sessionToken: string; - email: string; -} - -export interface ListeningResponse { - serverTotalMs: number; - syncedThroughMs: number; -} - -class HttpError extends Error { - constructor( - public status: number, - message: string, - ) { - super(message); - } -} - -async function call( - path: string, - init: RequestInit & { sessionToken?: string } = {}, -): Promise { - const { sessionToken, headers, ...rest } = init; - const res = await fetch(`${API_BASE}${path}`, { - ...rest, - headers: { - "Content-Type": "application/json", - ...(sessionToken ? { Authorization: `Bearer ${sessionToken}` } : {}), - ...headers, - }, - }); - if (!res.ok) { - let message = `request failed (${res.status})`; - try { - const body = (await res.json()) as { error?: string }; - if (body.error) message = body.error; - } catch { - // non-JSON error body — keep the default message - } - throw new HttpError(res.status, message); - } - return (await res.json()) as T; -} - -export function requestLink(email: string): Promise<{ ok: boolean }> { - return call("/auth/request", { - method: "POST", - body: JSON.stringify({ email }), - }); -} - -export function verify(token: string): Promise { - return call("/auth/verify", { - method: "POST", - body: JSON.stringify({ token }), - }); -} - -export function logout(sessionToken: string): Promise<{ ok: boolean }> { - return call("/auth/logout", { method: "POST", sessionToken }); -} - -export function putListening( - sessionToken: string, - deviceId: string, - deviceTotalMs: number, - keepalive = false, -): Promise { - return call("/listening", { - method: "PUT", - sessionToken, - keepalive, - body: JSON.stringify({ deviceId, deviceTotalMs }), - }); -} - -export function getListening( - sessionToken: string, -): Promise { - return call("/listening", { method: "GET", sessionToken }); -} - -export function deleteListening( - sessionToken: string, -): Promise<{ ok: boolean }> { - return call("/listening", { method: "DELETE", sessionToken }); -} - -export function deleteAccount(sessionToken: string): Promise<{ ok: boolean }> { - return call("/account", { method: "DELETE", sessionToken }); -} - -export { HttpError }; diff --git a/apps/web/src/sync/carrier.ts b/apps/web/src/sync/carrier.ts index 9f2da71..eee9915 100644 --- a/apps/web/src/sync/carrier.ts +++ b/apps/web/src/sync/carrier.ts @@ -1,100 +1,64 @@ -import type { Command, Effect } from "../core/types"; +import type { Command, Effect, ServerRequest } from "../core/types"; import type { DispatchOptions } from "../core/useCascade"; -import * as api from "./api"; +import { SYNC_API_BASE, syncAvailable } from "./config"; /** Dispatches a settle; that dispatch carries the settle's own effects. */ -type Settle = (outcome: Command) => void; +type Settle = (response: Command) => void; + +/** How long a request may take before it is given up and settled as status 0. */ +const REQUEST_TIMEOUT_MS = 30_000; /** - * Run one request and turn its result into the command that settles it. - * Without a sync server the request fails unsent, so it is still settled. + * Send one request exactly as the core described it and answer with the + * `serverResponse` that settles it: the HTTP status and body, or status 0 + * when there is no sync server, the network failed or it timed out. */ -async function settleWith( - call: () => Promise, - succeeded: (res: T) => Command, - failed: (unauthorized: boolean) => Command, +async function send( + request: ServerRequest, + keepalive: boolean, ): Promise { - if (!api.syncAvailable) return failed(false); + const response = (status: number, body = ""): Command => ({ + type: "serverResponse", + id: request.id, + status, + body, + }); + if (!syncAvailable) return response(0); try { - return succeeded(await call()); - } catch (err) { - return failed(err instanceof api.HttpError && err.status === 401); + const res = await fetch(`${SYNC_API_BASE}${request.path}`, { + method: request.method, + headers: { + ...(request.body !== undefined + ? { "Content-Type": "application/json" } + : {}), + ...(request.bearerToken + ? { Authorization: `Bearer ${request.bearerToken}` } + : {}), + }, + body: request.body, + keepalive, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + return response(res.status, await res.text().catch(() => "")); + } catch { + return response(0); } } /** * The one request carrier. Every dispatch hands it its effects, whichever * command produced them (a tick's push, a sign-in's refresh, an account call); - * it runs each request effect over HTTP and settles it through `settle`, which - * is itself a dispatch, so a settle's answer comes back here too. Every request - * but `revokeSession` must be settled, or the core won't start another. + * it sends each `serverRequest` over HTTP and settles it through `settle`, + * which is itself a dispatch, so a settle's answer comes back here too. Every + * request is settled, or the core won't start another. */ export function carryRequests( effects: Effect[], settle: Settle, { keepalive = false }: DispatchOptions = {}, ): void { - const accountFailed = (unauthorized: boolean): Command => ({ - type: "accountRequestFailed", - unauthorized, - }); for (const effect of effects) { - switch (effect.type) { - case "pushListening": - void settleWith( - () => - api.putListening( - effect.sessionToken, - effect.deviceId, - effect.deviceTotalMs, - keepalive, - ), - (res) => ({ - type: "listeningSyncSucceeded", - serverTotalMs: res.serverTotalMs, - }), - (unauthorized) => ({ type: "listeningSyncFailed", unauthorized }), - ).then(settle); - break; - case "sendSignInLink": - void settleWith( - () => api.requestLink(effect.email), - () => ({ type: "signInLinkSent" }), - accountFailed, - ).then(settle); - break; - case "verifySignInToken": - void settleWith( - () => api.verify(effect.token), - (res) => ({ - type: "signInVerified", - sessionToken: res.sessionToken, - email: res.email, - }), - accountFailed, - ).then(settle); - break; - case "revokeSession": - // Already gone server-side or offline — local sign-out stands. - if (api.syncAvailable) api.logout(effect.sessionToken).catch(() => {}); - break; - case "deleteServerListening": - void settleWith( - () => api.deleteListening(effect.sessionToken), - () => ({ - type: "listeningDataDeleted", - newDeviceId: crypto.randomUUID(), - }), - accountFailed, - ).then(settle); - break; - case "deleteServerAccount": - void settleWith( - () => api.deleteAccount(effect.sessionToken), - () => ({ type: "accountDeleted", newDeviceId: crypto.randomUUID() }), - accountFailed, - ).then(settle); - break; - } + if (effect.type === "serverRequest") + void send(effect, keepalive).then(settle); } } diff --git a/apps/web/src/sync/config.ts b/apps/web/src/sync/config.ts new file mode 100644 index 0000000..9aabdbd --- /dev/null +++ b/apps/web/src/sync/config.ts @@ -0,0 +1,12 @@ +/** + * Where the cascade-sync-server lives. The base URL comes from `VITE_SYNC_API` + * at build time; when it's unset the whole account/sync feature is considered + * unavailable and the UI hides it (the app still tracks listening time + * locally). + */ +export const SYNC_API_BASE = (import.meta.env.VITE_SYNC_API ?? "").replace( + /\/$/, + "", +); + +export const syncAvailable = SYNC_API_BASE.length > 0; diff --git a/apps/web/src/sync/useSync.ts b/apps/web/src/sync/useSync.ts index 4cdb2f3..e4e198e 100644 --- a/apps/web/src/sync/useSync.ts +++ b/apps/web/src/sync/useSync.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import type { Command, Snapshot, SyncReason } from "../core/types"; import type { DispatchOptions } from "../core/useCascade"; -import * as api from "./api"; +import { syncAvailable } from "./config"; type Dispatch = (command: Command, options?: DispatchOptions) => void; @@ -34,14 +34,14 @@ export function useSync( // talk to, so these triggers send nothing to the core. const dispatchIfSyncAvailable = useCallback( (command: Command) => { - if (api.syncAvailable) dispatch(command); + if (syncAvailable) dispatch(command); }, [dispatch], ); const sync = useCallback( (reason: SyncReason, keepalive = false) => { - if (api.syncAvailable) + if (syncAvailable) dispatch({ type: "beginListeningSync", reason }, { keepalive }); }, [dispatch], @@ -51,7 +51,7 @@ export function useSync( // URL. Waits for the core, which holds the account. const linkHandledRef = useRef(false); useEffect(() => { - if (!api.syncAvailable || !coreReady || linkHandledRef.current) return; + if (!syncAvailable || !coreReady || linkHandledRef.current) return; linkHandledRef.current = true; const url = new URL(window.location.href); const token = url.searchParams.get("token"); @@ -104,7 +104,7 @@ export function useSync( }, [sync]); return { - available: api.syncAvailable, + available: syncAvailable, desktopHandoff, requestSignInLink: useCallback( (email: string) => dispatchIfSyncAvailable({ type: "requestSignInLink", email }), @@ -112,9 +112,20 @@ export function useSync( ), signOut: useCallback(() => dispatchIfSyncAvailable({ type: "signOut" }), [dispatchIfSyncAvailable]), deleteListeningData: useCallback( - () => dispatchIfSyncAvailable({ type: "deleteListeningData" }), + () => + dispatchIfSyncAvailable({ + type: "deleteListeningData", + newDeviceId: crypto.randomUUID(), + }), + [dispatchIfSyncAvailable], + ), + deleteAccount: useCallback( + () => + dispatchIfSyncAvailable({ + type: "deleteAccount", + newDeviceId: crypto.randomUUID(), + }), [dispatchIfSyncAvailable], ), - deleteAccount: useCallback(() => dispatchIfSyncAvailable({ type: "deleteAccount" }), [dispatchIfSyncAvailable]), }; } From a753c493f2193b99c710fdf74973ecb28925c969 Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Sat, 3 Oct 2026 19:29:51 -0400 Subject: [PATCH 07/12] Address review: platform comment follows the core's wording Co-Authored-By: Claude Opus 5.5 --- apps/web/src/core/types.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/src/core/types.ts b/apps/web/src/core/types.ts index fec70b3..b353fe8 100644 --- a/apps/web/src/core/types.ts +++ b/apps/web/src/core/types.ts @@ -29,7 +29,8 @@ export type Command = } | { type: "beginListeningSync"; reason: SyncReason } | { type: "resetListeningData"; newDeviceId: string } - /** `platform` names the app the emailed link hands off to, if any. */ + /** `platform` names the platform whose shell the emailed link hands off + * to, if any; the web shell has no hand-off and leaves it out. */ | { type: "requestSignInLink"; email: string; platform?: string } | { type: "submitSignInLink"; input: string } | { type: "signOut" } From fedbcb6eb74525731653351daca4f17700a64ec2 Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Sat, 3 Oct 2026 19:30:44 -0400 Subject: [PATCH 08/12] Address review: drop the shell test harness, settle unstarted requests Issue #57 puts a shell test harness out of scope, so the JVM tests, their JUnit dependency and the CI step are removed. The adapter launches its carry atomically so a request cancelled before it starts is settled too. RequestSignInLink's platform is worded as the core words it, SyncManager no longer also calls itself the one carrier, and the response body gets its own name. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/android.yml | 4 +- apps/android/app/build.gradle.kts | 3 - .../java/page/stephens/cascade/core/Dto.kt | 3 +- .../page/stephens/cascade/sync/ServerHttp.kt | 13 +- .../page/stephens/cascade/sync/SyncManager.kt | 8 +- .../stephens/cascade/core/ServerWireTest.kt | 55 ------ .../stephens/cascade/sync/ServerHttpTest.kt | 185 ------------------ 7 files changed, 15 insertions(+), 256 deletions(-) delete mode 100644 apps/android/app/src/test/java/page/stephens/cascade/core/ServerWireTest.kt delete mode 100644 apps/android/app/src/test/java/page/stephens/cascade/sync/ServerHttpTest.kt diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 076c190..b69833a 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -51,6 +51,6 @@ jobs: - uses: Swatinem/rust-cache@v2 - name: Install cargo-ndk run: cargo install cargo-ndk --locked - - name: Build debug APK and run unit tests + - name: Build debug APK working-directory: apps/android - run: ./gradlew :app:assembleDebug :app:testDebugUnitTest --no-daemon + run: ./gradlew :app:assembleDebug --no-daemon diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts index 4f1ca59..901c227 100644 --- a/apps/android/app/build.gradle.kts +++ b/apps/android/app/build.gradle.kts @@ -98,9 +98,6 @@ dependencies { // UniFFI bindings rely on JNA at runtime. implementation("net.java.dev.jna:jna:5.15.0@aar") - // JVM unit tests (the sync-server adapter and the request wire). - testImplementation("junit:junit:4.13.2") - debugImplementation("androidx.compose.ui:ui-tooling") debugImplementation("androidx.compose.ui:ui-test-manifest") } diff --git a/apps/android/app/src/main/java/page/stephens/cascade/core/Dto.kt b/apps/android/app/src/main/java/page/stephens/cascade/core/Dto.kt index 5729fa7..7b4d653 100644 --- a/apps/android/app/src/main/java/page/stephens/cascade/core/Dto.kt +++ b/apps/android/app/src/main/java/page/stephens/cascade/core/Dto.kt @@ -45,7 +45,8 @@ sealed class Command { ) : Command() @Serializable @SerialName("beginListeningSync") data class BeginListeningSync(val reason: SyncReason) : Command() @Serializable @SerialName("resetListeningData") data class ResetListeningData(val newDeviceId: String) : Command() - /** [platform] names the shell's link hand-off; omitted from the wire when null. */ + /** [platform] names the platform whose app the emailed link should hand off + * to; omitted from the wire when null. Android sends none. */ @Serializable @SerialName("requestSignInLink") data class RequestSignInLink( val email: String, @EncodeDefault(EncodeDefault.Mode.NEVER) val platform: String? = null, diff --git a/apps/android/app/src/main/java/page/stephens/cascade/sync/ServerHttp.kt b/apps/android/app/src/main/java/page/stephens/cascade/sync/ServerHttp.kt index 2022d58..40cb3c4 100644 --- a/apps/android/app/src/main/java/page/stephens/cascade/sync/ServerHttp.kt +++ b/apps/android/app/src/main/java/page/stephens/cascade/sync/ServerHttp.kt @@ -1,6 +1,7 @@ package page.stephens.cascade.sync import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.launch @@ -30,15 +31,15 @@ class ServerHttp( ) { /** * Send [request] in [scope] and hand its [Command.ServerResponse] to - * [settle], exactly once — including when the request is cancelled - * mid-flight, which settles with status 0. Until it is settled the core - * holds the next account request or listening sync. + * [settle], exactly once — including when the request is cancelled, + * before it starts or mid-flight, which settles with status 0. Until it + * is settled the core holds the next account request or listening sync. */ fun carry( request: Effect.ServerRequest, scope: CoroutineScope, settle: (Command.ServerResponse) -> Unit, - ): Job = scope.launch { + ): Job = scope.launch(start = CoroutineStart.ATOMIC) { var response = unanswered(request) try { response = send(request) @@ -75,8 +76,8 @@ class ServerHttp( } val status = conn.responseCode val stream = if (status in 200..299) conn.inputStream else conn.errorStream - val body = stream?.bufferedReader()?.use { it.readText() }.orEmpty() - return Command.ServerResponse(request.id, status, body) + val responseBody = stream?.bufferedReader()?.use { it.readText() }.orEmpty() + return Command.ServerResponse(request.id, status, responseBody) } finally { conn.disconnect() } diff --git a/apps/android/app/src/main/java/page/stephens/cascade/sync/SyncManager.kt b/apps/android/app/src/main/java/page/stephens/cascade/sync/SyncManager.kt index aac0367..c0389f4 100644 --- a/apps/android/app/src/main/java/page/stephens/cascade/sync/SyncManager.kt +++ b/apps/android/app/src/main/java/page/stephens/cascade/sync/SyncManager.kt @@ -13,10 +13,10 @@ import java.util.UUID * Carries the account's and listening sync's requests on Android. The core * holds the account and decides what to send — the request table, the * threshold, the in-flight guard, the device id, the session, the status copy - * and the 401 rule all live there. This is the one request carrier: it takes - * every [page.stephens.cascade.core.Effect.ServerRequest] any dispatch - * produced (a tick's threshold push included) from the bridge, sends it - * through [ServerHttp] and settles it with its [Command.ServerResponse]. The + * and the 401 rule all live there. This routes the bridge's request channel + * to the HTTP adapter: every [page.stephens.cascade.core.Effect.ServerRequest] + * any dispatch produced (a tick's threshold push included) goes to + * [ServerHttp], which settles it with its [Command.ServerResponse]. The * shell decides only when it can talk (lifecycle triggers) and what the user * asked for. */ diff --git a/apps/android/app/src/test/java/page/stephens/cascade/core/ServerWireTest.kt b/apps/android/app/src/test/java/page/stephens/cascade/core/ServerWireTest.kt deleted file mode 100644 index d17aad9..0000000 --- a/apps/android/app/src/test/java/page/stephens/cascade/core/ServerWireTest.kt +++ /dev/null @@ -1,55 +0,0 @@ -package page.stephens.cascade.core - -import org.junit.Assert.assertEquals -import org.junit.Test - -/** The request wire, as `cascade-core` writes and reads it. */ -class ServerWireTest { - @Test - fun aServerRequestDecodesFromTheCoresJson() { - val effect = cascadeJson.decodeFromString( - """{"type":"serverRequest","id":3,"method":"PUT","path":"/listening","bearerToken":"tok","body":"{\"deviceId\":\"d\"}"}""", - ) - assertEquals(Effect.ServerRequest(3, "PUT", "/listening", "tok", """{"deviceId":"d"}"""), effect) - } - - @Test - fun aServerRequestWithoutBearerOrBodyDecodes() { - val effect = cascadeJson.decodeFromString( - """{"type":"serverRequest","id":4,"method":"DELETE","path":"/account"}""", - ) - assertEquals(Effect.ServerRequest(4, "DELETE", "/account", null, null), effect) - } - - @Test - fun aServerResponseEncodesForTheCore() { - assertEquals( - """{"type":"serverResponse","id":3,"status":401,"body":""}""", - cascadeJson.encodeToString(Command.serializer(), Command.ServerResponse(3, 401, "")), - ) - } - - @Test - fun deletesCarryTheNewDeviceId() { - assertEquals( - """{"type":"deleteListeningData","newDeviceId":"n"}""", - cascadeJson.encodeToString(Command.serializer(), Command.DeleteListeningData("n")), - ) - assertEquals( - """{"type":"deleteAccount","newDeviceId":"n"}""", - cascadeJson.encodeToString(Command.serializer(), Command.DeleteAccount("n")), - ) - } - - @Test - fun aSignInLinkRequestNamesItsPlatformOnlyWhenGiven() { - assertEquals( - """{"type":"requestSignInLink","email":"a@b.c"}""", - cascadeJson.encodeToString(Command.serializer(), Command.RequestSignInLink("a@b.c")), - ) - assertEquals( - """{"type":"requestSignInLink","email":"a@b.c","platform":"android"}""", - cascadeJson.encodeToString(Command.serializer(), Command.RequestSignInLink("a@b.c", "android")), - ) - } -} diff --git a/apps/android/app/src/test/java/page/stephens/cascade/sync/ServerHttpTest.kt b/apps/android/app/src/test/java/page/stephens/cascade/sync/ServerHttpTest.kt deleted file mode 100644 index d0ab750..0000000 --- a/apps/android/app/src/test/java/page/stephens/cascade/sync/ServerHttpTest.kt +++ /dev/null @@ -1,185 +0,0 @@ -package page.stephens.cascade.sync - -import com.sun.net.httpserver.HttpExchange -import com.sun.net.httpserver.HttpServer -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel -import kotlinx.coroutines.delay -import kotlinx.coroutines.runBlocking -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Test -import page.stephens.cascade.core.Command -import page.stephens.cascade.core.Effect -import java.net.InetSocketAddress -import java.net.ServerSocket -import java.util.concurrent.CountDownLatch -import java.util.concurrent.LinkedBlockingQueue -import java.util.concurrent.TimeUnit - -class ServerHttpTest { - /** What the fake sync server saw of one request. */ - private data class Seen( - val method: String, - val path: String, - val authorization: String?, - val contentType: String?, - val body: String, - ) - - private val seen = LinkedBlockingQueue() - private var server: HttpServer? = null - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - - @After - fun tearDown() { - scope.cancel() - server?.stop(0) - } - - /** A local sync server that records each request and answers with [answer]. */ - private fun serve(answer: (HttpExchange) -> Pair): String { - val http = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0) - http.createContext("/") { exchange -> - seen.add( - Seen( - method = exchange.requestMethod, - path = exchange.requestURI.path, - authorization = exchange.requestHeaders.getFirst("Authorization"), - contentType = exchange.requestHeaders.getFirst("Content-Type"), - body = exchange.requestBody.bufferedReader().use { it.readText() }, - ), - ) - val (status, body) = answer(exchange) - val bytes = body.toByteArray() - exchange.sendResponseHeaders(status, if (bytes.isEmpty()) -1 else bytes.size.toLong()) - if (bytes.isNotEmpty()) exchange.responseBody.use { it.write(bytes) } - exchange.close() - } - http.start() - server = http - return "http://127.0.0.1:${http.address.port}" - } - - private fun request( - id: Long = 7, - method: String = "POST", - path: String = "/auth/request", - bearerToken: String? = null, - body: String? = null, - ) = Effect.ServerRequest(id, method, path, bearerToken, body) - - /** Carry [request] and wait for its one settle. */ - private fun settle(http: ServerHttp, request: Effect.ServerRequest): Command.ServerResponse { - val settles = LinkedBlockingQueue() - runBlocking { http.carry(request, scope) { settles.add(it) }.join() } - assertEquals("settled exactly once", 1, settles.size) - return settles.single() - } - - @Test - fun sendsTheRequestVerbatimAndSettlesWithStatusAndBody() { - val base = serve { 200 to """{"serverTotalMs":42}""" } - val response = settle( - ServerHttp(base), - request( - id = 11, - method = "PUT", - path = "/listening", - bearerToken = "tok", - body = """{"deviceId":"d","deviceTotalMs":5}""", - ), - ) - - assertEquals(Command.ServerResponse(11, 200, """{"serverTotalMs":42}"""), response) - val sent = seen.single() - assertEquals("PUT", sent.method) - assertEquals("/listening", sent.path) - assertEquals("Bearer tok", sent.authorization) - assertEquals("application/json", sent.contentType) - assertEquals("""{"deviceId":"d","deviceTotalMs":5}""", sent.body) - } - - @Test - fun aRequestWithNoBearerOrBodySendsNeither() { - val base = serve { 204 to "" } - val response = settle(ServerHttp(base), request(method = "DELETE", path = "/account")) - - assertEquals(Command.ServerResponse(7, 204, ""), response) - val sent = seen.single() - assertEquals("DELETE", sent.method) - assertNull(sent.authorization) - assertEquals("", sent.body) - } - - @Test - fun aBodilessPostIsSent() { - // The sign-out revoke: POST /auth/logout with only the bearer token. - val base = serve { 204 to "" } - val response = settle(ServerHttp(base), request(path = "/auth/logout", bearerToken = "tok")) - - assertEquals(204, response.status) - assertEquals("POST", seen.single().method) - assertEquals("Bearer tok", seen.single().authorization) - } - - @Test - fun anErrorStatusIsSettledWithItsBody() { - val base = serve { 401 to "unauthorized" } - val response = settle(ServerHttp(base), request(bearerToken = "stale")) - - assertEquals(Command.ServerResponse(7, 401, "unauthorized"), response) - } - - @Test - fun anErrorStatusWithNoBodySettlesWithAnEmptyBody() { - val base = serve { 500 to "" } - assertEquals(Command.ServerResponse(7, 500, ""), settle(ServerHttp(base), request())) - } - - @Test - fun noSyncServerSettlesWithStatusZeroAndSendsNothing() { - serve { 200 to "" } - val response = settle(ServerHttp(""), request(body = "{}")) - - assertEquals(Command.ServerResponse(7, 0, ""), response) - assertTrue(seen.isEmpty()) - } - - @Test - fun aNetworkErrorSettlesWithStatusZero() { - val closedPort = ServerSocket(0).use { it.localPort } - val response = settle(ServerHttp("http://127.0.0.1:$closedPort"), request(body = "{}")) - - assertEquals(Command.ServerResponse(7, 0, ""), response) - } - - @Test - fun aTimeoutSettlesWithStatusZero() { - val base = serve { Thread.sleep(1_000); 200 to "late" } - val response = settle(ServerHttp(base, timeoutMs = 100), request()) - - assertEquals(Command.ServerResponse(7, 0, ""), response) - } - - @Test - fun aRequestCancelledMidFlightIsStillSettledWithStatusZero() { - val arrived = CountDownLatch(1) - val base = serve { arrived.countDown(); Thread.sleep(300); 200 to "too late" } - val settles = LinkedBlockingQueue() - - runBlocking { - val job = ServerHttp(base).carry(request(), scope) { settles.add(it) } - assertTrue(arrived.await(5, TimeUnit.SECONDS)) - delay(20) - job.cancel() - job.join() - } - - assertEquals(listOf(Command.ServerResponse(7, 0, "")), settles.toList()) - } -} From f0a447de80470f8db589f5603ed5bfe200a25a58 Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Sat, 3 Oct 2026 19:33:20 -0400 Subject: [PATCH 09/12] Windows shell: carry every server request through one HTTP adapter The shell's DTOs mirror the core's new wire: ServerRequest replaces the six request effects, ServerResponse the seven settle commands, the delete commands carry newDeviceId and RequestSignInLink an optional platform. SyncServer.CarryAsync sends any ServerRequest verbatim (method, base URL + path, Bearer token, JSON body) and answers with its ServerResponse: status and body, or 0 for no sync server, a network error, a timeout or a cancellation. The typed SyncApi client and its response types are gone; SyncConfig stays. The view model routes ServerRequest by an explicit case, settles every request (the sign-out revoke included) on the UI thread via the dispatcher queue, and sends platform "windows" for the link hand-off. Co-Authored-By: Claude Opus 5.5 --- apps/windows/Cascade/Services/Dto.cs | 47 +++---- apps/windows/Cascade/Services/SyncApi.cs | 85 ------------- apps/windows/Cascade/Services/SyncServer.cs | 61 +++++++++ .../Cascade/ViewModels/AppViewModel.cs | 118 ++++-------------- 4 files changed, 96 insertions(+), 215 deletions(-) delete mode 100644 apps/windows/Cascade/Services/SyncApi.cs create mode 100644 apps/windows/Cascade/Services/SyncServer.cs diff --git a/apps/windows/Cascade/Services/Dto.cs b/apps/windows/Cascade/Services/Dto.cs index fde024d..bb7d192 100644 --- a/apps/windows/Cascade/Services/Dto.cs +++ b/apps/windows/Cascade/Services/Dto.cs @@ -37,19 +37,13 @@ public static class CascadeJson [JsonDerivedType(typeof(SetListeningTrackingCommand), "setListeningTracking")] [JsonDerivedType(typeof(RestoreCommand), "restore")] [JsonDerivedType(typeof(BeginListeningSyncCommand), "beginListeningSync")] -[JsonDerivedType(typeof(ListeningSyncSucceededCommand), "listeningSyncSucceeded")] -[JsonDerivedType(typeof(ListeningSyncFailedCommand), "listeningSyncFailed")] [JsonDerivedType(typeof(ResetListeningDataCommand), "resetListeningData")] [JsonDerivedType(typeof(RequestSignInLinkCommand), "requestSignInLink")] [JsonDerivedType(typeof(SubmitSignInLinkCommand), "submitSignInLink")] [JsonDerivedType(typeof(SignOutCommand), "signOut")] [JsonDerivedType(typeof(DeleteListeningDataCommand), "deleteListeningData")] [JsonDerivedType(typeof(DeleteAccountCommand), "deleteAccount")] -[JsonDerivedType(typeof(SignInLinkSentCommand), "signInLinkSent")] -[JsonDerivedType(typeof(SignInVerifiedCommand), "signInVerified")] -[JsonDerivedType(typeof(ListeningDataDeletedCommand), "listeningDataDeleted")] -[JsonDerivedType(typeof(AccountDeletedCommand), "accountDeleted")] -[JsonDerivedType(typeof(AccountRequestFailedCommand), "accountRequestFailed")] +[JsonDerivedType(typeof(ServerResponseCommand), "serverResponse")] public abstract record CascadeCommand; public sealed record PlayCommand : CascadeCommand; @@ -69,25 +63,21 @@ public sealed record SetListeningTrackingCommand(bool Enabled) : CascadeCommand; // AccountJson is the stored account blob, verbatim; "" for none. public sealed record RestoreCommand(string SettingsJson, string ListeningJson, string FallbackDeviceId, string AccountJson) : CascadeCommand; public sealed record BeginListeningSyncCommand(SyncReason Reason) : CascadeCommand; -public sealed record ListeningSyncSucceededCommand(ulong ServerTotalMs) : CascadeCommand; -public sealed record ListeningSyncFailedCommand(bool Unauthorized) : CascadeCommand; public sealed record ResetListeningDataCommand(string NewDeviceId) : CascadeCommand; // What the user does with the account. Every one but SignOut is ignored while // an account request is pending (account.busy). -public sealed record RequestSignInLinkCommand(string Email) : CascadeCommand; +// Platform names the app the emailed link hands off to; null for none. +public sealed record RequestSignInLinkCommand(string Email, string? Platform) : CascadeCommand; public sealed record SubmitSignInLinkCommand(string Input) : CascadeCommand; public sealed record SignOutCommand : CascadeCommand; -public sealed record DeleteListeningDataCommand : CascadeCommand; -public sealed record DeleteAccountCommand : CascadeCommand; +// NewDeviceId is a fresh id the core rotates to once the server confirms. +public sealed record DeleteListeningDataCommand(string NewDeviceId) : CascadeCommand; +public sealed record DeleteAccountCommand(string NewDeviceId) : CascadeCommand; -// How an account request settled: one command per success, one shared failure -// (unauthorized = HTTP 401). -public sealed record SignInLinkSentCommand : CascadeCommand; -public sealed record SignInVerifiedCommand(string SessionToken, string Email) : CascadeCommand; -public sealed record ListeningDataDeletedCommand(string NewDeviceId) : CascadeCommand; -public sealed record AccountDeletedCommand(string NewDeviceId) : CascadeCommand; -public sealed record AccountRequestFailedCommand(bool Unauthorized) : CascadeCommand; +// Settles a ServerRequestEffect: its id, the HTTP status (0 = not sent or no +// response) and the response body verbatim ("" for none). +public sealed record ServerResponseCommand(ulong Id, ushort Status, string Body) : CascadeCommand; /// /// Why the shell is asking to sync. The shell decides when it can talk; the @@ -125,12 +115,7 @@ public override void Write(System.Text.Json.Utf8JsonWriter writer, SyncReason va [JsonDerivedType(typeof(SetPlatformVolumeEffect), "setPlatformVolume")] [JsonDerivedType(typeof(PersistSettingsEffect), "persistSettings")] [JsonDerivedType(typeof(PersistListeningEffect), "persistListening")] -[JsonDerivedType(typeof(PushListeningEffect), "pushListening")] -[JsonDerivedType(typeof(SendSignInLinkEffect), "sendSignInLink")] -[JsonDerivedType(typeof(VerifySignInTokenEffect), "verifySignInToken")] -[JsonDerivedType(typeof(RevokeSessionEffect), "revokeSession")] -[JsonDerivedType(typeof(DeleteServerListeningEffect), "deleteServerListening")] -[JsonDerivedType(typeof(DeleteServerAccountEffect), "deleteServerAccount")] +[JsonDerivedType(typeof(ServerRequestEffect), "serverRequest")] [JsonDerivedType(typeof(PersistAccountEffect), "persistAccount")] public abstract record CascadeEffect; @@ -140,14 +125,10 @@ public sealed record PausePlaybackEffect : CascadeEffect; public sealed record SetPlatformVolumeEffect(double Gain) : CascadeEffect; public sealed record PersistSettingsEffect(string Json) : CascadeEffect; public sealed record PersistListeningEffect(string Json) : CascadeEffect; -public sealed record PushListeningEffect(string DeviceId, ulong DeviceTotalMs, string SessionToken) : CascadeEffect; -// Account requests: carry each over HTTP and settle it with its success -// command or AccountRequestFailed. RevokeSession is fire-and-forget. -public sealed record SendSignInLinkEffect(string Email) : CascadeEffect; -public sealed record VerifySignInTokenEffect(string Token) : CascadeEffect; -public sealed record RevokeSessionEffect(string SessionToken) : CascadeEffect; -public sealed record DeleteServerListeningEffect(string SessionToken) : CascadeEffect; -public sealed record DeleteServerAccountEffect(string SessionToken) : CascadeEffect; +// One request to the sync server, sent exactly as described: Method to the +// base URL plus Path, with a Bearer token and a JSON Body when given. Every +// one is settled with a ServerResponseCommand carrying the same Id. +public sealed record ServerRequestEffect(ulong Id, string Method, string Path, string? BearerToken, string? Body) : CascadeEffect; // Store verbatim; "" means delete the stored account. public sealed record PersistAccountEffect(string Json) : CascadeEffect; diff --git a/apps/windows/Cascade/Services/SyncApi.cs b/apps/windows/Cascade/Services/SyncApi.cs deleted file mode 100644 index 0e41914..0000000 --- a/apps/windows/Cascade/Services/SyncApi.cs +++ /dev/null @@ -1,85 +0,0 @@ -using System; -using System.Net.Http; -using System.Net.Http.Json; -using System.Threading.Tasks; - -namespace Cascade.Services; - -/// Base URL of cascade-sync-server. Empty disables the sync feature. -public static class SyncConfig -{ - public const string ApiBase = "https://sync.cascade.stephens.page"; - public static bool Available => !string.IsNullOrEmpty(ApiBase); -} - -public sealed class SyncHttpException : Exception -{ - public int Status { get; } - public SyncHttpException(int status, string message) : base(message) => Status = status; -} - -public sealed record VerifyResponse(string SessionToken, string Email); -public sealed record ListeningResponse(long ServerTotalMs, long SyncedThroughMs); - -/// Typed client for the sync endpoints (System.Net.Http + System.Text.Json). -public sealed class SyncApi -{ - private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(15) }; - - public Task RequestLinkAsync(string email) => - // The "windows" hint makes the emailed link carry &app=windows, so the - // web /auth page hands the token to this app via cascade:// rather than - // consuming it in the browser. - PostAsync("/auth/request", new { email, platform = "windows" }, null); - - public async Task VerifyAsync(string token) - { - using var resp = await SendAsync(HttpMethod.Post, "/auth/verify", new { token }, null); - return (await resp.Content.ReadFromJsonAsync())!; - } - - public Task LogoutAsync(string sessionToken) => - PostAsync("/auth/logout", null, sessionToken); - - public async Task PutListeningAsync(string sessionToken, string deviceId, long deviceTotalMs) - { - using var resp = await SendAsync(HttpMethod.Put, "/listening", - new { deviceId, deviceTotalMs }, sessionToken); - return (await resp.Content.ReadFromJsonAsync())!; - } - - public Task DeleteListeningAsync(string sessionToken) => - SendVoidAsync(HttpMethod.Delete, "/listening", sessionToken); - - public Task DeleteAccountAsync(string sessionToken) => - SendVoidAsync(HttpMethod.Delete, "/account", sessionToken); - - private async Task PostAsync(string path, object? body, string? token) - { - using var _ = await SendAsync(HttpMethod.Post, path, body, token); - } - - private async Task SendVoidAsync(HttpMethod method, string path, string? token) - { - using var _ = await SendAsync(method, path, null, token); - } - - private static async Task SendAsync( - HttpMethod method, string path, object? body, string? token) - { - using var req = new HttpRequestMessage(method, $"{SyncConfig.ApiBase}{path}"); - if (token is not null) - req.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); - if (body is not null) - req.Content = JsonContent.Create(body); - var resp = await Http.SendAsync(req); - if (!resp.IsSuccessStatusCode) - { - var text = await resp.Content.ReadAsStringAsync(); - resp.Dispose(); - throw new SyncHttpException((int)resp.StatusCode, - string.IsNullOrEmpty(text) ? $"request failed ({(int)resp.StatusCode})" : text); - } - return resp; - } -} diff --git a/apps/windows/Cascade/Services/SyncServer.cs b/apps/windows/Cascade/Services/SyncServer.cs new file mode 100644 index 0000000..60aef97 --- /dev/null +++ b/apps/windows/Cascade/Services/SyncServer.cs @@ -0,0 +1,61 @@ +using System; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; + +namespace Cascade.Services; + +/// Base URL of cascade-sync-server. Empty disables the sync feature. +public static class SyncConfig +{ + public const string ApiBase = "https://sync.cascade.stephens.page"; + public static bool Available => !string.IsNullOrEmpty(ApiBase); +} + +/// +/// Carries the core's s to the sync server. +/// The core owns the request table; this sends exactly what it is given and +/// reports the status and body verbatim, with no per-request knowledge. +/// +public sealed class SyncServer +{ + private static readonly HttpClient DefaultHttp = new() { Timeout = TimeSpan.FromSeconds(15) }; + + private readonly string _baseUrl; + private readonly HttpClient _http; + + public SyncServer(string baseUrl = SyncConfig.ApiBase, HttpClient? http = null) + { + _baseUrl = baseUrl; + _http = http ?? DefaultHttp; + } + + /// + /// Send one request and answer it with the + /// that settles it. Never throws: no sync server, a network error, a + /// timeout or a cancellation all settle with status 0. + /// + public async Task CarryAsync(ServerRequestEffect request) + { + if (string.IsNullOrEmpty(_baseUrl)) return NoResponse(request); + try + { + using var req = new HttpRequestMessage(new HttpMethod(request.Method), _baseUrl + request.Path); + if (request.BearerToken is not null) + req.Headers.Authorization = + new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", request.BearerToken); + if (request.Body is not null) + req.Content = new StringContent(request.Body, Encoding.UTF8, "application/json"); + using var resp = await _http.SendAsync(req); + return new ServerResponseCommand(request.Id, (ushort)resp.StatusCode, + await resp.Content.ReadAsStringAsync()); + } + catch (Exception) + { + return NoResponse(request); + } + } + + private static ServerResponseCommand NoResponse(ServerRequestEffect request) => + new(request.Id, 0, ""); +} diff --git a/apps/windows/Cascade/ViewModels/AppViewModel.cs b/apps/windows/Cascade/ViewModels/AppViewModel.cs index b2bd834..9cde7cf 100644 --- a/apps/windows/Cascade/ViewModels/AppViewModel.cs +++ b/apps/windows/Cascade/ViewModels/AppViewModel.cs @@ -37,7 +37,7 @@ public sealed partial class AppViewModel : ObservableObject, IDisposable // ---- account / sync ---- private readonly AccountStore _accountStore = new(); - private readonly SyncApi _syncApi = new(); + private readonly SyncServer _syncServer = new(); public bool SyncAvailable => SyncConfig.Available; @@ -172,8 +172,8 @@ private void Apply(CascadeUpdate update) case PersistAccountEffect persistAccount: _accountStore.WriteAccountJson(persistAccount.Json); break; - default: - CarryRequest(effect); + case ServerRequestEffect request: + _ = CarryAsync(request); break; } } @@ -220,8 +220,11 @@ public void StartTimer(int minutes, bool sleep) => // ---- account / sync commands ---- + // The "windows" platform makes the emailed link carry &app=windows, so the + // web /auth page hands the token to this app via cascade:// rather than + // consuming it in the browser. [RelayCommand(CanExecute = nameof(AccountIdle))] - private void RequestSignInLink() => Send(new RequestSignInLinkCommand(EmailInput)); + private void RequestSignInLink() => Send(new RequestSignInLinkCommand(EmailInput, "windows")); [RelayCommand(CanExecute = nameof(AccountIdle))] private void SubmitSignInLink() => Send(new SubmitSignInLinkCommand(SignInLinkInput)); @@ -236,15 +239,15 @@ public void StartTimer(int minutes, bool sleep) => private void SignOut() => Send(new SignOutCommand()); [RelayCommand(CanExecute = nameof(AccountIdle))] - private void DeleteListeningData() => Send(new DeleteListeningDataCommand()); + private void DeleteListeningData() => Send(new DeleteListeningDataCommand(NewDeviceId())); [RelayCommand(CanExecute = nameof(AccountIdle))] - private void DeleteAccount() => Send(new DeleteAccountCommand()); + private void DeleteAccount() => Send(new DeleteAccountCommand(NewDeviceId())); /// /// The shell decides when it can talk; the core decides whether there is - /// anything to say, and what. A PushListening in the answer is carried by - /// . + /// anything to say, and what. The push in the answer is carried by + /// . /// private void BeginSync(SyncReason reason) { @@ -252,103 +255,24 @@ private void BeginSync(SyncReason reason) } /// - /// Carry one request effect over HTTP and settle it with the core. Every - /// request but RevokeSession must be settled (success or failure), or the - /// core never starts another. Not a request: nothing to do. + /// Carry one request to the sync server and settle it with the core. Every + /// request is settled, failures and the sign-out revoke included (status + /// 0 when nothing came back), or the core never starts another. /// - private void CarryRequest(CascadeEffect effect) + private async Task CarryAsync(ServerRequestEffect request) { - static CascadeCommand AccountFailed(bool unauthorized) => - new AccountRequestFailedCommand(unauthorized); - - switch (effect) - { - case PushListeningEffect push: - _ = SettleWithAsync( - () => _syncApi.PutListeningAsync( - push.SessionToken, push.DeviceId, (long)push.DeviceTotalMs), - res => new ListeningSyncSucceededCommand((ulong)Math.Max(0L, res.ServerTotalMs)), - unauthorized => new ListeningSyncFailedCommand(unauthorized)); - break; - case SendSignInLinkEffect send: - _ = SettleWithAsync( - () => _syncApi.RequestLinkAsync(send.Email), - () => new SignInLinkSentCommand(), - AccountFailed); - break; - case VerifySignInTokenEffect verify: - _ = SettleWithAsync( - () => _syncApi.VerifyAsync(verify.Token), - res => new SignInVerifiedCommand(res.SessionToken, res.Email), - AccountFailed); - break; - case RevokeSessionEffect revoke: - _ = RevokeAsync(revoke.SessionToken); - break; - case DeleteServerListeningEffect delete: - _ = SettleWithAsync( - () => _syncApi.DeleteListeningAsync(delete.SessionToken), - () => new ListeningDataDeletedCommand(NewDeviceId()), - AccountFailed); - break; - case DeleteServerAccountEffect delete: - _ = SettleWithAsync( - () => _syncApi.DeleteAccountAsync(delete.SessionToken), - () => new AccountDeletedCommand(NewDeviceId()), - AccountFailed); - break; - } + var response = await _syncServer.CarryAsync(request); + // Settle on the UI thread, which owns the dispatch and the bound + // snapshot, and never inside the dispatch that asked for the request: + // with no sync server the answer is already here and Apply is still + // running. + _dispatcher.TryEnqueue(() => Send(response)); } /// A fresh random device id. The core has no randomness, so the shell /// supplies one for the fallback id and each delete's slot rotation. private static string NewDeviceId() => Guid.NewGuid().ToString(); - /// Fire-and-forget: nothing settles a revoke. - private async Task RevokeAsync(string sessionToken) - { - try { await _syncApi.LogoutAsync(sessionToken); } - catch { /* already gone server-side or offline — local sign-out stands */ } - } - - /// Run one request and settle it with the command its result maps to. - /// Offline / transient / rejected all fail; only a 401 is unauthorized, - /// and what that does is the core's call. - private async Task SettleWithAsync( - Func> call, - Func succeeded, - Func failed) - { - CascadeCommand outcome; - try - { - outcome = succeeded(await call()); - } - catch (Exception e) - { - outcome = failed(e is SyncHttpException { Status: 401 }); - } - OnUiThread(() => Send(outcome)); - } - - private Task SettleWithAsync( - Func call, - Func succeeded, - Func failed) => - SettleWithAsync( - async () => { await call(); return true; }, - _ => succeeded(), - failed); - - /// Started on the UI thread, so await continuations normally resume on it; - /// marshal back defensively so the settle dispatch and the bound snapshot - /// are never touched from a background thread. - private void OnUiThread(Action action) - { - if (_dispatcher.HasThreadAccess) action(); - else _dispatcher.TryEnqueue(() => action()); - } - public void Dispose() { _tick.Stop(); From a0ae3d8bc9a4c29e4b0c22ac7330dff834554a27 Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Sat, 3 Oct 2026 19:34:58 -0400 Subject: [PATCH 10/12] Address review: name the view model's carry-and-settle step Co-Authored-By: Claude Opus 5.5 --- apps/windows/Cascade/ViewModels/AppViewModel.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/windows/Cascade/ViewModels/AppViewModel.cs b/apps/windows/Cascade/ViewModels/AppViewModel.cs index 9cde7cf..429c5a8 100644 --- a/apps/windows/Cascade/ViewModels/AppViewModel.cs +++ b/apps/windows/Cascade/ViewModels/AppViewModel.cs @@ -173,7 +173,7 @@ private void Apply(CascadeUpdate update) _accountStore.WriteAccountJson(persistAccount.Json); break; case ServerRequestEffect request: - _ = CarryAsync(request); + _ = CarryAndSettleAsync(request); break; } } @@ -247,7 +247,7 @@ public void StartTimer(int minutes, bool sleep) => /// /// The shell decides when it can talk; the core decides whether there is /// anything to say, and what. The push in the answer is carried by - /// . + /// . /// private void BeginSync(SyncReason reason) { @@ -259,7 +259,7 @@ private void BeginSync(SyncReason reason) /// request is settled, failures and the sign-out revoke included (status /// 0 when nothing came back), or the core never starts another. /// - private async Task CarryAsync(ServerRequestEffect request) + private async Task CarryAndSettleAsync(ServerRequestEffect request) { var response = await _syncServer.CarryAsync(request); // Settle on the UI thread, which owns the dispatch and the bound From 199b97fa721cbe142fd213bf7532390a0f72fdc9 Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Sat, 3 Oct 2026 19:51:14 -0400 Subject: [PATCH 11/12] Address review: verify parses to a Session, push asserts its reserved id parse_verified returns the account's Session instead of a bare (token, email) pair, and begin_listening_sync asserts that the push carries the id it handed the ledger. Co-Authored-By: Claude Opus 5.5 --- crates/cascade-core/src/server.rs | 12 ++++++++---- crates/cascade-core/src/state.rs | 16 ++++++---------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/crates/cascade-core/src/server.rs b/crates/cascade-core/src/server.rs index 00cd1fa..01e31ff 100644 --- a/crates/cascade-core/src/server.rs +++ b/crates/cascade-core/src/server.rs @@ -9,6 +9,7 @@ use serde::{Deserialize, Serialize}; +use crate::account::Session; use crate::effect::Effect; /// The HTTP verbs the request table uses, sent uppercase. @@ -151,11 +152,14 @@ struct VerifiedBody { email: String, } -/// The `(session_token, email)` a successful verify returned. `None` when the -/// body does not parse or carries an empty token, which is no session. -pub fn parse_verified(body: &str) -> Option<(String, String)> { +/// The session a successful verify returned. `None` when the body does not +/// parse or carries an empty token, which is no session. +pub fn parse_verified(body: &str) -> Option { let verified: VerifiedBody = serde_json::from_str(body).ok()?; - (!verified.session_token.is_empty()).then_some((verified.session_token, verified.email)) + (!verified.session_token.is_empty()).then_some(Session { + session_token: verified.session_token, + email: verified.email, + }) } #[derive(Deserialize)] diff --git a/crates/cascade-core/src/state.rs b/crates/cascade-core/src/state.rs index 72964bf..762e944 100644 --- a/crates/cascade-core/src/state.rs +++ b/crates/cascade-core/src/state.rs @@ -181,7 +181,8 @@ fn begin_listening_sync( let id = state.next_request_id; if let Some((device_id, device_total_ms)) = begin(&mut state.listening, id) { let request = Request::push_listening(&device_id, device_total_ms, session_token); - send(state, request, effects); + let sent = send(state, request, effects); + debug_assert_eq!(sent, id, "the push must carry the id the ledger remembers"); } } @@ -511,9 +512,7 @@ fn account_response( state.account.status = Some(AccountStatus::LinkSent { email }); } PendingRequest::Verify if succeeded => match parse_verified(body) { - Some((session_token, email)) => { - signed_in(state, session_token, email, effects); - } + Some(session) => signed_in(state, session, effects), None => state.account.status = Some(AccountStatus::LinkInvalid), }, PendingRequest::DeleteListening { new_device_id } if succeeded => { @@ -545,19 +544,16 @@ fn account_response( /// The sign-in token was redeemed for a session: persist the account and /// start a refresh sync for the cross-device total. -fn signed_in(state: &mut State, session_token: String, email: String, effects: &mut Vec) { +fn signed_in(state: &mut State, session: Session, effects: &mut Vec) { // Signing in over another account: nothing of its total may show. if state.account.session.is_some() { state.listening.forget_server(); push_persist_listening(state, effects); } state.account.status = Some(AccountStatus::SignedIn { - email: email.clone(), - }); - state.account.session = Some(Session { - session_token, - email, + email: session.email.clone(), }); + state.account.session = Some(session); push_persist_account(state, effects); begin_listening_sync(state, effects, |l, id| { l.begin_sync(SyncReason::Refresh, id) From b707276a06d895cb4b78bba3980e84d64f438e07 Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Sat, 3 Oct 2026 19:51:14 -0400 Subject: [PATCH 12/12] Address review: shell wording and wire shape follow the core Web names its ServerResponse command and types the carrier's settle with it; web and Windows say 'the platform whose app' the sign-in link hands off to; Windows leaves a null platform off the wire, as the core does. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/core/types.ts | 15 +++++++++++---- apps/web/src/sync/carrier.ts | 8 ++++---- apps/windows/Cascade/Services/Dto.cs | 7 +++++-- 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/apps/web/src/core/types.ts b/apps/web/src/core/types.ts index b353fe8..d53575b 100644 --- a/apps/web/src/core/types.ts +++ b/apps/web/src/core/types.ts @@ -29,7 +29,7 @@ export type Command = } | { type: "beginListeningSync"; reason: SyncReason } | { type: "resetListeningData"; newDeviceId: string } - /** `platform` names the platform whose shell the emailed link hands off + /** `platform` names the platform whose app the emailed link hands off * to, if any; the web shell has no hand-off and leaves it out. */ | { type: "requestSignInLink"; email: string; platform?: string } | { type: "submitSignInLink"; input: string } @@ -37,9 +37,16 @@ export type Command = /** `newDeviceId` is a fresh UUID, adopted once the server confirms. */ | { type: "deleteListeningData"; newDeviceId: string } | { type: "deleteAccount"; newDeviceId: string } - /** Settles the `serverRequest` with the same `id`: the HTTP status (0 when - * not sent or no response) and the response body verbatim. */ - | { type: "serverResponse"; id: number; status: number; body: string }; + | ServerResponse; + +/** Settles the `serverRequest` with the same `id`: the HTTP status (0 when + * not sent or no response) and the response body verbatim. */ +export interface ServerResponse { + type: "serverResponse"; + id: number; + status: number; + body: string; +} /** Why the shell is asking to sync; the core decides whether to send. The * routine threshold sync is the core's own, answered from a tick. */ diff --git a/apps/web/src/sync/carrier.ts b/apps/web/src/sync/carrier.ts index eee9915..7f1d4d3 100644 --- a/apps/web/src/sync/carrier.ts +++ b/apps/web/src/sync/carrier.ts @@ -1,9 +1,9 @@ -import type { Command, Effect, ServerRequest } from "../core/types"; +import type { Effect, ServerRequest, ServerResponse } from "../core/types"; import type { DispatchOptions } from "../core/useCascade"; import { SYNC_API_BASE, syncAvailable } from "./config"; /** Dispatches a settle; that dispatch carries the settle's own effects. */ -type Settle = (response: Command) => void; +type Settle = (response: ServerResponse) => void; /** How long a request may take before it is given up and settled as status 0. */ const REQUEST_TIMEOUT_MS = 30_000; @@ -16,8 +16,8 @@ const REQUEST_TIMEOUT_MS = 30_000; async function send( request: ServerRequest, keepalive: boolean, -): Promise { - const response = (status: number, body = ""): Command => ({ +): Promise { + const response = (status: number, body = ""): ServerResponse => ({ type: "serverResponse", id: request.id, status, diff --git a/apps/windows/Cascade/Services/Dto.cs b/apps/windows/Cascade/Services/Dto.cs index bb7d192..c2b2a8e 100644 --- a/apps/windows/Cascade/Services/Dto.cs +++ b/apps/windows/Cascade/Services/Dto.cs @@ -67,8 +67,11 @@ public sealed record ResetListeningDataCommand(string NewDeviceId) : CascadeComm // What the user does with the account. Every one but SignOut is ignored while // an account request is pending (account.busy). -// Platform names the app the emailed link hands off to; null for none. -public sealed record RequestSignInLinkCommand(string Email, string? Platform) : CascadeCommand; +// Platform names the platform whose app the emailed link hands off to; null +// for none, and then left off the wire as the core leaves it off. +public sealed record RequestSignInLinkCommand( + string Email, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Platform) : CascadeCommand; public sealed record SubmitSignInLinkCommand(string Input) : CascadeCommand; public sealed record SignOutCommand : CascadeCommand; // NewDeviceId is a fresh id the core rotates to once the server confirms.