diff --git a/.github/workflows/deploy-sync.yml b/.github/workflows/deploy-sync.yml new file mode 100644 index 0000000..02179ef --- /dev/null +++ b/.github/workflows/deploy-sync.yml @@ -0,0 +1,90 @@ +name: Deploy sync server + +# Build the cascade-sync-server release binary, then deploy it to the droplet +# via the Ansible role behind a protected `production` environment (manual +# approval). This is the GitOps-flavored counterpart to the hand-rolled web +# deploy script — same destination, but reviewed, gated, and reproducible. +# +# Manual-only (workflow_dispatch) so a deploy is always a deliberate act. +# +# Requires (configure once): +# - Environment `production` with a required reviewer (the approval gate). +# - Secrets (on the environment): +# SSH_PRIVATE_KEY deploy key for the droplet +# SSH_KNOWN_HOSTS `ssh-keyscan ` output +# DEPLOY_HOST droplet IP / hostname +# ANSIBLE_VAULT_PASSWORD password for group_vars/all/vault.yml + +on: + workflow_dispatch: + +concurrency: + group: deploy-sync + cancel-in-progress: false + +permissions: + contents: read + +jobs: + build: + name: Build release binary + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - name: Test + working-directory: server + run: cargo test --release + - name: Build + working-directory: server + run: cargo build --release + - uses: actions/upload-artifact@v4 + with: + name: cascade-sync-server + path: server/target/release/cascade-sync-server + if-no-files-found: error + retention-days: 7 + + deploy: + name: Deploy to production + needs: build + runs-on: ubuntu-latest + environment: production # approval gate lives here + steps: + - uses: actions/checkout@v4 + + - name: Fetch the built binary + uses: actions/download-artifact@v4 + with: + name: cascade-sync-server + path: server/deploy/ansible/roles/cascade_sync/files/ + + - name: Make the binary executable + run: chmod +x server/deploy/ansible/roles/cascade_sync/files/cascade-sync-server + + - name: Install Ansible + collections + run: | + pipx install --include-deps ansible + ansible-galaxy collection install -r server/deploy/ansible/requirements.yml + + - name: Configure SSH + run: | + mkdir -p ~/.ssh + echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + echo "${{ secrets.SSH_KNOWN_HOSTS }}" > ~/.ssh/known_hosts + + - name: Run the playbook + working-directory: server/deploy/ansible + env: + ANSIBLE_VAULT_PASSWORD: ${{ secrets.ANSIBLE_VAULT_PASSWORD }} + run: | + printf '%s' "$ANSIBLE_VAULT_PASSWORD" > .vault-pass + trap 'rm -f .vault-pass' EXIT + cat > inventory.ini < + + + + + + + c.pause() is Effect.SetPlatformVolume -> c.volume = perceptualVolume(effect.volumePercent) is Effect.PersistSettings -> { /* handled by CascadeBridgeHolder */ } + is Effect.PersistListening -> { /* handled by CascadeBridgeHolder */ } } } } 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 ec0bcb6..268d8d6 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 @@ -32,6 +32,15 @@ class CascadeBridgeHolder(private val settingsStore: SettingsStore) { private val _snapshot = MutableStateFlow(cascadeJson.decodeFromString(bridge.snapshot())) val snapshot: StateFlow = _snapshot.asStateFlow() + init { + // Restore the listening ledger once at startup. The core ignores a + // missing/incompatible blob and never lets a restore lower the counter. + val listeningJson = runBlocking { settingsStore.readListening() } + if (!listeningJson.isNullOrEmpty()) { + dispatch(Command.RestoreListening(listeningJson)) + } + } + /** Latest effects emitted by the most recent dispatch — consumers * (PlaybackController, settings persister) collect this. */ private val _effects = MutableStateFlow>(emptyList()) @@ -49,8 +58,10 @@ class CascadeBridgeHolder(private val settingsStore: SettingsStore) { // Persist any settings effect immediately — DataStore handles its // own coalescing, so flooding it on every slider tick is fine. for (effect in update.effects) { - if (effect is Effect.PersistSettings) { - scope.launch { settingsStore.write(effect.json) } + when (effect) { + is Effect.PersistSettings -> scope.launch { settingsStore.write(effect.json) } + is Effect.PersistListening -> scope.launch { settingsStore.writeListening(effect.json) } + else -> {} } } } 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 9cd6091..8c6fcc2 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 @@ -34,6 +34,10 @@ sealed class Command { @Serializable @SerialName("platformPlaybackStarted") data object PlatformPlaybackStarted : Command() @Serializable @SerialName("platformPlaybackPaused") data object PlatformPlaybackPaused : Command() @Serializable @SerialName("platformPlaybackError") data class PlatformPlaybackError(val message: String) : Command() + @Serializable @SerialName("setListeningTracking") data class SetListeningTracking(val enabled: Boolean) : Command() + @Serializable @SerialName("restoreListening") data class RestoreListening(val json: String) : Command() + @Serializable @SerialName("applySyncedTotal") data class ApplySyncedTotal(val syncedThroughMs: Long, val serverTotalMs: Long) : Command() + @Serializable @SerialName("resetListeningData") data object ResetListeningData : Command() } @Serializable @@ -43,6 +47,7 @@ sealed class Effect { @Serializable @SerialName("pausePlayback") data object PausePlayback : Effect() @Serializable @SerialName("setPlatformVolume") data class SetPlatformVolume(val volumePercent: Int) : Effect() @Serializable @SerialName("persistSettings") data class PersistSettings(val json: String) : Effect() + @Serializable @SerialName("persistListening") data class PersistListening(val json: String) : Effect() } @Serializable @@ -63,6 +68,15 @@ data class TimerSnapshot( val progress: Float, ) +@Serializable +data class ListeningSnapshot( + val trackingEnabled: Boolean, + val deviceTotalMs: Long, + val displayedTotalMs: Long, + val unsyncedMs: Long, + val totalLabel: String, +) + @Serializable data class Snapshot( val title: String, @@ -73,6 +87,7 @@ data class Snapshot( val primaryButtonLabel: String, val timer: TimerSnapshot, val errorMessage: String? = null, + val listening: ListeningSnapshot, ) @Serializable diff --git a/apps/android/app/src/main/java/page/stephens/cascade/settings/SettingsStore.kt b/apps/android/app/src/main/java/page/stephens/cascade/settings/SettingsStore.kt index 786257a..41facc5 100644 --- a/apps/android/app/src/main/java/page/stephens/cascade/settings/SettingsStore.kt +++ b/apps/android/app/src/main/java/page/stephens/cascade/settings/SettingsStore.kt @@ -16,6 +16,9 @@ private val Context.dataStore by preferencesDataStore("cascade-settings") */ class SettingsStore(private val context: Context) { private val key = stringPreferencesKey("settings_v1") + // Lifetime listening ledger — a separate blob from settings, so the two + // evolve and fail independently (mirrors `cascade.listening.v1` on web). + private val listeningKey = stringPreferencesKey("listening_v1") suspend fun read(): String? = context.dataStore.data.map { it[key] }.first() @@ -23,4 +26,11 @@ class SettingsStore(private val context: Context) { suspend fun write(json: String) { context.dataStore.edit { it[key] = json } } + + suspend fun readListening(): String? = + context.dataStore.data.map { it[listeningKey] }.first() + + suspend fun writeListening(json: String) { + context.dataStore.edit { it[listeningKey] = json } + } } diff --git a/apps/android/app/src/main/java/page/stephens/cascade/sync/AccountStore.kt b/apps/android/app/src/main/java/page/stephens/cascade/sync/AccountStore.kt new file mode 100644 index 0000000..8a797df --- /dev/null +++ b/apps/android/app/src/main/java/page/stephens/cascade/sync/AccountStore.kt @@ -0,0 +1,60 @@ +package page.stephens.cascade.sync + +import android.content.Context +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.first +import java.util.UUID + +private val Context.accountDataStore by preferencesDataStore("cascade-account") + +data class Account(val sessionToken: String, val email: String) + +/** + * Persists the optional sync account (session token + email) and the stable + * per-device id used as this device's G-Counter slot. The device id is rotated + * on "delete data" so a stale offline write can't resurrect a deleted total. + */ +class AccountStore(private val context: Context) { + private val tokenKey = stringPreferencesKey("session_token") + private val emailKey = stringPreferencesKey("email") + private val deviceKey = stringPreferencesKey("device_id") + + suspend fun readAccount(): Account? { + val prefs = context.accountDataStore.data.first() + val token = prefs[tokenKey] + val email = prefs[emailKey] + return if (token != null && email != null) Account(token, email) else null + } + + suspend fun writeAccount(account: Account) { + context.accountDataStore.edit { + it[tokenKey] = account.sessionToken + it[emailKey] = account.email + } + } + + suspend fun clearAccount() { + context.accountDataStore.edit { + it.remove(tokenKey) + it.remove(emailKey) + } + } + + /** Stable device id, created on first use. */ + suspend fun deviceId(): String { + val existing = context.accountDataStore.data.first()[deviceKey] + if (existing != null) return existing + val id = UUID.randomUUID().toString() + context.accountDataStore.edit { it[deviceKey] = id } + return id + } + + /** New device id; returns it. Call when deleting listening data. */ + suspend fun rotateDeviceId(): String { + val id = UUID.randomUUID().toString() + context.accountDataStore.edit { it[deviceKey] = id } + return id + } +} 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 new file mode 100644 index 0000000..0294cac --- /dev/null +++ b/apps/android/app/src/main/java/page/stephens/cascade/sync/SyncApi.kt @@ -0,0 +1,106 @@ +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 new file mode 100644 index 0000000..9b88a58 --- /dev/null +++ b/apps/android/app/src/main/java/page/stephens/cascade/sync/SyncManager.kt @@ -0,0 +1,161 @@ +package page.stephens.cascade.sync + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import page.stephens.cascade.core.CascadeBridgeHolder +import page.stephens.cascade.core.Command + +data class SyncUiState( + val available: Boolean, + val account: Account?, + val status: String?, + val busy: Boolean, +) + +/** + * Owns the optional account and the listening-sync loop on Android. The core + * stays pure: this reads `snapshot.listening` and decides when to talk to the + * server, then feeds the result back via `ApplySyncedTotal`. Sync cadence lives + * here (the shell), never in the core. + */ +class SyncManager( + private val bridge: CascadeBridgeHolder, + private val accountStore: AccountStore, +) { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val syncMutex = Mutex() + + private val _state = MutableStateFlow( + SyncUiState(available = syncAvailable, account = null, status = null, busy = false), + ) + val state: StateFlow = _state.asStateFlow() + + init { + if (syncAvailable) { + scope.launch { + val account = accountStore.readAccount() + if (account != null) { + _state.value = _state.value.copy(account = account) + sync(account) + } + } + // Threshold-driven sync: push once enough unsynced time has accrued. + scope.launch { + bridge.snapshot.collect { snap -> + val account = _state.value.account ?: return@collect + if (snap.listening.unsyncedMs >= SYNC_THRESHOLD_MS) sync(account) + } + } + } + } + + fun signIn(email: String) { + _state.value = _state.value.copy(busy = true, status = null) + scope.launch { + try { + SyncApi.requestLink(email) + _state.value = _state.value.copy(busy = false, status = "Check $email for a sign-in link.") + } catch (_: Exception) { + _state.value = _state.value.copy(busy = false, status = "Couldn't send the sign-in link.") + } + } + } + + /** Complete a magic-link sign-in from a deep-link token. */ + fun completeSignIn(token: String) { + _state.value = _state.value.copy(busy = true, status = "Signing in…") + scope.launch { + try { + val res = SyncApi.verify(token) + val account = Account(res.sessionToken, res.email) + accountStore.writeAccount(account) + _state.value = _state.value.copy(account = account, busy = false, status = "Signed in as ${res.email}.") + sync(account) + } catch (_: Exception) { + _state.value = _state.value.copy(busy = false, status = "That sign-in link was invalid or expired.") + } + } + } + + fun signOut() { + val account = _state.value.account + _state.value = _state.value.copy(account = null, status = null) + scope.launch { + accountStore.clearAccount() + if (account != null) runCatching { SyncApi.logout(account.sessionToken) } + } + } + + fun deleteData() { + val account = _state.value.account ?: return + _state.value = _state.value.copy(busy = true) + scope.launch { + try { + SyncApi.deleteListening(account.sessionToken) + accountStore.rotateDeviceId() + bridge.dispatch(Command.ResetListeningData) + _state.value = _state.value.copy(busy = false, status = "Listening data deleted.") + } catch (_: Exception) { + _state.value = _state.value.copy(busy = false, status = "Couldn't delete listening data.") + } + } + } + + fun deleteAccount() { + val account = _state.value.account ?: return + _state.value = _state.value.copy(busy = true) + scope.launch { + try { + SyncApi.deleteAccount(account.sessionToken) + accountStore.rotateDeviceId() + bridge.dispatch(Command.ResetListeningData) + accountStore.clearAccount() + _state.value = _state.value.copy(account = null, busy = false, status = "Account deleted.") + } catch (_: Exception) { + _state.value = _state.value.copy(busy = false, status = "Couldn't delete the account.") + } + } + } + + /** Push this device's slot and fold the server aggregate back into the core. */ + private suspend fun sync(account: Account) { + if (!syncMutex.tryLock()) return + try { + val deviceTotalMs = bridge.snapshot.value.listening.deviceTotalMs + val deviceId = accountStore.deviceId() + val res = SyncApi.putListening(account.sessionToken, deviceId, deviceTotalMs) + bridge.dispatch( + Command.ApplySyncedTotal( + syncedThroughMs = deviceTotalMs, + serverTotalMs = res.serverTotalMs, + ), + ) + } catch (e: SyncException) { + if (e.status == 401) { + accountStore.clearAccount() + _state.value = _state.value.copy(account = null, status = "Signed out — sign in again to sync.") + } + } catch (_: Exception) { + // Offline / transient — try again on the next trigger. + } finally { + syncMutex.unlock() + } + } + + /** Flush recent listening, e.g. when the activity is going to the background. */ + fun flush() { + val account = _state.value.account ?: return + scope.launch { sync(account) } + } + + companion object { + private const val SYNC_THRESHOLD_MS = 30_000L + } +} diff --git a/apps/android/app/src/main/java/page/stephens/cascade/ui/CascadeScreen.kt b/apps/android/app/src/main/java/page/stephens/cascade/ui/CascadeScreen.kt index 84f713d..9c743f3 100644 --- a/apps/android/app/src/main/java/page/stephens/cascade/ui/CascadeScreen.kt +++ b/apps/android/app/src/main/java/page/stephens/cascade/ui/CascadeScreen.kt @@ -28,6 +28,7 @@ import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Slider import androidx.compose.material3.SliderDefaults import androidx.compose.material3.Surface +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.ui.text.input.KeyboardType @@ -47,10 +48,12 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import page.stephens.cascade.core.TimerKind +import page.stephens.cascade.sync.SyncUiState @Composable fun CascadeScreen(viewModel: CascadeViewModel) { val snapshot by viewModel.snapshot.collectAsStateWithLifecycle() + val syncState by viewModel.syncState.collectAsStateWithLifecycle() Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { Box(modifier = Modifier.fillMaxSize()) { @@ -82,6 +85,22 @@ fun CascadeScreen(viewModel: CascadeViewModel) { onChange = viewModel::setVolume, onToggleMute = viewModel::toggleMute, ) + Spacer(Modifier.height(16.dp)) + ListeningStats( + totalLabel = snapshot.listening.totalLabel, + trackingEnabled = snapshot.listening.trackingEnabled, + onToggleTracking = viewModel::setListeningTracking, + ) + if (syncState.available) { + Spacer(Modifier.height(12.dp)) + AccountControls( + state = syncState, + onSignIn = viewModel::signIn, + onSignOut = viewModel::signOut, + onDeleteData = viewModel::deleteListeningData, + onDeleteAccount = viewModel::deleteAccount, + ) + } Spacer(Modifier.height(24.dp)) TimerControls( activeKind = snapshot.timer.kind, @@ -333,6 +352,99 @@ private fun CustomDurationSection( } } +@Composable +private fun ListeningStats( + totalLabel: String, + trackingEnabled: Boolean, + onToggleTracking: (Boolean) -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column { + Text( + text = totalLabel, + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Light), + ) + Text( + text = if (trackingEnabled) "Lifetime listening" else "Tracking paused", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f), + ) + } + Switch(checked = trackingEnabled, onCheckedChange = onToggleTracking) + } +} + +@Composable +private fun AccountControls( + state: SyncUiState, + onSignIn: (String) -> Unit, + onSignOut: () -> Unit, + onDeleteData: () -> Unit, + onDeleteAccount: () -> Unit, +) { + var email by remember { mutableStateOf("") } + var showManage by remember { mutableStateOf(false) } + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + val account = state.account + if (account != null) { + Text( + "Syncing · ${account.email}", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f), + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton(onClick = onSignOut) { Text("Sign out") } + TextButton(onClick = { showManage = !showManage }) { Text("Manage data") } + } + if (showManage) { + TextButton( + onClick = onDeleteData, + enabled = !state.busy, + ) { Text("Delete listening data", color = MaterialTheme.colorScheme.error) } + TextButton( + onClick = onDeleteAccount, + enabled = !state.busy, + ) { Text("Delete account", color = MaterialTheme.colorScheme.error) } + } + } else { + Text("Sync across devices", style = MaterialTheme.typography.labelMedium) + Spacer(Modifier.height(8.dp)) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + value = email, + onValueChange = { email = it }, + label = { Text("you@example.com") }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), + modifier = Modifier.size(width = 200.dp, height = 64.dp), + ) + Button( + onClick = { if (email.isNotBlank()) onSignIn(email.trim()) }, + enabled = !state.busy && email.isNotBlank(), + ) { Text("Sign in") } + } + } + state.status?.let { + Spacer(Modifier.height(6.dp)) + Text( + it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f), + ) + } + } +} + @Composable private fun WaterfallBackdrop(active: Boolean, progress: Float) { // Lightweight static gradient that intensifies when playing. The web app diff --git a/apps/android/app/src/main/java/page/stephens/cascade/ui/CascadeViewModel.kt b/apps/android/app/src/main/java/page/stephens/cascade/ui/CascadeViewModel.kt index 2c98b0d..604edcd 100644 --- a/apps/android/app/src/main/java/page/stephens/cascade/ui/CascadeViewModel.kt +++ b/apps/android/app/src/main/java/page/stephens/cascade/ui/CascadeViewModel.kt @@ -12,24 +12,37 @@ import page.stephens.cascade.core.CascadeBridgeHolder import page.stephens.cascade.core.Command import page.stephens.cascade.core.Snapshot import page.stephens.cascade.core.TimerKind +import page.stephens.cascade.sync.SyncManager +import page.stephens.cascade.sync.SyncUiState class CascadeViewModel( private val bridge: CascadeBridgeHolder, + private val syncManager: SyncManager, ) : ViewModel() { val snapshot: StateFlow = bridge.snapshot + val syncState: StateFlow = syncManager.state private var tickJob: Job? = null + private var tickInterval = 0L init { - // Mirror the web hook: only tick while a timer is running. The Rust - // core never reads the system clock; it relies on these ticks. + // Tick while a timer is counting (fine cadence) and also while audio is + // simply playing (coarse cadence, just to accrue listening time). The + // Rust core never reads the clock; it relies on these ticks. viewModelScope.launch { bridge.snapshot.collect { snap -> - val active = snap.timer.kind == TimerKind.SLEEP || + val timerActive = snap.timer.kind == TimerKind.SLEEP || snap.timer.kind == TimerKind.POMODORO || snap.timer.kind == TimerKind.STOPWATCH - if (active && tickJob == null) startTicking() - if (!active && tickJob != null) stopTicking() + val want = when { + timerActive -> TICK_INTERVAL_MS + snap.isPlaying -> LISTENING_TICK_INTERVAL_MS + else -> 0L + } + if (want != tickInterval) { + stopTicking() + if (want > 0L) startTicking(want) + } } } } @@ -41,12 +54,19 @@ class CascadeViewModel( fun startPomodoro(minutes: Int) = bridge.dispatch(Command.StartPomodoro(minutes)) fun startStopwatch() = bridge.dispatch(Command.StartStopwatch) fun cancelTimer() = bridge.dispatch(Command.CancelTimer) + fun setListeningTracking(enabled: Boolean) = bridge.dispatch(Command.SetListeningTracking(enabled)) - private fun startTicking() { + fun signIn(email: String) = syncManager.signIn(email) + fun signOut() = syncManager.signOut() + fun deleteListeningData() = syncManager.deleteData() + fun deleteAccount() = syncManager.deleteAccount() + + private fun startTicking(intervalMs: Long) { + tickInterval = intervalMs tickJob = viewModelScope.launch { var last = System.currentTimeMillis() while (true) { - delay(TICK_INTERVAL_MS) + delay(intervalMs) val now = System.currentTimeMillis() bridge.dispatch(Command.Tick(elapsedMs = now - last)) last = now @@ -57,16 +77,19 @@ class CascadeViewModel( private fun stopTicking() { tickJob?.cancel() tickJob = null + tickInterval = 0L } companion object { private const val TICK_INTERVAL_MS = 250L + private const val LISTENING_TICK_INTERVAL_MS = 1000L - fun factory(bridge: CascadeBridgeHolder) = object : ViewModelProvider.Factory { - override fun create(modelClass: Class): T { - @Suppress("UNCHECKED_CAST") - return CascadeViewModel(bridge) as T + fun factory(bridge: CascadeBridgeHolder, syncManager: SyncManager) = + object : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + @Suppress("UNCHECKED_CAST") + return CascadeViewModel(bridge, syncManager) as T + } } - } } } diff --git a/apps/apple/CascadeMac/CascadeMacApp.swift b/apps/apple/CascadeMac/CascadeMacApp.swift index ae712bf..4907680 100644 --- a/apps/apple/CascadeMac/CascadeMacApp.swift +++ b/apps/apple/CascadeMac/CascadeMacApp.swift @@ -9,6 +9,7 @@ struct CascadeMacApp: App { MainWindowView() .environment(store) .frame(minWidth: 420, minHeight: 520) + .onOpenURL { store.handleOpenURL($0) } } .windowResizability(.contentSize) .commands { CascadeCommands(store: store) } diff --git a/apps/apple/CascadeMac/Views/MainWindowView.swift b/apps/apple/CascadeMac/Views/MainWindowView.swift index e4ae4c3..712e600 100644 --- a/apps/apple/CascadeMac/Views/MainWindowView.swift +++ b/apps/apple/CascadeMac/Views/MainWindowView.swift @@ -23,6 +23,12 @@ struct MainWindowView: View { onToggleMute: { store.dispatch(.toggleMute) } ) .padding(.horizontal, 4) + ListeningRow(listening: snapshot.listening) { + store.dispatch(.setListeningTracking(enabled: !snapshot.listening.trackingEnabled)) + } + .padding(.horizontal, 4) + AccountControlsView() + .padding(.horizontal, 4) TimerControls() Spacer(minLength: 0) if let message = snapshot.errorMessage ?? store.lastError { @@ -143,6 +149,30 @@ private struct VolumeSlider: View { } } +private struct ListeningRow: View { + let listening: ListeningSnapshot + let onToggle: () -> Void + var body: some View { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(listening.totalLabel) + .font(.title3.weight(.light)) + .monospacedDigit() + Text(listening.trackingEnabled ? "LIFETIME LISTENING" : "TRACKING PAUSED") + .font(.caption) + .foregroundStyle(.secondary) + .tracking(2) + } + Spacer() + Toggle( + "Track listening", + isOn: Binding(get: { listening.trackingEnabled }, set: { _ in onToggle() }) + ) + .labelsHidden() + } + } +} + private enum CustomMode: String, CaseIterable, Identifiable { case focus = "Focus" case sleep = "Sleep" diff --git a/apps/apple/CascadeShared/App/AppStore.swift b/apps/apple/CascadeShared/App/AppStore.swift index f6cc9db..dd975ff 100644 --- a/apps/apple/CascadeShared/App/AppStore.swift +++ b/apps/apple/CascadeShared/App/AppStore.swift @@ -16,6 +16,17 @@ final class AppStore { private(set) var snapshot: Snapshot private(set) var lastError: String? + // Optional account + sync. The core stays pure; this layer reads + // snapshot.listening and decides when to talk to the server. + private(set) var account: SyncAccount? + private(set) var syncStatus: String? + var syncAvailable: Bool { SyncConfig.available } + + private let accountStore = AccountStore() + private let syncApi = SyncApi() + private var syncing = false + private static let syncThresholdMs: UInt64 = 30_000 + /// Side-channel observer for non-SwiftUI consumers (the iPhone's /// `PhoneConnectivityService` uses this to push every snapshot down to /// the watch). SwiftUI views observe `snapshot` directly via `@Observable`. @@ -29,7 +40,8 @@ final class AppStore { private let nowPlaying: NowPlayingController private var tickTimer: Timer? - private static let tickIntervalMs: UInt64 = 250 + /// Interval the tick loop is currently running at, in ms; 0 when stopped. + private var tickIntervalMs: UInt64 = 0 /// Bootstrap from disk. Failures fall back to defaults — the user never /// gets stuck on a startup error for something as trivial as malformed @@ -38,7 +50,17 @@ final class AppStore { let settings = SettingsStore() let json = settings.readSafely() let bridge = CoreBridge(persistedSettings: json) - return AppStore(bridge: bridge, settings: settings) + let store = AppStore(bridge: bridge, settings: settings) + // Restore the listening ledger once at startup. The core ignores a + // missing/incompatible blob and never lets a restore lower the counter. + if let listeningJson = settings.readListeningSafely() { + store.dispatch(.restoreListening(json: listeningJson)) + } + store.account = store.accountStore.readAccount() + if store.account != nil { + Task { await store.sync() } + } + return store } private init(bridge: CoreBridge, settings: SettingsStore) { @@ -77,7 +99,6 @@ final class AppStore { } private func apply(_ update: Update) { - let prev = snapshot snapshot = update.snapshot lastError = update.snapshot.errorMessage onSnapshotChanged?(update.snapshot) @@ -95,34 +116,152 @@ final class AppStore { audio.setVolume(volumePercent: volumePercent) case .persistSettings(let json): settings.writeSafely(json) + case .persistListening(let json): + settings.writeListeningSafely(json) } } - // Tick loop: run while a timer is active. We mirror the web app's - // 250ms cadence so the countdown reads smoothly. + // Tick loop: run while a timer is active (fine cadence, so the countdown + // reads smoothly) and also while audio is simply playing (coarse cadence, + // just to accrue listening time). Restart only when the cadence changes. let timerActive = update.snapshot.timer.kind == .sleep || update.snapshot.timer.kind == .pomodoro || update.snapshot.timer.kind == .stopwatch - let wasActive = prev.timer.kind == .sleep - || prev.timer.kind == .pomodoro - || prev.timer.kind == .stopwatch - if timerActive && !wasActive { startTicking() } - if !timerActive && wasActive { stopTicking() } + let desired: UInt64 = timerActive ? 250 : (update.snapshot.isPlaying ? 1000 : 0) + if desired != tickIntervalMs { + stopTicking() + if desired > 0 { startTicking(intervalMs: desired) } + } nowPlaying.update(snapshot: update.snapshot) + + // Sync cadence lives in the shell: push once enough unsynced time has + // accrued. + if account != nil, update.snapshot.listening.unsyncedMs >= Self.syncThresholdMs { + Task { await sync() } + } + } + + // MARK: - Sync + + func sync() async { + guard !syncing, let account else { return } + syncing = true + defer { syncing = false } + let deviceTotal = Int64(snapshot.listening.deviceTotalMs) + do { + let res = try await syncApi.putListening( + token: account.sessionToken, + deviceId: accountStore.deviceId(), + deviceTotalMs: deviceTotal) + dispatch(.applySyncedTotal( + syncedThroughMs: UInt64(deviceTotal), + serverTotalMs: UInt64(max(0, res.serverTotalMs)))) + } catch let error as SyncError where error.status == 401 { + self.account = nil + accountStore.clearAccount() + syncStatus = "Signed out — sign in again to sync." + } catch { + // offline / transient — retry on the next trigger + } + } + + func signIn(email: String) async { + syncStatus = nil + do { + try await syncApi.requestLink(email: email) + syncStatus = "Check \(email) for a sign-in link." + } catch { + syncStatus = "Couldn't send the sign-in link." + } + } + + /// Complete a magic-link sign-in from a pasted link (…/auth?token=XYZ) or a + /// raw token. (Universal Links / a URL scheme are the on-device follow-up.) + func completeSignIn(fromLinkOrToken input: String) async { + guard let token = Self.extractToken(input) else { + syncStatus = "Paste the full sign-in link." + return + } + syncStatus = "Signing in…" + do { + let res = try await syncApi.verify(token: token) + let acct = SyncAccount(sessionToken: res.sessionToken, email: res.email) + accountStore.writeAccount(acct) + account = acct + syncStatus = "Signed in as \(res.email)." + await sync() + } catch { + syncStatus = "That sign-in link was invalid or expired." + } + } + + /// Entry point for `.onOpenURL` once Universal Links are configured. + func handleOpenURL(_ url: URL) { + Task { await completeSignIn(fromLinkOrToken: url.absoluteString) } + } + + func signOut() async { + let previous = account + account = nil + accountStore.clearAccount() + syncStatus = nil + if let previous { + try? await syncApi.logout(token: previous.sessionToken) + } + } + + func deleteListeningData() async { + guard let account else { return } + do { + try await syncApi.deleteListening(token: account.sessionToken) + accountStore.rotateDeviceId() + dispatch(.resetListeningData) + syncStatus = "Listening data deleted." + } catch { + syncStatus = "Couldn't delete listening data." + } + } + + func deleteAccount() async { + guard let account else { return } + do { + try await syncApi.deleteAccount(token: account.sessionToken) + accountStore.rotateDeviceId() + dispatch(.resetListeningData) + self.account = nil + accountStore.clearAccount() + syncStatus = "Account deleted." + } catch { + syncStatus = "Couldn't delete the account." + } + } + + private static func extractToken(_ input: String) -> String? { + let s = input.trimmingCharacters(in: .whitespacesAndNewlines) + if s.isEmpty { return nil } + if let range = s.range(of: "token=") { + let rest = s[range.upperBound...] + if let amp = rest.firstIndex(of: "&") { + return String(rest[.. String? { + func readSafely() -> String? { Self.read(url) } + + func writeSafely(_ json: String) { Self.write(json, to: url, label: "settings") } + + func readListeningSafely() -> String? { Self.read(listeningUrl) } + + func writeListeningSafely(_ json: String) { Self.write(json, to: listeningUrl, label: "listening") } + + private static func read(_ url: URL) -> String? { guard let data = try? Data(contentsOf: url) else { return nil } return String(data: data, encoding: .utf8) } - func writeSafely(_ json: String) { + private static func write(_ json: String, to url: URL, label: String) { guard let data = json.data(using: .utf8) else { return } do { try data.write(to: url, options: [.atomic]) } catch { - NSLog("[Cascade] settings write failed: \(error)") + NSLog("[Cascade] \(label) write failed: \(error)") } } diff --git a/apps/apple/CascadeShared/Sync/AccountControlsView.swift b/apps/apple/CascadeShared/Sync/AccountControlsView.swift new file mode 100644 index 0000000..5254f0e --- /dev/null +++ b/apps/apple/CascadeShared/Sync/AccountControlsView.swift @@ -0,0 +1,54 @@ +import SwiftUI + +/// Optional account controls for syncing listening time across devices. Renders +/// nothing when no sync backend is configured. Shared by the macOS and iOS UIs. +struct AccountControlsView: View { + @Environment(AppStore.self) private var store + @State private var email = "" + @State private var link = "" + + var body: some View { + if store.syncAvailable { + VStack(alignment: .leading, spacing: 8) { + Text("SYNC ACROSS DEVICES") + .font(.caption) + .foregroundStyle(.secondary) + .tracking(2) + + if let account = store.account { + Text(account.email).font(.callout) + HStack(spacing: 12) { + Button("Sign out") { Task { await store.signOut() } } + Button("Delete data") { Task { await store.deleteListeningData() } } + Button("Delete account", role: .destructive) { Task { await store.deleteAccount() } } + } + .buttonStyle(.borderless) + } else { + HStack { + TextField("you@example.com", text: $email) + .textFieldStyle(.roundedBorder) + .frame(maxWidth: 220) + Button("Email me a link") { + Task { await store.signIn(email: email.trimmingCharacters(in: .whitespaces)) } + } + } + HStack { + TextField("paste the sign-in link", text: $link) + .textFieldStyle(.roundedBorder) + .frame(maxWidth: 220) + Button("Sign in") { + Task { + await store.completeSignIn(fromLinkOrToken: link) + link = "" + } + } + } + } + + if let status = store.syncStatus { + Text(status).font(.caption).foregroundStyle(.secondary) + } + } + } + } +} diff --git a/apps/apple/CascadeShared/Sync/AccountStore.swift b/apps/apple/CascadeShared/Sync/AccountStore.swift new file mode 100644 index 0000000..2dab38e --- /dev/null +++ b/apps/apple/CascadeShared/Sync/AccountStore.swift @@ -0,0 +1,43 @@ +import Foundation + +struct SyncAccount: Codable, Equatable { + let sessionToken: String + let email: String +} + +/// Persists the optional sync account and a stable per-device id. The device id +/// is rotated on "delete data" so a stale offline write can't resurrect a +/// deleted G-Counter slot. (UserDefaults for the scaffold; the Keychain is the +/// on-device hardening follow-up for the session token.) +final class AccountStore { + private let defaults = UserDefaults.standard + private let accountKey = "cascade.account.v1" + private let deviceKey = "cascade.device.v1" + + func readAccount() -> SyncAccount? { + guard let data = defaults.data(forKey: accountKey) else { return nil } + return try? JSONDecoder().decode(SyncAccount.self, from: data) + } + + func writeAccount(_ account: SyncAccount) { + if let data = try? JSONEncoder().encode(account) { + defaults.set(data, forKey: accountKey) + } + } + + func clearAccount() { + defaults.removeObject(forKey: accountKey) + } + + func deviceId() -> String { + if let id = defaults.string(forKey: deviceKey), !id.isEmpty { return id } + return rotateDeviceId() + } + + @discardableResult + func rotateDeviceId() -> String { + let id = UUID().uuidString + defaults.set(id, forKey: deviceKey) + return id + } +} diff --git a/apps/apple/CascadeShared/Sync/SyncApi.swift b/apps/apple/CascadeShared/Sync/SyncApi.swift new file mode 100644 index 0000000..d118796 --- /dev/null +++ b/apps/apple/CascadeShared/Sync/SyncApi.swift @@ -0,0 +1,75 @@ +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/CascadeiOS/CascadeiOSApp.swift b/apps/apple/CascadeiOS/CascadeiOSApp.swift index 4a36ccd..6c73b44 100644 --- a/apps/apple/CascadeiOS/CascadeiOSApp.swift +++ b/apps/apple/CascadeiOS/CascadeiOSApp.swift @@ -23,6 +23,7 @@ struct CascadeiOSApp: App { .onAppear { wireWatchConnectivity() } + .onOpenURL { store.handleOpenURL($0) } } } diff --git a/apps/apple/CascadeiOS/Views/CascadeScreen.swift b/apps/apple/CascadeiOS/Views/CascadeScreen.swift index 53920b2..af315fd 100644 --- a/apps/apple/CascadeiOS/Views/CascadeScreen.swift +++ b/apps/apple/CascadeiOS/Views/CascadeScreen.swift @@ -29,6 +29,10 @@ struct CascadeScreen: View { onChange: { store.dispatch(.setVolume(percent: $0)) }, onToggleMute: { store.dispatch(.toggleMute) } ) + ListeningRow(listening: snapshot.listening) { + store.dispatch(.setListeningTracking(enabled: !snapshot.listening.trackingEnabled)) + } + AccountControlsView() TimerControls() if let message = snapshot.errorMessage ?? store.lastError { Text(message) @@ -143,6 +147,30 @@ private struct VolumeSlider: View { } } +private struct ListeningRow: View { + let listening: ListeningSnapshot + let onToggle: () -> Void + var body: some View { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(listening.totalLabel) + .font(.title3.weight(.light)) + .monospacedDigit() + Text(listening.trackingEnabled ? "LIFETIME LISTENING" : "TRACKING PAUSED") + .font(.caption2) + .foregroundStyle(.secondary) + .tracking(2) + } + Spacer() + Toggle( + "Track listening", + isOn: Binding(get: { listening.trackingEnabled }, set: { _ in onToggle() }) + ) + .labelsHidden() + } + } +} + private enum CustomMode: String, CaseIterable, Identifiable { case focus = "Focus" case sleep = "Sleep" diff --git a/apps/web/.env.production b/apps/web/.env.production new file mode 100644 index 0000000..ba84667 --- /dev/null +++ b/apps/web/.env.production @@ -0,0 +1,3 @@ +# Loaded automatically by `vite build`. Points the web shell at the deployed +# listening-time sync service so accounts/sync are enabled in production. +VITE_SYNC_API=https://sync.cascade.stephens.page diff --git a/apps/web/public/privacy/index.html b/apps/web/public/privacy/index.html index 2e5b648..4b84744 100644 --- a/apps/web/public/privacy/index.html +++ b/apps/web/public/privacy/index.html @@ -8,7 +8,7 @@ Cascade — Privacy Policy + + + +

Tracking Listening Time Across Six Platforms — Without Building a Surveillance Tool

+

+ Jacob Stephens  + June 5, 2026  + Engineering +

+ + +

Cascade is a white-noise app: it plays one waterfall recording, reliably, on web, Android, macOS, Windows, iOS, and watchOS. I wanted to show people how long they'd spent listening — totalled across every device they use. That is a deceptively dangerous feature to build, because the obvious implementation is a surveillance tool. Here's how I built it so that it structurally cannot be one.

+ +

One core, six shells

+

Cascade has an unusual shape: a single headless Rust core (cascade-core) owns all the state and intent, and six thin native shells own the side effects — audio, OS integration, UI. The core has no clock, no filesystem, no network. Time is fed in: the platform sends a Tick command with the elapsed milliseconds.

+

That constraint turned out to be the whole trick. Because every shell already shares one definition of "what's happening," I could add listening-time accrual in exactly one place and have it mean the same thing on all six platforms. No per-platform stopwatch, no six subtly-different definitions of a "session."

+ +

Count audio, not intent

+

The naïve version counts time while the app thinks it's playing. That over-counts badly. A browser that blocks autoplay reports "playing" the instant you click — but no sound is coming out until a user gesture unlocks it. So I gated accrual on confirmed playback, not intent: a flag flipped by the platform's real "audio actually started" signal, cleared on pause or error.

+
// inside the pure reducer, on the existing Tick path
+if tracking_enabled && audio_confirmed_playing && !muted {
+    let delta = elapsed_ms.min(MAX_TICK_ACCRUAL_MS); // clamp clock jumps
+    device_total_ms = device_total_ms.saturating_add(delta);
+}
+

Note the clamp. The merge math I'm about to describe is provably correct, but it consumes a per-tick delta as input — and a laptop sleeping for three hours produces one enormous "tick" on wake. Capping each tick at five seconds (shells tick roughly every 250ms) means a sleep/wake gap or a fiddled clock can't inflate the number. The input delta is the only attack surface a grow-only counter has, so that's where the guard goes.

+ +

The merge problem: why "latest total" is wrong

+

Here's the bug almost everyone ships first. You listen for two hours on your phone and one hour on your laptop. Both sync. If the server keeps "the latest total it received," one of those devices silently erases the other's hours. The whole point of cross-device tracking is that concurrent listening must add, not overwrite.

+

The clean answer is a G-Counter — a grow-only counter, one of the simplest CRDTs. Each device owns its own slot and only ever increases it. The server merges with two boring operations:

+
-- on write: keep the higher value for this device's slot
+INSERT INTO device_counters (user_id, device_id, total_ms)
+VALUES ($1, $2, $3)
+ON CONFLICT (user_id, device_id)
+DO UPDATE SET total_ms = GREATEST(device_counters.total_ms, EXCLUDED.total_ms);
+
+-- on read: the lifetime total is the sum across the user's devices
+SELECT COALESCE(SUM(total_ms), 0) FROM device_counters WHERE user_id = $1;
+

That's the entire sync engine. No vector clocks, no conflict UI, no "which version wins" — GREATEST on write, SUM on read. Two devices adding concurrently is not a conflict; it's just addition. The device is a CRDT replica and the server is almost stupid, which is exactly what you want a server holding other people's data to be.

+ +

The part I actually care about: it can't store a timeline

+

Tracking how long someone listens is one schema change away from tracking when they listen — what time they fall asleep, when they're at their desk, the shape of their week. I did not want to hold that data, and "we promise not to look" is a weak claim. So I made it impossible to express.

+

The account stores an email address and one integer per device. No timestamps. No session log. No event stream. You can ask the database "how much has this person listened?" and it can answer. You cannot ask it "when?" — there is nowhere for that answer to live.

+

This is the difference between we choose not to store your timeline and we cannot. The second is checkable: open the four-table schema and there is simply no column for it. It's also why I was comfortable making tracking on-by-default — the data minimization is a structural property of the design, not a setting someone has to trust.

+ +

Auth without the baggage

+

The account exists only to add numbers across devices, so it should carry as little as possible. Sign-in is an emailed magic link — no passwords to store or leak. Sessions are opaque server-side tokens, not JWTs, stored only as SHA-256 hashes. That choice pays off at deletion time: "log out everywhere" and "delete my account" are a single DELETE that takes effect immediately, instead of waiting out a signed token's expiry.

+

One subtle bug hides in every grow-only-counter design: deletion. If you delete your data but a forgotten phone is offline in a drawer, it can later sync a stale-but-higher counter and resurrect the total you deleted. The fix is small — deleting rotates the device's id, so any late write lands in a fresh slot instead of the grave you just dug. Grow-only counters and "delete my data" are in tension, and that rotation is the reconciliation.

+ +

Where the cleverness does not go

+

The core never decides when to sync. It exposes one number — how much time hasn't been pushed yet — and each shell owns the cadence, because syncing depends on things only the shell knows: are we online, are we signed in, is the app about to be suspended. The web shell flushes on pagehide; Android flushes in onStop. Keeping network policy out of the core is what lets the same pure reducer drive a watch and a desktop without caring which it is.

+ +

What it added up to

+

One Rust core, six shells, and a deliberately small Rust/Axum + Postgres service — four tables, a handful of endpoints — behind a hardened systemd unit, deployed as Terraform + Ansible. The measurement is identical everywhere because it lives in one place; concurrent devices add instead of clobbering because the merge is a CRDT; and the scariest version of the feature is off the table because the schema can't represent it.

+

The lesson I keep relearning: the strongest privacy guarantee isn't a policy, it's a data model that makes the bad version unbuildable.

+ + + + + diff --git a/server/.env.example b/server/.env.example new file mode 100644 index 0000000..aba8bd0 --- /dev/null +++ b/server/.env.example @@ -0,0 +1,21 @@ +# Copy to .env (and keep it out of git). Required: +DATABASE_URL=postgres://cascade:CHANGEME@localhost:5432/cascade_sync + +# Where the service listens (Apache reverse-proxies a subdomain to this). +BIND_ADDR=127.0.0.1:3470 + +# The web app origin the magic link points at, and the CORS allow-list. +FRONTEND_ORIGIN=https://cascade.stephens.page +# Optional, comma-separated; defaults to FRONTEND_ORIGIN. +# CORS_ALLOWED_ORIGINS=https://cascade.stephens.page + +# SMTP for magic-link delivery. Leave SMTP_HOST empty to log links instead of +# sending them (useful in dev). See reference_infrastructure for real creds. +SMTP_HOST= +SMTP_PORT=587 +SMTP_USERNAME= +SMTP_PASSWORD= +SMTP_FROM=Cascade + +# Logging +RUST_LOG=info,sqlx=warn diff --git a/server/.gitignore b/server/.gitignore new file mode 100644 index 0000000..fedaa2b --- /dev/null +++ b/server/.gitignore @@ -0,0 +1,2 @@ +/target +.env diff --git a/server/Cargo.lock b/server/Cargo.lock new file mode 100644 index 0000000..7d07e8b --- /dev/null +++ b/server/Cargo.lock @@ -0,0 +1,2844 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d7ced0ae9557296835c32bf1b1e02b44c746701f898460fb000d7eaa84f00a" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cascade-sync-server" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum", + "base64", + "chrono", + "lettre", + "metrics", + "metrics-exporter-prometheus", + "rand 0.8.6", + "serde", + "serde_json", + "sha2", + "sqlx", + "thiserror 1.0.69", + "tokio", + "tower-http", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "cc" +version = "1.2.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +dependencies = [ + "serde", +] + +[[package]] +name = "email-encoding" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6" +dependencies = [ + "base64", + "memchr", +] + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "hostname" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" +dependencies = [ + "cfg-if", + "libc", + "windows-link", +] + +[[package]] +name = "http" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "lettre" +version = "0.11.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0da65617f6cb926332d039cb578aad56178da86e128db6a1b09f4c94fa5b3349" +dependencies = [ + "async-trait", + "base64", + "email-encoding", + "email_address", + "fastrand", + "futures-io", + "futures-util", + "hostname", + "httpdate", + "idna", + "mime", + "nom", + "percent-encoding", + "quoted_printable", + "rustls", + "socket2", + "tokio", + "tokio-rustls", + "url", + "webpki-roots 1.0.7", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +dependencies = [ + "bitflags", + "libc", + "plain", + "redox_syscall 0.8.1", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "pkg-config", + "vcpkg", +] + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" + +[[package]] +name = "metrics" +version = "0.24.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89550ee9f79e88fef3119de263694973a8adb26c21d75322164fb8c493039fe2" +dependencies = [ + "portable-atomic", + "rapidhash", +] + +[[package]] +name = "metrics-exporter-prometheus" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd7399781913e5393588a8d8c6a2867bf85fb38eaf2502fdce465aad2dc6f034" +dependencies = [ + "base64", + "indexmap", + "metrics", + "metrics-util", + "quanta", + "thiserror 1.0.69", +] + +[[package]] +name = "metrics-util" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8496cc523d1f94c1385dd8f0f0c2c480b2b8aeccb5b7e4485ad6365523ae376" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", + "hashbrown 0.15.5", + "metrics", + "quanta", + "rand 0.9.4", + "rand_xoshiro", + "sketches-ddsketch", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.6", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quanta" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7" +dependencies = [ + "crossbeam-utils", + "libc", + "once_cell", + "raw-cpuid", + "wasi", + "web-sys", + "winapi", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "quoted_printable" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972" + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_xoshiro" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f703f4665700daf5512dcca5f43afa6af89f09db47fb56be587f80636bda2d41" +dependencies = [ + "rand_core 0.9.5", +] + +[[package]] +name = "rapidhash" +version = "4.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e48930979c155e2f33aa36ab3119b5ee81332beb6482199a8ecd6029b80b59" +dependencies = [ + "rustversion", +] + +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_syscall" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b44b894f2a6e36457d665d1e08c3866add6ed5e70050c1b4ba8a8ddedb02ce7" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "sketches-ddsketch" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c6f73aeb92d671e0cc4dca167e59b2deb6387c375391bc99ee743f326994a2b" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64", + "bytes", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink", + "indexmap", + "log", + "memchr", + "once_cell", + "percent-encoding", + "rustls", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tracing", + "url", + "uuid", + "webpki-roots 0.26.11", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "bytes", + "chrono", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.6", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.18", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand 0.8.6", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.18", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "chrono", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror 2.0.18", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "http", + "http-body", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.7", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/server/Cargo.toml b/server/Cargo.toml new file mode 100644 index 0000000..fc61639 --- /dev/null +++ b/server/Cargo.toml @@ -0,0 +1,46 @@ +[package] +name = "cascade-sync-server" +version = "0.1.0" +edition = "2021" +authors = ["Jacob Stephens "] +license = "MIT" +repository = "https://github.com/JacobStephens2/cascade" +description = "Cascade listening-time sync service: magic-link auth + G-Counter aggregation over Postgres." + +# Detach from the parent cascade workspace: this service has its own heavy +# dependency tree (axum/sqlx/tokio) that has no business in the core/shell +# lockfile, and it never cross-compiles to wasm/mobile. An empty [workspace] +# table makes this its own workspace root. +[workspace] + +[dependencies] +axum = { version = "0.7", features = ["json"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "signal"] } +tower-http = { version = "0.6", features = ["cors", "trace"] } +sqlx = { version = "0.8", default-features = false, features = [ + "runtime-tokio-rustls", + "postgres", + "uuid", + "chrono", + "migrate", + "macros", +] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +uuid = { version = "1", features = ["v4", "serde"] } +chrono = { version = "0.4", features = ["serde"] } +rand = "0.8" +sha2 = "0.10" +base64 = "0.22" +lettre = { version = "0.11", default-features = false, features = [ + "smtp-transport", + "tokio1-rustls-tls", + "builder", + "hostname", +] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +anyhow = "1" +thiserror = "1" +metrics = "0.24" +metrics-exporter-prometheus = { version = "0.16", default-features = false } diff --git a/server/README.md b/server/README.md new file mode 100644 index 0000000..cc980a2 --- /dev/null +++ b/server/README.md @@ -0,0 +1,66 @@ +# cascade-sync-server + +The optional backend for Cascade's listening-time feature: magic-link sign-in +and cross-device aggregation of how long you've listened. It is a standalone +Rust/Axum service (its own cargo workspace) backed by Postgres. + +It is deliberately tiny in what it stores: an **email address** and **one +grow-only integer per device**. There is no session log and no per-listen +timestamps, so the data can answer "how much have I listened?" but never "when +did I listen?". + +## Data model + +Four tables (`migrations/0001_init.sql`): + +- `users` — id + email (the only PII; no password). +- `login_tokens` — single-use, short-lived magic-link tokens (stored hashed). +- `sessions` — opaque server-side session tokens (stored hashed). Opaque, not + JWT, so logout / delete-account revokes instantly with one `DELETE`. +- `device_counters` — the **G-Counter**: one `total_ms` per `(user, device)`. + Merge is `GREATEST(existing, incoming)` on write; a user's lifetime total is + `SUM(total_ms)` on read. Concurrent listening on two devices adds, never + overwrites. + +## API + +All bodies are JSON; camelCase fields. + +| Method | Path | Auth | Body | Returns | +|--------|------------------|------|-----------------------------------|---------| +| GET | `/health` | — | — | `{ok}` | +| POST | `/auth/request` | — | `{email}` | `{ok}` (always — never leaks who has an account) | +| POST | `/auth/verify` | — | `{token}` | `{sessionToken, email}` | +| POST | `/auth/logout` | Bearer | — | `{ok}` | +| PUT | `/listening` | Bearer | `{deviceId, deviceTotalMs}` | `{serverTotalMs, syncedThroughMs}` | +| GET | `/listening` | Bearer | — | `{serverTotalMs}` | +| DELETE | `/listening` | Bearer | — | `{ok}` (clears counters; client rotates `deviceId`) | +| DELETE | `/account` | Bearer | — | `{ok}` (cascades to sessions + counters) | + +Auth is `Authorization: Bearer `. + +## Run locally + +```bash +cp .env.example .env # set DATABASE_URL; leave SMTP_HOST empty to log links +createdb cascade_sync # or use the existing Postgres-on-volume instance +cargo run # migrations run automatically on boot +``` + +With `SMTP_HOST` empty the magic link is written to the log instead of emailed, +so you can complete the flow without a mail server. + +## Deploy (to do — coordinated with the feature release) + +This is intentionally **not deployed yet**; it must go live in the same release +as the shells and the privacy-policy update. When we cut that release: + +1. **Postgres** — create a `cascade_sync` database + role on the existing + Postgres-on-volume instance. +2. **Subdomain** — e.g. `sync.cascade.stephens.page`, Apache vhost reverse-proxying + to `BIND_ADDR`, plus certbot for TLS (see the new-subdomain pattern in the + infra notes). +3. **systemd** — a `cascade-sync` unit running the release binary with the env + file; `EnvironmentFile=` for `.env`. +4. **SMTP** — real `SMTP_*` creds so magic links actually send. +5. Point the shells' `VITE_SYNC_API` (web) / equivalent at the subdomain. diff --git a/server/deploy/.gitignore b/server/deploy/.gitignore new file mode 100644 index 0000000..f1bde5c --- /dev/null +++ b/server/deploy/.gitignore @@ -0,0 +1,11 @@ +# Terraform +terraform/.terraform/ +terraform/*.tfstate +terraform/*.tfstate.* +terraform/terraform.tfvars +terraform/.terraform.lock.hcl + +# Ansible secrets, real inventory, staged binary +ansible/inventory.ini +ansible/group_vars/all/vault.yml +ansible/roles/cascade_sync/files/cascade-sync-server diff --git a/server/deploy/README.md b/server/deploy/README.md new file mode 100644 index 0000000..85c1589 --- /dev/null +++ b/server/deploy/README.md @@ -0,0 +1,73 @@ +# Deploying cascade-sync-server (Infrastructure as Code) + +This directory codifies the deploy of the Cascade listening-time sync service — +a real, load-bearing service on an existing DigitalOcean droplet — as +**Terraform** (the DNS wiring) plus **Ansible** (the host config). The point is +reproducibility: the service can be rebuilt from text, reviewed in a PR, and +audited, instead of living as undocumented steps in someone's shell history. + +It is written to be **safe against live infrastructure**: Terraform only +*creates* the new DNS record and references the existing droplet read-only, so an +apply can't recreate or destroy anything load-bearing. Ansible is idempotent and +converges to the declared state. + +## Layout + +``` +terraform/ DNS record for sync.cascade.stephens.page -> existing droplet +ansible/ role `cascade_sync`: postgres role/db, systemd unit (hardened), + Apache reverse-proxy vhost, certbot TLS, env file +``` + +## What it provisions + +- A DigitalOcean **DNS A record** for the sync subdomain (Terraform). +- A dedicated **`cascade_sync` Postgres role + database** on the existing + Postgres instance (Ansible). +- A **systemd service** running the release binary as an unprivileged + `cascade-sync` user, with filesystem/syscall hardening (`ProtectSystem=strict`, + empty `CapabilityBoundingSet`, `MemoryDenyWriteExecute`, …) — the runtime half + of the threat model in [`../docs/threat-model.md`](../docs/threat-model.md). +- An **Apache reverse-proxy vhost** that proxies 443→localhost and explicitly + refuses to expose `/metrics`. +- A **Let's Encrypt certificate** via `certbot --apache` (idempotent). + +## Prerequisites + +- A DigitalOcean API token (`export TF_VAR_do_token=...`). +- Ansible + collections: `ansible-galaxy collection install -r ansible/requirements.yml`, + and `psycopg2` on the target host for the Postgres modules. +- A built release binary staged at + `ansible/roles/cascade_sync/files/cascade-sync-server` + (CI uploads it — see [`.github/workflows/deploy-sync.yml`](../../.github/workflows/deploy-sync.yml)). + +## Apply + +```bash +# 1. DNS +cd terraform +cp terraform.tfvars.example terraform.tfvars # set droplet_name +terraform init && terraform plan && terraform apply + +# 2. Host config (secrets live in an encrypted vault) +cd ../ansible +cp inventory.example.ini inventory.ini # set droplet IP +cp group_vars/all/vault.example.yml group_vars/all/vault.yml # fill in, then: +ansible-vault encrypt group_vars/all/vault.yml +ansible-playbook -i inventory.ini playbook.yml --ask-vault-pass +``` + +## Codifying the rest of the fleet + +This service is the first slice. The existing droplet, volume, and firewall are +referenced read-only; bring them under management incrementally with +`terraform import` (examples in `terraform/main.tf`) rather than a blind apply — +match the live config first, then evolve. A sanitized copy of this module is +mirrored to [`infrastructure-patterns`](https://github.com/JacobStephens2/infrastructure-patterns). + +## Secrets + +Nothing secret is committed. Terraform reads the DO token from the environment; +Ansible reads the DB password and SMTP creds from an `ansible-vault`-encrypted +`group_vars/all/vault.yml`. `deploy/.gitignore` blocks tfstate, tfvars, the real +inventory, the vault file, and the staged binary. diff --git a/server/deploy/ansible/group_vars/all/vault.example.yml b/server/deploy/ansible/group_vars/all/vault.example.yml new file mode 100644 index 0000000..15bdc2d --- /dev/null +++ b/server/deploy/ansible/group_vars/all/vault.example.yml @@ -0,0 +1,12 @@ +--- +# Copy to group_vars/all/vault.yml, fill in real values, then encrypt: +# ansible-vault encrypt group_vars/all/vault.yml +# Run plays with --ask-vault-pass (or a vault password file). + +cascade_sync_db_password: "CHANGE-ME-strong-random" + +# To actually send magic links, set a real SMTP host + creds. Leaving the host +# empty (the default) makes the service log links instead of emailing them. +# cascade_sync_smtp_host: "smtp.your-provider.com" +cascade_sync_smtp_username: "CHANGE-ME" +cascade_sync_smtp_password: "CHANGE-ME" diff --git a/server/deploy/ansible/inventory.example.ini b/server/deploy/ansible/inventory.example.ini new file mode 100644 index 0000000..802a582 --- /dev/null +++ b/server/deploy/ansible/inventory.example.ini @@ -0,0 +1,3 @@ +# Copy to inventory.ini (gitignored) and set the droplet's address. +[cascade_sync] +cascade-web ansible_host=YOUR.DROPLET.IP ansible_user=root diff --git a/server/deploy/ansible/playbook.yml b/server/deploy/ansible/playbook.yml new file mode 100644 index 0000000..f76ca73 --- /dev/null +++ b/server/deploy/ansible/playbook.yml @@ -0,0 +1,6 @@ +--- +- name: Deploy the Cascade listening-time sync service + hosts: cascade_sync + become: true + roles: + - cascade_sync diff --git a/server/deploy/ansible/requirements.yml b/server/deploy/ansible/requirements.yml new file mode 100644 index 0000000..0033b28 --- /dev/null +++ b/server/deploy/ansible/requirements.yml @@ -0,0 +1,6 @@ +--- +# Install with: ansible-galaxy collection install -r requirements.yml +collections: + - name: community.postgresql + - name: community.general + - name: ansible.posix diff --git a/server/deploy/ansible/roles/cascade_sync/defaults/main.yml b/server/deploy/ansible/roles/cascade_sync/defaults/main.yml new file mode 100644 index 0000000..798b989 --- /dev/null +++ b/server/deploy/ansible/roles/cascade_sync/defaults/main.yml @@ -0,0 +1,40 @@ +--- +# Non-secret defaults. Secrets (db password, SMTP creds) come from an +# Ansible Vault file — see group_vars/all/vault.example.yml. Override any of +# these in group_vars/host_vars as needed. + +cascade_sync_user: cascade-sync + +cascade_sync_host: sync.cascade.stephens.page +cascade_sync_port: 3470 +cascade_sync_bind_addr: "127.0.0.1:3470" + +cascade_sync_frontend_origin: "https://cascade.stephens.page" +cascade_sync_cors_origins: "https://cascade.stephens.page" +cascade_sync_rust_log: "info,sqlx=warn" + +# Postgres (on the existing instance). Set cascade_sync_manage_postgres: false +# if you create the role/db out of band. +cascade_sync_manage_postgres: true +cascade_sync_db_name: cascade_sync +cascade_sync_db_user: cascade_sync +# cascade_sync_db_password is a VAULT secret (see vault.example.yml). +cascade_sync_database_url: "postgres://{{ cascade_sync_db_user }}:{{ cascade_sync_db_password }}@localhost:5432/{{ cascade_sync_db_name }}" + +# SMTP for magic links. Leave host empty to log links instead of sending. +cascade_sync_smtp_host: "" +cascade_sync_smtp_port: 587 +# cascade_sync_smtp_username / _password are VAULT secrets. +cascade_sync_smtp_from: "Cascade " + +# Where the built binary is staged before deploy (CI uploads it here). +cascade_sync_binary_src: "files/cascade-sync-server" + +# Distro-specific: defaults target Ubuntu / Apache 2.4 (the real fleet). On a +# Rocky/httpd host set apache_service=httpd, confdir=/etc/httpd/conf.d, and +# apache_use_a2ensite=false (httpd auto-loads conf.d/*.conf). +cascade_sync_apache_service: apache2 +cascade_sync_apache_confdir: /etc/apache2/sites-available +cascade_sync_apache_use_a2ensite: true + +cascade_sync_certbot_email: "jacob.stephens.701@gmail.com" diff --git a/server/deploy/ansible/roles/cascade_sync/files/.gitkeep b/server/deploy/ansible/roles/cascade_sync/files/.gitkeep new file mode 100644 index 0000000..c328f59 --- /dev/null +++ b/server/deploy/ansible/roles/cascade_sync/files/.gitkeep @@ -0,0 +1,2 @@ +# The release binary (cascade-sync-server) is staged here by CI or `cargo build +# --release` before `ansible-playbook`. It is gitignored — never commit binaries. diff --git a/server/deploy/ansible/roles/cascade_sync/handlers/main.yml b/server/deploy/ansible/roles/cascade_sync/handlers/main.yml new file mode 100644 index 0000000..82a0434 --- /dev/null +++ b/server/deploy/ansible/roles/cascade_sync/handlers/main.yml @@ -0,0 +1,14 @@ +--- +- name: Reload systemd + ansible.builtin.systemd_service: + daemon_reload: true + +- name: Restart cascade-sync + ansible.builtin.systemd_service: + name: cascade-sync + state: restarted + +- name: Reload apache + ansible.builtin.service: + name: "{{ cascade_sync_apache_service }}" + state: reloaded diff --git a/server/deploy/ansible/roles/cascade_sync/tasks/main.yml b/server/deploy/ansible/roles/cascade_sync/tasks/main.yml new file mode 100644 index 0000000..ad2c0d9 --- /dev/null +++ b/server/deploy/ansible/roles/cascade_sync/tasks/main.yml @@ -0,0 +1,117 @@ +--- +# Provision and run the Cascade listening-time sync service on the existing +# droplet. Idempotent: safe to re-run; it converges to the declared state. + +- name: Create the cascade-sync system user + ansible.builtin.user: + name: "{{ cascade_sync_user }}" + system: true + shell: /usr/sbin/nologin + create_home: false + +- name: Create install and config directories + ansible.builtin.file: + path: "{{ item.path }}" + state: directory + owner: "{{ item.owner }}" + group: "{{ item.owner }}" + mode: "{{ item.mode }}" + loop: + - { path: /opt/cascade-sync, owner: root, mode: "0755" } + - { path: /etc/cascade-sync, owner: "{{ cascade_sync_user }}", mode: "0750" } + +# --- Postgres (on the existing instance) --------------------------------- +# Requires the community.postgresql collection and psycopg2 on the host. +- name: Create the Postgres role + become: true + become_user: postgres + community.postgresql.postgresql_user: + name: "{{ cascade_sync_db_user }}" + password: "{{ cascade_sync_db_password }}" + when: cascade_sync_manage_postgres + +- name: Create the Postgres database + become: true + become_user: postgres + community.postgresql.postgresql_db: + name: "{{ cascade_sync_db_name }}" + owner: "{{ cascade_sync_db_user }}" + when: cascade_sync_manage_postgres + +# --- Application binary --------------------------------------------------- +# Built in CI (or `cargo build --release`) and dropped into the role's files/. +- name: Install the service binary + ansible.builtin.copy: + src: "{{ cascade_sync_binary_src }}" + dest: /opt/cascade-sync/cascade-sync-server + owner: root + group: root + mode: "0755" + notify: Restart cascade-sync + +# --- Config, unit, vhost -------------------------------------------------- +- name: Template the environment file + ansible.builtin.template: + src: cascade-sync.env.j2 + dest: /etc/cascade-sync/cascade-sync.env + owner: "{{ cascade_sync_user }}" + group: "{{ cascade_sync_user }}" + mode: "0640" + notify: Restart cascade-sync + +- name: Install the systemd unit + ansible.builtin.template: + src: cascade-sync.service.j2 + dest: /etc/systemd/system/cascade-sync.service + owner: root + group: root + mode: "0644" + notify: + - Reload systemd + - Restart cascade-sync + +- name: Enable and start the service + ansible.builtin.systemd_service: + name: cascade-sync + enabled: true + state: started + daemon_reload: true + +- name: Ensure Apache proxy modules are enabled (Ubuntu) + community.general.apache2_module: + name: "{{ item }}" + state: present + loop: + - proxy + - proxy_http + when: cascade_sync_apache_use_a2ensite + notify: Reload apache + +- name: Install the Apache reverse-proxy vhost + ansible.builtin.template: + src: apache-vhost.conf.j2 + dest: "{{ cascade_sync_apache_confdir }}/{{ cascade_sync_host }}.conf" + owner: root + group: root + mode: "0644" + notify: Reload apache + +- name: Enable the vhost (a2ensite) + ansible.builtin.command: + cmd: "a2ensite {{ cascade_sync_host }}.conf" + creates: "/etc/apache2/sites-enabled/{{ cascade_sync_host }}.conf" + when: cascade_sync_apache_use_a2ensite + notify: Reload apache + +# --- TLS ------------------------------------------------------------------ +# Idempotent: --keep-until-expiring is a no-op when a valid cert exists, and the +# `creates` guard skips the run entirely once the live cert directory exists. +- name: Obtain/expand the TLS certificate with certbot + ansible.builtin.command: + cmd: >- + certbot --apache --non-interactive --agree-tos + -m {{ cascade_sync_certbot_email }} + -d {{ cascade_sync_host }} + --keep-until-expiring --redirect + creates: "/etc/letsencrypt/live/{{ cascade_sync_host }}/fullchain.pem" + notify: Reload apache diff --git a/server/deploy/ansible/roles/cascade_sync/templates/apache-vhost.conf.j2 b/server/deploy/ansible/roles/cascade_sync/templates/apache-vhost.conf.j2 new file mode 100644 index 0000000..4ee2c17 --- /dev/null +++ b/server/deploy/ansible/roles/cascade_sync/templates/apache-vhost.conf.j2 @@ -0,0 +1,20 @@ +# {{ ansible_managed }} +# Reverse proxy for the Cascade sync API. certbot --apache promotes this to a +# TLS vhost and adds an HTTP->HTTPS redirect on first run. + + ServerName {{ cascade_sync_host }} + + ProxyPreserveHost On + + # Never expose /metrics publicly — Prometheus scrapes it on localhost. + ProxyPass "/metrics" "!" + + Require all denied + + + ProxyPass "/" "http://127.0.0.1:{{ cascade_sync_port }}/" + ProxyPassReverse "/" "http://127.0.0.1:{{ cascade_sync_port }}/" + + ErrorLog /var/log/httpd/{{ cascade_sync_host }}-error.log + CustomLog /var/log/httpd/{{ cascade_sync_host }}-access.log combined + diff --git a/server/deploy/ansible/roles/cascade_sync/templates/cascade-sync.env.j2 b/server/deploy/ansible/roles/cascade_sync/templates/cascade-sync.env.j2 new file mode 100644 index 0000000..7f95dae --- /dev/null +++ b/server/deploy/ansible/roles/cascade_sync/templates/cascade-sync.env.j2 @@ -0,0 +1,11 @@ +# {{ ansible_managed }} +DATABASE_URL={{ cascade_sync_database_url }} +BIND_ADDR={{ cascade_sync_bind_addr }} +FRONTEND_ORIGIN={{ cascade_sync_frontend_origin }} +CORS_ALLOWED_ORIGINS={{ cascade_sync_cors_origins }} +SMTP_HOST={{ cascade_sync_smtp_host }} +SMTP_PORT={{ cascade_sync_smtp_port }} +SMTP_USERNAME={{ cascade_sync_smtp_username }} +SMTP_PASSWORD={{ cascade_sync_smtp_password }} +SMTP_FROM={{ cascade_sync_smtp_from }} +RUST_LOG={{ cascade_sync_rust_log }} diff --git a/server/deploy/ansible/roles/cascade_sync/templates/cascade-sync.service.j2 b/server/deploy/ansible/roles/cascade_sync/templates/cascade-sync.service.j2 new file mode 100644 index 0000000..0d97c80 --- /dev/null +++ b/server/deploy/ansible/roles/cascade_sync/templates/cascade-sync.service.j2 @@ -0,0 +1,33 @@ +# {{ ansible_managed }} +[Unit] +Description=Cascade listening-time sync service +After=network-online.target postgresql.service +Wants=network-online.target + +[Service] +Type=simple +User={{ cascade_sync_user }} +Group={{ cascade_sync_user }} +EnvironmentFile=/etc/cascade-sync/cascade-sync.env +ExecStart=/opt/cascade-sync/cascade-sync-server +Restart=on-failure +RestartSec=3 + +# Hardening: the service needs only the network and to read its env file. It +# writes nothing to disk, so lock the filesystem down. (This same least- +# privilege posture is the runtime half of the threat model — see +# server/docs/threat-model.md.) +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +PrivateTmp=true +PrivateDevices=true +ProtectKernelTunables=true +ProtectControlGroups=true +RestrictAddressFamilies=AF_INET AF_INET6 +CapabilityBoundingSet= +LockPersonality=true +MemoryDenyWriteExecute=true + +[Install] +WantedBy=multi-user.target diff --git a/server/deploy/observability/README.md b/server/deploy/observability/README.md new file mode 100644 index 0000000..ecf79b2 --- /dev/null +++ b/server/deploy/observability/README.md @@ -0,0 +1,52 @@ +# Observability for cascade-sync + +Monitoring-as-code for the listening-time sync service: the app exposes +Prometheus metrics, and this directory has the scrape jobs, alert rules, and a +Grafana dashboard to consume them. It replaces ad-hoc "curl /health in a loop" +checking with the standard Prometheus + Grafana + Alertmanager stack. + +## Signals + +The service emits (see `server/src/main.rs`): + +- `cascade_sync_http_requests_total{method,path,status}` — request counter, + labelled by the *matched route template* so cardinality stays bounded. +- `cascade_sync_http_request_duration_seconds{...}` — latency. + +`/metrics` is bound to localhost and is **not** exposed through Apache, so +Prometheus scrapes it directly on `127.0.0.1:3470`. An external blackbox probe +of the public `/health` covers the Apache/TLS/DNS layer the app can't see. + +## Files + +| File | Purpose | +|---|---| +| `prometheus/cascade-sync.scrape.yml` | scrape jobs (app + blackbox probe) | +| `prometheus/cascade-sync.rules.yml` | alerts: down, probe-failing, cert-expiry, 5xx rate, latency | +| `grafana/cascade-sync-dashboard.json` | dashboard (up, probe, cert days, req rate by route, status mix, error ratio, mean latency) | + +## Apply + +To an existing Prometheus (`/etc/prometheus/prometheus.yml`): + +```yaml +scrape_config_files: + - /etc/prometheus/cascade-sync.scrape.yml +rule_files: + - /etc/prometheus/cascade-sync.rules.yml +``` + +then `promtool check rules cascade-sync.rules.yml && systemctl reload prometheus`. +Import the dashboard JSON in Grafana (it prompts for the Prometheus data source). + +The blackbox job assumes `prometheus-blackbox-exporter` on `127.0.0.1:9115` +with an `http_2xx` module (the package default). + +## Provisioning with Ansible + +On Ubuntu the stack is `apt install prometheus prometheus-blackbox-exporter +grafana` (Grafana via its apt repo), then drop these files into +`/etc/prometheus/` and Grafana's provisioning dir. This mirrors the +`cascade_sync` role pattern; wire it as an `observability` role when the stack +is stood up. Until then the app already emits metrics and these configs are +ready to point at it. diff --git a/server/deploy/observability/grafana/cascade-sync-dashboard.json b/server/deploy/observability/grafana/cascade-sync-dashboard.json new file mode 100644 index 0000000..661b689 --- /dev/null +++ b/server/deploy/observability/grafana/cascade-sync-dashboard.json @@ -0,0 +1,117 @@ +{ + "__inputs": [ + { + "name": "DS_PROMETHEUS", + "label": "Prometheus", + "description": "Prometheus data source scraping cascade-sync", + "type": "datasource", + "pluginId": "prometheus", + "pluginName": "Prometheus" + } + ], + "__requires": [ + { "type": "grafana", "id": "grafana", "name": "Grafana", "version": "10.0.0" }, + { "type": "datasource", "id": "prometheus", "name": "Prometheus", "version": "1.0.0" } + ], + "title": "Cascade Sync", + "uid": "cascade-sync", + "tags": ["cascade", "sync"], + "timezone": "browser", + "schemaVersion": 39, + "version": 1, + "refresh": "30s", + "time": { "from": "now-6h", "to": "now" }, + "templating": { "list": [] }, + "annotations": { "list": [] }, + "panels": [ + { + "id": 1, + "title": "Service up", + "type": "stat", + "gridPos": { "h": 4, "w": 6, "x": 0, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { + "defaults": { + "mappings": [ + { "type": "value", "options": { "0": { "text": "DOWN", "color": "red" }, "1": { "text": "UP", "color": "green" } } } + ] + } + }, + "targets": [ + { "refId": "A", "expr": "up{job=\"cascade-sync\"}", "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" } } + ] + }, + { + "id": 2, + "title": "External health probe", + "type": "stat", + "gridPos": { "h": 4, "w": 6, "x": 6, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { + "defaults": { + "mappings": [ + { "type": "value", "options": { "0": { "text": "FAIL", "color": "red" }, "1": { "text": "OK", "color": "green" } } } + ] + } + }, + "targets": [ + { "refId": "A", "expr": "probe_success{job=\"blackbox-cascade-sync\"}", "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" } } + ] + }, + { + "id": 3, + "title": "TLS cert days remaining", + "type": "stat", + "gridPos": { "h": 4, "w": 6, "x": 12, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { "defaults": { "unit": "d" } }, + "targets": [ + { "refId": "A", "expr": "(probe_ssl_earliest_cert_expiry{job=\"blackbox-cascade-sync\"} - time()) / 86400", "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" } } + ] + }, + { + "id": 4, + "title": "Request rate by route", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 4 }, + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { "defaults": { "unit": "reqps" } }, + "targets": [ + { "refId": "A", "expr": "sum by (path) (rate(cascade_sync_http_requests_total[5m]))", "legendFormat": "{{path}}", "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" } } + ] + }, + { + "id": 5, + "title": "Requests by status", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 4 }, + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { "defaults": { "unit": "reqps" } }, + "targets": [ + { "refId": "A", "expr": "sum by (status) (rate(cascade_sync_http_requests_total[5m]))", "legendFormat": "{{status}}", "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" } } + ] + }, + { + "id": 6, + "title": "5xx error ratio", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 12 }, + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { "defaults": { "unit": "percentunit", "min": 0 } }, + "targets": [ + { "refId": "A", "expr": "sum(rate(cascade_sync_http_requests_total{status=~\"5..\"}[5m])) / clamp_min(sum(rate(cascade_sync_http_requests_total[5m])), 1)", "legendFormat": "5xx ratio", "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" } } + ] + }, + { + "id": 7, + "title": "Mean request latency", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 12 }, + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { "defaults": { "unit": "s" } }, + "targets": [ + { "refId": "A", "expr": "sum(rate(cascade_sync_http_request_duration_seconds_sum[5m])) / clamp_min(sum(rate(cascade_sync_http_request_duration_seconds_count[5m])), 1)", "legendFormat": "mean", "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" } } + ] + } + ] +} diff --git a/server/deploy/observability/prometheus/cascade-sync.rules.yml b/server/deploy/observability/prometheus/cascade-sync.rules.yml new file mode 100644 index 0000000..f25e476 --- /dev/null +++ b/server/deploy/observability/prometheus/cascade-sync.rules.yml @@ -0,0 +1,48 @@ +# Alerting rules for the Cascade sync service. Load via `rule_files:` in +# prometheus.yml; route to Alertmanager. +groups: + - name: cascade-sync + rules: + - alert: CascadeSyncDown + expr: up{job="cascade-sync"} == 0 + for: 2m + labels: { severity: critical } + annotations: + summary: "cascade-sync /metrics is unreachable" + description: "Prometheus can't scrape the service on localhost for >2m — the systemd unit is likely down." + + - alert: CascadeSyncHealthProbeFailing + expr: probe_success{job="blackbox-cascade-sync"} == 0 + for: 3m + labels: { severity: critical } + annotations: + summary: "sync.cascade.stephens.page health probe failing" + description: "The external HTTPS probe of /health has failed for >3m (app, Apache, TLS, or DNS)." + + - alert: CascadeSyncTLSCertExpiringSoon + expr: (probe_ssl_earliest_cert_expiry{job="blackbox-cascade-sync"} - time()) < 7 * 24 * 3600 + for: 1h + labels: { severity: warning } + annotations: + summary: "TLS cert for sync.cascade.stephens.page expires in <7 days" + description: "certbot renewal may have stalled." + + - alert: CascadeSyncHighErrorRate + expr: | + sum(rate(cascade_sync_http_requests_total{status=~"5.."}[5m])) + / clamp_min(sum(rate(cascade_sync_http_requests_total[5m])), 1) > 0.05 + for: 10m + labels: { severity: warning } + annotations: + summary: "cascade-sync 5xx rate above 5%" + description: "More than 5% of requests have returned 5xx for 10m." + + - alert: CascadeSyncHighLatency + expr: | + sum(rate(cascade_sync_http_request_duration_seconds_sum[5m])) + / clamp_min(sum(rate(cascade_sync_http_request_duration_seconds_count[5m])), 1) > 1 + for: 10m + labels: { severity: warning } + annotations: + summary: "cascade-sync average latency above 1s" + description: "Mean request latency has exceeded 1s for 10m." diff --git a/server/deploy/observability/prometheus/cascade-sync.scrape.yml b/server/deploy/observability/prometheus/cascade-sync.scrape.yml new file mode 100644 index 0000000..9dfafd2 --- /dev/null +++ b/server/deploy/observability/prometheus/cascade-sync.scrape.yml @@ -0,0 +1,29 @@ +# Scrape jobs for the Cascade sync service. Drop into Prometheus via +# scrape_config_files, or paste under `scrape_configs:` in prometheus.yml. +# +# Two complementary signals: +# - cascade-sync: the app's own /metrics, scraped on localhost (it is never +# exposed publicly — see the Apache vhost). +# - blackbox-cascade-sync: an external HTTPS probe of the public health +# endpoint, so we also catch Apache/TLS/DNS failures the app can't report. + +scrape_configs: + - job_name: cascade-sync + metrics_path: /metrics + static_configs: + - targets: ["127.0.0.1:3470"] + + - job_name: blackbox-cascade-sync + metrics_path: /probe + params: + module: [http_2xx] + static_configs: + - targets: + - https://sync.cascade.stephens.page/health + relabel_configs: + - source_labels: [__address__] + target_label: __param_target + - source_labels: [__param_target] + target_label: instance + - target_label: __address__ + replacement: 127.0.0.1:9115 # blackbox_exporter diff --git a/server/deploy/terraform/main.tf b/server/deploy/terraform/main.tf new file mode 100644 index 0000000..ac8eede --- /dev/null +++ b/server/deploy/terraform/main.tf @@ -0,0 +1,60 @@ +# Infrastructure for the Cascade listening-time sync service. +# +# Scope is deliberately *additive and non-destructive*: the only resource this +# creates is the DNS record for the new subdomain. The droplet, volume, and +# firewall it depends on already exist and are referenced as read-only data +# sources, so `terraform apply` here cannot recreate or destroy load-bearing +# infrastructure. The commented blocks at the bottom show how those existing +# resources would be brought under management via `terraform import` when you +# codify the rest of the fleet (see README). + +terraform { + required_version = ">= 1.5" + required_providers { + digitalocean = { + source = "digitalocean/digitalocean" + version = "~> 2.40" + } + } +} + +provider "digitalocean" { + token = var.do_token +} + +# The existing application droplet (read-only reference). +data "digitalocean_droplet" "web" { + name = var.droplet_name +} + +# The new subdomain for the sync API → the existing droplet. Apache on the +# droplet reverse-proxies 443 to the service on localhost (see the Ansible +# vhost template), so no new public port is opened. +resource "digitalocean_record" "sync" { + domain = var.domain + type = "A" + name = var.record_name + value = data.digitalocean_droplet.web.ipv4_address + ttl = 300 +} + +# --------------------------------------------------------------------------- +# Codifying the rest of the fleet (do this incrementally with `import`, never a +# blind apply). Example for the existing droplet: +# +# terraform import digitalocean_droplet.web +# +# resource "digitalocean_droplet" "web" { +# name = var.droplet_name +# region = "nyc3" +# size = "s-2vcpu-4gb" +# image = "rockylinux-9-x64" +# # ...match the live config exactly before the first apply, then evolve. +# } +# +# resource "digitalocean_volume" "data" { +# name = "volume-nyc3-01" +# region = "nyc3" +# size = 100 +# } +# --------------------------------------------------------------------------- diff --git a/server/deploy/terraform/outputs.tf b/server/deploy/terraform/outputs.tf new file mode 100644 index 0000000..2521795 --- /dev/null +++ b/server/deploy/terraform/outputs.tf @@ -0,0 +1,9 @@ +output "sync_fqdn" { + description = "Fully-qualified hostname for the sync API." + value = "${var.record_name}.${var.domain}" +} + +output "droplet_ip" { + description = "Public IPv4 the record resolves to." + value = data.digitalocean_droplet.web.ipv4_address +} diff --git a/server/deploy/terraform/terraform.tfvars.example b/server/deploy/terraform/terraform.tfvars.example new file mode 100644 index 0000000..da1b969 --- /dev/null +++ b/server/deploy/terraform/terraform.tfvars.example @@ -0,0 +1,5 @@ +# Copy to terraform.tfvars (gitignored) and fill in. Or export TF_VAR_do_token. +# do_token = "dop_v1_..." # better: export TF_VAR_do_token instead of writing it here +droplet_name = "cascade-web" +# domain = "stephens.page" +# record_name = "sync.cascade" diff --git a/server/deploy/terraform/variables.tf b/server/deploy/terraform/variables.tf new file mode 100644 index 0000000..63d580f --- /dev/null +++ b/server/deploy/terraform/variables.tf @@ -0,0 +1,22 @@ +variable "do_token" { + description = "DigitalOcean API token (provide via TF_VAR_do_token or a tfvars file kept out of git)." + type = string + sensitive = true +} + +variable "domain" { + description = "The apex domain managed in DigitalOcean DNS." + type = string + default = "stephens.page" +} + +variable "record_name" { + description = "Subdomain record under `domain` for the sync API (host = record_name.domain)." + type = string + default = "sync.cascade" +} + +variable "droplet_name" { + description = "Name of the existing application droplet to point the record at." + type = string +} diff --git a/server/docs/threat-model.md b/server/docs/threat-model.md new file mode 100644 index 0000000..bac9d17 --- /dev/null +++ b/server/docs/threat-model.md @@ -0,0 +1,68 @@ +# Threat model — cascade-sync-server + +The sync service exists to do one small thing — let a user see their total +Cascade listening time across devices — and the design goal is that doing it +should *create as little risk as possible*. This document states what it +protects, against whom, and where the residual risk is. + +## What it stores (assets) + +| Asset | Sensitivity | Why it's small | +|---|---|---| +| Email address | PII (low) | The only personal data. Used solely to sign in. | +| Magic-link tokens | Secret (short-lived) | Stored **hashed** (SHA-256); single-use; 15-min TTL. | +| Session tokens | Secret | Stored **hashed**; opaque (not JWT), so revocable instantly. | +| Listening counters | Low | One integer per `(user, device)`. **No timestamps, no session log** — a breach reveals *how much*, never *when*. | + +The single most important control is **data minimization by construction**: the +schema cannot express a listening timeline, so no amount of access reconstructs +one. This bounds the blast radius of every other threat below. + +## Trust boundaries + +``` +[ browser / native app ] + | HTTPS (TLS, certbot) ← public internet boundary +[ Apache reverse proxy ] -- blocks /metrics + | localhost:3470 +[ cascade-sync-server (hardened systemd unit) ] + | localhost:5432 ← DB boundary +[ Postgres (cascade_sync role, least priv) ] + +[ cascade-sync-server ] --STARTTLS--> [ Resend SMTP ] ← email channel +``` + +## Threats and mitigations + +| # | Threat | Mitigation | +|---|---|---| +| T1 | **Account enumeration** via the sign-in endpoint | `POST /auth/request` always returns 200 — it never reveals whether an address has an account. | +| T2 | **DB leak yields working tokens** | Magic-link and session tokens are stored only as SHA-256 hashes; the plaintext exists only in the email / the client. | +| T3 | **Magic-link replay / interception** | 256-bit random token, single-use (atomic `UPDATE ... WHERE used=false RETURNING`), 15-min expiry, HTTPS-only delivery. | +| T4 | **Session theft → can't revoke** | Opaque server-side sessions; logout / delete-account is a single `DELETE`, taking effect immediately (no wait for a signed token to expire). | +| T5 | **CSRF** | API authenticates with a `Bearer` header, not cookies, so cross-site requests can't ride ambient credentials. | +| T6 | **SQL injection** | All queries are parameterized (`sqlx` bind); no string interpolation into SQL. | +| T7 | **Cross-origin abuse** | CORS allow-list is exactly the web origin; other origins get no `Access-Control-Allow-Origin`. | +| T8 | **Counter resurrection after deletion** | "Delete my data" rotates the client `device_id`, so a stale offline write lands in a fresh slot instead of restoring a deleted total. | +| T9 | **Metrics disclosure** | `/metrics` is localhost-bound and blocked at Apache; it carries only operational counters — no PII, no per-user data. | +| T10 | **Host compromise via the service** | systemd hardening: unprivileged user, `ProtectSystem=strict`, empty `CapabilityBoundingSet`, `MemoryDenyWriteExecute`, restricted address families, `PrivateTmp`. The service writes nothing to disk. | +| T11 | **Secret exposure** | DB password + SMTP creds live in a `0640` env file owned by the service user (vault-encrypted in the repo); the DO token is env-only. Nothing secret is committed. | +| T12 | **Counter inflation** | A user can only inflate *their own* total (the G-Counter slot is per-device, summed per-user) — it never affects another user. The core also clamps per-tick deltas client-side. Accepted: a vanity number has no integrity value worth defending server-side. | + +## Residual risks / known gaps + +- **No rate limiting (R1).** `POST /auth/request` will email a link to any + address on every call, so it can be abused to send unsolicited mail (bounded + by Resend's own quotas/abuse controls) or to brute-force nothing useful (tokens + are 256-bit). *Recommended next step:* per-IP + per-email rate limiting on the + auth endpoints. +- **Web tokens live in `localStorage` (R2),** so a successful XSS on + cascade.stephens.page could exfiltrate a session. Mitigated by the app shipping + no third-party scripts; a strict CSP would harden it further. +- **No audit log (R3).** Intentional — it's the flip side of data minimization. + Acceptable for a listening-time toy; revisit if scope grows. + +## Out of scope + +DDoS absorption (handled at the proxy/network layer), Resend's own security, +and physical/host security of the droplet beyond the service's own sandboxing. diff --git a/server/migrations/0001_init.sql b/server/migrations/0001_init.sql new file mode 100644 index 0000000..8d0423c --- /dev/null +++ b/server/migrations/0001_init.sql @@ -0,0 +1,50 @@ +-- Cascade listening-time sync schema. +-- +-- The whole privacy posture rests on what is NOT here: there is no session log, +-- no per-listen timestamps, no event stream. The only listening data stored is +-- one grow-only integer per (user, device) — a G-Counter slot. The server can +-- therefore report how much a user has listened in total, but can never +-- reconstruct when. + +-- A user identity. Email is the only PII; there is no password (magic-link). +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- One-time magic-link tokens. We store only the SHA-256 hash of the token, so a +-- database leak does not hand out working sign-in links. Short-lived and +-- single-use. +CREATE TABLE IF NOT EXISTS login_tokens ( + token_hash BYTEA PRIMARY KEY, + email TEXT NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + used BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS login_tokens_expires_idx ON login_tokens (expires_at); + +-- Opaque server-side session tokens (again stored only as a hash). Opaque, not +-- JWT, so logout / delete-account / revoke-all is a single DELETE — no waiting +-- for a signed token to expire. +CREATE TABLE IF NOT EXISTS sessions ( + token_hash BYTEA PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_at TIMESTAMPTZ NOT NULL +); +CREATE INDEX IF NOT EXISTS sessions_user_idx ON sessions (user_id); + +-- The G-Counter: one grow-only millisecond slot per device. Merge is +-- GREATEST(existing, incoming) on write; a user's lifetime total is SUM across +-- their devices on read. device_id is opaque and client-generated; rotating it +-- (on "delete my data") simply means the next write lands in a fresh slot. +CREATE TABLE IF NOT EXISTS device_counters ( + user_id UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE, + device_id TEXT NOT NULL, + total_ms BIGINT NOT NULL CHECK (total_ms >= 0), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, device_id) +); +CREATE INDEX IF NOT EXISTS device_counters_user_idx ON device_counters (user_id); diff --git a/server/src/error.rs b/server/src/error.rs new file mode 100644 index 0000000..b4bea0f --- /dev/null +++ b/server/src/error.rs @@ -0,0 +1,43 @@ +//! A single error type for handlers, with a sane `IntoResponse`. + +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use serde_json::json; + +#[derive(Debug, thiserror::Error)] +pub enum AppError { + #[error("bad request: {0}")] + BadRequest(String), + #[error("unauthorized")] + Unauthorized, + /// Anything internal — DB failure, mail failure, etc. The detail is logged + /// but never sent to the client. + #[error(transparent)] + Internal(#[from] anyhow::Error), +} + +impl From for AppError { + fn from(e: sqlx::Error) -> Self { + AppError::Internal(e.into()) + } +} + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + let (status, message) = match self { + AppError::BadRequest(m) => (StatusCode::BAD_REQUEST, m), + AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized".to_string()), + AppError::Internal(e) => { + tracing::error!(error = ?e, "internal error"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "internal error".to_string(), + ) + } + }; + (status, Json(json!({ "error": message }))).into_response() + } +} + +pub type AppResult = Result; diff --git a/server/src/mail.rs b/server/src/mail.rs new file mode 100644 index 0000000..9b292f9 --- /dev/null +++ b/server/src/mail.rs @@ -0,0 +1,76 @@ +//! Magic-link delivery. +//! +//! In production this sends through SMTP (credentials from the environment). In +//! development, or when SMTP isn't configured, it falls back to logging the +//! link so the flow is exercisable without a mail server. + +use anyhow::Context; +use lettre::transport::smtp::authentication::Credentials; +use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor}; + +#[derive(Clone)] +pub enum Mailer { + Smtp { + transport: AsyncSmtpTransport, + from: String, + }, + /// No SMTP configured — log the link instead of sending it. + LogOnly, +} + +impl Mailer { + /// Build an SMTP mailer from explicit settings, or a log-only mailer if + /// `host` is empty. + pub fn from_settings( + host: &str, + port: u16, + username: &str, + password: &str, + from: &str, + ) -> anyhow::Result { + if host.is_empty() { + tracing::warn!("SMTP_HOST not set — magic links will be logged, not emailed"); + return Ok(Mailer::LogOnly); + } + let creds = Credentials::new(username.to_string(), password.to_string()); + // Port 465 is implicit TLS (SMTPS); 587 (submission) and anything else + // negotiate TLS via STARTTLS after a plaintext greeting. Using the wrong + // one yields "received corrupt message of type InvalidContentType". + let builder = if port == 465 { + AsyncSmtpTransport::::relay(host).context("invalid SMTP host")? + } else { + AsyncSmtpTransport::::starttls_relay(host) + .context("invalid SMTP host")? + }; + let transport = builder.port(port).credentials(creds).build(); + Ok(Mailer::Smtp { + transport, + from: from.to_string(), + }) + } + + /// Send a sign-in link to `to`. The link is already a full URL. + pub async fn send_login_link(&self, to: &str, link: &str) -> anyhow::Result<()> { + match self { + Mailer::LogOnly => { + tracing::info!(%to, %link, "magic link (log-only mailer)"); + Ok(()) + } + Mailer::Smtp { transport, from } => { + let body = format!( + "Hi,\n\nClick to sign in to Cascade and sync your listening time:\n\n{link}\n\n\ + This link expires in 15 minutes and can only be used once. \ + If you didn't request it, you can ignore this email.\n\n— Cascade" + ); + let email = Message::builder() + .from(from.parse().context("invalid From address")?) + .to(to.parse().context("invalid recipient address")?) + .subject("Your Cascade sign-in link") + .body(body) + .context("could not build email")?; + transport.send(email).await.context("SMTP send failed")?; + Ok(()) + } + } + } +} diff --git a/server/src/main.rs b/server/src/main.rs new file mode 100644 index 0000000..356518e --- /dev/null +++ b/server/src/main.rs @@ -0,0 +1,475 @@ +//! Cascade listening-time sync service. +//! +//! A small Axum + Postgres service that does exactly two things: sign a user in +//! by email (magic-link, opaque server-side sessions) and aggregate their +//! listening time across devices as a G-Counter (`GREATEST` on write, `SUM` on +//! read). It stores an email address and one integer per device — nothing else, +//! and in particular no record of *when* anyone listened. + +mod error; +mod mail; + +use std::net::SocketAddr; +use std::time::Duration as StdDuration; + +use axum::extract::{MatchedPath, Request, State}; +use axum::http::{HeaderMap, Method}; +use axum::middleware::{self, Next}; +use axum::response::Response; +use axum::routing::{delete, get, post, put}; +use axum::{Json, Router}; +use metrics_exporter_prometheus::{PrometheusBuilder, PrometheusHandle}; +use std::time::Instant; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine; +use chrono::{Duration, Utc}; +use rand::RngCore; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use sqlx::postgres::PgPoolOptions; +use sqlx::PgPool; +use tower_http::cors::CorsLayer; +use tower_http::trace::TraceLayer; +use uuid::Uuid; + +use crate::error::{AppError, AppResult}; +use crate::mail::Mailer; + +const LOGIN_TOKEN_TTL_MINUTES: i64 = 15; +const SESSION_TTL_DAYS: i64 = 90; +const MAX_DEVICE_ID_LEN: usize = 128; + +#[derive(Clone)] +struct AppState { + pool: PgPool, + mailer: Mailer, + /// Where the magic link points (the web app), e.g. `https://cascade.stephens.page`. + frontend_origin: String, + /// Renders the Prometheus exposition for `GET /metrics`. + metrics_handle: PrometheusHandle, +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "info,sqlx=warn".into()), + ) + .init(); + + let database_url = env("DATABASE_URL")?; + let bind_addr = std::env::var("BIND_ADDR").unwrap_or_else(|_| "127.0.0.1:3470".to_string()); + let frontend_origin = + std::env::var("FRONTEND_ORIGIN").unwrap_or_else(|_| "https://cascade.stephens.page".into()); + + let mailer = Mailer::from_settings( + &std::env::var("SMTP_HOST").unwrap_or_default(), + std::env::var("SMTP_PORT") + .ok() + .and_then(|p| p.parse().ok()) + .unwrap_or(587), + &std::env::var("SMTP_USERNAME").unwrap_or_default(), + &std::env::var("SMTP_PASSWORD").unwrap_or_default(), + &std::env::var("SMTP_FROM").unwrap_or_else(|_| "Cascade ".into()), + )?; + + let pool = PgPoolOptions::new() + .max_connections(5) + .acquire_timeout(StdDuration::from_secs(5)) + .connect(&database_url) + .await?; + + sqlx::migrate!("./migrations").run(&pool).await?; + + // Prometheus recorder. We render it through our own `/metrics` route rather + // than the exporter's built-in HTTP server, so it stays on the same + // localhost bind and is never proxied to the public internet. + let metrics_handle = PrometheusBuilder::new() + .install_recorder() + .map_err(|e| anyhow::anyhow!("failed to install metrics recorder: {e}"))?; + + let cors = build_cors(&frontend_origin); + let state = AppState { + pool, + mailer, + frontend_origin, + metrics_handle, + }; + + let app = Router::new() + .route("/health", get(health)) + .route("/auth/request", post(auth_request)) + .route("/auth/verify", post(auth_verify)) + .route("/auth/logout", post(auth_logout)) + .route("/listening", put(listening_put)) + .route("/listening", get(listening_get)) + .route("/listening", delete(listening_delete)) + .route("/account", delete(account_delete)) + // route_layer wraps only the routes registered above it, so the API is + // request-counted... + .route_layer(middleware::from_fn(track_metrics)) + // ...and /metrics is added afterward, so a 15s scrape doesn't dominate + // the counters. It must not be exposed through the public Apache vhost — + // Prometheus scrapes it on the localhost bind. + .route("/metrics", get(metrics)) + .layer(cors) + .layer(TraceLayer::new_for_http()) + .with_state(state); + + let addr: SocketAddr = bind_addr.parse()?; + tracing::info!(%addr, "cascade-sync-server listening"); + let listener = tokio::net::TcpListener::bind(addr).await?; + axum::serve(listener, app) + .with_graceful_shutdown(shutdown_signal()) + .await?; + Ok(()) +} + +fn env(key: &str) -> anyhow::Result { + std::env::var(key).map_err(|_| anyhow::anyhow!("missing required env var {key}")) +} + +fn build_cors(frontend_origin: &str) -> CorsLayer { + // Allow the web origin(s) to call the API cross-subdomain. Comma-separated + // CORS_ALLOWED_ORIGINS overrides; otherwise just the frontend origin. + let origins: Vec<_> = std::env::var("CORS_ALLOWED_ORIGINS") + .unwrap_or_else(|_| frontend_origin.to_string()) + .split(',') + .filter_map(|o| o.trim().parse().ok()) + .collect(); + CorsLayer::new() + .allow_origin(origins) + .allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE]) + .allow_headers([ + axum::http::header::AUTHORIZATION, + axum::http::header::CONTENT_TYPE, + ]) +} + +async fn shutdown_signal() { + let _ = tokio::signal::ctrl_c().await; + tracing::info!("shutting down"); +} + +// ---- observability ------------------------------------------------------- + +/// Render the Prometheus exposition format for scraping. +async fn metrics(State(state): State) -> String { + state.metrics_handle.render() +} + +/// Count every API request and record its latency, labelled by the matched +/// route template (not the raw path) so label cardinality stays bounded. +async fn track_metrics(req: Request, next: Next) -> Response { + let path = req + .extensions() + .get::() + .map(|m| m.as_str().to_owned()) + .unwrap_or_else(|| "unmatched".to_owned()); + let method = req.method().clone(); + let start = Instant::now(); + + let response = next.run(req).await; + + let labels = [ + ("method", method.to_string()), + ("path", path), + ("status", response.status().as_u16().to_string()), + ]; + metrics::counter!("cascade_sync_http_requests_total", &labels).increment(1); + metrics::histogram!("cascade_sync_http_request_duration_seconds", &labels) + .record(start.elapsed().as_secs_f64()); + response +} + +// ---- token helpers ------------------------------------------------------- + +/// A fresh 256-bit URL-safe random token (the plaintext we hand the client). +fn generate_token() -> String { + let mut bytes = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut bytes); + URL_SAFE_NO_PAD.encode(bytes) +} + +/// SHA-256 of a token — what we actually store, so a DB leak yields no usable +/// tokens. +fn hash_token(token: &str) -> Vec { + Sha256::digest(token.as_bytes()).to_vec() +} + +fn normalize_email(raw: &str) -> Option { + let e = raw.trim().to_lowercase(); + // Deliberately minimal: one '@' with something on each side. Real delivery + // is the actual validity check. + let parts: Vec<&str> = e.split('@').collect(); + if parts.len() == 2 && !parts[0].is_empty() && parts[1].contains('.') { + Some(e) + } else { + None + } +} + +/// Resolve the bearer session token to a user id, or `Unauthorized`. +async fn authenticate(pool: &PgPool, headers: &HeaderMap) -> AppResult { + let token = headers + .get(axum::http::header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")) + .map(|v| v.trim()) + .filter(|v| !v.is_empty()) + .ok_or(AppError::Unauthorized)?; + let hash = hash_token(token); + let user_id: Option = + sqlx::query_scalar("SELECT user_id FROM sessions WHERE token_hash = $1 AND expires_at > now()") + .bind(hash) + .fetch_optional(pool) + .await?; + user_id.ok_or(AppError::Unauthorized) +} + +async fn aggregate_total_ms(pool: &PgPool, user_id: Uuid) -> AppResult { + let total: i64 = sqlx::query_scalar( + "SELECT COALESCE(SUM(total_ms), 0)::BIGINT FROM device_counters WHERE user_id = $1", + ) + .bind(user_id) + .fetch_one(pool) + .await?; + Ok(total) +} + +// ---- request / response shapes ------------------------------------------ + +#[derive(Deserialize)] +struct AuthRequest { + email: String, +} + +#[derive(Deserialize)] +struct VerifyRequest { + token: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct VerifyResponse { + session_token: String, + email: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct ListeningPut { + device_id: String, + device_total_ms: i64, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ListeningResponse { + server_total_ms: i64, + synced_through_ms: i64, +} + +#[derive(Serialize)] +struct Ok { + ok: bool, +} + +fn ok() -> Json { + Json(Ok { ok: true }) +} + +// ---- handlers ------------------------------------------------------------ + +async fn health() -> Json { + ok() +} + +/// Start sign-in: mint a one-time link and email it. Always 200, even for an +/// unknown or malformed address, so the endpoint never reveals who has an +/// account. +async fn auth_request( + State(state): State, + Json(body): Json, +) -> AppResult> { + let Some(email) = normalize_email(&body.email) else { + return Ok(ok()); + }; + let token = generate_token(); + let expires_at = Utc::now() + Duration::minutes(LOGIN_TOKEN_TTL_MINUTES); + sqlx::query("INSERT INTO login_tokens (token_hash, email, expires_at) VALUES ($1, $2, $3)") + .bind(hash_token(&token)) + .bind(&email) + .bind(expires_at) + .execute(&state.pool) + .await?; + let link = format!("{}/auth?token={}", state.frontend_origin, token); + if let Err(e) = state.mailer.send_login_link(&email, &link).await { + // Don't fail the request — log and still return 200. A retry just mints + // another link. + tracing::error!(error = ?e, "failed to send magic link"); + } + Ok(ok()) +} + +/// Complete sign-in: consume the one-time token, upsert the user, mint an opaque +/// session token. +async fn auth_verify( + State(state): State, + Json(body): Json, +) -> AppResult> { + // Atomically consume the token: single-use is enforced by `used = FALSE` in + // the UPDATE, so two concurrent verifies can't both succeed. + let email: Option = sqlx::query_scalar( + "UPDATE login_tokens SET used = TRUE \ + WHERE token_hash = $1 AND used = FALSE AND expires_at > now() \ + RETURNING email", + ) + .bind(hash_token(&body.token)) + .fetch_optional(&state.pool) + .await?; + let Some(email) = email else { + return Err(AppError::BadRequest("invalid or expired link".into())); + }; + + let user_id: Uuid = sqlx::query_scalar( + "INSERT INTO users (id, email) VALUES ($1, $2) \ + ON CONFLICT (email) DO UPDATE SET email = EXCLUDED.email \ + RETURNING id", + ) + .bind(Uuid::new_v4()) + .bind(&email) + .fetch_one(&state.pool) + .await?; + + let session_token = generate_token(); + let expires_at = Utc::now() + Duration::days(SESSION_TTL_DAYS); + sqlx::query("INSERT INTO sessions (token_hash, user_id, expires_at) VALUES ($1, $2, $3)") + .bind(hash_token(&session_token)) + .bind(user_id) + .bind(expires_at) + .execute(&state.pool) + .await?; + + Ok(Json(VerifyResponse { + session_token, + email, + })) +} + +async fn auth_logout(State(state): State, headers: HeaderMap) -> AppResult> { + // Best-effort: delete whatever session the bearer token names. + if let Some(token) = headers + .get(axum::http::header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")) + .map(|v| v.trim()) + { + sqlx::query("DELETE FROM sessions WHERE token_hash = $1") + .bind(hash_token(token)) + .execute(&state.pool) + .await?; + } + Ok(ok()) +} + +/// Merge this device's grow-only counter and return the user's cross-device +/// aggregate. +async fn listening_put( + State(state): State, + headers: HeaderMap, + Json(body): Json, +) -> AppResult> { + let user_id = authenticate(&state.pool, &headers).await?; + let device_id = body.device_id.trim(); + if device_id.is_empty() || device_id.len() > MAX_DEVICE_ID_LEN { + return Err(AppError::BadRequest("invalid deviceId".into())); + } + if body.device_total_ms < 0 { + return Err(AppError::BadRequest("deviceTotalMs must be >= 0".into())); + } + + sqlx::query( + "INSERT INTO device_counters (user_id, device_id, total_ms) VALUES ($1, $2, $3) \ + ON CONFLICT (user_id, device_id) \ + DO UPDATE SET total_ms = GREATEST(device_counters.total_ms, EXCLUDED.total_ms), \ + updated_at = now()", + ) + .bind(user_id) + .bind(device_id) + .bind(body.device_total_ms) + .execute(&state.pool) + .await?; + + let server_total_ms = aggregate_total_ms(&state.pool, user_id).await?; + Ok(Json(ListeningResponse { + server_total_ms, + // Echo back what we accepted so the client can advance its synced + // high-water mark to exactly this. + synced_through_ms: body.device_total_ms, + })) +} + +async fn listening_get( + State(state): State, + headers: HeaderMap, +) -> AppResult> { + let user_id = authenticate(&state.pool, &headers).await?; + let server_total_ms = aggregate_total_ms(&state.pool, user_id).await?; + Ok(Json(ListeningResponse { + server_total_ms, + synced_through_ms: 0, + })) +} + +/// Delete all of this user's listening counters (but keep the account). The +/// client rotates its `device_id` alongside this so a stale offline write can't +/// resurrect the deleted total. +async fn listening_delete( + State(state): State, + headers: HeaderMap, +) -> AppResult> { + let user_id = authenticate(&state.pool, &headers).await?; + sqlx::query("DELETE FROM device_counters WHERE user_id = $1") + .bind(user_id) + .execute(&state.pool) + .await?; + Ok(ok()) +} + +/// Delete the account entirely: the cascade removes sessions and counters too. +async fn account_delete(State(state): State, headers: HeaderMap) -> AppResult> { + let user_id = authenticate(&state.pool, &headers).await?; + sqlx::query("DELETE FROM users WHERE id = $1") + .bind(user_id) + .execute(&state.pool) + .await?; + Ok(ok()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn email_normalization() { + assert_eq!(normalize_email(" Foo@Bar.COM "), Some("foo@bar.com".into())); + assert_eq!(normalize_email("no-at-sign"), None); + assert_eq!(normalize_email("a@b"), None); // no dot in domain + assert_eq!(normalize_email("@bar.com"), None); + } + + #[test] + fn token_hash_is_stable_and_opaque() { + let t = generate_token(); + assert_eq!(hash_token(&t), hash_token(&t)); + assert_ne!(hash_token(&t), t.as_bytes().to_vec()); + assert_eq!(hash_token(&t).len(), 32); + } + + #[test] + fn tokens_are_unique() { + assert_ne!(generate_token(), generate_token()); + } +}