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..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 @@ -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,26 @@ 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 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, + ) : 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 +74,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 +83,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..40cb3c4 --- /dev/null +++ b/apps/android/app/src/main/java/page/stephens/cascade/sync/ServerHttp.kt @@ -0,0 +1,88 @@ +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 +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, + * 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(start = CoroutineStart.ATOMIC) { + 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 responseBody = stream?.bufferedReader()?.use { it.readText() }.orEmpty() + return Command.ServerResponse(request.id, status, responseBody) + } 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..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 @@ -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 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. */ 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/apple/CascadeShared/App/AppStore.swift b/apps/apple/CascadeShared/App/AppStore.swift index f94941d..fc24c86 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,16 @@ 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 - /// 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) } + /// 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 - 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 +180,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..3307797 --- /dev/null +++ b/apps/apple/CascadeShared/Sync/ServerCarrier.swift @@ -0,0 +1,40 @@ +import Foundation + +/// 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 - } -} 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 } +} diff --git a/apps/web/src/core/types.ts b/apps/web/src/core/types.ts index 772e521..d53575b 100644 --- a/apps/web/src/core/types.ts +++ b/apps/web/src/core/types.ts @@ -28,19 +28,25 @@ 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 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 } | { 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 } + | 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. */ @@ -53,21 +59,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..7f1d4d3 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 { Effect, ServerRequest, ServerResponse } 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: ServerResponse) => 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, -): Promise { - if (!api.syncAvailable) return failed(false); +async function send( + request: ServerRequest, + keepalive: boolean, +): Promise { + const response = (status: number, body = ""): ServerResponse => ({ + 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]), }; } diff --git a/apps/windows/Cascade/Services/Dto.cs b/apps/windows/Cascade/Services/Dto.cs index fde024d..c2b2a8e 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,24 @@ 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 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; -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 +118,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 +128,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..429c5a8 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: + _ = CarryAndSettleAsync(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 CarryAndSettleAsync(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(); 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..783d5f9 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,54 @@ 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 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`). - 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..43d3a12 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 { @@ -106,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. @@ -142,28 +160,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 +190,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 +202,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 +225,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 +258,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 +270,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 +354,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 +424,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 +476,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..01e31ff --- /dev/null +++ b/crates/cascade-core/src/server.rs @@ -0,0 +1,176 @@ +//! 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::account::Session; +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_only(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_only(HttpMethod::Put, "/listening", session_token) + } + } + + /// DELETE `/listening` with the bearer token. + pub fn delete_listening(session_token: String) -> Self { + Self::bearer_only(HttpMethod::Delete, "/listening", session_token) + } + + /// DELETE `/account` with the bearer token. + pub fn delete_account(session_token: String) -> Self { + Self::bearer_only(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_only(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 HTTP status means, before its body is read. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StatusClass { + /// Any 2xx. + Success, + /// 401: the session is gone. + Unauthorized, + /// Anything else, including `0` (not sent, or no response). + Failed, +} + +impl StatusClass { + 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 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(Session { + session_token: verified.session_token, + email: 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..762e944 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, StatusClass}; 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,30 @@ 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); + let sent = send(state, request, effects); + debug_assert_eq!(sent, id, "the push must carry the id the ledger remembers"); } } @@ -176,12 +192,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 +241,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 +397,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 +408,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 +431,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 +462,65 @@ 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 class = StatusClass::of(status); + if let Some(request) = state.account.settle(id) { + account_response(state, request, class, &body, effects); + } else if state.listening.awaits(id) { + sync_response(state, class, &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, 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); } - Command::ListeningDataDeleted { new_device_id } => { - if !state.account.settle(PendingRequest::DeleteListening) { - return; - } + } else if state.listening.sync_failed() && class == StatusClass::Unauthorized { + session_expired(state, effects); + } +} + +/// Settle the pending account `request`, already taken off the account. +fn account_response( + state: &mut State, + request: PendingRequest, + class: StatusClass, + body: &str, + effects: &mut Vec, +) { + if class == StatusClass::Unauthorized && state.account.session.is_some() { + session_expired(state, effects); + return; + } + let succeeded = class == StatusClass::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) => signed_in(state, session, 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,24 +529,37 @@ 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: 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: 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) + }); +} + /// The server rejected the session (a 401 on any request): sign out and tell /// the user to sign in again. Listening carries on locally. fn session_expired(state: &mut State, effects: &mut Vec) { diff --git a/crates/cascade-core/tests/account.rs b/crates/cascade-core/tests/account.rs index 0efb576..73570a9 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, pushes, 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,11 +73,59 @@ 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 { .. })) - .cloned() + pushes(effects).first().map(|e| (*e).clone()) +} + +/// 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 { @@ -98,50 +155,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 +317,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 +330,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 +414,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 +428,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 +441,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 +463,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 +495,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 +574,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 +635,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 +656,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 +689,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 +702,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 +712,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 +723,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 +741,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 +799,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 +817,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 +836,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 +870,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 +901,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 +914,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 +930,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 +956,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 +973,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 +999,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 +1017,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 +1035,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..63a2221 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, 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. +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,26 +134,24 @@ 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, }) } -fn pushes(effects: &[Effect]) -> Vec<&Effect> { - effects - .iter() - .filter(|e| matches!(e, Effect::PushListening { .. })) - .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 +159,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 +186,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 +230,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 +272,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 +310,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 +339,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 +379,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 +396,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 +422,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 +433,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 +560,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 +577,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 +590,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 +608,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 +632,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 +665,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 +686,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 +706,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 +733,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 +753,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..7671863 --- /dev/null +++ b/crates/cascade-core/tests/support/mod.rs @@ -0,0 +1,57 @@ +//! 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, HttpMethod}; + +/// Every `ServerRequest` in `effects`, in order. +pub fn requests(effects: &[Effect]) -> Vec<&Effect> { + effects + .iter() + .filter(|e| matches!(e, Effect::ServerRequest { .. })) + .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 { + 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..b7b088e 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. @@ -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.)* @@ -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.