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,233 @@
|
||||
//! The link record: which server this app is paired with, the device token that
|
||||
//! authenticates to it, and how far it has consumed that server's change feed.
|
||||
//!
|
||||
//! One row, enforced by `CHECK (id = 1)` and seeded during migration, so every
|
||||
//! operation here is an UPDATE — there is no create-or-missing case to handle.
|
||||
//!
|
||||
//! The token lives in the app-data SQLite file rather than an OS keyring on purpose:
|
||||
//! 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 or minimal-WM
|
||||
//! setups. Protecting the database file is the portable trade.
|
||||
|
||||
use rusqlite::{params, Connection};
|
||||
use serde::Serialize;
|
||||
|
||||
/// The full link record, token included. Internal to the Rust side.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct SyncState {
|
||||
pub server_url: Option<String>,
|
||||
pub device_token: Option<String>,
|
||||
pub last_cursor: i64,
|
||||
}
|
||||
|
||||
impl SyncState {
|
||||
/// Linked means BOTH a server and a credential for it. Either one alone is a
|
||||
/// half-written link that nothing can act on, so it must not read as linked.
|
||||
pub fn is_linked(&self) -> bool {
|
||||
self.server_url.is_some() && self.device_token.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// What the UI is allowed to see.
|
||||
///
|
||||
/// Deliberately has no `device_token` field: this crosses into the webview, and a
|
||||
/// long-lived bearer token has no business being reachable from page scripts.
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||
pub struct Status {
|
||||
pub linked: bool,
|
||||
pub server_url: Option<String>,
|
||||
pub last_cursor: i64,
|
||||
}
|
||||
|
||||
impl From<&SyncState> for Status {
|
||||
fn from(s: &SyncState) -> Self {
|
||||
Status {
|
||||
linked: s.is_linked(),
|
||||
server_url: s.server_url.clone(),
|
||||
last_cursor: s.last_cursor,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Treat a blank string as absent, so a half-cleared row can't masquerade as linked.
|
||||
fn present(value: Option<String>) -> Option<String> {
|
||||
value.filter(|s| !s.trim().is_empty())
|
||||
}
|
||||
|
||||
pub fn read(conn: &Connection) -> rusqlite::Result<SyncState> {
|
||||
conn.query_row(
|
||||
"SELECT server_url, device_token, last_cursor FROM sync_state WHERE id = 1",
|
||||
[],
|
||||
|row| {
|
||||
let cursor: Option<String> = row.get(2)?;
|
||||
Ok(SyncState {
|
||||
server_url: present(row.get(0)?),
|
||||
device_token: present(row.get(1)?),
|
||||
// Stored TEXT (schema) but used as an integer watermark. Absent or
|
||||
// unparseable means "start from the beginning" — always the safe
|
||||
// reading, because a redundant full sync costs time, never data,
|
||||
// whereas a too-high cursor silently skips changes.
|
||||
last_cursor: cursor.and_then(|c| c.trim().parse().ok()).unwrap_or(0),
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Record a link.
|
||||
///
|
||||
/// Resets the change-feed cursor whenever the server differs from the one previously
|
||||
/// linked. 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 that looks like a successful sync. Re-linking the SAME
|
||||
/// server (after a token refresh, say) keeps the cursor, so a routine re-auth doesn't
|
||||
/// force a full re-download.
|
||||
pub fn set_link(conn: &Connection, server_url: &str, device_token: &str) -> rusqlite::Result<()> {
|
||||
let keep_cursor = read(conn)?.server_url.as_deref() == Some(server_url);
|
||||
conn.execute(
|
||||
"UPDATE sync_state
|
||||
SET server_url = ?1,
|
||||
device_token = ?2,
|
||||
last_cursor = CASE WHEN ?3 THEN last_cursor ELSE NULL END
|
||||
WHERE id = 1",
|
||||
params![server_url, device_token, keep_cursor],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Forget the server entirely.
|
||||
///
|
||||
/// Clears the cursor as well as the credentials: a cursor left behind would, on the
|
||||
/// next link, be interpreted against a server that never issued it.
|
||||
pub fn clear_link(conn: &Connection) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"UPDATE sync_state
|
||||
SET server_url = NULL, device_token = NULL, last_cursor = NULL
|
||||
WHERE id = 1",
|
||||
[],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Advance the consumed-change watermark. Called by the pull loop (M10.7b) only
|
||||
/// after a page has been fully applied.
|
||||
pub fn set_cursor(conn: &Connection, cursor: i64) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"UPDATE sync_state SET last_cursor = ?1 WHERE id = 1",
|
||||
params![cursor.to_string()],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn status(conn: &Connection) -> rusqlite::Result<Status> {
|
||||
Ok(Status::from(&read(conn)?))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::local::schema;
|
||||
|
||||
fn db() -> Connection {
|
||||
let conn = Connection::open_in_memory().expect("in-memory db");
|
||||
schema::migrate(&conn).expect("migrate");
|
||||
conn
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fresh_store_is_unlinked() {
|
||||
let conn = db();
|
||||
let state = read(&conn).expect("read");
|
||||
assert_eq!(state, SyncState::default());
|
||||
assert!(!state.is_linked());
|
||||
assert_eq!(state.last_cursor, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_round_trips() {
|
||||
let conn = db();
|
||||
set_link(&conn, "https://notes.example.com", "tok-1").expect("link");
|
||||
let state = read(&conn).expect("read");
|
||||
assert!(state.is_linked());
|
||||
assert_eq!(state.server_url.as_deref(), Some("https://notes.example.com"));
|
||||
assert_eq!(state.device_token.as_deref(), Some("tok-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relinking_the_same_server_keeps_the_cursor() {
|
||||
let conn = db();
|
||||
set_link(&conn, "https://a.example.com", "tok-1").expect("link");
|
||||
set_cursor(&conn, 4242).expect("cursor");
|
||||
// e.g. the token was revoked and the user re-authenticated.
|
||||
set_link(&conn, "https://a.example.com", "tok-2").expect("relink");
|
||||
let state = read(&conn).expect("read");
|
||||
assert_eq!(state.last_cursor, 4242, "a re-auth shouldn't force a full re-sync");
|
||||
assert_eq!(state.device_token.as_deref(), Some("tok-2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linking_a_different_server_resets_the_cursor() {
|
||||
let conn = db();
|
||||
set_link(&conn, "https://a.example.com", "tok-1").expect("link");
|
||||
set_cursor(&conn, 4242).expect("cursor");
|
||||
set_link(&conn, "https://b.example.com", "tok-2").expect("relink");
|
||||
assert_eq!(
|
||||
read(&conn).expect("read").last_cursor,
|
||||
0,
|
||||
"a cursor from another server would skip everything below it"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unlink_clears_the_cursor_too() {
|
||||
let conn = db();
|
||||
set_link(&conn, "https://a.example.com", "tok-1").expect("link");
|
||||
set_cursor(&conn, 99).expect("cursor");
|
||||
clear_link(&conn).expect("unlink");
|
||||
let state = read(&conn).expect("read");
|
||||
assert!(!state.is_linked());
|
||||
assert_eq!(state.last_cursor, 0);
|
||||
assert!(state.server_url.is_none());
|
||||
assert!(state.device_token.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn half_written_link_is_not_linked() {
|
||||
let conn = db();
|
||||
conn.execute(
|
||||
"UPDATE sync_state SET server_url = 'https://a.example.com' WHERE id = 1",
|
||||
[],
|
||||
)
|
||||
.expect("partial write");
|
||||
assert!(!read(&conn).expect("read").is_linked());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blank_strings_count_as_absent() {
|
||||
let conn = db();
|
||||
conn.execute(
|
||||
"UPDATE sync_state SET server_url = ' ', device_token = '' WHERE id = 1",
|
||||
[],
|
||||
)
|
||||
.expect("blank write");
|
||||
let state = read(&conn).expect("read");
|
||||
assert!(!state.is_linked());
|
||||
assert!(state.server_url.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unparseable_cursor_falls_back_to_a_full_sync() {
|
||||
let conn = db();
|
||||
conn.execute("UPDATE sync_state SET last_cursor = 'garbage' WHERE id = 1", [])
|
||||
.expect("bad cursor");
|
||||
assert_eq!(read(&conn).expect("read").last_cursor, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_never_carries_the_token() {
|
||||
let conn = db();
|
||||
set_link(&conn, "https://a.example.com", "super-secret").expect("link");
|
||||
let json = serde_json::to_string(&status(&conn).expect("status")).expect("serialize");
|
||||
assert!(!json.contains("super-secret"), "token leaked to the webview: {json}");
|
||||
assert!(json.contains("\"linked\":true"), "got {json}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user