Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
286 changes: 252 additions & 34 deletions crates/cascade-core/src/account.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,17 @@
//! 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 (each one generic
//! request, settled with one generic response) and stores the blob.
//!
//! The [`Account`] answers each account intent and each of its responses
//! itself, and tells the reducer only what follows for listening and storage,
//! as one [`AccountChange`].

use serde::{Deserialize, Serialize};

use crate::effect::Effect;
use crate::server::{parse_verified, Request, RequestIds, StatusClass};
use crate::snapshot::AccountSnapshot;

/// Storage-schema version for the persisted account blob.
pub const ACCOUNT_VERSION: u32 = 1;

Expand All @@ -22,14 +30,14 @@ pub struct Session {
/// 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,
struct Pending {
id: u64,
request: PendingRequest,
}

/// What a pending account request is for, and what its success needs.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PendingRequest {
enum PendingRequest {
SignInLink {
email: String,
},
Expand All @@ -46,7 +54,7 @@ pub enum PendingRequest {
/// What the user was last told about their account. [`Self::label`] holds the
/// copy, so every shell shows the same words.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AccountStatus {
enum AccountStatus {
EnterEmail,
LinkSent { email: String },
LinkFailed,
Expand All @@ -62,7 +70,7 @@ pub enum AccountStatus {
}

impl AccountStatus {
pub fn label(&self) -> String {
fn label(&self) -> String {
match self {
Self::EnterEmail => "Enter your email address.".into(),
Self::LinkSent { email } => format!("Check {email} for a sign-in link."),
Expand All @@ -80,33 +88,57 @@ impl AccountStatus {
}
}

/// The account as the core holds it. Held inside [`crate::State`].
/// What an account transition means for listening and storage. The reducer
/// carries it through to the listening ledger and the persisted blobs; an
/// outcome that only changes what the user is told is not a change.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AccountChange {
/// A session was verified. `replaced` when it signed in over another
/// account, whose cross-device total must not show.
SignedIn { replaced: bool },
/// The session is gone: signed out, or a 401.
SignedOut,
/// The server deleted this device's listening data: rotate the slot.
ListeningDeleted { new_device_id: String },
/// The server deleted the account: rotate the slot, and the session is
/// gone.
AccountDeleted { new_device_id: String },
}

/// The account as the core holds it: the session, the one pending request,
/// and what the user was last told. Held inside [`crate::State`]; every
/// account rule lives here.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Account {
pub session: Option<Session>,
pub pending: Option<Pending>,
pub status: Option<AccountStatus>,
session: Option<Session>,
pending: Option<Pending>,
status: Option<AccountStatus>,
}

impl Account {
/// An account request is out; every account command but sign-out waits.
pub fn busy(&self) -> bool {
self.pending.is_some()
}

/// 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<PendingRequest> {
if self.pending.as_ref()?.id != id {
return None;
}
self.pending.take().map(|p| p.request)
/// Take the session from a stored blob: the versioned shape, or the
/// version-less one web and Apple stored before the core held the
/// account. Empty, garbage, an unknown version or an empty token mean no
/// account.
pub fn restore(&mut self, json: &str) {
self.session = session_from_json(json);
}

pub fn session_token(&self) -> Option<String> {
self.session.as_ref().map(|s| s.session_token.clone())
}

/// The account view for the UI. Never carries the session token.
pub fn snapshot(&self) -> AccountSnapshot {
let email = self.session.as_ref().map(|s| s.email.clone());
AccountSnapshot {
signed_in_label: email.as_ref().map(|e| format!("Syncing · {e}")),
email,
status_label: self.status.as_ref().map(AccountStatus::label),
busy: self.busy(),
}
}

/// The blob for [`crate::Effect::PersistAccount`]: empty when signed out,
/// meaning "delete the stored account".
pub fn to_json(&self) -> String {
Expand All @@ -123,19 +155,205 @@ impl Account {
.unwrap_or_default()
}

/// Read a stored blob: the versioned shape, or the version-less one web
/// and Apple stored before the core held the account. Empty, garbage, an
/// unknown version or an empty token mean no account.
pub fn session_from_json(json: &str) -> Option<Session> {
let stored: PersistedAccount = serde_json::from_str(json).ok()?;
if stored.version.is_some_and(|v| v != ACCOUNT_VERSION) || stored.session_token.is_empty() {
return None;
/// Ask the server to email a sign-in link to `email`, trimmed. A blank
/// email asks for one instead.
pub fn request_sign_in_link(
&mut self,
email: &str,
platform: Option<&str>,
ids: &mut RequestIds,
effects: &mut Vec<Effect>,
) {
if self.busy() {
return;
}
let email = email.trim().to_string();
if email.is_empty() {
self.status = Some(AccountStatus::EnterEmail);
return;
}
let request = Request::sign_in_link(&email, platform);
self.begin(
PendingRequest::SignInLink { email },
None,
request,
ids,
effects,
);
}

/// Redeem the sign-in token found in what the user pasted.
pub fn submit_sign_in_link(
&mut self,
input: &str,
ids: &mut RequestIds,
effects: &mut Vec<Effect>,
) {
if self.busy() {
return;
}
match parse_sign_in_token(input) {
Some(token) => self.begin(
PendingRequest::Verify,
Some(AccountStatus::SigningIn),
Request::verify(&token),
ids,
effects,
),
None => self.status = Some(AccountStatus::PasteFullLink),
}
}

/// Ask the server to delete this account's listening data. The slot
/// rotates to `new_device_id` only once the server confirms.
pub fn delete_listening(
&mut self,
new_device_id: String,
ids: &mut RequestIds,
effects: &mut Vec<Effect>,
) {
if self.busy() {
return;
}
if let Some(session_token) = self.session_token() {
self.begin(
PendingRequest::DeleteListening { new_device_id },
None,
Request::delete_listening(session_token),
ids,
effects,
);
}
}

/// Ask the server to delete the account. The slot rotates to
/// `new_device_id` only once the server confirms.
pub fn delete_account(
&mut self,
new_device_id: String,
ids: &mut RequestIds,
effects: &mut Vec<Effect>,
) {
if self.busy() {
return;
}
if let Some(session_token) = self.session_token() {
self.begin(
PendingRequest::DeleteAccount { new_device_id },
None,
Request::delete_account(session_token),
ids,
effects,
);
}
Some(Session {
session_token: stored.session_token,
email: stored.email,
})
}

/// Sign out, cutting in on any pending request. Revokes the session if
/// there is one; nothing waits for the revoke's response.
pub fn sign_out(
&mut self,
ids: &mut RequestIds,
effects: &mut Vec<Effect>,
) -> Option<AccountChange> {
self.pending = None;
self.status = None;
let session = self.session.take()?;
ids.send(Request::revoke(session.session_token), effects);
Some(AccountChange::SignedOut)
}

/// Whether the pending request is request `id`.
pub fn awaits(&self, id: u64) -> bool {
self.pending.as_ref().is_some_and(|p| p.id == id)
}

/// Settle the pending request with its response. The caller has already
/// matched the response's id with [`Self::awaits`]. A 401 while signed in
/// expires the session; any other failure only changes what the user is
/// told.
pub fn settle(&mut self, class: StatusClass, body: &str) -> Option<AccountChange> {
let request = self.pending.take()?.request;
if class == StatusClass::Unauthorized && self.session.is_some() {
return Some(self.expire());
}
let succeeded = class == StatusClass::Success;
let (status, change) = match request {
PendingRequest::SignInLink { email } if succeeded => {
(AccountStatus::LinkSent { email }, None)
}
PendingRequest::Verify if succeeded => match parse_verified(body) {
Some(session) => {
let status = AccountStatus::SignedIn {
email: session.email.clone(),
};
let replaced = self.session.replace(session).is_some();
(status, Some(AccountChange::SignedIn { replaced }))
}
None => (AccountStatus::LinkInvalid, None),
},
PendingRequest::DeleteListening { new_device_id } if succeeded => (
AccountStatus::ListeningDeleted,
Some(AccountChange::ListeningDeleted { new_device_id }),
),
PendingRequest::DeleteAccount { new_device_id } if succeeded => {
self.session = None;
(
AccountStatus::AccountDeleted,
Some(AccountChange::AccountDeleted { new_device_id }),
)
}
PendingRequest::SignInLink { .. } => (AccountStatus::LinkFailed, None),
PendingRequest::Verify => (AccountStatus::LinkInvalid, None),
PendingRequest::DeleteListening { .. } => (AccountStatus::ListeningDeleteFailed, None),
PendingRequest::DeleteAccount { .. } => (AccountStatus::AccountDeleteFailed, None),
};
self.status = Some(status);
change
}

/// The server rejected the session (a 401 on any request, including a
/// listening push): sign out and tell the user to sign in again. No
/// revoke, since the session is already gone server-side.
pub fn expire(&mut self) -> AccountChange {
self.pending = None;
self.status = Some(AccountStatus::SessionExpired);
self.session = None;
AccountChange::SignedOut
}

/// An account request is out; every account intent but sign-out waits.
fn busy(&self) -> bool {
self.pending.is_some()
}

/// Send the one account request, remembering its `purpose`.
fn begin(
&mut self,
purpose: PendingRequest,
status: Option<AccountStatus>,
request: Request,
ids: &mut RequestIds,
effects: &mut Vec<Effect>,
) {
let id = ids.send(request, effects);
self.pending = Some(Pending {
id,
request: purpose,
});
self.status = status;
}
}

/// Read a stored account blob into a session; see [`Account::restore`].
fn session_from_json(json: &str) -> Option<Session> {
let stored: PersistedAccount = serde_json::from_str(json).ok()?;
if stored.version.is_some_and(|v| v != ACCOUNT_VERSION) || stored.session_token.is_empty() {
return None;
}
Some(Session {
session_token: stored.session_token,
email: stored.email,
})
}

/// The persisted account blob. `version` is optional on read only, so the
Expand All @@ -151,7 +369,7 @@ pub struct PersistedAccount {

/// Find the sign-in token in what the user pasted: a link carrying a query
/// parameter named exactly `token`, or the bare token itself.
pub fn parse_sign_in_token(input: &str) -> Option<String> {
fn parse_sign_in_token(input: &str) -> Option<String> {
let input = input.trim();
if let Some(value) = token_query_value(input) {
let token = percent_decode(value);
Expand Down
Loading
Loading