From dbf92bc2acd1a722a13c37ba8d44c7889585f231 Mon Sep 17 00:00:00 2001 From: Daniel Klimuntowski Date: Wed, 22 Apr 2026 23:03:50 +0200 Subject: [PATCH 1/5] docs: add todo for rearchitecting this repo to abstract away implementation details --- TODO.md | 79 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/TODO.md b/TODO.md index 4640904..4aaa4bf 100644 --- a/TODO.md +++ b/TODO.md @@ -1 +1,80 @@ # TODO + +--- + +## [ARCH] Introduce sync-platform-api — clean platform boundary + +### Background + +The sync server currently bundles two concerns that must be separated to support multiple +deployment targets (cloud Docker, Android embedded service): + +1. **Core sync protocol** — `rslib/` (upstream, verbatim), `sync-storage-backends/` (Google Drive + + local impls), `sync-storage-server/` (composition root). These are platform-agnostic. + +2. **Platform-specific glue** — `sync-storage-config/` (SQLite queries, AES-256-GCM token + decrypt, bcrypt auth, OAuth HTTP exchange). This knows about a specific DB schema and + credential storage mechanism. It must be extracted. + +The trait boundary already exists in `sync-storage-api`: `AuthProvider`, `BackendResolver`, +`StorageBackend`. This task formalises it as `sync-platform-api` — the **only** public contract +that external deployment targets depend on — and strips all platform knowledge from this repo. + +After this task: +- `anki-cloud-sync` knows nothing about SQLite schemas, AES keys, JNI, or Android. +- `anki-cloud` owns its own `sync-platform-cloud` crate (SQLite + OAuth + AES). +- `anki-cloud-android` owns its own `sync-platform-android` crate (Room + Android Credential + Manager + JNI callbacks). + +### Tasks + +**1. Rename `sync-storage-api` → `sync-platform-api`** +- Rename directory and crate name in `sync-platform-api/Cargo.toml` +- Update workspace `Cargo.toml`: replace `sync-storage-api` entry with `sync-platform-api` +- Update all import paths across the workspace: + - `sync-storage-backends/` — `use sync_storage_api::*` → `use sync_platform_api::*` + - `sync-storage-server/` — same + - `rslib/src/sync/http_server/mod.rs` — same + - `rslib/src/sync/http_server/user.rs` — same +- No logic changes. Traits are already correct as-is. + +**2. Delete `sync-storage-config` crate** +- Coordinate with `anki-cloud` team: `sync-platform-cloud` must land there first (it takes + ownership of all DB queries, token decryption, OAuth exchange, and bcrypt auth currently + in `sync-storage-config`). +- Once `anki-cloud` is ready: + - Remove `sync-storage-config/` directory + - Remove from workspace `Cargo.toml` + - Remove from `sync-storage-server/Cargo.toml` dependencies + +**3. Strip Cloud impls from `sync-storage-server`** +- In `sync-storage-server/src/auth.rs`: delete `CloudAuthProvider` +- In `sync-storage-server/src/resolver.rs`: delete `CloudBackendResolver` +- In `sync-storage-server/src/lib.rs`: + - Delete `SyncMode::Cloud` variant and its `make_providers()` branch + - Remove `sync-storage-config` import +- `sync-storage-server` retains only `StandaloneAuthProvider` + `StandaloneBackendResolver` + (env-var user list, no DB, no OAuth). + +**4. Update CLAUDE.md** +- Update crate table to reflect new names +- Add section explaining `sync-platform-api` as the public contract for external impls +- Clarify that this repo has zero knowledge of any DB schema, JNI, or Android + +### Acceptance criteria +- `cargo build --bin anki-sync-server` succeeds (standalone mode) +- `cargo test -p sync-platform-api` passes +- `cargo test -p sync-storage-backends` passes +- No `sync_storage_config` imports anywhere in the workspace +- No JNI or SQLite schema references anywhere in the workspace + +--- + +## [BACKLOG] Expose `SimpleServer` as a stable library interface + +Currently `SimpleServer::new(base_folder, auth, resolver)` is in `rslib` (upstream, no-edit). +For external callers (anki-cloud-android's `sync-server-jni`) to call it, a thin shim crate +may be needed that re-exports it with a stable API surface. + +Defer until anki-cloud-android needs it — implement only if the existing import path is +impractical from an external crate. From 1a75b6b1f7226a164c82be599dec41024a12f190 Mon Sep 17 00:00:00 2001 From: Daniel Klimuntowski Date: Thu, 23 Apr 2026 18:45:58 +0200 Subject: [PATCH 2/5] refactor: rename `sync-storage-api` to `sync-platform-api` and update references across codebase - Unified naming convention by replacing `sync-storage-api` with `sync-platform-api` in all dependencies, imports, and paths. - Removed unused cloud-specific code to streamline implementation. - Simplified `make_providers` by removing `SyncMode` and related logic. --- Cargo.lock | 9 ++-- Cargo.toml | 4 +- rslib/Cargo.toml | 2 +- rslib/src/sync/collection/tests.rs | 6 +-- rslib/src/sync/http_server/mod.rs | 4 +- rslib/src/sync/http_server/user.rs | 2 +- .../Cargo.toml | 2 +- .../src/lib.rs | 0 sync-storage-backends/Cargo.toml | 2 +- .../src/backends/google_drive.rs | 2 +- sync-storage-backends/src/backends/local.rs | 2 +- sync-storage-backends/src/lib.rs | 2 +- sync-storage-server/Cargo.toml | 3 +- sync-storage-server/src/auth.rs | 23 +--------- sync-storage-server/src/lib.rs | 42 ++++++------------- sync-storage-server/src/resolver.rs | 22 +--------- 16 files changed, 33 insertions(+), 94 deletions(-) rename {sync-storage-api => sync-platform-api}/Cargo.toml (79%) rename {sync-storage-api => sync-platform-api}/src/lib.rs (100%) diff --git a/Cargo.lock b/Cargo.lock index e98d8f0..65fc07c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -181,7 +181,7 @@ dependencies = [ "snafu", "strum 0.27.2", "syn", - "sync-storage-api", + "sync-platform-api", "tempfile", "tokio", "tokio-util", @@ -5227,7 +5227,7 @@ dependencies = [ ] [[package]] -name = "sync-storage-api" +name = "sync-platform-api" version = "0.1.0" dependencies = [ "anyhow", @@ -5242,7 +5242,7 @@ dependencies = [ "rand 0.9.2", "reqwest", "serde_json", - "sync-storage-api", + "sync-platform-api", "tempfile", "tokio", "wiremock", @@ -5278,9 +5278,8 @@ dependencies = [ "serde", "serde_json", "snafu", - "sync-storage-api", + "sync-platform-api", "sync-storage-backends", - "sync-storage-config", "tokio", "tracing", ] diff --git a/Cargo.toml b/Cargo.toml index e392936..c0bf352 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ rust-version = "1.80" [workspace] members = [ - "sync-storage-api", + "sync-platform-api", "sync-storage-backends", "sync-storage-config", "sync-storage-server", @@ -53,7 +53,7 @@ anki_io = { path = "rslib/io" } anki_process = { path = "rslib/process" } anki_proto = { path = "rslib/proto" } anki_proto_gen = { path = "rslib/proto_gen" } -sync-storage-api = { path = "sync-storage-api" } +sync-platform-api = { path = "sync-platform-api" } sync-storage-backends = { path = "sync-storage-backends" } sync-storage-config = { path = "sync-storage-config" } sync-storage-server = { path = "sync-storage-server" } diff --git a/rslib/Cargo.toml b/rslib/Cargo.toml index 1c7ba25..3dc9abe 100644 --- a/rslib/Cargo.toml +++ b/rslib/Cargo.toml @@ -92,7 +92,7 @@ serde.workspace = true serde-aux.workspace = true serde_json.workspace = true serde_repr.workspace = true -sync-storage-api.workspace = true +sync-platform-api.workspace = true serde_tuple.workspace = true sha1.workspace = true snafu.workspace = true diff --git a/rslib/src/sync/collection/tests.rs b/rslib/src/sync/collection/tests.rs index bd8a307..610dde7 100644 --- a/rslib/src/sync/collection/tests.rs +++ b/rslib/src/sync/collection/tests.rs @@ -50,9 +50,9 @@ use crate::sync::http_server::SyncServerConfig; use crate::sync::login::HostKeyRequest; use crate::sync::login::SyncAuth; use crate::sync::request::IntoSyncRequest; -use sync_storage_api::AuthProvider; -use sync_storage_api::BackendResolver; -use sync_storage_api::StorageBackend; +use sync_platform_api::AuthProvider; +use sync_platform_api::BackendResolver; +use sync_platform_api::StorageBackend; struct TestCredentials { username: String, diff --git a/rslib/src/sync/http_server/mod.rs b/rslib/src/sync/http_server/mod.rs index 72ae900..0dd25da 100644 --- a/rslib/src/sync/http_server/mod.rs +++ b/rslib/src/sync/http_server/mod.rs @@ -25,8 +25,8 @@ use axum::Router; use axum_client_ip::ClientIpSource; use snafu::ResultExt; use snafu::Whatever; -use sync_storage_api::AuthProvider; -use sync_storage_api::BackendResolver; +use sync_platform_api::AuthProvider; +use sync_platform_api::BackendResolver; use tokio::net::TcpListener; use tracing::Span; diff --git a/rslib/src/sync/http_server/user.rs b/rslib/src/sync/http_server/user.rs index 94e81f0..4fa0839 100644 --- a/rslib/src/sync/http_server/user.rs +++ b/rslib/src/sync/http_server/user.rs @@ -4,7 +4,7 @@ use std::path::PathBuf; use std::sync::Arc; -use sync_storage_api::BackendResolver; +use sync_platform_api::BackendResolver; use tracing::info; use crate::collection::Collection; diff --git a/sync-storage-api/Cargo.toml b/sync-platform-api/Cargo.toml similarity index 79% rename from sync-storage-api/Cargo.toml rename to sync-platform-api/Cargo.toml index fdcf088..8bd62c0 100644 --- a/sync-storage-api/Cargo.toml +++ b/sync-platform-api/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "sync-storage-api" +name = "sync-platform-api" version = "0.1.0" edition = "2021" license = "AGPL-3.0-or-later" diff --git a/sync-storage-api/src/lib.rs b/sync-platform-api/src/lib.rs similarity index 100% rename from sync-storage-api/src/lib.rs rename to sync-platform-api/src/lib.rs diff --git a/sync-storage-backends/Cargo.toml b/sync-storage-backends/Cargo.toml index 847a23d..b50fb71 100644 --- a/sync-storage-backends/Cargo.toml +++ b/sync-storage-backends/Cargo.toml @@ -5,7 +5,7 @@ edition = "2021" license = "AGPL-3.0-or-later" [dependencies] -sync-storage-api = { path = "../sync-storage-api" } +sync-platform-api = { path = "../sync-platform-api" } anyhow = { workspace = true } reqwest = { workspace = true, features = ["json", "stream"] } serde_json = { workspace = true } diff --git a/sync-storage-backends/src/backends/google_drive.rs b/sync-storage-backends/src/backends/google_drive.rs index b12c290..4a2a2d6 100644 --- a/sync-storage-backends/src/backends/google_drive.rs +++ b/sync-storage-backends/src/backends/google_drive.rs @@ -5,7 +5,7 @@ use anyhow::{anyhow, Result}; use bytes::Bytes; use reqwest::header::{HeaderMap, AUTHORIZATION, CONTENT_TYPE}; use serde_json::{json, Value}; -use sync_storage_api::StorageBackend; +use sync_platform_api::StorageBackend; use tokio::fs; const COLLECTION_FILE_NAME: &str = "collection.anki2"; diff --git a/sync-storage-backends/src/backends/local.rs b/sync-storage-backends/src/backends/local.rs index d9aec38..c7555f6 100644 --- a/sync-storage-backends/src/backends/local.rs +++ b/sync-storage-backends/src/backends/local.rs @@ -1,7 +1,7 @@ use std::path::Path; use anyhow::Result; -use sync_storage_api::StorageBackend; +use sync_platform_api::StorageBackend; /// No-op backend — collection already lives on local filesystem. /// Used for local dev/testing without cloud storage. diff --git a/sync-storage-backends/src/lib.rs b/sync-storage-backends/src/lib.rs index 2946666..9a65250 100644 --- a/sync-storage-backends/src/lib.rs +++ b/sync-storage-backends/src/lib.rs @@ -3,7 +3,7 @@ mod backends; use anyhow::{anyhow, Result}; pub use backends::google_drive::GoogleDriveBackend; pub use backends::local::LocalBackend; -use sync_storage_api::StorageBackend; +use sync_platform_api::StorageBackend; pub struct StorageBackendFactory; diff --git a/sync-storage-server/Cargo.toml b/sync-storage-server/Cargo.toml index 04183a4..561acbd 100644 --- a/sync-storage-server/Cargo.toml +++ b/sync-storage-server/Cargo.toml @@ -10,9 +10,8 @@ description = "Composition root: wires auth/storage strategies into the Anki syn [dependencies] anki.workspace = true -sync-storage-api.workspace = true +sync-platform-api.workspace = true sync-storage-backends.workspace = true -sync-storage-config.workspace = true anyhow.workspace = true axum.workspace = true diff --git a/sync-storage-server/src/auth.rs b/sync-storage-server/src/auth.rs index ad8239a..75032e0 100644 --- a/sync-storage-server/src/auth.rs +++ b/sync-storage-server/src/auth.rs @@ -3,7 +3,7 @@ use std::collections::HashMap; use anyhow::{anyhow, Result}; use pbkdf2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}; use pbkdf2::Pbkdf2; -use sync_storage_api::AuthProvider; +use sync_platform_api::AuthProvider; /// Authenticates via `SYNC_USER*` env vars + PBKDF2. No DB required. pub struct StandaloneAuthProvider { @@ -66,24 +66,3 @@ impl AuthProvider for StandaloneAuthProvider { .ok_or_else(|| anyhow!("unknown hkey")) } } - -/// Authenticates via SQLite DB with bcrypt. Persists session keys for cross-instance re-hydration. -/// Note: these methods are called from within `block_in_place` contexts in rslib, so sync DB -/// calls are safe here. -pub struct CloudAuthProvider; - -impl AuthProvider for CloudAuthProvider { - fn authenticate(&self, username: &str, password: &str) -> Result<(String, String)> { - use sync_storage_config as ssc; - // block_in_place: called from async context; sqlite is blocking I/O - tokio::task::block_in_place(|| ssc::verify_sync_credentials(username, password))?; - let hkey = anki::sync::http_server::derive_hkey(&format!("{username}:{password}")); - tokio::task::block_in_place(|| ssc::store_sync_key(username, &hkey))?; - Ok((hkey, username.to_string())) - } - - fn lookup_by_hkey(&self, hkey: &str) -> Result { - use sync_storage_config as ssc; - tokio::task::block_in_place(|| ssc::lookup_user_by_sync_key(hkey)) - } -} diff --git a/sync-storage-server/src/lib.rs b/sync-storage-server/src/lib.rs index 7cdb4db..d6ab6a1 100644 --- a/sync-storage-server/src/lib.rs +++ b/sync-storage-server/src/lib.rs @@ -5,44 +5,27 @@ mod sidecar; use std::sync::Arc; +use anki::error; use anki::sync::http_server::SimpleServer; use anki::sync::http_server::SyncServerConfig; -use anki::error; use snafu::ResultExt; use snafu::Whatever; -use sync_storage_api::AuthProvider; -use sync_storage_api::BackendResolver; +use sync_platform_api::AuthProvider; +use sync_platform_api::BackendResolver; -pub use auth::{CloudAuthProvider, StandaloneAuthProvider}; -pub use resolver::{CloudBackendResolver, StandaloneBackendResolver}; +pub use auth::StandaloneAuthProvider; +pub use resolver::StandaloneBackendResolver; pub use sidecar::InternalServer; -pub enum SyncMode { - Standalone, - Cloud, -} - pub fn make_providers( - mode: SyncMode, ) -> error::Result<(Arc, Arc), Whatever> { - Ok(match mode { - SyncMode::Standalone => ( - Arc::new( - StandaloneAuthProvider::from_env() - .whatever_context("load SYNC_USER* env vars")?, - ), - Arc::new(StandaloneBackendResolver), + Ok(( + Arc::new( + StandaloneAuthProvider::from_env() + .whatever_context("load SYNC_USER* env vars")?, ), - SyncMode::Cloud => (Arc::new(CloudAuthProvider), Arc::new(CloudBackendResolver)), - }) -} - -/// Read `SYNC_MODE` env var (default: standalone). -pub fn mode_from_env() -> SyncMode { - match std::env::var("SYNC_MODE").as_deref() { - Ok("cloud") => SyncMode::Cloud, - _ => SyncMode::Standalone, - } + Arc::new(StandaloneBackendResolver), + )) } #[snafu::report] @@ -52,8 +35,7 @@ pub async fn run() -> error::Result<(), Whatever> { .from_env::() .whatever_context("reading SYNC_* env vars")?; - let mode = mode_from_env(); - let (auth, resolver) = make_providers(mode)?; + let (auth, resolver) = make_providers()?; let server = Arc::new( SimpleServer::new(&config.base_folder, auth, resolver) .whatever_context("create server")?, diff --git a/sync-storage-server/src/resolver.rs b/sync-storage-server/src/resolver.rs index e6ece17..9f1efe2 100644 --- a/sync-storage-server/src/resolver.rs +++ b/sync-storage-server/src/resolver.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use sync_storage_api::{BackendResolver, StorageBackend}; +use sync_platform_api::{BackendResolver, StorageBackend}; use sync_storage_backends::StorageBackendFactory; /// No-op: collection lives on local filesystem. Used for dev / Standalone mode. @@ -10,23 +10,3 @@ impl BackendResolver for StandaloneBackendResolver { StorageBackendFactory::create("local", "", "") } } - -/// Looks up storage config from DB, exchanges OAuth token, creates provider-specific backend. -/// This is the single canonical location of the backend-resolution logic (previously duplicated 4×). -pub struct CloudBackendResolver; - -impl BackendResolver for CloudBackendResolver { - fn resolve_for_user(&self, username: &str) -> Result> { - use sync_storage_config as db; - let (provider, refresh_token, folder_path) = db::fetch_storage_connection(username)?; - let access_token = if provider == "local" { - String::new() - } else { - tokio::task::block_in_place(|| { - tokio::runtime::Handle::current() - .block_on(db::exchange_refresh_token(&refresh_token)) - })? - }; - StorageBackendFactory::create(&provider, &access_token, &folder_path) - } -} From a6399e2d0b16a942f9474d48773cd0727ab83ff7 Mon Sep 17 00:00:00 2001 From: Daniel Klimuntowski Date: Thu, 23 Apr 2026 18:46:27 +0200 Subject: [PATCH 3/5] docs: update docs with new architecture --- CLAUDE.md | 217 ++++++++++-------- README.md | 131 +++-------- TODO.md | 51 ++-- ...14-introduce-sync-platform-api-boundary.md | 76 ++++++ 4 files changed, 256 insertions(+), 219 deletions(-) create mode 100644 docs/decisions/0014-introduce-sync-platform-api-boundary.md diff --git a/CLAUDE.md b/CLAUDE.md index 3d2502a..6c5d840 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,19 +7,21 @@ ## 1. What This Repository Is -A **modified Anki sync server** that stores user collections in user-owned cloud storage -(Google Drive, Dropbox, S3) instead of the local filesystem. Extracted from the -[anki-cloud](https://github.com/danielpmichalski/anki-cloud) monorepo. +A **platform-neutral Anki sync server** — a fork of the Rust sync server built into +[ankitects/anki](https://github.com/ankitects/anki) at tag `25.09`, plus four custom crates +that define the adapter trait boundary and one built-in implementation. -It is a **fork of the Rust sync server** built into [ankitects/anki](https://github.com/ankitects/anki) -at tag `25.09`, plus three custom crates that implement the cloud storage adapter layer. +The server exposes a stable trait boundary (`sync-platform-api`) so external platform crates can +supply their own `AuthProvider` and `BackendResolver` implementations without touching this repo. +This repo ships one built-in implementation: **standalone mode** (env-var users, local filesystem), +suitable for self-hosting and local development. -The sync server is consumed by the wider `anki-cloud` platform as an **external Docker image**. +The sync server is published as an **external Docker image** consumed by the `anki-cloud` platform. It has no knowledge of the REST API, MCP server, or web UI — it only knows about: - The Anki sync protocol (upstream, unmodified) -- SQLite (shared with the rest of the platform; read-only from this server's perspective) -- Cloud storage backends (Google Drive, local) +- The `sync-platform-api` trait boundary (AuthProvider, BackendResolver, StorageBackend) +- Cloud storage backends (Google Drive, local) — via `sync-storage-backends` ### Trademark note @@ -40,8 +42,8 @@ not to Ankitects' product. ├── .version ← Anki version this is pinned to (e.g. "25.09") ├── README.md │ -├── sync-storage-api/ ← OUR CRATE: StorageBackend trait -│ └── src/lib.rs +├── sync-platform-api/ ← OUR CRATE: stable public trait boundary +│ └── src/lib.rs ← AuthProvider, BackendResolver, StorageBackend │ ├── sync-storage-backends/ ← OUR CRATE: backend factory + implementations │ └── src/ @@ -52,7 +54,14 @@ not to Ankitects' product. │ └── google_drive.rs ← Google Drive implementation │ ├── sync-storage-config/ ← OUR CRATE: DB lookups, token decryption, bcrypt auth -│ └── src/lib.rs +│ └── src/lib.rs ← TRANSITIONAL: moves to anki-cloud's sync-platform-cloud +│ +├── sync-storage-server/ ← OUR CRATE: composition root (standalone mode) +│ └── src/ +│ ├── lib.rs ← make_providers() → standalone pair +│ ├── auth.rs ← StandaloneAuthProvider (SYNC_USER* env vars + PBKDF2) +│ ├── resolver.rs ← StandaloneBackendResolver (local filesystem) +│ └── sidecar.rs ← InternalServer (optional sidecar HTTP API) │ ├── rslib/ ← VERBATIM UPSTREAM (ankitects/anki@25.09 rslib/) │ ├── Cargo.toml @@ -69,31 +78,49 @@ not to Ankitects' product. ``` **Critical rule:** Never edit anything inside `rslib/`, `ftl/`, or `proto/` by hand. -Those are verbatim upstream copies. Changes go in the three custom crates only. +Those are verbatim upstream copies. Changes go in the four custom crates only. +Exception: mechanical import-path updates (`use sync_platform_api::` etc.) when renaming our +crates — these are unavoidable and do not touch protocol logic. To upgrade upstream, run `scripts/fork-anki-sync-server.zsh `. --- ## 3. Custom Crates vs. Upstream -| Crate | Files | Purpose | Edit? | -|-------------------------|--------------------------------|-----------------------------------------------------------------------------------------|--------| -| `sync-storage-api` | `src/lib.rs` (12 lines) | `StorageBackend` trait — the only interface between upstream and our code | Yes | -| `sync-storage-backends` | `src/lib.rs` + `backends/*.rs` | Factory + per-provider implementations | Yes | -| `sync-storage-config` | `src/lib.rs` | DB lookups, AES-256-GCM token decryption, bcrypt credential check, OAuth token exchange | Yes | -| `rslib` and sub-crates | all files | Upstream Anki sync protocol + binary | **No** | -| `ftl/`, `proto/` | all files | Upstream build deps | **No** | +| Crate | Files | Purpose | Edit? | +|-------------------------|--------------------------------|------------------------------------------------------------------------------------------------------------------|--------| +| `sync-platform-api` | `src/lib.rs` (~30 lines) | Stable public contract: `AuthProvider`, `BackendResolver`, `StorageBackend` — external impls depend on this | Yes | +| `sync-storage-backends` | `src/lib.rs` + `backends/*.rs` | Factory + per-provider storage implementations (Google Drive, local) | Yes | +| `sync-storage-config` | `src/lib.rs` | **Transitional** — DB lookups, AES-256-GCM token decryption, bcrypt auth, OAuth exchange. Moving to `anki-cloud` | Yes* | +| `sync-storage-server` | `src/*.rs` | Composition root: standalone auth + resolver, optional sidecar server | Yes | +| `rslib` and sub-crates | all files | Upstream Anki sync protocol + binary | **No** | +| `ftl/`, `proto/` | all files | Upstream build deps | **No** | -The three custom crates are minimal and deliberately decoupled so upstream upgrades -don't require touching our code. +*`sync-storage-config` will be deleted once `anki-cloud` has its own `sync-platform-cloud` crate. --- ## 4. Architecture -### 4.1 StorageBackend Trait +### 4.1 Platform-API Traits (`sync-platform-api`) + +These three traits are the **only interface** between upstream rslib and any deployment target. +External platform crates implement them; rslib imports them; this repo's built-in standalone +implementation lives in `sync-storage-server`. ```rust +pub trait AuthProvider: Send + Sync { + /// Validate credentials. Returns `(hkey, email)` on success. + fn authenticate(&self, username: &str, password: &str) -> Result<(String, String)>; + + /// Reverse-lookup: `hkey` → `email`. Called once per authenticated request. + fn lookup_by_hkey(&self, hkey: &str) -> Result; +} + +pub trait BackendResolver: Send + Sync { + fn resolve_for_user(&self, username: &str) -> Result>; +} + pub trait StorageBackend: Send + Sync { /// Download user's collection to `dest` before sync begins. fn fetch(&self, user: &str, dest: &Path) -> Result<()>; @@ -103,59 +130,70 @@ pub trait StorageBackend: Send + Sync { } ``` -This is the **entire interface** between upstream rslib and cloud storage. -All storage complexity lives behind `fetch` and `commit`. - ### 4.2 Request Lifecycle +The server is wired at startup with concrete `AuthProvider` and `BackendResolver` instances. +At runtime each request flows through them: + ``` 1. Anki client → POST /sync/{method} 2. Axum router (rslib/src/sync/http_server/routes.rs) 3. SyncProtocol::with_authenticated_user() - → validates hkey: first check in-memory map, then DB (users_sync_state.sync_key) + → auth.lookup_by_hkey(hkey) + Standalone: in-memory map (populated from SYNC_USER* at startup) + Platform: DB query on users_sync_state.sync_key 4. User::open_collection() - → sync_storage_config::fetch_storage_connection(email) - ← SELECT provider, oauth_refresh_token FROM storage_connections JOIN users WHERE email = ? - → sync_storage_config::exchange_refresh_token(refresh_token) [if provider != "local"] - ← POST https://oauth2.googleapis.com/token - → StorageBackendFactory::create(provider, access_token) - → backend.fetch(user, dest) [downloads collection from Google Drive] + → resolver.resolve_for_user(email) → Box + Standalone: StorageBackendFactory::create("local", …) → no-op + Platform: DB lookup + OAuth token exchange → StorageBackendFactory::create("google", …) + → backend.fetch(user, dest) + Standalone: no-op (collection already on local disk) + Platform: download collection.anki2 from Google Drive 5. Sync operations run against local SQLite copy of collection -6. backend.commit(user, src) [uploads collection back to Google Drive] +6. backend.commit(user, src) + Standalone: no-op + Platform: upload collection.anki2 back to Google Drive ``` -**Key property:** Fully stateless per request. Every request independently fetches storage -config from DB and exchanges a fresh OAuth access token. Safe for horizontal scaling. +**Key property:** Fully stateless per request. Each request re-resolves auth and storage from +scratch. Safe for horizontal scaling (platform impls must ensure the same). ### 4.3 Authentication -Anki clients authenticate with **email + sync password** (not Google OAuth). -The sync password is a separate credential generated in the web UI and stored as a -bcrypt hash in `users.sync_password_hash`. +**Standalone mode** (built into this binary): authenticates via `SYNC_USER*` env vars + PBKDF2. ``` POST /sync/hostKey {username: email, password: sync_password} -→ bcrypt.verify(password, users.sync_password_hash) [timing-safe; always runs even for unknown users] -→ hkey = SHA1(email:password) -→ upsert users_sync_state SET sync_key = hkey WHERE user_id = ... +→ lookup (username:password) in in-memory SYNC_USER* map +→ PBKDF2.verify(password, stored_hash) +→ hkey = SHA1(username:password) → return {key: hkey} Subsequent requests carry hkey in anki-sync header. +On restart: hkey not in memory → re-derive from SYNC_USER* map (deterministic). +``` + +**Platform implementations** supply their own `AuthProvider`. A cloud implementation +(e.g. `anki-cloud`'s `sync-platform-cloud`) uses bcrypt against a DB and persists hkeys: + +``` +→ bcrypt.verify(password, users.sync_password_hash) +→ hkey = SHA1(email:password) +→ upsert users_sync_state SET sync_key = hkey WHERE user_id = ... On restart/failover: hkey not in memory → lookup_user_by_sync_key(hkey) → re-hydrate. ``` -### 4.4 Token Encryption +### 4.4 Token Encryption (platform layer concern) OAuth refresh tokens are stored **encrypted at rest** (AES-256-GCM) in `storage_connections.oauth_refresh_token`. Format: `base64url(IV[12 bytes] || ciphertext+tag)` -The Rust `decrypt_token()` function in `sync-storage-config` must stay byte-for-byte compatible -with the TypeScript `encrypt()`/`decrypt()` in `packages/db/src/encrypt.ts` in the main monorepo, -since both read/write the same DB column. +Currently implemented in `sync-storage-config::decrypt_token()` — **transitional**. Once +`anki-cloud` owns `sync-platform-cloud`, this logic moves there and must remain byte-for-byte +compatible with the TypeScript `encrypt()`/`decrypt()` in `packages/db/src/encrypt.ts`. -Encryption key: `TOKEN_ENCRYPTION_KEY` env var — 32 bytes expressed as either 64 hex chars or -44 base64 chars. +Encryption key: `TOKEN_ENCRYPTION_KEY` env var — 32 bytes as 64 hex chars or 44 base64 chars. ### 4.5 Google Drive Backend @@ -177,27 +215,22 @@ Used for local development and self-hosting without cloud storage. ## 5. Environment Variables -### Required - -| Variable | Description | Example | -|------------------------|-------------------------------------------------|------------------------------------| -| `DATABASE_URL` | Path to shared SQLite DB | `file:/data/anki-cloud.db` | -| `TOKEN_ENCRYPTION_KEY` | 32-byte AES-256 key (64 hex or 44 base64 chars) | `deadbeef...` | -| `GOOGLE_CLIENT_ID` | Google OAuth2 client ID | `123...apps.googleusercontent.com` | -| `GOOGLE_CLIENT_SECRET` | Google OAuth2 client secret | `GOCSPX-...` | - -### Optional (with defaults) - -| Variable | Default | Description | -|-------------|-----------------|-------------------------------------------------| -| `SYNC_BASE` | `~/.syncserver` | Temp directory for user collections during sync | -| `SYNC_HOST` | `0.0.0.0` | Bind address | -| `SYNC_PORT` | `8080` | Bind port | -| `RUST_LOG` | `anki=info` | Log level (tracing filter) | +All variables listed here apply to the **standalone binary** published from this repo. +Platform-specific vars (DATABASE_URL, TOKEN_ENCRYPTION_KEY, GOOGLE_CLIENT_*) belong to +the external platform crate, not this binary. + +| Variable | Default | Description | +|-----------------------|-----------------|---------------------------------------------------------------------| +| `SYNC_BASE` | `~/.syncserver` | Temp directory for user collections during sync | +| `SYNC_HOST` | `0.0.0.0` | Bind address | +| `SYNC_PORT` | `8080` | Bind port | +| `SYNC_USER1` | — | `username:password` — repeat as `SYNC_USER2`, `SYNC_USER3`, … | +| `SYNC_INTERNAL_TOKEN` | — | Bearer token for internal API; if unset, sidecar server is disabled | +| `SYNC_INTERNAL_HOST` | `127.0.0.1` | Bind address for internal API | +| `SYNC_INTERNAL_PORT` | `8081` | Port for internal API | +| `RUST_LOG` | `anki=info` | Log level (tracing filter) | `SYNC_*` vars are loaded via `envy::prefixed("SYNC_")` into `SyncServerConfig`. -The others (`DATABASE_URL`, `TOKEN_ENCRYPTION_KEY`, `GOOGLE_CLIENT_*`) are read directly -via `std::env::var()` in `sync-storage-config`. --- @@ -219,9 +252,9 @@ cargo build --bin anki-sync-server cargo build --release --bin anki-sync-server # run tests (custom crates only — upstream tests are not our concern) -cargo test -p sync-storage-config +cargo test -p sync-platform-api cargo test -p sync-storage-backends -cargo test -p sync-storage-api +cargo test -p sync-storage-server ``` ### Docker @@ -230,13 +263,9 @@ cargo test -p sync-storage-api # build image docker build -t anki-cloud-sync:local . -# run (all required env vars must be set) +# run (standalone mode — no DB or cloud credentials required) docker run \ - -e DATABASE_URL=file:/data/anki-cloud.db \ - -e TOKEN_ENCRYPTION_KEY= \ - -e GOOGLE_CLIENT_ID= \ - -e GOOGLE_CLIENT_SECRET= \ - -v /path/to/data:/data \ + -e SYNC_USER1=alice@example.com:secret \ -p 8080:8080 \ anki-cloud-sync:local ``` @@ -284,8 +313,9 @@ When Anki releases a new version, the upgrade path is: ./scripts/fork-anki-sync-server.zsh 25.10 # after syncing: -cargo build --bin anki-sync-server # verify it compiles -cargo test -p sync-storage-config # verify custom crates still work +cargo build --bin anki-sync-server # verify it compiles +cargo test -p sync-storage-backends # verify custom crates still work +cargo test -p sync-storage-server ``` **Never manually edit** `rslib/`, `ftl/`, or `proto/`. @@ -295,38 +325,27 @@ If upstream breaks our integration points, fix by adapting the custom crates, no ## 9. Integration with anki-cloud Monorepo -This server is consumed by the main [anki-cloud](https://github.com/danielpmichalski/anki-cloud) -repo as an **external Docker image** in `docker-compose.yml`: - -```yaml -sync-server: - image: ghcr.io/danielpmichalski/anki-cloud-sync:25.09 - environment: - DATABASE_URL: file:/data/anki-cloud.db - TOKEN_ENCRYPTION_KEY: ${TOKEN_ENCRYPTION_KEY} - GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID} - GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET} - volumes: - - db-data:/data -``` +This repo publishes a Docker image containing the **standalone sync server binary**. +The `anki-cloud` platform builds a **separate binary** that depends on `sync-platform-api` +and links in its own `sync-platform-cloud` crate (DB auth + OAuth token exchange + AES decryption). -**Dependency contract:** +**Schema contract (platform layer reads/writes, not this binary):** -- Shares the **same SQLite database** as the REST API and DB packages - Reads from: `users`, `storage_connections`, `users_sync_state` tables - Writes to: `users_sync_state.sync_key` (upsert on auth) - Never reads or writes: `api_keys` table -- Schema migrations are owned by `packages/db` in the monorepo — this server is a read/write consumer, not the schema owner +- Schema migrations are owned by `packages/db` in the monorepo — the platform crate is a consumer, not the schema owner --- ## 10. Key Design Principles 1. **Never store deck data.** Collections pass through (temp file during sync), uploaded to user's cloud storage, then deleted locally. -2. **Stateless per request.** No in-memory state that can't be re-derived from DB + OAuth. Safe for restart and horizontal scale. -3. **Custom crates are thin adapters.** They translate between the upstream rslib interfaces and external systems (DB, cloud APIs). Keep them small. -4. **Upstream is upstream.** `rslib/`, `ftl/`, `proto/` are verbatim copies. No hand-edits, ever. -5. **AES-256-GCM encryption format must stay compatible** with `packages/db/src/encrypt.ts` in the monorepo. Both sides read the same DB column. +2. **Stateless per request.** No in-memory state that can't be re-derived from auth/storage config. Safe for restart and horizontal scale. +3. **`sync-platform-api` is the stable external contract.** External deployment targets (`anki-cloud`, `anki-cloud-android`) implement `AuthProvider`, `BackendResolver`, and `StorageBackend`. This repo has zero knowledge + of any DB schema, JNI, or Android specifics. +4. **Custom crates are thin adapters.** They translate between the upstream rslib interfaces and external systems. Keep them small. +5. **Upstream is upstream.** `rslib/`, `ftl/`, `proto/` are verbatim copies. No hand-edits, ever (exception: mechanical crate-import renames when our crate names change). 6. **Conventional commits.** Required for automated changelog and semantic release. 7. **AI Agents: Never auto-commit code.** Inform user that changes are ready; let user handle git commits themselves. @@ -336,11 +355,11 @@ sync-server: GitHub Actions workflows: -- **`ci.yml`** — runs on every push/PR: `cargo build`, `cargo test -p sync-storage-*`, Docker build +- **`ci.yml`** — runs on every push/PR: `cargo build`, `cargo test -p sync-platform-api`, `cargo test -p sync-storage-*`, Docker build - **`release.yml`** — release-please + conventional commits → auto-bumps version, publishes Docker image to `ghcr.io/danielpmichalski/anki-cloud-sync:` Docker image published to: `ghcr.io/danielpmichalski/anki-cloud-sync` --- -*Last updated: extracted from anki-cloud monorepo, 2026-04-19.* +*Last updated: 2026-04-23.* diff --git a/README.md b/README.md index c5a12a2..cab03c1 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Fork of [`ankitects/anki@25.09`](https://github.com/ankitects/anki/tree/25.09) r ├── Cargo.toml ← workspace root — the only file NOT from upstream ├── Cargo.lock ← copied from upstream for reproducible builds ├── README.md ← this file -├── sync-storage-api/ ← StorageBackend trait (no cloud deps) +├── sync-platform-api/ ← AuthProvider, BackendResolver, StorageBackend traits │ └── src/lib.rs ├── sync-storage-backends/ ← StorageBackendFactory + per-provider impls │ └── src/ @@ -50,9 +50,9 @@ Fork of [`ankitects/anki@25.09`](https://github.com/ankitects/anki/tree/25.09) r | Crate | Origin | Touches upgrade? | |-------------------------|---------------------|----------------------------------------------| -| `sync-storage-api` | ours | Never | +| `sync-platform-api` | ours | Never | | `sync-storage-backends` | ours | Never | -| `sync-storage-config` | ours | Never | +| `sync-storage-config` | ours (transitional) | Never | | `sync-storage-server` | ours | Only if rslib's public types change | | `rslib/` and sub-crates | upstream (verbatim) | Yes — replaced by fork script, then re-patch | @@ -61,15 +61,15 @@ Fork of [`ankitects/anki@25.09`](https://github.com/ankitects/anki/tree/25.09) r `rslib/` is replaced wholesale by the fork script. After each replacement, **four files** must be patched to wire in our auth and storage providers. All other rslib files stay verbatim. -**Design:** two traits from `sync-storage-api` are injected at server startup; rslib never imports -`sync-storage-config` or `sync-storage-backends` directly. `SyncMode` logic lives entirely in -`sync-storage-server`. See the [upgrade section](#upgrading-to-a-new-anki-release) for how to re-apply. +**Design:** three traits from `sync-platform-api` are injected at server startup via `SimpleServer::new(base_folder, auth, resolver)`; +rslib never imports `sync-storage-config` or `sync-storage-backends` directly. +See the [upgrade section](#upgrading-to-a-new-anki-release) for how to re-apply after an upstream sync. #### `rslib/Cargo.toml` — add one dependency ```toml # in [dependencies] -sync-storage-api.workspace = true +sync-platform-api.workspace = true # also add anyhow to [dev-dependencies] ``` @@ -89,7 +89,7 @@ println!("{}", sync_storage_server::run()); #### `rslib/src/sync/http_server/mod.rs` - `SimpleServer` struct: replace `mode: SyncMode` with `auth: Arc` and - `backend_resolver: Arc` (both from `sync_storage_api`) + `backend_resolver: Arc` (both from `sync_platform_api`) - `SyncServerConfig`: remove `mode: SyncMode` field (SYNC_MODE is read in `sync-storage-server`) - `SimpleServer::new()`: takes `auth` + `backend_resolver` instead of `mode` - `SimpleServer::make_server()`: takes pre-built `Arc` as second argument; no sidecar spawn (that moves to `sync-storage-server`) @@ -144,7 +144,7 @@ docker build -t anki-cloud-sync:local . ## Standalone mode No database or cloud credentials are required. Users are defined via `SYNC_USER*` env vars. -Behaves identically to the original [Anki's rslib sync server](https://github.com/ankitects/anki/tree/master/rslib). Default mode (`SYNC_MODE=standalone`). +Behaves identically to the original [Anki's rslib sync server](https://github.com/ankitects/anki/tree/master/rslib). ### Run @@ -190,84 +190,34 @@ On `/sync/hostKey` (Anki login): On subsequent sync requests: looks up `hkey` in in-memory session map. -## Cloud mode +## Platform implementations -Backed by a shared SQLite database and per-user cloud storage (Google Drive, etc.). -Set `SYNC_MODE=cloud`. +The binary in this repo runs in standalone mode only. Cloud deployments (or any other +deployment target) supply their own `AuthProvider` and `BackendResolver` by implementing the +three traits from `sync-platform-api`: -### Run - -#### Local build - -```bash -SYNC_MODE=cloud \ - DATABASE_URL=file:/path/to/anki-cloud.db \ - TOKEN_ENCRYPTION_KEY=<64-hex-chars> \ - GOOGLE_CLIENT_ID= \ - GOOGLE_CLIENT_SECRET= \ - SYNC_INTERNAL_TOKEN= \ - ./target/debug/anki-sync-server -# Listens on 0.0.0.0:8080 (sync) and 127.0.0.1:8081 (internal API) by default. -``` - -#### Docker - -```bash -docker run \ - -e SYNC_MODE=cloud \ - -e DATABASE_URL=file:/data/anki-cloud.db \ - -e TOKEN_ENCRYPTION_KEY=<64-hex-chars> \ - -e GOOGLE_CLIENT_ID= \ - -e GOOGLE_CLIENT_SECRET= \ - -e SYNC_INTERNAL_HOST=0.0.0.0 \ - -e SYNC_INTERNAL_TOKEN= \ - -v /path/to/data:/data \ - -p 8080:8080 \ - -p 8081:8081 \ - anki-cloud-sync:local +```rust +pub trait AuthProvider: Send + Sync { + fn authenticate(&self, username: &str, password: &str) -> Result<(String, String)>; + fn lookup_by_hkey(&self, hkey: &str) -> Result; +} + +pub trait BackendResolver: Send + Sync { + fn resolve_for_user(&self, username: &str) -> Result>; +} + +pub trait StorageBackend: Send + Sync { + fn fetch(&self, user: &str, dest: &Path) -> Result<()>; + fn commit(&self, user: &str, src: &Path) -> Result<()>; +} ``` -### Environment variables - -| Variable | Default | Description | -|------------------------|-----------------|----------------------------------------------------------------------------------------------| -| `DATABASE_URL` | — | Path to the shared SQLite database (e.g. `file:/data/anki-cloud.db`) | -| `TOKEN_ENCRYPTION_KEY` | — | 32-byte AES-256 key used to decrypt OAuth tokens in the DB (64 hex chars or 44 base64 chars) | -| `GOOGLE_CLIENT_ID` | — | Google OAuth2 client ID — used to exchange refresh tokens for fresh access tokens | -| `GOOGLE_CLIENT_SECRET` | — | Google OAuth2 client secret | -| `SYNC_BASE` | `~/.syncserver` | Directory for temporary user collection files during sync | -| `SYNC_HOST` | `0.0.0.0` | Bind address for the Anki sync protocol | -| `SYNC_PORT` | `8080` | Port for the Anki sync protocol | -| `SYNC_INTERNAL_HOST` | `127.0.0.1` | Bind address for the internal REST API — set `0.0.0.0` in Docker | -| `SYNC_INTERNAL_PORT` | `8081` | Port for the internal REST API (see [Internal API](#internal-api)) | -| `SYNC_INTERNAL_TOKEN` | — | Bearer token for internal API requests; if unset, internal API is disabled | - -### Authentication - -Users authenticate with their email address and a per-user sync password set via the web UI. -No `SYNC_USER*` env vars are needed. - -On `/sync/hostKey` (Anki login): - -1. Verifies `email` + `password` against `users.sync_password_hash` db table (bcrypt, timing-safe) -2. Derives `hkey = SHA1(email:password)` and upserts it into `users_sync_state.sync_key` db table -3. Returns `hkey` to Anki client as session token - -On subsequent sync requests (hkey in `anki-sync` header): +Wire them into the server with `SimpleServer::new(base_folder, auth, resolver)` and call +`sync_storage_server::run()`. The platform crate owns all DB lookups, token decryption, and OAuth +token exchange — this repo has no knowledge of any of those. -1. Looks up hkey in in-memory session map -2. If not found (server restart or different instance): queries `users_sync_state` by hkey to re-hydrate - -### Per-request storage lookup - -On each sync operation that requires storage access (open, finish, upload), the sync server: - -1. Looks up `storage_connections` in the shared SQLite DB, joining on `users.email` -2. Decrypts the stored `oauth_refresh_token` (AES-256-GCM) — skipped for `provider = "local"` -3. Exchanges the refresh token for a fresh Google access token via `https://oauth2.googleapis.com/token` -4. Passes the access token to `StorageBackendFactory` to create the appropriate backend - -This makes each sync server instance stateless — no per-user config in memory, safe to run behind a load balancer. +The `anki-cloud` repo contains the reference cloud platform implementation (`sync-platform-cloud`). +See [ADR-0014](docs/decisions/0014-introduce-sync-platform-api-boundary.md) for the rationale. ## Internal API @@ -338,16 +288,12 @@ curl -s -X POST "http://localhost:8081/internal/v1/decks/1234567890/notes/bulk" ## Test ```bash -cargo test -p anki-sync-server -``` - -To run only the sync-storage-config tests: - -```bash -cargo test -p sync-storage-config +cargo test -p sync-platform-api +cargo test -p sync-storage-backends +cargo test -p sync-storage-server ``` -## Versioning +## Versioning Tags follow `v-r` (e.g. `v25.09-r1`). @@ -382,7 +328,7 @@ that needs updating. Quick checklist: -- [ ] `rslib/Cargo.toml` — `sync-storage-api` in `[dependencies]`, `anyhow` in `[dev-dependencies]` +- [ ] `rslib/Cargo.toml` — `sync-platform-api` in `[dependencies]`, `anyhow` in `[dev-dependencies]` - [ ] `rslib/sync/Cargo.toml` — `sync-storage-server` in both platform dependency blocks - [ ] `rslib/sync/main.rs` — calls `sync_storage_server::run()` not `SimpleServer::run()` - [ ] `rslib/src/sync/http_server/mod.rs` — DI fields, simplified auth methods, `SidecarUserHandle`, `derive_hkey`, `base_folder()`, updated `new()` / `make_server()` signatures @@ -396,7 +342,6 @@ Quick checklist: ```bash # zero-tolerance checks — all must return empty -grep -r 'SyncMode' rslib/src/ grep -r 'sync_storage_config' rslib/src/ grep -r 'sync_storage_backends' rslib/src/ ls rslib/src/sync/http_server/internal_*.rs 2>/dev/null @@ -411,7 +356,7 @@ cargo build --bin anki-sync-server cargo test -p anki # custom crate unit tests -cargo test -p sync-storage-config -p sync-storage-backends -p sync-storage-server +cargo test -p sync-platform-api -p sync-storage-backends -p sync-storage-server ``` All tests must pass before tagging. diff --git a/TODO.md b/TODO.md index 4aaa4bf..3b7a837 100644 --- a/TODO.md +++ b/TODO.md @@ -10,7 +10,7 @@ The sync server currently bundles two concerns that must be separated to support deployment targets (cloud Docker, Android embedded service): 1. **Core sync protocol** — `rslib/` (upstream, verbatim), `sync-storage-backends/` (Google Drive - + local impls), `sync-storage-server/` (composition root). These are platform-agnostic. + + local impls), `sync-storage-server/` (composition root). These are platform-agnostic. 2. **Platform-specific glue** — `sync-storage-config/` (SQLite queries, AES-256-GCM token decrypt, bcrypt auth, OAuth HTTP exchange). This knows about a specific DB schema and @@ -21,6 +21,7 @@ The trait boundary already exists in `sync-storage-api`: `AuthProvider`, `Backen that external deployment targets depend on — and strips all platform knowledge from this repo. After this task: + - `anki-cloud-sync` knows nothing about SQLite schemas, AES keys, JNI, or Android. - `anki-cloud` owns its own `sync-platform-cloud` crate (SQLite + OAuth + AES). - `anki-cloud-android` owns its own `sync-platform-android` crate (Room + Android Credential @@ -28,40 +29,36 @@ After this task: ### Tasks -**1. Rename `sync-storage-api` → `sync-platform-api`** -- Rename directory and crate name in `sync-platform-api/Cargo.toml` -- Update workspace `Cargo.toml`: replace `sync-storage-api` entry with `sync-platform-api` -- Update all import paths across the workspace: - - `sync-storage-backends/` — `use sync_storage_api::*` → `use sync_platform_api::*` - - `sync-storage-server/` — same - - `rslib/src/sync/http_server/mod.rs` — same - - `rslib/src/sync/http_server/user.rs` — same -- No logic changes. Traits are already correct as-is. +**1. ✅ Rename `sync-storage-api` → `sync-platform-api`** _(done v25.09-r8)_ + +- Renamed directory and crate name in `sync-platform-api/Cargo.toml` +- Updated workspace `Cargo.toml`: replaced `sync-storage-api` entry with `sync-platform-api` +- Updated all import paths across the workspace (9 files) **2. Delete `sync-storage-config` crate** + - Coordinate with `anki-cloud` team: `sync-platform-cloud` must land there first (it takes ownership of all DB queries, token decryption, OAuth exchange, and bcrypt auth currently in `sync-storage-config`). - Once `anki-cloud` is ready: - - Remove `sync-storage-config/` directory - - Remove from workspace `Cargo.toml` - - Remove from `sync-storage-server/Cargo.toml` dependencies - -**3. Strip Cloud impls from `sync-storage-server`** -- In `sync-storage-server/src/auth.rs`: delete `CloudAuthProvider` -- In `sync-storage-server/src/resolver.rs`: delete `CloudBackendResolver` -- In `sync-storage-server/src/lib.rs`: - - Delete `SyncMode::Cloud` variant and its `make_providers()` branch - - Remove `sync-storage-config` import -- `sync-storage-server` retains only `StandaloneAuthProvider` + `StandaloneBackendResolver` - (env-var user list, no DB, no OAuth). - -**4. Update CLAUDE.md** -- Update crate table to reflect new names -- Add section explaining `sync-platform-api` as the public contract for external impls -- Clarify that this repo has zero knowledge of any DB schema, JNI, or Android + - Remove `sync-storage-config/` directory + - Remove from workspace `Cargo.toml` + - Remove from `sync-storage-server/Cargo.toml` dependencies + +**3. ✅ Strip Cloud impls from `sync-storage-server`** _(done v25.09-r8)_ + +- Deleted `CloudAuthProvider` from `sync-storage-server/src/auth.rs` +- Deleted `CloudBackendResolver` from `sync-storage-server/src/resolver.rs` +- Removed `SyncMode` enum, `mode_from_env()`, and Cloud branch from `sync-storage-server/src/lib.rs` +- Removed `sync-storage-config` dep from `sync-storage-server/Cargo.toml` +- `sync-storage-server` now retains only `StandaloneAuthProvider` + `StandaloneBackendResolver` + +**4. ✅ Update docs** _(done v25.09-r8)_ + +- Updated CLAUDE.md, README.md, TODO.md, added ADR-0014 ### Acceptance criteria + - `cargo build --bin anki-sync-server` succeeds (standalone mode) - `cargo test -p sync-platform-api` passes - `cargo test -p sync-storage-backends` passes diff --git a/docs/decisions/0014-introduce-sync-platform-api-boundary.md b/docs/decisions/0014-introduce-sync-platform-api-boundary.md new file mode 100644 index 0000000..359674b --- /dev/null +++ b/docs/decisions/0014-introduce-sync-platform-api-boundary.md @@ -0,0 +1,76 @@ +# 14. Introduce sync-platform-api as a stable platform boundary + +Date: 2026-04-23 + +## Status + +Accepted + +## Context + +The sync server originally bundled two concerns in a single binary: + +1. **Core sync protocol** — `rslib/` (upstream, verbatim), `sync-storage-backends/` (Google Drive + + local impls), `sync-storage-server/` (composition root). These are inherently platform-agnostic. + +2. **Platform-specific glue** — `sync-storage-config/` (SQLite queries, AES-256-GCM token + decryption, bcrypt credential check, OAuth token exchange). This is specific to the `anki-cloud` + deployment's DB schema and credential storage mechanism. + +This bundling blocked two planned deployment targets: + +- **anki-cloud** (cloud Docker): needs a DB-backed `AuthProvider` and a Drive-backed `BackendResolver` + that knows about its own SQLite schema and AES key format. +- **anki-cloud-android** (embedded JVM service): needs Room-backed auth and Android Credential + Manager for token storage — completely different from the cloud stack. + +The `AuthProvider`, `BackendResolver`, and `StorageBackend` trait definitions already existed in +`sync-storage-api` and were injected into `rslib` at startup. They formed a natural seam. + +## Decision + +Formalise the trait boundary as a dedicated crate named `sync-platform-api`, and remove all +platform-specific knowledge from this repository: + +1. **Rename `sync-storage-api` → `sync-platform-api`** to signal its role as the stable external + contract. External repos `use sync_platform_api::*`; this repo's internals never need to change. + +2. **Delete `CloudAuthProvider` and `CloudBackendResolver`** from `sync-storage-server`. The + standalone binary published from this repo runs in standalone mode only (env-var users, local + filesystem). Cloud deployments supply their own implementations externally. + +3. **Remove `SyncMode` enum and `mode_from_env()`** from `sync-storage-server`. With only one + built-in mode, the enum is dead weight. `make_providers()` always returns the standalone pair. + +4. **Remove `sync-storage-config` as a dependency of `sync-storage-server`**. The crate stays in + the workspace temporarily (as a reference implementation for `anki-cloud` to port from) but is + no longer compiled into the binary. It will be deleted once `anki-cloud` owns its own + `sync-platform-cloud` crate. + +After this change: +- `anki-cloud-sync` has zero knowledge of any DB schema, AES key format, JNI, or Android specifics. +- `anki-cloud` owns `sync-platform-cloud` (DB auth + OAuth + AES). +- `anki-cloud-android` owns `sync-platform-android` (Room + Android Credential Manager + JNI). + +## Consequences + +**Positive:** + +- Clear ownership: this repo owns the sync protocol and the trait boundary; platform repos own their + auth and storage glue. +- Multiple deployment targets can share the same `sync-platform-api` crate without forking this repo. +- Upstream upgrades to `rslib` require no changes to platform crates. +- The Docker image published from this repo is a clean standalone server with no cloud dependencies. + +**Negative / transitional:** + +- `sync-storage-config` temporarily remains in the workspace as a dead dependency. This is + intentional — it serves as the reference for `anki-cloud`'s port. It will be removed in a + follow-up once the port is complete. +- The published Docker image no longer supports cloud deployments directly. Platform teams must + build their own binary that links `sync-platform-api` + their own platform crate. + +## References + +- TODO.md §[ARCH] Introduce sync-platform-api — clean platform boundary +- [ADR-0003](0003-fork-rust-ankitects-sync-server.md) — original fork decision and storage abstraction rationale From 740df66fc518bfdaabedae6066d133ffa3060e2a Mon Sep 17 00:00:00 2001 From: Daniel Klimuntowski Date: Thu, 23 Apr 2026 19:55:34 +0200 Subject: [PATCH 4/5] docs: update TODO.md to reflect recent progress and clarify next steps for refactoring --- TODO.md | 44 +++++++++++++++++++++++++++----------------- 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/TODO.md b/TODO.md index 3b7a837..eedff28 100644 --- a/TODO.md +++ b/TODO.md @@ -6,22 +6,20 @@ ### Background -The sync server currently bundles two concerns that must be separated to support multiple +The sync server originally bundled two concerns that must be separated to support multiple deployment targets (cloud Docker, Android embedded service): 1. **Core sync protocol** — `rslib/` (upstream, verbatim), `sync-storage-backends/` (Google Drive - + local impls), `sync-storage-server/` (composition root). These are platform-agnostic. + + local impls), `sync-storage-server/` (composition root). These are platform-agnostic. 2. **Platform-specific glue** — `sync-storage-config/` (SQLite queries, AES-256-GCM token decrypt, bcrypt auth, OAuth HTTP exchange). This knows about a specific DB schema and credential storage mechanism. It must be extracted. -The trait boundary already exists in `sync-storage-api`: `AuthProvider`, `BackendResolver`, -`StorageBackend`. This task formalises it as `sync-platform-api` — the **only** public contract -that external deployment targets depend on — and strips all platform knowledge from this repo. - -After this task: +The trait boundary already exists in `sync-platform-api`: `AuthProvider`, `BackendResolver`, +`StorageBackend`. This is the **only** public contract that external deployment targets depend on. +After this refactor: - `anki-cloud-sync` knows nothing about SQLite schemas, AES keys, JNI, or Android. - `anki-cloud` owns its own `sync-platform-cloud` crate (SQLite + OAuth + AES). - `anki-cloud-android` owns its own `sync-platform-android` crate (Room + Android Credential @@ -29,7 +27,7 @@ After this task: ### Tasks -**1. ✅ Rename `sync-storage-api` → `sync-platform-api`** _(done v25.09-r8)_ +**1. ✅ Rename `sync-storage-api` → `sync-platform-api`** - Renamed directory and crate name in `sync-platform-api/Cargo.toml` - Updated workspace `Cargo.toml`: replaced `sync-storage-api` entry with `sync-platform-api` @@ -37,15 +35,27 @@ After this task: **2. Delete `sync-storage-config` crate** -- Coordinate with `anki-cloud` team: `sync-platform-cloud` must land there first (it takes - ownership of all DB queries, token decryption, OAuth exchange, and bcrypt auth currently - in `sync-storage-config`). -- Once `anki-cloud` is ready: - - Remove `sync-storage-config/` directory - - Remove from workspace `Cargo.toml` - - Remove from `sync-storage-server/Cargo.toml` dependencies +Blocked on `anki-cloud` team landing `sync-platform-cloud` first (it takes ownership of all DB +queries, token decryption, OAuth exchange, and bcrypt auth currently in `sync-storage-config`). + +Sequencing: + +``` +anki-cloud-sync (rename + strip) → TAG +├── anki-cloud (sync-platform-cloud) ┐ parallel +└── anki-cloud-android (sync-platform-android) ┘ +↓ +anki-cloud-sync (delete sync-storage-config) → TAG +↓ +anki-cloud (bump pinned tag) +``` + +Once `anki-cloud` signals ready: +- Remove `sync-storage-config/` directory +- Remove from workspace `Cargo.toml` +- Remove from `sync-storage-server/Cargo.toml` dependencies -**3. ✅ Strip Cloud impls from `sync-storage-server`** _(done v25.09-r8)_ +**3. ✅ Strip Cloud impls from `sync-storage-server`** - Deleted `CloudAuthProvider` from `sync-storage-server/src/auth.rs` - Deleted `CloudBackendResolver` from `sync-storage-server/src/resolver.rs` @@ -53,7 +63,7 @@ After this task: - Removed `sync-storage-config` dep from `sync-storage-server/Cargo.toml` - `sync-storage-server` now retains only `StandaloneAuthProvider` + `StandaloneBackendResolver` -**4. ✅ Update docs** _(done v25.09-r8)_ +**4. ✅ Update docs** - Updated CLAUDE.md, README.md, TODO.md, added ADR-0014 From 28c404fd028a8fda7dd3f2500ccff00fdf57573c Mon Sep 17 00:00:00 2001 From: Daniel Klimuntowski Date: Thu, 23 Apr 2026 19:59:36 +0200 Subject: [PATCH 5/5] ci: update test step to reflect `sync-platform-api` rename --- .github/workflows/ci.yml | 2 +- README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 83e0171..244a401 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,7 +35,7 @@ jobs: run: cargo build --bin anki-sync-server - name: Test - run: cargo test -p sync-storage-config -p sync-storage-backends -p sync-storage-api + run: cargo test -p sync-storage-config -p sync-storage-backends -p sync-platform-api docker-smoke-test: runs-on: ubuntu-latest diff --git a/README.md b/README.md index 232b2e0..740eafc 100644 --- a/README.md +++ b/README.md @@ -297,7 +297,7 @@ cargo test -p sync-storage-server ### Crate versions -Custom crates (`sync-storage-api`, `sync-storage-backends`, `sync-storage-config`, +Custom crates (`sync-platform-api`, `sync-storage-backends`, `sync-storage-config`, `sync-storage-server`) are versioned as `..` in semver form — e.g. Anki `25.09` → `25.9.0`, Anki `25.09.2` → `25.9.2`. Leading zeros are dropped (Cargo strips them anyway). The `-rX` revision counter is **not** baked into the crate version to avoid collisions