From 717f3abe8b6cd377b3d1e79d7925927dbc0b64a3 Mon Sep 17 00:00:00 2001 From: devnull03 <56480041+devnull03@users.noreply.github.com> Date: Sat, 15 Aug 2026 21:01:27 +0530 Subject: [PATCH 1/6] feat(google): resolve credentials at runtime and sync to a chosen sheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The client id and secret were `option_env!` constants, so rotating the Cloud project meant cutting a release. They are now a `ClientCreds` passed in, resolved by a ladder in `app/src/google.rs`: the persisted copy while it is fresh, else a conditional re-check against the credential endpoint, else the compiled-in pair. The check rides the first Google action that needs it — no timer, no startup fetch — and a network failure keeps what is stored, because working offline is the normal case for this audience. The refresh token moves to the platform credential store. It never expires on its own, so anything holding it can mint Drive tokens indefinitely; `AppSettings` serialises to a file that travels in backups and roaming profiles. No migration and no fallback read: there are no existing installs. `create_sheet` no longer throws away the spreadsheet id, and the write half is `write_values`, which both a new sheet and an existing one go through. A project remembers its sheet in `.qrate`, so "Sync to Google Sheet" refills the same file. Reaching a sheet the user already owns needs Google's own chooser: `drive.file` grants per file, not per id, and answers 404 rather than 403 for a file it never granted — which reads as "deleted", not "not yours". So the Picker is the only route, and the 404 is mapped to a message naming what actually happened. The loopback parsing behind consent is now `wait_for_query`, shared with the Picker's return trip. Refs ASNT-80, ASNT-93, ASNT-94, ASNT-95 --- Cargo.lock | 72 ++++++- crates/app/Cargo.toml | 5 + crates/app/src/export.rs | 224 +++++++++++++++----- crates/app/src/google.rs | 208 +++++++++++++++++++ crates/app/src/main.rs | 1 + crates/data-exchange/src/google.rs | 318 ++++++++++++++++++++++------- crates/settings/src/project.rs | 6 + 7 files changed, 708 insertions(+), 126 deletions(-) create mode 100644 crates/app/src/google.rs diff --git a/Cargo.lock b/Cargo.lock index a289439e..52ea3820 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -216,6 +216,7 @@ dependencies = [ "gpui-component", "gpui-component-assets", "gpui_platform", + "keyring", "log", "os_info", "plugin-api", @@ -1522,6 +1523,27 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" +[[package]] +name = "dbus" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "dbus-secret-service" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" +dependencies = [ + "dbus", + "zeroize", +] + [[package]] name = "deflate64" version = "0.1.11" @@ -3498,6 +3520,21 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "byteorder", + "dbus-secret-service", + "log", + "security-framework 2.11.1", + "security-framework 3.7.0", + "windows-sys 0.60.2", + "zeroize", +] + [[package]] name = "khronos-egl" version = "6.0.0" @@ -3603,6 +3640,15 @@ version = "0.2.182" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + [[package]] name = "libfuzzer-sys" version = "0.4.12" @@ -4117,7 +4163,7 @@ dependencies = [ "openssl-probe", "openssl-sys", "schannel", - "security-framework", + "security-framework 3.7.0", "security-framework-sys", "tempfile", ] @@ -6256,7 +6302,7 @@ dependencies = [ "openssl-probe", "rustls-pki-types", "schannel", - "security-framework", + "security-framework 3.7.0", ] [[package]] @@ -6425,6 +6471,19 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -9014,6 +9073,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + [[package]] name = "windows-sys" version = "0.61.2" diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index 10c23433..a991ab5b 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -20,6 +20,11 @@ schemars.workspace = true # The agent bridge's request/response types; the transport lives here because only the # composition root links both the contract and the live table. ai = { path = "../ai" } +# The Google refresh token goes in the platform credential store rather than the settings DB: +# it never expires on its own, so anything holding it can reach the user's Drive indefinitely. +keyring = { version = "3", features = [ + "apple-native", "windows-native", "sync-secret-service", +] } window-wrapper = { path = "../window-wrapper" } project-wizard = { path = "../project-wizard" } checks = { path = "../checks" } diff --git a/crates/app/src/export.rs b/crates/app/src/export.rs index b4440a54..6b8701f0 100644 --- a/crates/app/src/export.rs +++ b/crates/app/src/export.rs @@ -32,6 +32,7 @@ pub enum ExportFormat { Csl, Zip, GoogleSheet, + GoogleSheetSync, } #[derive(Clone, PartialEq, Deserialize, JsonSchema, Action)] @@ -43,14 +44,23 @@ pub struct Export { /// Menu order. Each entry is the label and the filename the save dialog offers; the Sheets target /// never touches disk, so it has no name to suggest. -pub const EXPORT_FORMATS: [(ExportFormat, &str, Option<&str>); 5] = [ +pub const EXPORT_FORMATS: [(ExportFormat, &str, Option<&str>); 6] = [ (ExportFormat::Csv, "CSV…", Some("export.csv")), (ExportFormat::JsonLd, "JSON-LD…", Some("export.jsonld")), (ExportFormat::Csl, "Zotero (CSL-JSON)…", Some("export.json")), (ExportFormat::Zip, "ZIP Archive…", Some("export.zip")), (ExportFormat::GoogleSheet, "New Google Sheet…", None), + (ExportFormat::GoogleSheetSync, "Sync to Google Sheet…", None), ]; +/// Whether a menu entry belongs to Google sync, which is hidden entirely until the user opts in. +pub fn is_google(format: ExportFormat) -> bool { + matches!( + format, + ExportFormat::GoogleSheet | ExportFormat::GoogleSheetSync + ) +} + /// The open project's grid as the writers want it, with in-session edits — the same snapshot /// `table::save_now` persists. `None` with no project or no live table. fn grid(cx: &App) -> Option<(Vec, Vec>)> { @@ -68,8 +78,21 @@ pub fn run(format: ExportFormat, window: &mut Window, cx: &mut App) { }; let (file, title) = (project.file.clone(), project.display_name()); - if format == ExportFormat::GoogleSheet { - return to_google_sheet(title, headers, rows, window, cx); + if is_google(format) { + // A project that already knows its spreadsheet refills that one; otherwise "Sync" asks + // Google's chooser, which is also what grants qrate access to the file. + let linked = project + .data + .values + .get(settings::project::GOOGLE_SHEET_ID_KEY) + .map(|v| v.text().to_string()) + .filter(|id| !id.is_empty()); + let target = match (format, linked) { + (ExportFormat::GoogleSheet, _) => SheetTarget::New, + (_, Some(id)) => SheetTarget::Existing(id), + (_, None) => SheetTarget::Choose, + }; + return to_google_sheet(title, headers, rows, target, window, cx); } if format == ExportFormat::Csl { return ask_csl_mapping(file, headers, rows, window, cx); @@ -125,8 +148,8 @@ fn save_as( export::write_json(&path, &export::csl_items(&headers, &rows, &mapping)) } ExportFormat::Zip => export::write_zip(&path, &headers, &rows, &images), - // Handled in `run` — it has no path to write to. - ExportFormat::GoogleSheet => return, + // Handled in `run` — they have no path to write to. + ExportFormat::GoogleSheet | ExportFormat::GoogleSheetSync => return, }; if let Err(err) = result { log::error!("could not export to {}: {err}", path.display()); @@ -247,69 +270,170 @@ fn ask_csl_mapping( }); } -/// Where the Google refresh token lives once the user has consented — user-wide, so signing in -/// once covers every project. -const GOOGLE_REFRESH_KEY: &str = "google_refresh_token"; +/// Which spreadsheet a sync writes to. +enum SheetTarget { + New, + /// The one this project is already linked to. + Existing(String), + /// Ask Google's chooser. Picking a file there is what grants `drive.file` access to it — an + /// id the user typed would name a file the token cannot reach. + Choose, +} -/// Sign in if we have to, create the sheet, open it. Every step blocks, so the whole thing runs on -/// the background executor; only opening the consent URL and the finished sheet come back to the -/// main thread. +/// Sign in if we have to, settle on a spreadsheet, fill it. Every step blocks, so the whole thing +/// runs on the background executor; only opening a URL and writing settings back come to the main +/// thread. fn to_google_sheet( title: String, headers: Vec, rows: Vec>, + target: SheetTarget, _window: &mut Window, cx: &mut App, ) { - let stored = settings::AppSettings::get(cx) - .values - .get(GOOGLE_REFRESH_KEY) - .map(|v| v.text().to_string()); - - // Bound before the browser opens, so Google's redirect can't arrive before we're listening. - let consent = match stored.is_none().then(data_exchange::google::begin_consent) { - Some(Ok(consent)) => { - cx.open_url(&consent.url); - Some(consent) - } - Some(Err(err)) => return log::error!("Google export could not start sign-in: {err}"), - None => None, - }; + let stored = crate::google::stored(cx); + let refresh_token = crate::google::refresh_token(); cx.spawn(async move |cx| { - let token = cx - .background_spawn(async move { - match consent { - Some(consent) => consent.wait_for_token(), - None => data_exchange::google::refresh(&stored.unwrap_or_default()), - } - }) - .await; - let token = match token { - Ok(token) => token, - Err(err) => return log::error!("Google export could not sign in: {err}"), + let (creds, persist) = cx.background_spawn(async move { stored.refreshed() }).await; + let Some(creds) = creds else { + return log::error!( + "Google sync has no client credentials — this build has none compiled in and the \ + credential endpoint could not be reached" + ); }; - // Only the first consent issues one; a refresh reuses what is already stored. - if let Some(refresh) = token.refresh.clone() { - cx.update(|cx| settings::AppSettings::set_text(GOOGLE_REFRESH_KEY, refresh.into(), cx)); + if let Some((creds, etag)) = persist { + cx.update(|cx| crate::google::remember(&creds, etag, cx)); } - let created = cx + let Some(token) = sign_in(&creds, refresh_token, cx).await else { + return; + }; + + let chosen = match target { + SheetTarget::New => None, + SheetTarget::Existing(id) => Some(id), + SheetTarget::Choose => match choose_sheet(&token.access, cx).await { + Some(id) => Some(id), + None => return, + }, + }; + + let sheet = cx .background_spawn(async move { - data_exchange::google::create_sheet( - &token.access, - &format!("{title} — qrate"), - &headers, - &rows, - ) + match chosen { + Some(id) => { + data_exchange::google::write_values(&token.access, &id, &headers, &rows) + .map(|()| (id, None)) + } + None => data_exchange::google::create_sheet( + &token.access, + &format!("{title} — qrate"), + ) + .and_then(|(id, url)| { + data_exchange::google::write_values(&token.access, &id, &headers, &rows) + .map(|()| (id, Some(url))) + }), + } }) .await; - match created { - Ok(url) => { - cx.update(|cx| cx.open_url(&url)); + match sheet { + // A new sheet is worth opening; one the user already had is not — they went looking + // for their data to be current, not for another browser tab. + Ok((id, url)) => { + cx.update(|cx| { + settings::project::CurrentProject::set_text( + settings::project::GOOGLE_SHEET_ID_KEY, + id.into(), + cx, + ); + if let Some(url) = url { + cx.open_url(&url); + } + }); } - Err(err) => log::error!("Google export could not create the sheet: {err}"), + Err(err) => log::error!("Google sync could not write the spreadsheet: {err}"), } }) .detach(); } + +/// Hand the user Google's own file chooser and wait for what they pick. Same two-step shape as +/// consent, and for the same reason: the loopback port is bound before the page opens so the +/// answer cannot arrive before we are listening. +async fn choose_sheet(access_token: &str, cx: &mut gpui::AsyncApp) -> Option { + let picker = data_exchange::google::begin_picker( + data_exchange::google::DEFAULT_PICKER_PAGE, + access_token, + ); + let picker = match picker { + Ok(picker) => picker, + Err(err) => { + log::error!("Google sync could not open the spreadsheet chooser: {err}"); + return None; + } + }; + cx.update(|cx| cx.open_url(&picker.url)); + match cx + .background_spawn(async move { picker.wait_for_file_id() }) + .await + { + Ok(id) => Some(id), + Err(err) => { + log::info!("no spreadsheet was chosen: {err}"); + None + } + } +} + +/// A stored grant, else a fresh consent. Opening the browser has to happen on the main thread and +/// the listener has to be bound before it does, so those two steps are interleaved here rather +/// than run as one background job. +async fn sign_in( + creds: &data_exchange::google::ClientCreds, + refresh_token: Option, + cx: &mut gpui::AsyncApp, +) -> Option { + if let Some(stored) = refresh_token { + let creds = creds.clone(); + let token = cx + .background_spawn(async move { data_exchange::google::refresh(&creds, &stored) }) + .await; + match token { + Ok(token) => return Some(token), + // An expired or revoked grant is not an error the user should see — it means consent + // again, which is what falls through below. + Err(err) => { + log::info!("the stored Google sign-in no longer works, asking again: {err}") + } + } + } + + // Bound before the browser opens, so Google's redirect can't arrive before we're listening. + let consent = cx.update(|cx| match data_exchange::google::begin_consent(creds) { + Ok(consent) => { + cx.open_url(&consent.url); + Some(consent) + } + Err(err) => { + log::error!("Google sync could not start sign-in: {err}"); + None + } + })?; + + match cx + .background_spawn(async move { consent.wait_for_token() }) + .await + { + Ok(token) => { + if let Some(refresh) = &token.refresh { + crate::google::set_refresh_token(refresh); + } + Some(token) + } + Err(err) => { + log::error!("Google sync could not sign in: {err}"); + None + } + } +} diff --git a/crates/app/src/google.rs b/crates/app/src/google.rs new file mode 100644 index 00000000..7a6e7e89 --- /dev/null +++ b/crates/app/src/google.rs @@ -0,0 +1,208 @@ +//! The app side of Google sync: whether it is switched on, which Cloud project it signs in +//! against, and where the user's grant is kept. +//! +//! `data-exchange` owns the protocol and knows nothing about settings; this module owns the three +//! decisions that need `cx` — the opt-in gate, the credential resolution ladder, and the keychain. + +use std::time::{SystemTime, UNIX_EPOCH}; + +use data_exchange::google::{ClientCreds, DEFAULT_CONFIG_ENDPOINT}; +use gpui::App; +use settings::{AppSettings, GOOGLE_CONFIG_ENDPOINT_KEY}; + +/// The platform credential store entry. Windows Credential Manager, macOS Keychain, or the +/// Secret Service on Linux. +const KEYCHAIN_SERVICE: &str = "qrate"; +const KEYCHAIN_ACCOUNT: &str = "google-refresh-token"; + +/// Where the fetched pair is remembered between launches. These are the *application's* +/// credentials, the same for every user, so `AppSettings` is the right home — unlike the refresh +/// token, which is one person's grant and goes in the keychain. +const CLIENT_ID_KEY: &str = "google_client_id"; +const CLIENT_SECRET_KEY: &str = "google_client_secret"; +const ETAG_KEY: &str = "google_config_etag"; +const CHECKED_AT_KEY: &str = "google_config_checked_at"; + +/// How long a fetched pair is trusted before the next Google action re-checks it. Long enough that +/// the endpoint is off the critical path; short enough that a rotation reaches users within a week. +const CHECK_INTERVAL: u64 = 7 * 24 * 60 * 60; + +/// The credentials to sign in with, and whether the endpoint should be re-checked first. +/// +/// Split from the fetch because the check blocks on the network and this reads globals: the caller +/// reads here on the main thread, hands [`Stored::refreshed`] to a background thread, and writes +/// the answer back. +pub struct Stored { + pub creds: Option, + pub endpoint: String, + pub etag: Option, + pub stale: bool, +} + +pub fn stored(cx: &App) -> Stored { + let values = &AppSettings::get(cx).values; + let text = |key: &str| { + values + .get(key) + .map(|v| v.text().to_string()) + .filter(|s| !s.is_empty()) + }; + let checked_at: u64 = text(CHECKED_AT_KEY) + .and_then(|s| s.parse().ok()) + .unwrap_or(0); + Stored { + creds: text(CLIENT_ID_KEY).map(|client_id| ClientCreds { + client_id, + client_secret: text(CLIENT_SECRET_KEY), + }), + endpoint: text(GOOGLE_CONFIG_ENDPOINT_KEY) + .unwrap_or_else(|| DEFAULT_CONFIG_ENDPOINT.to_string()), + etag: text(ETAG_KEY), + stale: now().saturating_sub(checked_at) > CHECK_INTERVAL, + } +} + +impl Stored { + /// The resolution ladder, run on a background thread: the persisted copy while it is fresh, + /// otherwise a conditional re-check and then the persisted copy, and the compiled-in pair if + /// nothing was ever persisted. + /// + /// Returns the credentials to use and, when the endpoint answered with a new pair, what to + /// persist. A network failure keeps whatever is stored — working offline is the normal case + /// for this audience, so it must never block an export. + pub fn refreshed(self) -> (Option, Option<(ClientCreds, Option)>) { + if !self.stale && self.creds.is_some() { + return (self.creds, None); + } + match data_exchange::google::fetch_config(&self.endpoint, self.etag.as_deref()) { + Ok(Some(config)) => ( + Some(config.creds.clone()), + Some((config.creds, config.etag)), + ), + // 304 — what we have is current, so only the check time moves. + Ok(None) => ( + self.creds.clone(), + self.creds.map(|creds| (creds, self.etag)), + ), + Err(err) => { + log::warn!( + "could not check the Google credential endpoint {}, carrying on with what is \ + stored: {err}", + self.endpoint + ); + (self.creds.or_else(ClientCreds::compiled_in), None) + } + } + } +} + +/// Remember what the endpoint answered, and that it was asked just now. +pub fn remember(creds: &ClientCreds, etag: Option, cx: &mut App) { + AppSettings::set_text(CLIENT_ID_KEY, creds.client_id.clone().into(), cx); + AppSettings::set_text( + CLIENT_SECRET_KEY, + creds.client_secret.clone().unwrap_or_default().into(), + cx, + ); + AppSettings::set_text(ETAG_KEY, etag.unwrap_or_default().into(), cx); + AppSettings::set_text(CHECKED_AT_KEY, now().to_string().into(), cx); +} + +fn now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +fn entry() -> Option { + match keyring::Entry::new(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT) { + Ok(entry) => Some(entry), + Err(err) => { + log::warn!( + "this machine has no usable credential store (Credential Manager, Keychain or \ + Secret Service), so qrate will ask for Google consent every time: {err}" + ); + None + } + } +} + +/// The stored grant, if the user has consented on this machine and the credential store still has +/// it. `None` means consent runs again — which is the whole fallback, since qrate never writes the +/// token anywhere else. +pub fn refresh_token() -> Option { + entry()?.get_password().ok() +} + +pub fn set_refresh_token(token: &str) { + if let Some(entry) = entry() + && let Err(err) = entry.set_password(token) + { + log::warn!("could not store the Google sign-in in the credential store: {err}"); + } +} + +/// Switching Google sync off has to end the grant, not just hide the buttons — otherwise "off" +/// leaves a live token on the machine. +pub fn clear_refresh_token() { + if let Some(entry) = entry() + && let Err(err) = entry.delete_credential() + && !matches!(err, keyring::Error::NoEntry) + { + log::warn!("could not remove the stored Google sign-in: {err}"); + } +} + +#[cfg(test)] +mod tests { + use data_exchange::google::ClientCreds; + + use super::Stored; + + /// An endpoint nothing listens on: a connection refused in milliseconds, which is the failure + /// every offline user hits. + const DEAD: &str = "http://127.0.0.1:1/oauth/config"; + + fn stored(creds: Option, stale: bool) -> Stored { + Stored { + creds, + endpoint: DEAD.into(), + etag: None, + stale, + } + } + + fn creds() -> ClientCreds { + ClientCreds { + client_id: "stored-id".into(), + client_secret: Some("stored-secret".into()), + } + } + + /// A fresh copy is the answer on its own — reaching the endpoint at all would make every + /// export wait on the network. + #[test] + fn a_fresh_copy_is_used_without_asking_the_endpoint() { + let (resolved, persist) = stored(Some(creds()), false).refreshed(); + assert_eq!(resolved, Some(creds())); + assert!(persist.is_none()); + } + + /// Offline is the normal case for this audience, so an unreachable endpoint has to leave the + /// stored pair in place rather than fail the sign-in. + #[test] + fn an_unreachable_endpoint_keeps_what_is_stored() { + let (resolved, persist) = stored(Some(creds()), true).refreshed(); + assert_eq!(resolved, Some(creds())); + assert!(persist.is_none()); + } + + /// Nothing stored and nothing reachable falls through to the build-time pair, which is absent + /// in a test build — so this is the "no credentials anywhere" answer, not a panic. + #[test] + fn with_nothing_stored_it_falls_through_to_the_compiled_in_pair() { + let (resolved, _) = stored(None, true).refreshed(); + assert_eq!(resolved, ClientCreds::compiled_in()); + } +} diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index bc35c5f0..117225be 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -4,6 +4,7 @@ mod app_menus; mod app_settings; mod assets; mod export; +mod google; mod logging; mod status_items; mod theming; diff --git a/crates/data-exchange/src/google.rs b/crates/data-exchange/src/google.rs index e1bf4700..5acfd1cd 100644 --- a/crates/data-exchange/src/google.rs +++ b/crates/data-exchange/src/google.rs @@ -5,11 +5,12 @@ //! redirects the code back to that port. The scope is `drive.file`, which reaches only the files //! qrate itself creates — consenting here does not hand qrate the rest of a Drive. +use std::collections::HashMap; use std::io::{BufRead, BufReader, Write}; use std::net::TcpListener; use base64::Engine; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use thiserror::Error; @@ -20,6 +21,19 @@ const CLIENT_ID: Option<&str> = option_env!("QRATE_GOOGLE_CLIENT_ID"); const CLIENT_SECRET: Option<&str> = option_env!("QRATE_GOOGLE_CLIENT_SECRET"); const SCOPE: &str = "https://www.googleapis.com/auth/drive.file"; +/// Sent as a bearer to the credential endpoint. It ships inside the binary, so it stops casual +/// scraping and drive-by indexing — it is not access control, and what it guards is not +/// confidential anyway (see the module docs on the client secret). +const CONFIG_TOKEN: Option<&str> = option_env!("QRATE_GOOGLE_CONFIG_TOKEN"); + +/// Where [`fetch_config`] looks unless the user points it somewhere else. Anyone can run the same +/// contract on their own infrastructure — see `docs/site-oauth-handoff.md`. +pub const DEFAULT_CONFIG_ENDPOINT: &str = "https://qrate.dvnl.work/oauth/config"; + +/// Where the Google Picker page lives. `drive.file` reaches only files the app created or the user +/// picked *through the Picker*, so this page is the sole route to an already-owned spreadsheet. +pub const DEFAULT_PICKER_PAGE: &str = "https://qrate.dvnl.work/picker"; + #[derive(Debug, Error)] pub enum GoogleError { #[error("This build of qrate has no Google client ID, so it can't sign in to Google")] @@ -32,6 +46,43 @@ pub enum GoogleError { NoCode, #[error("Google turned the sign-in down — {0}")] Denied(String), + #[error( + "qrate hasn't been given access to that spreadsheet. Google only grants access to sheets \ + you pick through its own chooser, so open File ▸ Export ▸ Sync to Google Sheet and choose \ + it there." + )] + NoAccess, +} + +/// Which Google Cloud project qrate signs in against. Passed in rather than read from the consts +/// below, because the live pair can come from the credential endpoint instead — see +/// [`fetch_config`] and `crates/app/src/google.rs`. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ClientCreds { + pub client_id: String, + /// Absent for a client created without one. Google does not require it for an installed app. + #[serde(default)] + pub client_secret: Option, +} + +impl ClientCreds { + /// The build-time pair — the last rung of the resolution ladder, used when nothing has ever + /// been fetched or persisted. + pub fn compiled_in() -> Option { + CLIENT_ID.map(|client_id| Self { + client_id: client_id.to_string(), + client_secret: CLIENT_SECRET.map(str::to_string), + }) + } + + /// The pieces every token request carries. + fn form(&self) -> Vec<(&'static str, String)> { + let mut form = vec![("client_id", self.client_id.clone())]; + if let Some(secret) = &self.client_secret { + form.push(("client_secret", secret.clone())); + } + form + } } fn client() -> Result { @@ -56,10 +107,10 @@ pub struct Consent { verifier: String, state: String, redirect: String, + creds: ClientCreds, } -pub fn begin_consent() -> Result { - let client_id = CLIENT_ID.ok_or(GoogleError::NotConfigured)?; +pub fn begin_consent(creds: &ClientCreds) -> Result { let listener = TcpListener::bind("127.0.0.1:0")?; let redirect = format!("http://127.0.0.1:{}", listener.local_addr()?.port()); let (verifier, state) = (nonce(), nonce()); @@ -68,7 +119,7 @@ pub fn begin_consent() -> Result { let mut url = reqwest::Url::parse("https://accounts.google.com/o/oauth2/v2/auth") .expect("the constant Google authorization URL is valid"); url.query_pairs_mut() - .append_pair("client_id", client_id) + .append_pair("client_id", &creds.client_id) .append_pair("redirect_uri", &redirect) .append_pair("response_type", "code") .append_pair("scope", SCOPE) @@ -84,6 +135,7 @@ pub fn begin_consent() -> Result { verifier, state, redirect, + creds: creds.clone(), }) } @@ -118,56 +170,63 @@ impl TokenResponse { } } +/// Accept the one loopback request we are waiting for, answer the browser, and hand back its +/// query parameters. Shared by the consent redirect and the Picker's return trip, which differ +/// only in which parameter they came for. +/// +/// A `state` that doesn't match means this request is not the reply we asked for, so it yields +/// nothing rather than being trusted. +fn wait_for_query( + listener: &TcpListener, + state: &str, + done: &str, +) -> Result, GoogleError> { + let mut stream = listener.incoming().next().ok_or(GoogleError::NoCode)??; + let mut request = String::new(); + BufReader::new(&stream).read_line(&mut request)?; + // "GET /?code=…&state=… HTTP/1.1" — everything we need is in the first line. + let params: HashMap = request + .split_whitespace() + .nth(1) + .and_then(|path| path.split_once('?')) + .map(|(_, query)| query.to_string()) + .unwrap_or_default() + .split('&') + .filter_map(|pair| { + let (key, value) = pair.split_once('=')?; + Some((key.to_string(), urldecode(value))) + }) + .collect(); + + let answered = params.get("state").map(String::as_str) == Some(state); + let _ = stream.write_all( + if answered { + format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n\ +

{done}

You can close this tab.

" + ) + } else { + "HTTP/1.1 400 Bad Request\r\nContent-Type: text/html\r\n\r\n\ +

That didn't complete

Close this tab and try again in qrate.

" + .to_string() + } + .as_bytes(), + ); + answered.then_some(params).ok_or(GoogleError::NoCode) +} + impl Consent { /// Blocks until the browser hits the loopback port, then trades the code for tokens. pub fn wait_for_token(self) -> Result { - let mut stream = self - .listener - .incoming() - .next() - .ok_or(GoogleError::NoCode)??; - let mut request = String::new(); - BufReader::new(&stream).read_line(&mut request)?; - // "GET /?code=…&state=… HTTP/1.1" — everything we need is in the first line. - let query = request - .split_whitespace() - .nth(1) - .and_then(|path| path.split_once('?')) - .map(|(_, query)| query.to_string()) - .unwrap_or_default(); - let param = |wanted: &str| { - query.split('&').find_map(|pair| { - let (key, value) = pair.split_once('=')?; - (key == wanted).then(|| urldecode(value)) - }) - }; - let code = param("code"); - let answered = param("state").as_deref() == Some(&self.state); - let _ = stream.write_all( - if code.is_some() && answered { - "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n\ -

qrate is signed in

You can close this tab.

" - } else { - "HTTP/1.1 400 Bad Request\r\nContent-Type: text/html\r\n\r\n\ -

Sign-in didn't complete

Close this tab and try the export again.

" - } - .as_bytes(), - ); - - // A mismatched state means this reply is not the one we asked for, so it is not a code. - let (Some(code), true) = (code, answered) else { - return Err(GoogleError::NoCode); - }; - let mut form = vec![ - ("code", code), - ("client_id", CLIENT_ID.unwrap_or_default().to_string()), + let params = wait_for_query(&self.listener, &self.state, "qrate is signed in")?; + let code = params.get("code").ok_or(GoogleError::NoCode)?; + let mut form = self.creds.form(); + form.extend([ + ("code", code.clone()), ("grant_type", "authorization_code".into()), ("redirect_uri", self.redirect), ("code_verifier", self.verifier), - ]; - if let Some(secret) = CLIENT_SECRET { - form.push(("client_secret", secret.to_string())); - } + ]); client()? .post("https://oauth2.googleapis.com/token") .form(&form) @@ -178,16 +237,12 @@ impl Consent { } /// Trade a stored refresh token for a fresh access token, so consent is once per machine. -pub fn refresh(refresh_token: &str) -> Result { - let client_id = CLIENT_ID.ok_or(GoogleError::NotConfigured)?; - let mut form = vec![ +pub fn refresh(creds: &ClientCreds, refresh_token: &str) -> Result { + let mut form = creds.form(); + form.extend([ ("refresh_token", refresh_token.to_string()), - ("client_id", client_id.to_string()), ("grant_type", "refresh_token".to_string()), - ]; - if let Some(secret) = CLIENT_SECRET { - form.push(("client_secret", secret.to_string())); - } + ]); client()? .post("https://oauth2.googleapis.com/token") .form(&form) @@ -196,6 +251,74 @@ pub fn refresh(refresh_token: &str) -> Result { .into_token() } +/// A pending Picker choice. Same split as [`Consent`] and for the same reason: opening the URL is +/// the caller's job, waiting for the answer blocks. +pub struct Picker { + pub url: String, + listener: TcpListener, + state: String, +} + +/// Open the hosted Picker page, handing it the access token in the URL **fragment** — browsers +/// never send a fragment to the server, so the token reaches only that page's script. +pub fn begin_picker(page: &str, access_token: &str) -> Result { + let listener = TcpListener::bind("127.0.0.1:0")?; + let state = nonce(); + let url = format!( + "{page}#token={}&state={}&port={}", + urlencode(access_token), + urlencode(&state), + listener.local_addr()?.port() + ); + Ok(Picker { + url, + listener, + state, + }) +} + +impl Picker { + /// Blocks until the page redirects back with the chosen spreadsheet's id. + pub fn wait_for_file_id(self) -> Result { + wait_for_query(&self.listener, &self.state, "Spreadsheet chosen")? + .remove("fileId") + .ok_or(GoogleError::NoCode) + } +} + +/// What the credential endpoint serves. See `docs/site-oauth-handoff.md` for the contract. +#[derive(Deserialize)] +pub struct Config { + #[serde(flatten)] + pub creds: ClientCreds, + #[serde(skip)] + pub etag: Option, +} + +/// Ask the credential endpoint whether the stored pair is stale. `Ok(None)` is a `304` — what the +/// caller already has is current, which is the answer on almost every check. +pub fn fetch_config(endpoint: &str, etag: Option<&str>) -> Result, GoogleError> { + let mut request = client()? + .get(endpoint) + .bearer_auth(CONFIG_TOKEN.unwrap_or_default()); + if let Some(etag) = etag { + request = request.header(reqwest::header::IF_NONE_MATCH, etag); + } + let response = request.send()?; + if response.status() == reqwest::StatusCode::NOT_MODIFIED { + return Ok(None); + } + let response = response.error_for_status()?; + let etag = response + .headers() + .get(reqwest::header::ETAG) + .and_then(|v| v.to_str().ok()) + .map(str::to_string); + let mut config: Config = response.json()?; + config.etag = etag; + Ok(Some(config)) +} + #[derive(Deserialize)] struct CreatedSheet { #[serde(rename = "spreadsheetId")] @@ -204,36 +327,63 @@ struct CreatedSheet { url: String, } -/// Create a spreadsheet in the user's Drive and fill its first tab. Returns the URL to open. -pub fn create_sheet( - token: &str, - title: &str, - headers: &[String], - rows: &[Vec], -) -> Result { - let client = client()?; - let created: CreatedSheet = client +/// Create an empty spreadsheet in the user's Drive. Returns its id and the URL to open — the id is +/// what a project stores to stay linked to it, so [`write_values`] can refill it later. +pub fn create_sheet(token: &str, title: &str) -> Result<(String, String), GoogleError> { + let created: CreatedSheet = client()? .post("https://sheets.googleapis.com/v4/spreadsheets") .bearer_auth(token) .json(&serde_json::json!({ "properties": { "title": title } })) .send()? .error_for_status()? .json()?; + Ok((created.id, created.url)) +} +/// Fill a spreadsheet's first tab, replacing what is there. Works for one qrate just created and +/// for one the user chose through the Picker — those are the only two it can reach. +pub fn write_values( + token: &str, + spreadsheet_id: &str, + headers: &[String], + rows: &[Vec], +) -> Result<(), GoogleError> { let mut values: Vec<&[String]> = vec![headers]; values.extend(rows.iter().map(Vec::as_slice)); - // No sheet name in the range: a new spreadsheet has exactly one tab, and its title is - // localised — "A1" alone means "the first tab", which is the one we just made. - client + // No sheet name in the range: "A1" alone means the first tab, whose title is localised. + let response = client()? .put(format!( - "https://sheets.googleapis.com/v4/spreadsheets/{}/values/A1?valueInputOption=RAW", - created.id + "https://sheets.googleapis.com/v4/spreadsheets/{spreadsheet_id}/values/A1?valueInputOption=RAW" )) .bearer_auth(token) .json(&serde_json::json!({ "values": values })) - .send()? - .error_for_status()?; - Ok(created.url) + .send()?; + // `drive.file` answers 404, not 403, for a file the token was never granted — so the plain + // status is indistinguishable from a deleted sheet and has to be named for the user. + if response.status() == reqwest::StatusCode::NOT_FOUND { + return Err(GoogleError::NoAccess); + } + response.error_for_status()?; + Ok(()) +} + +/// The link a stored spreadsheet id points at. +pub fn sheet_url(spreadsheet_id: &str) -> String { + format!("https://docs.google.com/spreadsheets/d/{spreadsheet_id}") +} + +/// Percent-encoding for the fragment [`begin_picker`] builds. Deliberately encodes everything +/// outside RFC 3986's unreserved set, so a `+` in a token can't come back out of the page's +/// `URLSearchParams` as a space. +fn urlencode(raw: &str) -> String { + raw.bytes() + .map(|byte| match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => { + (byte as char).to_string() + } + _ => format!("%{byte:02X}"), + }) + .collect() } /// Percent-decoding for the one query string we read back off the loopback socket. @@ -268,7 +418,7 @@ fn urldecode(raw: &str) -> String { #[cfg(test)] mod tests { - use super::urldecode; + use super::{ClientCreds, urldecode, urlencode}; #[test] fn decodes_the_pieces_a_redirect_actually_carries() { @@ -278,4 +428,24 @@ mod tests { assert_eq!(urldecode("100%"), "100%"); assert_eq!(urldecode("%zz"), "%zz"); } + + /// The fragment carries an access token verbatim, so a round trip has to be lossless — and + /// `+` in particular must not survive as a literal for the page to read back as a space. + #[test] + fn encodes_a_token_so_it_survives_the_fragment() { + let token = "ya29.a0+Ae/4-_x.y~z"; + assert_eq!(urldecode(&urlencode(token)), token); + assert!(!urlencode(token).contains('+')); + } + + /// A client created without a secret must not send an empty one — Google reads that as a + /// mismatch rather than as absence. + #[test] + fn a_secretless_client_sends_only_its_id() { + let creds = ClientCreds { + client_id: "id".into(), + client_secret: None, + }; + assert_eq!(creds.form(), vec![("client_id", "id".to_string())]); + } } diff --git a/crates/settings/src/project.rs b/crates/settings/src/project.rs index c6bd3b33..0985b272 100644 --- a/crates/settings/src/project.rs +++ b/crates/settings/src/project.rs @@ -41,6 +41,12 @@ const QRATE_SCHEMA_VERSION: i32 = 2; /// this folder every time the project opens (see `table::photos`). pub const FILES_FOLDER_KEY: &str = "files_folder"; +/// `__settings` key for the spreadsheet this project pushes to. The id alone, not the URL — the +/// link is derivable (`data_exchange::google::sheet_url`) and the id is what the Sheets API takes. +/// Written when the user picks a sheet through Google's chooser, which is also what grants qrate +/// access to it; storing an id the user typed would name a file the token cannot reach. +pub const GOOGLE_SHEET_ID_KEY: &str = "google_sheet_id"; + pub struct ProjectColumn { pub name: String, pub data_type: String, From 7ef6dedc6c671ba9e033da6acf9aa414eb196c78 Mon Sep 17 00:00:00 2001 From: devnull03 <56480041+devnull03@users.noreply.github.com> Date: Sat, 15 Aug 2026 21:01:41 +0530 Subject: [PATCH 2/6] feat(google): put every Google surface behind an opt-in consent gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Google sync ships off. An archivist may not be permitted to connect an institutional Drive at all, and a build that never offers it is easier to get approved than one that has to be argued down. The surfaces are hidden rather than disabled — a greyed-out control nobody can explain invites a support question, an absent one doesn't. That replaces the condition that greyed out the export entry for want of a client id. Switching on opens a dialog first, so what qrate asks Google for is read before any browser does. Switching off also forgets the stored sign-in: otherwise "off" leaves a live grant on the machine. The gate reads `AppSettings` directly rather than through `effective_bool` — a project must not be able to turn on a connection the person using it never agreed to. Refs ASNT-96 --- crates/app/src/app_menus.rs | 12 ++-- crates/app/src/app_settings/mod.rs | 100 +++++++++++++++++++++++++- crates/project-wizard/src/launcher.rs | 16 +++-- crates/settings/src/lib.rs | 19 +++++ 4 files changed, 134 insertions(+), 13 deletions(-) diff --git a/crates/app/src/app_menus.rs b/crates/app/src/app_menus.rs index 5858468c..74dbbb18 100644 --- a/crates/app/src/app_menus.rs +++ b/crates/app/src/app_menus.rs @@ -2,7 +2,7 @@ use gpui::*; use window_wrapper::OpenBrowser; use crate::actions::{NewProject, Save, ToggleBottomDock, ToggleLeftDock, ToggleRightDock}; -use crate::export::{EXPORT_FORMATS, Export, ExportFormat}; +use crate::export::{EXPORT_FORMATS, Export}; use crate::theming::{SwitchTheme, theme_choices}; // The Edit menu's items act on the grid, so they're the grid's actions — `table` declares and @@ -69,14 +69,18 @@ fn app_menus(cx: &gpui::App) -> Vec { disabled: false, items: EXPORT_FORMATS .iter() + // Absent, not greyed out: a control nobody can explain invites a support + // question, and an institution that forbids Google should see no trace of + // it. Settings re-runs `install` when the opt-in flips. + .filter(|(format, _, _)| { + !crate::export::is_google(*format) || settings::google_enabled(cx) + }) .map(|(format, label, _)| MenuItem::Action { name: (*label).into(), action: Box::new(Export { format: *format }), os_action: None, checked: false, - // ponytail: written but switched off until qrate has a Google OAuth - // client — see the "Google Sheets export" tracker task. - disabled: *format == ExportFormat::GoogleSheet, + disabled: false, }) .collect(), }), diff --git a/crates/app/src/app_settings/mod.rs b/crates/app/src/app_settings/mod.rs index e715c641..c6688921 100644 --- a/crates/app/src/app_settings/mod.rs +++ b/crates/app/src/app_settings/mod.rs @@ -5,17 +5,19 @@ use std::rc::Rc; use gpui::prelude::FluentBuilder as _; use gpui::{ - AnyElement, App, AppContext as _, Axis, Entity, Global, IntoElement, ParentElement as _, - SharedString, Styled as _, Subscription, Window, div, px, + AnyElement, App, AppContext as _, Axis, ClickEvent, Entity, Global, IntoElement, + ParentElement as _, SharedString, Styled as _, Subscription, Window, div, px, }; use gpui_component::{ - ActiveTheme as _, Icon, IconName, IndexPath, Sizable as _, + ActiveTheme as _, Icon, IconName, IndexPath, Sizable as _, WindowExt as _, button::Button, combobox::{Combobox, ComboboxEvent, ComboboxState}, + dialog::DialogButtonProps, h_flex, input::{Input, InputEvent, InputState}, searchable_list::{SearchableListItem, SearchableVec}, setting::{SettingField, SettingGroup, SettingItem, SettingPage}, + switch::Switch, tab::{Tab, TabBar}, v_flex, }; @@ -42,6 +44,7 @@ pub fn build_pages(cx: &App) -> Vec { columns_page(cx), authorities_page(cx), SettingPage::new("Spelling").group(spelling_group(cx)), + google_page(cx), plugins_page(cx), ]; pages.extend(plugin_pages(cx)); @@ -317,6 +320,97 @@ fn previews_group() -> SettingGroup { ) } +/// Google sync, off until the user says otherwise. Everything here is user-wide whatever the scope +/// switcher says: consent is given by a person, not by a collection, so these rows read and write +/// `AppSettings` directly rather than through `settings::scoped_*`. +fn google_page(cx: &App) -> SettingPage { + let mut group = SettingGroup::new().title("Google Sheets").item( + SettingItem::new( + "Connect to Google", + // A plain switch would flip on the way down; switching *on* has to survive the consent + // dialog first, which needs the `Window` only a render closure is handed. + SettingField::render(|_opts: &_, _window: &mut _, cx: &mut App| { + let on = settings::google_enabled(cx); + Switch::new("google-sync") + .checked(on) + .on_click(|on: &bool, window, cx| { + if *on { + return ask_google_consent(window, cx); + } + settings::AppSettings::set_bool(settings::GOOGLE_SYNC_KEY, false, cx); + // Off has to end the grant, not just hide the buttons. + crate::google::clear_refresh_token(); + crate::app_menus::install(cx); + }) + }), + ) + .description( + "Export a project to Google Sheets and keep that sheet current. Off until you connect, \ + and disconnecting forgets the sign-in kept on this machine.", + ), + ); + + if settings::google_enabled(cx) { + group = group.item( + SettingItem::new( + "Credential endpoint", + SettingField::input( + |cx: &App| { + settings::AppSettings::get(cx) + .values + .get(settings::GOOGLE_CONFIG_ENDPOINT_KEY) + .map(|v| v.text()) + .unwrap_or_default() + }, + |val: SharedString, cx: &mut App| { + settings::AppSettings::set_text( + settings::GOOGLE_CONFIG_ENDPOINT_KEY, + val, + cx, + ); + }, + ), + ) + .description( + "Where qrate asks which Google project to sign in against. Leave it empty for \ + ours. An institution that runs its own Google project points this at its own \ + endpoint — see docs/site-oauth-handoff.md for what to serve.", + ) + .layout(Axis::Vertical), + ); + } + + SettingPage::new("Google").group(group) +} + +/// What the user agrees to, in their own terms, before any browser opens. Accepting records the +/// setting; declining leaves everything off and hidden. +fn ask_google_consent(window: &mut Window, cx: &mut App) { + window.open_dialog(cx, move |dialog, _, _| { + dialog + .title("Connect qrate to Google") + .w(px(460.0)) + .content(move |content, _, _| { + content.p_4().gap_2().children([ + "qrate will ask Google for permission to work with spreadsheets it creates, \ + and with any spreadsheet you choose through Google's own file chooser. It \ + cannot see the rest of your Drive.", + "Your sign-in is kept in this computer's credential store — Credential \ + Manager on Windows, Keychain on macOS — and never leaves it. Nothing is sent \ + anywhere except to Google.", + "You can withdraw permission at any time from your Google account, or by \ + switching this off again, which also forgets the sign-in stored here.", + ]) + }) + .button_props(DialogButtonProps::default().ok_text("Connect")) + .on_ok(move |_: &ClickEvent, _, cx| { + settings::AppSettings::set_bool(settings::GOOGLE_SYNC_KEY, true, cx); + crate::app_menus::install(cx); + true + }) + }); +} + fn saving_group(cx: &App) -> SettingGroup { let mut group = SettingGroup::new().title("Saving").item( SettingItem::new( diff --git a/crates/project-wizard/src/launcher.rs b/crates/project-wizard/src/launcher.rs index 05582b0d..234f8eb0 100644 --- a/crates/project-wizard/src/launcher.rs +++ b/crates/project-wizard/src/launcher.rs @@ -243,12 +243,16 @@ impl Render for Launcher { "Import a spreadsheet and its files.", cx, )) - .child(create_card( - "new-sheet", - "Google Sheet", - "Use a shared spreadsheet link.", - cx, - )), + // Absent until Google sync is switched on in Settings — a fresh + // install shows no Google surface at all. + .when(settings::google_enabled(cx), |column| { + column.child(create_card( + "new-sheet", + "Google Sheet", + "Use a shared spreadsheet link.", + cx, + )) + }), ), ) .children(dialog_layer) diff --git a/crates/settings/src/lib.rs b/crates/settings/src/lib.rs index 9f92359c..df72fd77 100644 --- a/crates/settings/src/lib.rs +++ b/crates/settings/src/lib.rs @@ -42,6 +42,25 @@ pub const AUTOSAVE_KEY: &str = "autosave"; /// author rather than guessing at an identity from the OS account. pub const NOTE_AUTHOR_KEY: &str = "note_author"; +/// `AppSettings` key for whether Google sync is switched on. User-wide only — consent is given by +/// a person, not by a collection — and off until the user passes the consent dialog, so a fresh +/// install shows no Google surface at all. +pub const GOOGLE_SYNC_KEY: &str = "google_sync_enabled"; + +/// Whether Google sync is switched on. Deliberately not [`effective_bool`]: a project must not be +/// able to turn on a connection the person using it never agreed to. +pub fn google_enabled(cx: &App) -> bool { + AppSettings::get(cx) + .values + .get(GOOGLE_SYNC_KEY) + .map(|v| v.bool()) + .unwrap_or(false) +} + +/// `AppSettings` key for the credential endpoint. Empty means the built-in default; pointing it +/// elsewhere is how someone runs this flow against their own Google Cloud project. +pub const GOOGLE_CONFIG_ENDPOINT_KEY: &str = "google_config_endpoint"; + /// Setting key (either scope) for the string that separates several values inside one cell, e.g. /// `;` in `Film; Video`. Empty means a cell is one indivisible value. /// From 45e56c0bd7bcd9f1e906f83ffd935506c159a145 Mon Sep 17 00:00:00 2001 From: devnull03 <56480041+devnull03@users.noreply.github.com> Date: Sat, 15 Aug 2026 21:01:53 +0530 Subject: [PATCH 3/6] docs(google): hand the Worker and Picker page to the site agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The credential endpoint and the Picker page are the half of this that lives in qrate-site, and nothing in CI would notice them missing — the Rust side just falls back to its compiled-in credentials and can only ever create new sheets. So the contract is written down: the request, the ETag behaviour, the fragment the Picker page is handed, and what it must redirect back to. Self-hosting is the point of writing it down at all. An institution that will not route its staff through our endpoint sets its own and runs the same flow against its own Cloud project, without reading Rust. `client_secret_*.json` is ignored by name — the download lands with that shape, and the values belong in the environment or in the endpoint, never in source. --- .github/workflows/release.yml | 4 + .gitignore | 4 + docs/SETUP.md | 21 ++++ docs/google-sync-handoff.md | 31 ++++-- docs/site-oauth-handoff.md | 202 ++++++++++++++++++++++++++++++++++ 5 files changed, 253 insertions(+), 9 deletions(-) create mode 100644 docs/site-oauth-handoff.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c4674435..8f4beb49 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,6 +19,10 @@ env: # the repo. Absent (any local build): the Google Sheets export reports it isn't configured. QRATE_GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} QRATE_GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }} + # Sent to the credential endpoint. It ships inside the binary, so it turns away casual scraping + # and nothing more — see docs/site-oauth-handoff.md. Absent: the endpoint answers 401 and the + # app carries on with the pair above. + QRATE_GOOGLE_CONFIG_TOKEN: ${{ secrets.GOOGLE_CONFIG_TOKEN }} jobs: # --------------------------------------------------------------------------- diff --git a/.gitignore b/.gitignore index e8b9910d..d2a1bf70 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,7 @@ GEMINI.md # The Islandora plugin is its own repository, checked out here so a dev build loads it. plugins/islandora/ + +# The OAuth client JSON Google hands you. Named here because the download lands with this shape +# and a stray copy in the repo root is the easiest way to publish it by accident. +client_secret_*.json diff --git a/docs/SETUP.md b/docs/SETUP.md index 6c0260e1..8c2ea1a3 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -33,6 +33,21 @@ Workspace crates (versions are inherited from `[workspace.package].version`): - Node 20+ and npm. `npm install`, then `npm run dev` (`http://localhost:4321/qrate/`). +**Google sign-in (optional locally):** the client id and secret are read at *compile* time by +`option_env!`, so they have to be in the environment before `cargo run` and a change to them needs +a rebuild of `data-exchange`. From the OAuth client JSON Google hands you (type **Desktop app**): + +```powershell +$env:QRATE_GOOGLE_CLIENT_ID = "…apps.googleusercontent.com" +$env:QRATE_GOOGLE_CLIENT_SECRET = "…" +cargo run +``` + +A build without them still compiles and runs — Google sign-in reports that this build has no +client id. Never commit the JSON; `.gitignore` covers `client_secret_*.json`, and the values +themselves belong in the environment or in the credential endpoint (`docs/site-oauth-handoff.md`), +never in source. + --- ## 3. One-time GitHub setup (do this before the first release) @@ -64,6 +79,12 @@ produce nothing visible. 5. **Custom domain (optional).** Settings → Pages writes a `CNAME`; then set `site:` in `astro.config.mjs` to the domain and drop/empty `base`. +6. **Google credentials (only if release builds should sign in).** Add `GOOGLE_CLIENT_ID`, + `GOOGLE_CLIENT_SECRET` and `GOOGLE_CONFIG_TOKEN` as Actions secrets. `release.yml` already maps + all three into the `QRATE_*` build vars, so nothing else needs editing. They are the fallback + the binary carries; the live pair comes from the credential endpoint at runtime, so rotating the + Cloud project does **not** need a new release. + --- ## 4. CI/CD pipelines diff --git a/docs/google-sync-handoff.md b/docs/google-sync-handoff.md index 993fad3e..f6779dac 100644 --- a/docs/google-sync-handoff.md +++ b/docs/google-sync-handoff.md @@ -6,6 +6,12 @@ Those four are one body of work and are meant to be done together. The per-task in Notion; this file holds only what is shared between them and would otherwise have to be rediscovered by reading the OAuth code. +> **Status, 2026-08-15.** The Rust side of all four is written. What is left is outside this repo: +> the Cloudflare Worker and the Picker page (`docs/site-oauth-handoff.md`), and the Google Cloud +> console work — enabling the APIs, a Picker API key, and moving the consent screen to Production. +> **ASNT-93's open decision is closed: Route A.** `drive.file` stays, and the Picker is how a user +> points qrate at a spreadsheet they already own. + There are no existing installs, so nothing here needs a migration path. Where the current code does the wrong thing, replace it rather than adding a fallback for users who do not exist. @@ -19,9 +25,11 @@ Far more than the disabled menu item suggests. All of this is written and tested | Sheets write: create a spreadsheet, fill tab 1 | `google.rs::create_sheet` | | Export action, background threading, token storage | `crates/app/src/export.rs::to_google_sheet` | | Sheet **import** (public link, xlsx, incl. cell notes) | `crates/data-exchange/src/sheet.rs` | +| Credential ladder, keychain, the opt-in read | `crates/app/src/google.rs` | +| Picker round trip, `write_values`, the config fetch | `google.rs::begin_picker`, `write_values`, `fetch_config` | -The only reason the menu item is off (`crates/app/src/app_menus.rs:79`) is that no Cloud client ID -exists yet. That is ASNT-80, and it gates everything here. +The Google entries are no longer disabled — they are **absent** until the user switches Google sync +on in Settings ▸ Google, which is also where they read what they are agreeing to. ## How login works today @@ -97,6 +105,9 @@ contract documented in `docs/`, and the Worker plus its deploy steps in `qrate-s policy pages. An institution that won't route its staff through our endpoint has a supported answer rather than a fork. +The setting exists (Settings ▸ Google ▸ Credential endpoint) and the contract is written down in +`docs/site-oauth-handoff.md`. The Worker itself is the site agent's half. + ## The one structural blocker qrate has no stable row identity. `save_dataset` (`crates/settings/src/project.rs:640`) drops and @@ -115,8 +126,10 @@ ASNT-85 (pull) is blocked on it. | `spreadsheets` — every spreadsheet in the account | sensitive | brand verification review | | `drive`, `drive.readonly` | restricted | verification + annual CASA assessment (billed in thousands) | -We are on `drive.file`. Note that a spreadsheet **ID is not authorization** under `drive.file` — a -file the token was never granted returns 404, not 403. This is the whole substance of ASNT-93. +We are on `drive.file`, and staying there. Note that a spreadsheet **ID is not authorization** under +`drive.file` — a file the token was never granted returns 404, not 403. That is why ASNT-93 ships +the Picker rather than a URL box, and why `write_values` maps a 404 to `GoogleError::NoAccess` +instead of letting a bare status reach the user. Also: the consent screen must move from Testing to Production before release, or refresh tokens expire after 7 days and the app caps at 100 users. Production needs hosted privacy policy and @@ -139,8 +152,7 @@ ASNT-84 stable row identity (independent; also cheaper than three: the opt-in toggle, the config endpoint field, and the keychain swap land together. -ASNT-93 carries an open product decision (Picker vs. widening the scope) that must be answered -before code — see the task. +ASNT-93's open decision (Picker vs. widening the scope) was answered: Route A, the Picker. ## Open questions the design left for the user @@ -152,9 +164,10 @@ Full option analysis: + If-None-Match: "" (only after the first successful fetch) + +→ 200 {"client_id": "…", "client_secret": "…"} ETag: "…" +→ 304 ETag: "…" +→ 401 (wrong or missing bearer) +``` + +`client_secret` may be omitted; the app treats it as absent rather than empty. + +```ts +// src/pages/oauth/config.ts +import type { APIRoute } from 'astro'; + +export const prerender = false; + +export const GET: APIRoute = async ({ request, locals }) => { + const env = locals.runtime.env; + if (request.headers.get('authorization') !== `Bearer ${env.QRATE_GOOGLE_CONFIG_TOKEN}`) { + return new Response(null, { status: 401 }); + } + const body = JSON.stringify({ + client_id: env.GOOGLE_CLIENT_ID, + client_secret: env.GOOGLE_CLIENT_SECRET, + }); + // Derived from the body, so rotating a secret changes the ETag with nothing to remember. + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(body)); + const etag = `"${[...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('')}"`; + + if (request.headers.get('if-none-match') === etag) { + return new Response(null, { status: 304, headers: { ETag: etag } }); + } + return new Response(body, { + headers: { 'content-type': 'application/json', ETag: etag }, + }); +}; +``` + +Secrets via `wrangler secret put GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` / +`QRATE_GOOGLE_CONFIG_TOKEN`. Never in committed source, never in `wrangler.toml`'s `[vars]`. + +`QRATE_GOOGLE_CONFIG_TOKEN` does not exist anywhere yet — invent one (`openssl rand -base64 32`) +and put the *same* string in two places: this Worker secret, and the `QRATE_GOOGLE_CONFIG_TOKEN` +build variable qrate compiles in (`GOOGLE_CONFIG_TOKEN` as a GitHub Actions secret; see +`docs/SETUP.md` §3.6). Omitting `GOOGLE_CLIENT_SECRET` is fine for a client that has none — the +route leaves the field out rather than sending `""`, and the app reads that as absent. + +A mismatch is silent: qrate gets a 401, keeps its compiled-in pair, and logs at `warn`. So check it +once by hand after deploying, which also proves the ETag path: + +```sh +curl -isS -H "Authorization: Bearer $TOKEN" https:///oauth/config # 200 + ETag +curl -isS -H "Authorization: Bearer $TOKEN" -H 'If-None-Match: ""' \ + https:///oauth/config # 304 +``` + +**Be accurate about the bearer.** It ships inside the qrate binary, so it stops casual scraping and +drive-by indexing and nothing else. Do not describe it as access control in code comments or on the +site — and it does not need to be, because Google treats an installed app's client secret as +non-confidential (RFC 8252 §8.5). Loopback + PKCE is what secures the exchange. + +**This must never grow into a token-exchange proxy.** Google's redirect has to reach the app on the +user's own machine. Anything in the middle either keeps the loopback listener anyway, asks the user +to paste a code, or needs a session store — and all three put user Drive tokens on our +infrastructure. The Worker serves *application* credentials and nothing else. + +A 401, a 500, or an unreachable host all land in the same branch on the app side: keep whatever is +stored, log at `warn`, carry on. Sign-in never blocks on this endpoint. + +## 3. `/picker` + +A prerendered page — no server involvement at all. qrate opens it with the access token, the +nonce, and the loopback port in the URL **fragment**, which browsers never send to a server: + +``` +https:///picker#token=&state=&port= +``` + +The page must, after the user picks, redirect to: + +``` +http://127.0.0.1:/?fileId=&state= +``` + +qrate rejects the reply if `state` does not match, so echoing it is not optional. A user who closes +the window without picking simply never redirects; qrate's listener stays blocked until the app +closes, which is the same shape as an abandoned consent. + +```js +const p = new URLSearchParams(location.hash.slice(1)); +// Don't leave an access token in browser history. +history.replaceState(null, '', location.pathname); + +gapi.load('picker', () => { + new google.picker.PickerBuilder() + .setAppId(APP_ID) // "805791669854" — the Cloud project number + .setOAuthToken(p.get('token')) + .setDeveloperKey(PICKER_API_KEY) // a browser API key, referrer-restricted to this site + .addView(new google.picker.DocsView(google.picker.ViewId.SPREADSHEETS)) + .setCallback((data) => { + if (data.action !== google.picker.Action.PICKED) return; + location.replace( + `http://127.0.0.1:${p.get('port')}/?fileId=${encodeURIComponent(data.docs[0].id)}` + + `&state=${encodeURIComponent(p.get('state'))}`, + ); + }) + .build() + .setVisible(true); +}); +``` + +`setAppId` is the load-bearing line. Picking a file through a Picker configured with the same Cloud +project number is exactly what grants that file to a `drive.file` token — without it the user picks +something and qrate still gets a 404 on write. The API key is a browser key, safe to ship in the +page, and should be restricted by HTTP referrer to this site. + +The page needs no styling beyond a heading; it exists for about four seconds. + +## 4. Google Cloud console — still to be done by a human + +Not the site agent's job, but the routes above are inert without it: + +- Enable the **Sheets**, **Drive** and **Picker** APIs on project `805791669854`. +- Create a **browser API key**, referrer-restricted to the site, for `setDeveloperKey`. +- Fill the OAuth consent screen, including the `/privacy` and `/terms` URLs, and move it from + Testing to Production. Until then refresh tokens expire after 7 days and the app caps at 100 users. +- Scope stays `drive.file` — non-sensitive, no brand-verification review. Widening it to + `spreadsheets` is a decision that was considered and rejected; see `docs/google-sync-handoff.md`. + +## 5. Self-hosting is a requirement + +The Worker source, this contract, and the setting that points somewhere else are all public. An +institution that will not route its staff through our endpoint sets **Settings ▸ Google ▸ Credential +endpoint** to their own and runs the same flow against their own Cloud project — no fork, no Rust. +Whatever ends up in `qrate-site` should be readable as a template for exactly that. From 9f48c6d3c790552c4252a3a41cfef26c51f8d341 Mon Sep 17 00:00:00 2001 From: devnull03 <56480041+devnull03@users.noreply.github.com> Date: Sat, 15 Aug 2026 21:07:57 +0530 Subject: [PATCH 4/6] docs(google): record what the site now serves, and what the cutover broke MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit qrate.dvnl.work is live on a Cloudflare Worker and both routes answer as specified — verified from here: 401 without a bearer, 200 with, 304 on If-None-Match, and /picker serves. The two constants the app targets already point there, so nothing in the Rust changes. The handoff stops being a to-do and becomes the contract, which is what the self-hosting requirement needs it to be anyway. The Astro migration steps go: they are done, and they now describe the wrong versions. One thing the cutover broke is worth a warning rather than a footnote. The site workflows still deploy to GitHub Pages, which no longer serves the domain — so publishing a release leaves the live site stale and reports success either way. A pipeline that fails loudly is a bug; one that succeeds at nothing is a trap. --- docs/SETUP.md | 7 ++++ docs/google-sync-handoff.md | 18 +++++--- docs/site-oauth-handoff.md | 84 +++++++++++-------------------------- 3 files changed, 44 insertions(+), 65 deletions(-) diff --git a/docs/SETUP.md b/docs/SETUP.md index 8c2ea1a3..32e6fbb8 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -115,6 +115,13 @@ Four workflows, two on `main`, one on `site`, and CI on `dev`/PRs. - **Publishes:** a **DRAFT** release with the artifacts + `SHA256SUMS.txt`. It does **not** set the pre-release flag — you choose that when you publish (§5). +> **Broken since the Cloudflare cutover (2026-08-15).** `qrate.dvnl.work` is served by a +> Cloudflare Worker, not GitHub Pages, so the two site workflows below still run and still deploy — +> to a Pages site nobody visits. **Publishing a release leaves the live site stale and says +> nothing.** The fix is a Cloudflare deploy hook and swapping the dispatch in +> `redeploy-site-on-release.yml` for a `curl` at it. Until then, redeploy the site by hand after +> publishing a release. + ### `deploy-site.yml` — build & deploy the site (on `site`) - **Triggers:** push to `site`; `workflow_dispatch`. - **Does:** `withastro/action` builds the Astro site, which **fetches the release diff --git a/docs/google-sync-handoff.md b/docs/google-sync-handoff.md index f6779dac..ea69e081 100644 --- a/docs/google-sync-handoff.md +++ b/docs/google-sync-handoff.md @@ -6,11 +6,19 @@ Those four are one body of work and are meant to be done together. The per-task in Notion; this file holds only what is shared between them and would otherwise have to be rediscovered by reading the OAuth code. -> **Status, 2026-08-15.** The Rust side of all four is written. What is left is outside this repo: -> the Cloudflare Worker and the Picker page (`docs/site-oauth-handoff.md`), and the Google Cloud -> console work — enabling the APIs, a Picker API key, and moving the consent screen to Production. -> **ASNT-93's open decision is closed: Route A.** `drive.file` stays, and the Picker is how a user -> points qrate at a spreadsheet they already own. +> **Status, 2026-08-15.** The Rust side of all four is written, and `qrate.dvnl.work` serves both +> `/oauth/config` and `/picker` (see `docs/site-oauth-handoff.md`). **ASNT-93's open decision is +> closed: Route A** — `drive.file` stays, and the Picker is how a user points qrate at a +> spreadsheet they already own. +> +> Nothing Google-facing works end to end yet, and all three remaining pieces are outside this repo: +> the Cloud console work (enable Sheets/Drive/Picker, a referrer-restricted Picker API key, consent +> screen out of Testing), the three GitHub Actions secrets, and a deploy hook to replace the +> release→site dispatch the Cloudflare cutover broke (`docs/SETUP.md` §4). +> +> Both the config token and the client secret were pasted into agent transcripts while setting this +> up. Neither is confidential by design, but rotating them costs one Cloudflare secret each *until +> the first binary ships with them baked in* — after that it costs a release. Cheap now. There are no existing installs, so nothing here needs a migration path. Where the current code does the wrong thing, replace it rather than adding a fallback for users who do not exist. diff --git a/docs/site-oauth-handoff.md b/docs/site-oauth-handoff.md index 289bc1f6..feb0641d 100644 --- a/docs/site-oauth-handoff.md +++ b/docs/site-oauth-handoff.md @@ -1,65 +1,29 @@ -# qrate-site — handoff for the Cloudflare move and the Google routes +# The Google routes on qrate-site -Written 2026-08-15, for whoever (or whichever agent) works on `qrate-site` — the `site` branch of -this repo, checked out separately at `../qrate-site`. It is an Astro site, currently static on -GitHub Pages at `https://devnull03.github.io/qrate` with `base: '/qrate'`, holding -`index`, `get-started`, `changelog`, `license`, `privacy` and `terms`. - -The desktop app now needs two things from it that a static GitHub Pages build cannot give: +Written 2026-08-15 as a handoff; kept as the contract between the desktop app and the two routes it +depends on. `qrate-site` is the `site` branch of this repo, checked out separately at +`../qrate-site` — an Astro site on Cloudflare Workers via `@astrojs/cloudflare`. | Route | Why the site has to serve it | |---|---| | `GET /oauth/config` | Needs a server to hold a secret and answer conditionally. Rotating the Google Cloud project becomes a deploy instead of a qrate release. | | `/picker` | Google's file chooser is a browser component. `drive.file` grants access **only** to files the app created or the user picked *through that chooser*, so without this page qrate can never write to a spreadsheet the user already owns. | -Neither is optional-nice-to-have: the Rust side is already written against both, and until they -exist Google sync can create new spreadsheets but not touch existing ones. - -## What the app already does - -- `crates/data-exchange/src/google.rs` — `fetch_config` and `begin_picker`/`Picker` are written and - compile. `DEFAULT_CONFIG_ENDPOINT` and `DEFAULT_PICKER_PAGE` are **placeholders**: - `https://qrate.dvnl.work/oauth/config` and `https://qrate.dvnl.work/picker`. -- `crates/app/src/google.rs` — the resolution ladder and the persisted copy. - -**The one thing to send back:** the real hostname. Both constants change to whatever the Pages -project ends up on, and the Google Cloud console's referrer restriction has to match it. - -## 1. Cloudflare migration - -```sh -npm i @astrojs/cloudflare -``` - -```js -// astro.config.mjs -import cloudflare from '@astrojs/cloudflare'; - -export default defineConfig({ - site: 'https://qrate.dvnl.work', // the real one, once it is decided - adapter: cloudflare(), - output: 'static', // per-route opt-out below; everything else stays prerendered - trailingSlash: 'ignore', - vite: { plugins: [tailwindcss()] }, -}); -``` +**Deployed and verified 2026-08-15** on `qrate.dvnl.work`: `/oauth/config` answers 401 without a +bearer, 200 + `ETag` with one, 304 on `If-None-Match`; `/picker` serves. The two constants the app +targets — `DEFAULT_CONFIG_ENDPOINT` and `DEFAULT_PICKER_PAGE` in +`crates/data-exchange/src/google.rs` — already point there and need no change. -Notes that will bite otherwise: +Two things the cutover left behind, neither in this repo: -- **`base: '/qrate'` goes away.** Every internal link and asset currently routes through it. Drop it - and sweep the source for `import.meta.env.BASE_URL` and any hand-written `/qrate/…` before - deploying, or half the site 404s. -- The release list is fetched **at build time** with the workflow's `GITHUB_TOKEN` - (see `docs/SETUP.md` §4). Cloudflare Pages builds have no such token — either keep building on - GitHub Actions and deploy the output with `wrangler pages deploy`, or give the Pages build a - GitHub token of its own. The first is less to explain. -- `redeploy-site-on-release.yml` on `main` triggers the site rebuild when a release is published. - Whatever replaces `deploy-site.yml` has to keep the same `workflow_dispatch` handle, or publishing - a release silently stops updating the site. -- Keep `/privacy` and `/terms` at exactly those paths. Google's consent screen points at them, and - changing a URL Google has on file means going back through the consent-screen form. +- **Publishing a release no longer updates the site.** `redeploy-site-on-release.yml` (on `main`) + dispatches `deploy-site.yml`, which builds to GitHub Pages — but the domain serves the Worker + now, so a published release leaves the site stale and says nothing. It needs a Cloudflare deploy + hook and a five-line swap in that job. See `docs/SETUP.md` §4. +- `/privacy` and `/terms` must stay at exactly those paths. Google's consent screen has them on + file, and changing either URL means going back through the consent-screen form. -## 2. `GET /oauth/config` +## 1. `GET /oauth/config` The contract, in full. Anyone should be able to serve this and point a qrate at it. @@ -106,11 +70,11 @@ export const GET: APIRoute = async ({ request, locals }) => { Secrets via `wrangler secret put GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` / `QRATE_GOOGLE_CONFIG_TOKEN`. Never in committed source, never in `wrangler.toml`'s `[vars]`. -`QRATE_GOOGLE_CONFIG_TOKEN` does not exist anywhere yet — invent one (`openssl rand -base64 32`) -and put the *same* string in two places: this Worker secret, and the `QRATE_GOOGLE_CONFIG_TOKEN` -build variable qrate compiles in (`GOOGLE_CONFIG_TOKEN` as a GitHub Actions secret; see -`docs/SETUP.md` §3.6). Omitting `GOOGLE_CLIENT_SECRET` is fine for a client that has none — the -route leaves the field out rather than sending `""`, and the app reads that as absent. +`QRATE_GOOGLE_CONFIG_TOKEN` is invented, not issued (`openssl rand -base64 32`). The *same* string +goes in two places: this Worker secret, and the `QRATE_GOOGLE_CONFIG_TOKEN` build variable qrate +compiles in (`GOOGLE_CONFIG_TOKEN` as a GitHub Actions secret; see `docs/SETUP.md` §3.6). +Omitting `GOOGLE_CLIENT_SECRET` is fine for a client that has none — the route leaves the field out +rather than sending `""`, and the app reads that as absent. A mismatch is silent: qrate gets a 401, keeps its compiled-in pair, and logs at `warn`. So check it once by hand after deploying, which also proves the ETag path: @@ -134,7 +98,7 @@ infrastructure. The Worker serves *application* credentials and nothing else. A 401, a 500, or an unreachable host all land in the same branch on the app side: keep whatever is stored, log at `warn`, carry on. Sign-in never blocks on this endpoint. -## 3. `/picker` +## 2. `/picker` A prerendered page — no server involvement at all. qrate opens it with the access token, the nonce, and the loopback port in the URL **fragment**, which browsers never send to a server: @@ -183,7 +147,7 @@ page, and should be restricted by HTTP referrer to this site. The page needs no styling beyond a heading; it exists for about four seconds. -## 4. Google Cloud console — still to be done by a human +## 3. Google Cloud console — still to be done by a human Not the site agent's job, but the routes above are inert without it: @@ -194,7 +158,7 @@ Not the site agent's job, but the routes above are inert without it: - Scope stays `drive.file` — non-sensitive, no brand-verification review. Widening it to `spreadsheets` is a decision that was considered and rejected; see `docs/google-sync-handoff.md`. -## 5. Self-hosting is a requirement +## 4. Self-hosting is a requirement The Worker source, this contract, and the setting that points somewhere else are all public. An institution that will not route its staff through our endpoint sets **Settings ▸ Google ▸ Credential From aee8f4e18605cb775cc5f7d03ca26087e4f8e502 Mon Sep 17 00:00:00 2001 From: devnull03 <56480041+devnull03@users.noreply.github.com> Date: Sat, 15 Aug 2026 23:26:22 +0530 Subject: [PATCH 5/6] fix(ci): fire the Cloudflare deploy hook when a release is published MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The site is built by Cloudflare Workers Builds, which triggers on a commit to `site`. Publishing a release makes no commit anywhere, so nothing rebuilt and the releases page kept showing the previous version — while the workflow that was supposed to prevent exactly that reported success, because dispatching the old Pages deploy still worked. It just deployed somewhere nobody visits. `curl -f`, so a bad hook URL fails the job. Without it curl exits 0 on a 404 and this is back to a green run that did nothing, which is how it went unnoticed. The runbook described the Pages pipeline throughout — the branch table, the dev server URL, the one-time setup, the diagram. Corrected together, since a runbook that is right in one section and wrong in four is worse than one that is simply out of date. Also states which Google secrets actually matter: only the config token is load bearing. The client id and secret are the last rung of the credential ladder, for a first sign-in while our own endpoint is down. --- .../workflows/redeploy-site-on-release.yml | 24 +++---- docs/SETUP.md | 65 ++++++++++--------- 2 files changed, 45 insertions(+), 44 deletions(-) diff --git a/.github/workflows/redeploy-site-on-release.yml b/.github/workflows/redeploy-site-on-release.yml index 50700f45..6d805906 100644 --- a/.github/workflows/redeploy-site-on-release.yml +++ b/.github/workflows/redeploy-site-on-release.yml @@ -1,24 +1,24 @@ name: Redeploy site on release publish -# The releases page (on the `site` branch) is rendered at build time, so it only -# reflects releases that existed when it was last built. `release` events fire -# only for workflows on the default branch, so this lives on `main` and simply -# triggers the site's own deploy workflow over on the `site` branch. +# The releases page is rendered at build time, so it only reflects releases that +# existed when it was last built. `release` events fire only for workflows on the +# default branch, so this lives on `main`. +# +# The site is built by Cloudflare Workers Builds, which triggers on a commit to +# `site` — and publishing a release makes no commit anywhere. Without this the +# site simply goes stale, with nothing failing to say so. on: release: types: [published] workflow_dispatch: -permissions: - actions: write # needed to dispatch another workflow - jobs: trigger: runs-on: ubuntu-latest steps: - - name: Trigger site deploy - # --repo is required: this job has no checkout, so gh can't infer the - # repository from a local git remote. - run: gh workflow run deploy-site.yml --ref site --repo "${{ github.repository }}" + - name: Trigger the Cloudflare build + # -f, or curl exits 0 on a 404 and this job stays green having done + # nothing — which is the failure this workflow exists to prevent. + run: curl -fsS -X POST "$HOOK" env: - GH_TOKEN: ${{ github.token }} + HOOK: ${{ secrets.CLOUDFLARE_DEPLOY_HOOK }} diff --git a/docs/SETUP.md b/docs/SETUP.md index 32e6fbb8..427caeba 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -11,7 +11,7 @@ release, since a release depends on several pieces being configured ahead of tim | Branch | Contents | Purpose | |---|---|---| | `main` | The Rust **GPUI** desktop app (`crates/*`) + release pipeline | Default branch and source of truth. Routine work lands here directly via short-lived **feature branches** (no PR required); tag-driven releases are cut from here. | -| `site` | An **Astro** static site (no Rust) | The public releases page deployed to GitHub Pages. Independent of the app. | +| `site` | An **Astro** site (no Rust) | `qrate.dvnl.work`, served by a Cloudflare Worker. Mostly prerendered; `/oauth/config` is a live endpoint the app depends on (`docs/site-oauth-handoff.md`). | > Branch features off `main` and land them back on `main` directly. @@ -30,8 +30,9 @@ Workspace crates (versions are inherited from `[workspace.package].version`): so build/test there. **Site (on `site`):** -- Node 20+ and npm. `npm install`, then `npm run dev` - (`http://localhost:4321/qrate/`). +- Bun. `bun install`, then `bun run dev` (`http://localhost:4321/` — the `/qrate` + base went away with the custom domain). The dev server serves `/oauth/config` + too, so the endpoint can be exercised without deploying. **Google sign-in (optional locally):** the client id and secret are read at *compile* time by `option_env!`, so they have to be in the environment before `cargo run` and a change to them needs @@ -55,14 +56,14 @@ never in source. These are the "things to set up beforehand" — without them the pipelines fail or produce nothing visible. -1. **GitHub Pages → GitHub Actions source.** - Settings → Pages → Build and deployment → Source = **GitHub Actions** - (API `build_type: "workflow"`). Required for `deploy-site.yml` to publish. - Verify: `gh api repos/devnull03/qrate/pages --jq .build_type` → `workflow`. +1. **Cloudflare deploy hook.** Cloudflare → the qrate Worker → Settings → Builds → + Deploy hooks. Store the URL as the repo secret `CLOUDFLARE_DEPLOY_HOOK`; it is + what `redeploy-site-on-release.yml` posts to (§4). Verify by hand once: + `curl -fsS -X POST ""` → `{"result":{...},"success":true}`. 2. **Actions permissions.** Settings → Actions → General → Workflow permissions: - allow workflows to write (the release job needs `contents: write`; the site - dispatcher needs `actions: write`). These are also declared per-workflow. + allow workflows to write (the release job needs `contents: write`). These are + also declared per-workflow. 3. **Branch protection (optional).** This repo pushes directly to `main`, so there is no PR gate by default. If you want one later, protect `main` and require the @@ -76,14 +77,16 @@ produce nothing visible. a Windows code-signing cert) and signing steps in `release.yml`. None exist yet, so no secrets are required to build today. -5. **Custom domain (optional).** Settings → Pages writes a `CNAME`; then set - `site:` in `astro.config.mjs` to the domain and drop/empty `base`. +5. **Google credentials.** Actions secrets, mapped into the `QRATE_*` build vars by + `release.yml` — nothing else needs editing. Only one of the three is required: -6. **Google credentials (only if release builds should sign in).** Add `GOOGLE_CLIENT_ID`, - `GOOGLE_CLIENT_SECRET` and `GOOGLE_CONFIG_TOKEN` as Actions secrets. `release.yml` already maps - all three into the `QRATE_*` build vars, so nothing else needs editing. They are the fallback - the binary carries; the live pair comes from the credential endpoint at runtime, so rotating the - Cloud project does **not** need a new release. + | Secret | Needed? | + |---|---| + | `GOOGLE_CONFIG_TOKEN` | **Yes.** Without it the binary sends an empty bearer, the credential endpoint answers 401, and a fresh install can never sign in at all. | + | `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | Optional. The live pair comes from the endpoint at runtime; these are only the last rung, for a first-ever sign-in while **our** endpoint is down but Google is up. | + + Rotating the Cloud project does not need a release — that is the whole point of + the endpoint (`docs/site-oauth-handoff.md`). --- @@ -115,25 +118,23 @@ Four workflows, two on `main`, one on `site`, and CI on `dev`/PRs. - **Publishes:** a **DRAFT** release with the artifacts + `SHA256SUMS.txt`. It does **not** set the pre-release flag — you choose that when you publish (§5). -> **Broken since the Cloudflare cutover (2026-08-15).** `qrate.dvnl.work` is served by a -> Cloudflare Worker, not GitHub Pages, so the two site workflows below still run and still deploy — -> to a Pages site nobody visits. **Publishing a release leaves the live site stale and says -> nothing.** The fix is a Cloudflare deploy hook and swapping the dispatch in -> `redeploy-site-on-release.yml` for a `curl` at it. Until then, redeploy the site by hand after -> publishing a release. +### Building the site — Cloudflare Workers Builds (on `site`) +`qrate.dvnl.work` is served by a Cloudflare Worker, which Cloudflare rebuilds on +each commit to `site`. Nothing in this repo drives that; it is configured on the +Cloudflare side. The build fetches the **release list at build time**, so the +releases page only ever shows what existed when it last ran. -### `deploy-site.yml` — build & deploy the site (on `site`) -- **Triggers:** push to `site`; `workflow_dispatch`. -- **Does:** `withastro/action` builds the Astro site, which **fetches the release - list at build time** using the job's `GITHUB_TOKEN` (1000 req/hr, no token in the - shipped HTML), then `actions/deploy-pages` publishes it. +`deploy-site.yml` on `site` still publishes the static half to GitHub Pages. That +is now a second, unvisited copy — decide whether to keep it, but don't mistake a +green run there for the live site updating. ### `redeploy-site-on-release.yml` — refresh the site on release (on `main`) - **Trigger:** `release: published` (and `workflow_dispatch`). - **Why it lives on `main`:** `release` events only fire for workflows on the - **default branch**. The site's build is on `site`, so this small workflow bridges - the gap: it runs `gh workflow run deploy-site.yml --ref site`. -- **Net effect:** publishing a release ⇒ the site rebuilds and shows it. + **default branch**. +- **Does:** `POST`s the Cloudflare deploy hook (`CLOUDFLARE_DEPLOY_HOOK`). A + release publish makes no commit anywhere, so without this Cloudflare has no + reason to rebuild and the site silently keeps showing the previous release. ``` tag vX.Y.Z ─▶ release.yml (build dmg/zip/exe) ─▶ DRAFT release @@ -142,9 +143,9 @@ tag vX.Y.Z ─▶ release.yml (build dmg/zip/exe) ─▶ DRAFT release release: published │ redeploy-site-on-release.yml (on main) - │ gh workflow run --ref site + │ POST the deploy hook ▼ - deploy-site.yml (on site) ─▶ Pages updates + Cloudflare Workers Builds ─▶ qrate.dvnl.work updates ``` --- From 3a352f6e58e1fe68e6a40cd5db4d8323d0f4497a Mon Sep 17 00:00:00 2001 From: devnull03 <56480041+devnull03@users.noreply.github.com> Date: Sun, 16 Aug 2026 00:17:20 +0530 Subject: [PATCH 6/6] fix(app): reach the Linux keychain over zbus, not libdbus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit keyring's `sync-secret-service` links `libdbus-sys`, so the Linux CI job failed in clippy on a missing `dbus-1.pc` — and every Linux builder would have had to install `libdbus-1-dev` first. `async-secret-service` is the same store over zbus, which is pure Rust. The `async` is the transport, not the API: `Entry` stays blocking and nothing in `google.rs` changes. Paired with keyutils as keyring's session cache, so a box with no Secret Service running degrades to re-consent after a reboot rather than having no credential store at all. `crypto-rust` over `crypto-openssl` for the same reason as zbus over libdbus. --- Cargo.lock | 212 +++++++++++++++++++++++++++++++++++++----- crates/app/Cargo.toml | 10 +- 2 files changed, 194 insertions(+), 28 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 52ea3820..ca8701dc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -23,7 +23,7 @@ dependencies = [ "atspi-common", "phf 0.13.1", "serde", - "zvariant", + "zvariant 5.10.0", ] [[package]] @@ -75,7 +75,7 @@ dependencies = [ "futures-lite", "futures-util", "serde", - "zbus", + "zbus 5.14.0", ] [[package]] @@ -314,7 +314,7 @@ dependencies = [ "futures-util", "getrandom 0.4.2", "serde", - "zbus", + "zbus 5.14.0", ] [[package]] @@ -513,11 +513,11 @@ dependencies = [ "enumflags2", "serde", "static_assertions", - "zbus", + "zbus 5.14.0", "zbus-lockstep", "zbus-lockstep-macros", - "zbus_names", - "zvariant", + "zbus_names 4.3.1", + "zvariant 5.10.0", ] [[package]] @@ -528,7 +528,7 @@ checksum = "2230e48787ed3eb4088996eab66a32ca20c0b67bbd4fd6cdfe79f04f1f04c9fc" dependencies = [ "atspi-common", "serde", - "zbus", + "zbus 5.14.0", ] [[package]] @@ -1540,7 +1540,15 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" dependencies = [ + "aes", + "block-padding", + "cbc", "dbus", + "fastrand", + "hkdf", + "num", + "once_cell", + "sha2", "zeroize", ] @@ -3528,10 +3536,13 @@ checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" dependencies = [ "byteorder", "dbus-secret-service", + "linux-keyutils", "log", + "secret-service", "security-framework 2.11.1", "security-framework 3.7.0", "windows-sys 0.60.2", + "zbus 4.4.0", "zeroize", ] @@ -3716,6 +3727,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8c7b0a3383c2a1002d11349c92c85a666a5fb679e96c79d782cf0dbe557fd6ee" +[[package]] +name = "linux-keyutils" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83270a18e9f90d0707c41e9f35efada77b64c0e6f3f1810e71c8368a864d5590" +dependencies = [ + "bitflags 2.11.0", + "libc", +] + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -4203,6 +4224,19 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + [[package]] name = "nix" version = "0.31.2" @@ -4906,10 +4940,10 @@ dependencies = [ "serde_bytes", "sha2", "subtle", - "zbus", - "zbus_macros", + "zbus 5.14.0", + "zbus_macros 5.14.0", "zeroize", - "zvariant", + "zvariant 5.10.0", ] [[package]] @@ -4997,7 +5031,7 @@ checksum = "9cf20a545b305cf1da722b236b5155c9bb35f1d5ceb28c048bd96ca842f41b5b" dependencies = [ "android_system_properties", "log", - "nix", + "nix 0.31.2", "objc2 0.6.4", "objc2-foundation 0.3.2", "objc2-ui-kit", @@ -6471,6 +6505,25 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" +[[package]] +name = "secret-service" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4d35ad99a181be0a60ffcbe85d680d98f87bdc4d7644ade319b87076b9dbfd4" +dependencies = [ + "aes", + "cbc", + "futures-util", + "generic-array", + "hkdf", + "num", + "once_cell", + "rand 0.8.5", + "serde", + "sha2", + "zbus 4.4.0", +] + [[package]] name = "security-framework" version = "2.11.1" @@ -9477,6 +9530,16 @@ dependencies = [ "x11", ] +[[package]] +name = "xdg-home" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "xml-rs" version = "0.8.28" @@ -9555,6 +9618,44 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725" +dependencies = [ + "async-broadcast", + "async-executor", + "async-fs", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-sink", + "futures-util", + "hex", + "nix 0.29.0", + "ordered-stream", + "rand 0.8.5", + "serde", + "serde_repr", + "sha1", + "static_assertions", + "tracing", + "uds_windows", + "windows-sys 0.52.0", + "xdg-home", + "zbus_macros 4.4.0", + "zbus_names 3.0.0", + "zvariant 4.2.0", +] + [[package]] name = "zbus" version = "5.14.0" @@ -9585,9 +9686,9 @@ dependencies = [ "uuid", "windows-sys 0.61.2", "winnow", - "zbus_macros", - "zbus_names", - "zvariant", + "zbus_macros 5.14.0", + "zbus_names 4.3.1", + "zvariant 5.10.0", ] [[package]] @@ -9597,7 +9698,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6998de05217a084b7578728a9443d04ea4cd80f2a0839b8d78770b76ccd45863" dependencies = [ "zbus_xml", - "zvariant", + "zvariant 5.10.0", ] [[package]] @@ -9611,7 +9712,20 @@ dependencies = [ "syn", "zbus-lockstep", "zbus_xml", - "zvariant", + "zvariant 5.10.0", +] + +[[package]] +name = "zbus_macros" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zvariant_utils 2.1.0", ] [[package]] @@ -9624,9 +9738,20 @@ dependencies = [ "proc-macro2", "quote", "syn", - "zbus_names", - "zvariant", - "zvariant_utils", + "zbus_names 4.3.1", + "zvariant 5.10.0", + "zvariant_utils 3.3.0", +] + +[[package]] +name = "zbus_names" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" +dependencies = [ + "serde", + "static_assertions", + "zvariant 4.2.0", ] [[package]] @@ -9637,7 +9762,7 @@ checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" dependencies = [ "serde", "winnow", - "zvariant", + "zvariant 5.10.0", ] [[package]] @@ -9648,8 +9773,8 @@ checksum = "441a0064125265655bccc3a6af6bef56814d9277ac83fce48b1cd7e160b80eac" dependencies = [ "quick-xml 0.38.4", "serde", - "zbus_names", - "zvariant", + "zbus_names 4.3.1", + "zvariant 5.10.0", ] [[package]] @@ -10000,6 +10125,19 @@ dependencies = [ "zune-core 0.5.1", ] +[[package]] +name = "zvariant" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe" +dependencies = [ + "endi", + "enumflags2", + "serde", + "static_assertions", + "zvariant_derive 4.2.0", +] + [[package]] name = "zvariant" version = "5.10.0" @@ -10011,8 +10149,21 @@ dependencies = [ "serde", "serde_bytes", "winnow", - "zvariant_derive", - "zvariant_utils", + "zvariant_derive 5.10.0", + "zvariant_utils 3.3.0", +] + +[[package]] +name = "zvariant_derive" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zvariant_utils 2.1.0", ] [[package]] @@ -10025,7 +10176,18 @@ dependencies = [ "proc-macro2", "quote", "syn", - "zvariant_utils", + "zvariant_utils 3.3.0", +] + +[[package]] +name = "zvariant_utils" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index a991ab5b..ae6223b2 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -20,10 +20,14 @@ schemars.workspace = true # The agent bridge's request/response types; the transport lives here because only the # composition root links both the contract and the live table. ai = { path = "../ai" } -# The Google refresh token goes in the platform credential store rather than the settings DB: -# it never expires on its own, so anything holding it can reach the user's Drive indefinitely. +# Where the Google refresh token lives; it never expires, so it doesn't go in the settings DB. +# The Linux features are the pure-Rust ones — the `sync`/`openssl` alternatives link libdbus. keyring = { version = "3", features = [ - "apple-native", "windows-native", "sync-secret-service", + "apple-native", + "windows-native", + "linux-native-async-persistent", + "async-io", + "crypto-rust", ] } window-wrapper = { path = "../window-wrapper" } project-wizard = { path = "../project-wizard" }