Files
Marcos Paulo 181aad91e6 feat: sync, accounts, live editing, vault launcher, compact layout
Sync and the server
- crates/sync: revision ledger (stale writers conflict, edit beats delete), client planner, three-way merge, conflict names, path and hash gates
- apps/notescraft-sync-server: pairing, device tokens, change log with long poll, blobs, accounts with many vaults, roles, invites, audit log, quota, GC, readiness, request ids
- server history (revisions, restore, retention), chunked resumable uploads, live editing endpoints (SSE rooms, presence, flush through the ledger)
- engine: HttpRemote, sync_once, SyncWorker, sync.* commands, share.* and server.* commands, live session client, note.capture, vault catalog and vaults.* commands

Editing together
- crates/collab: operational transformation core (TextOp, transform, client state machine, room, presence)
- ui-egui: Settings > Sync, sync pill, conflicts, Share dialog, live pill and remote carets, launcher Connect and Join flows

App shell
- vault launcher (desktop and phone layouts) wired into the app host (--launcher, File > Vaults...)
- compact (phone) layout with a bottom tab bar

Verified with cargo xtask ci (fmt, clippy -D warnings, workspace tests, layers, tree, i18n). Android and iOS shells are not built: the build machine has no NDK or Xcode.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 07:46:24 -03:00

192 lines
9.4 KiB
Rust

//! Accounts over real HTTP: legacy migration, pairing creates or reuses an account,
//! `GET /v1/vaults` lists only the caller's memberships, and the ledger cache cap
//! keeps every vault answering after eviction.
mod common;
use common::{ADMIN, Server, temp_dir};
use notescraft_sync::hash_hex;
use serde_json::json;
/// The legacy schema, before accounts existed (devices were bound to vaults directly).
const LEGACY: &str = "
CREATE TABLE vaults (id TEXT PRIMARY KEY, created_ms INTEGER NOT NULL);
CREATE TABLE pairings (code_hash TEXT PRIMARY KEY, vault TEXT NOT NULL, expires_ms INTEGER NOT NULL);
CREATE TABLE devices (id TEXT PRIMARY KEY, vault TEXT NOT NULL, name TEXT NOT NULL, token_hash TEXT NOT NULL UNIQUE, created_ms INTEGER NOT NULL);
CREATE TABLE files (vault TEXT NOT NULL, path TEXT NOT NULL, hash TEXT NOT NULL, size INTEGER NOT NULL,
mtime_ms INTEGER NOT NULL, seq INTEGER NOT NULL, deleted INTEGER NOT NULL, PRIMARY KEY (vault, path));
";
#[test]
fn migration_keeps_an_old_token_working_and_creates_an_owner() {
let dir = temp_dir("accounts-legacy");
let path = dir.join("sync.db");
{
let conn = rusqlite::Connection::open(&path).expect("db");
conn.execute_batch(LEGACY).expect("legacy schema");
let token = "legacy-device-token";
conn.execute("INSERT INTO vaults (id, created_ms) VALUES ('v-old', 1)", []).expect("vault");
conn.execute("INSERT INTO devices (id, vault, name, token_hash, created_ms) VALUES ('d-old', 'v-old', 'keeper', ?1, 1)", [hash_hex(token.as_bytes())])
.expect("device");
}
let state = notescraft_sync_server::AppState::new(notescraft_sync_server::Store::open(&path).expect("open"), dir.join("blobs"), ADMIN);
let server = common::start_with_state(state);
// The old token now belongs to a fresh account that owns the old vault.
let (status, rows) = server.json("GET", "/v1/vaults", Some("legacy-device-token"), &[], None);
assert_eq!(status, 200, "the legacy token must keep working");
let rows = rows.as_array().expect("rows");
assert_eq!(rows.len(), 1);
assert_eq!(rows[0]["id"], "v-old");
assert_eq!(rows[0]["role"], "owner");
assert_eq!(rows[0]["name"], "v-old");
// The migrated user is named after the device and is the vault's owner.
let (status, members) = server.json("GET", "/v1/vaults/v-old/members", Some("legacy-device-token"), &[], None);
assert_eq!(status, 200);
let members = members.as_array().expect("members");
assert_eq!(members.len(), 1);
assert_eq!(members[0]["name"], "keeper");
assert_eq!(members[0]["role"], "owner");
assert!(members[0]["user_id"].as_str().expect("user id").starts_with("u-"));
// Reading and writing through the legacy token is unchanged.
assert_eq!(server.put("v-old", "legacy-device-token", "a.md", "x", 0).0, 201);
assert!(server.audit().iter().any(|row| row["kind"] == "member_added"));
}
#[test]
fn pair_twice_with_the_same_user_leads_to_one_account() {
let server = Server::start_temp("accounts-two-devices");
let user = server.create_user("marc");
let vault = Server::fresh_vault_id("two-devices");
let (status, phone) = server.pair_user(&user, &vault, "phone");
assert_eq!(status, 200);
let (status, laptop) = server.pair_user(&user, &vault, "laptop");
assert_eq!(status, 200);
assert_ne!(phone, laptop);
// Both devices belong to the same account and see exactly that account's vaults.
let (_, mine_phone) = server.json("GET", "/v1/vaults", Some(&phone), &[], None);
let (_, mine_laptop) = server.json("GET", "/v1/vaults", Some(&laptop), &[], None);
assert_eq!(mine_phone, mine_laptop);
assert_eq!(mine_phone.as_array().expect("rows").len(), 1);
assert_eq!(mine_phone[0]["id"], vault);
let (_, devices) = server.json("GET", &format!("/v1/vaults/{vault}/devices"), Some(&phone), &[], None);
assert_eq!(devices.as_array().expect("rows").len(), 2);
// A second pairing did not demote the existing membership: still one member row.
let (_, members) = server.json("GET", &format!("/v1/vaults/{vault}/members"), Some(&phone), &[], None);
assert_eq!(members.as_array().expect("members").len(), 1);
// `?user=` for a user that does not exist is refused.
let (status, _) = server.json("POST", &format!("/v1/vaults/{}/pairing?user=u-missing", Server::fresh_vault_id("none")), Some(ADMIN), &[], None);
assert_eq!(status, 404);
}
#[test]
fn vault_listing_shows_only_my_memberships_sorted_by_name() {
let server = Server::start_temp("accounts-twelve");
let (bootstrap, token, _) = server.owned_vault("home");
assert_eq!(server.json("PATCH", &format!("/v1/vaults/{bootstrap}"), Some(&token), &[], Some(&json!({"name": "00 home"}))).0, 204);
let wanted =
["20 work", "10 personal", "30 course", "40 game jam", "50 cooking", "60 music", "70 travel", "80 reading", "90 garden", "95 blog", "99 archive"];
let mut ids = vec![bootstrap.clone()];
for name in wanted {
let (status, vault) = server.make_vault(&token, name);
assert_eq!(status, 200, "{name}");
ids.push(vault);
}
assert_eq!(ids.len(), 12);
// Another account's vault never shows up.
let (intruder_vault, _) = server.paired("intruder");
let _ = intruder_vault;
let (_, mine) = server.json("GET", "/v1/vaults", Some(&token), &[], None);
let rows = mine.as_array().expect("rows");
assert_eq!(rows.len(), 12);
let names: Vec<&str> = rows.iter().map(|row| row["name"].as_str().expect("name")).collect();
let mut sorted = names.clone();
sorted.sort_unstable();
assert_eq!(names, sorted, "sorted by display name");
assert!(!names.contains(&"intruder"), "no other user's vault");
assert!(!names.contains(&"v-intruder"));
for row in rows {
assert_eq!(row["role"], "owner");
assert_eq!(row["members"], 1);
let _ = (row["bytes_used"].as_u64(), row["quota_bytes"].as_u64().unwrap_or(u64::MAX) > 0, row["head_seq"].as_u64(), row["last_change_ms"].as_u64());
assert!(ids.contains(&row["id"].as_str().expect("id").to_owned()), "row {} of mine", row["id"]);
}
assert_eq!(rows.iter().find(|row| row["id"] == bootstrap.as_str()).map(|row| row["name"].as_str()), Some(Some("00 home")));
// An unknown token lists nothing: 401.
assert_eq!(server.json("GET", "/v1/vaults", Some("nonsense"), &[], None).0, 401);
assert_eq!(server.json("GET", "/v1/vaults", None, &[], None).0, 401);
}
#[test]
fn vault_names_are_trimmed_validated_and_capped_per_user() {
let server = Server::start_temp("accounts-cap");
let (bootstrap, token, _) = server.owned_vault("cap");
assert_eq!(server.make_vault(&token, " ").0, 400, "empty names are refused");
assert_eq!(server.make_vault(&token, "x".repeat(81).as_str()).0, 400);
let (status, vault) = server.make_vault(&token, " padded ");
assert_eq!(status, 200);
let (_, mine) = server.json("GET", "/v1/vaults", Some(&token), &[], None);
let row = mine.as_array().expect("rows").iter().find(|row| row["id"] == vault.as_str()).expect("row");
assert_eq!(row["name"], "padded", "names are trimmed");
// The bootstrap vault plus 49 created reach the ownership cap; 51 is refused.
for index in 0..48 {
let (status, _) = server.make_vault(&token, &format!("vault {index:02}"));
assert_eq!(status, 200, "vault {index}");
}
assert_eq!(server.make_vault(&token, "one too many").0, 409);
let _ = bootstrap;
}
#[test]
fn seventy_vaults_stay_answerable_across_lru_evictions() {
let server = Server::start_temp("accounts-lru");
let (owner_vault, owner, _) = server.owned_vault("lru-owner");
let mut vaults = vec![owner_vault.clone()];
for index in 0..49 {
let (status, vault) = server.make_vault(&owner, &format!("owned {index:02}"));
assert_eq!(status, 200);
vaults.push(vault);
}
// A second account adds 20 more, so the cap of 64 is passed together.
let (other_vault, other) = server.paired("lru-other");
vaults.push(other_vault);
for index in 0..19 {
let (status, vault) = server.make_vault(&other, &format!("theirs {index:02}"));
assert_eq!(status, 200);
vaults.push(vault);
}
assert_eq!(vaults.len(), 70);
// Touch all 70: loading walks past the cache cap, so older ledgers are evicted.
for (index, vault) in vaults.iter().enumerate() {
let token = if index < 50 { owner.as_str() } else { other.as_str() };
let name = format!("note{index}.md");
assert_eq!(server.put(vault, token, &name, "written", 0).0, 201, "vault {index}");
let (status, page) = server.json("GET", &format!("/v1/vaults/{vault}/changes?since=0"), Some(token), &[], None);
assert_eq!((status, page["records"].as_array().map(Vec::len)), (200, Some(1)), "vault {index}");
}
// Every vault, including the earliest (long-evicted), still answers correctly.
for (index, vault) in vaults.iter().enumerate() {
let token = if index < 50 { owner.as_str() } else { other.as_str() };
let (status, page) = server.json("GET", &format!("/v1/vaults/{vault}/changes?since=0"), Some(token), &[], None);
assert_eq!((status, page["records"][0]["path"].as_str()), (200, Some(format!("note{index}.md").as_str())), "vault {index}");
}
let (_, mine) = server.json("GET", "/v1/vaults", Some(&owner), &[], None);
assert_eq!(mine.as_array().expect("rows").len(), 50);
assert_eq!(mine[0]["role"], "owner");
let _ = owner_vault;
}