M10.7a: link/unlink a server — device auth + sync_state (task 2104)
The pairing step. Nothing else in the sync arc can move until this works. sync/state.rs owns the link record in the sync_state row M10.4 already put in the local schema. Two safety properties are the reason it isn't just three setters: - Linking a DIFFERENT server resets the change-feed cursor. A cursor is only meaningful against the server that issued it; carrying one across would silently skip every change on the new server below that watermark — data loss wearing the costume of a successful sync. Re-linking the SAME server (a token refresh) keeps it, so a routine re-auth doesn't force a full re-download. - Unlink clears the cursor too, so a later link can't inherit a watermark from a server that never issued it. An unparseable or absent cursor reads as 0 (full sync). That direction is always safe: a redundant re-sync costs time, a too-high cursor costs notes. Likewise a half-written row (server but no token) reports NOT linked. state::Status deliberately has no device_token field — it crosses into the webview, and a long-lived bearer token has no business reachable from page scripts. A test asserts the token never appears in its serialization. Token lives in the app-data SQLite file, not an OS keyring: the keyring crate needs libsecret/DBus on Linux, which adds a C dependency to a binary that has to cross-compile and fails outright on headless/minimal-WM setups — the same class of environment assumption behind the black-window bug. sync_link runs the M10.6 handshake FIRST and refuses an incompatible server before any credential is sent. Two credential paths, because neither covers everyone: device-login (a fresh install has no session to mint a token from) and a pasted token (some users would rather not type a password into a desktop app). A pasted token is verified against /api/auth/me before being stored — auth.py's login_required accepts bearer — since an unverified paste would turn a copy/paste slip into a failure surfacing at the next sync, far from its cause. The store lock is taken only after all network work: a std MutexGuard isn't Send so it cannot cross an await, and holding the store for a round-trip would freeze every note operation in the UI. Unlink is LOCAL only — the token stays valid server-side until revoked under Account -> Linked devices. A pasted token arrives without its device id, so a reliable remote revoke isn't possible from here; the UI must say so rather than imply a revoke that didn't happen. Follow-up filed. No UI yet — that's M10.7e. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
//! Tauri commands for pairing with a server (M10.7a).
|
||||
//!
|
||||
//! Linking is opt-in and reversible; the app is fully usable having never touched
|
||||
//! any of this. The Settings UI (M10.7e) drives these.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::State;
|
||||
|
||||
use crate::local::Db;
|
||||
use crate::sync::client::{self, Identity, ProbeResult};
|
||||
use crate::sync::compat::Compatibility;
|
||||
use crate::sync::state;
|
||||
|
||||
/// Ask a server who it is, without committing to anything. The UI calls this as the
|
||||
/// user finishes typing an address, so they see what answered before handing over
|
||||
/// credentials.
|
||||
#[tauri::command]
|
||||
pub async fn sync_probe(url: String) -> Result<ProbeResult, String> {
|
||||
client::probe(&url).await
|
||||
}
|
||||
|
||||
/// Either a password login or a token pasted from the web app. Both are offered
|
||||
/// because neither covers everyone: a fresh install has no session to mint a token
|
||||
/// from, while someone using a password manager or SSO may prefer not to type a
|
||||
/// password into a desktop app at all.
|
||||
#[derive(Deserialize)]
|
||||
pub struct LinkInput {
|
||||
pub url: String,
|
||||
#[serde(default)]
|
||||
pub email: Option<String>,
|
||||
#[serde(default)]
|
||||
pub password: Option<String>,
|
||||
#[serde(default)]
|
||||
pub token: Option<String>,
|
||||
/// How this device is labelled in the server's device list.
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct LinkResult {
|
||||
pub status: state::Status,
|
||||
pub identity: Identity,
|
||||
/// Carried through so the UI can warn about a `degraded` server right after
|
||||
/// linking, instead of staying silent until a feature quietly does nothing.
|
||||
pub compatibility: Compatibility,
|
||||
}
|
||||
|
||||
/// A recognizable default, so a server's device list doesn't fill up with "Device".
|
||||
fn default_device_name() -> String {
|
||||
format!("ThoughtSync desktop ({})", std::env::consts::OS)
|
||||
}
|
||||
|
||||
fn trimmed(value: &Option<String>) -> Option<&str> {
|
||||
value.as_deref().map(str::trim).filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn sync_link(input: LinkInput, db: State<'_, Db>) -> Result<LinkResult, String> {
|
||||
// 1. Handshake FIRST. Never hand credentials to a server we've established we
|
||||
// can't sync with — and an incompatible server is exactly the case where a
|
||||
// later failure would be hardest to attribute.
|
||||
let probe = client::probe(&input.url).await?;
|
||||
if let Compatibility::Incompatible { reason, .. } = &probe.compatibility {
|
||||
return Err(reason.clone());
|
||||
}
|
||||
let base_url = probe.base_url;
|
||||
|
||||
// 2. Obtain a credential.
|
||||
let (token, identity) = match trimmed(&input.token) {
|
||||
Some(token) => {
|
||||
// Verify before storing: an unverified paste turns a copy/paste slip
|
||||
// into a failure that only surfaces at the next sync.
|
||||
let identity = client::fetch_identity(&base_url, token).await?;
|
||||
(token.to_string(), identity)
|
||||
}
|
||||
None => {
|
||||
let (Some(email), Some(password)) = (trimmed(&input.email), trimmed(&input.password))
|
||||
else {
|
||||
return Err("Enter your email and password, or paste a device token.".to_string());
|
||||
};
|
||||
let name = trimmed(&input.name)
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(default_device_name);
|
||||
client::device_login(&base_url, email, password, &name).await?
|
||||
}
|
||||
};
|
||||
|
||||
// 3. Persist. The lock is taken only now, for two reasons: a std MutexGuard
|
||||
// isn't Send so it cannot be held across an await, and holding the store
|
||||
// locked for a network round-trip would freeze every note operation in the UI.
|
||||
let status = {
|
||||
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||
state::set_link(&conn, &base_url, &token).map_err(|e| e.to_string())?;
|
||||
state::status(&conn).map_err(|e| e.to_string())?
|
||||
};
|
||||
|
||||
log::info!("linked to {} as {}", base_url, identity.email);
|
||||
Ok(LinkResult {
|
||||
status,
|
||||
identity,
|
||||
compatibility: probe.compatibility,
|
||||
})
|
||||
}
|
||||
|
||||
/// Stop syncing and forget the server.
|
||||
///
|
||||
/// Local only: the device token remains valid on the SERVER until revoked there
|
||||
/// (Account → Linked devices). We can't reliably revoke it from here — a pasted
|
||||
/// token arrives without its device id — so the UI must say so rather than imply a
|
||||
/// remote revoke that didn't happen. Tracked for follow-up.
|
||||
#[tauri::command]
|
||||
pub fn sync_unlink(db: State<'_, Db>) -> Result<state::Status, String> {
|
||||
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||
state::clear_link(&conn).map_err(|e| e.to_string())?;
|
||||
log::info!("unlinked from server");
|
||||
state::status(&conn).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn sync_status(db: State<'_, Db>) -> Result<state::Status, String> {
|
||||
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||
state::status(&conn).map_err(|e| e.to_string())
|
||||
}
|
||||
Reference in New Issue
Block a user