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/.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/Cargo.lock b/Cargo.lock index a289439e..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]] @@ -216,6 +216,7 @@ dependencies = [ "gpui-component", "gpui-component-assets", "gpui_platform", + "keyring", "log", "os_info", "plugin-api", @@ -313,7 +314,7 @@ dependencies = [ "futures-util", "getrandom 0.4.2", "serde", - "zbus", + "zbus 5.14.0", ] [[package]] @@ -512,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]] @@ -527,7 +528,7 @@ checksum = "2230e48787ed3eb4088996eab66a32ca20c0b67bbd4fd6cdfe79f04f1f04c9fc" dependencies = [ "atspi-common", "serde", - "zbus", + "zbus 5.14.0", ] [[package]] @@ -1522,6 +1523,35 @@ 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 = [ + "aes", + "block-padding", + "cbc", + "dbus", + "fastrand", + "hkdf", + "num", + "once_cell", + "sha2", + "zeroize", +] + [[package]] name = "deflate64" version = "0.1.11" @@ -3498,6 +3528,24 @@ 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", + "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", +] + [[package]] name = "khronos-egl" version = "6.0.0" @@ -3603,6 +3651,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" @@ -3670,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" @@ -4117,7 +4184,7 @@ dependencies = [ "openssl-probe", "openssl-sys", "schannel", - "security-framework", + "security-framework 3.7.0", "security-framework-sys", "tempfile", ] @@ -4157,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" @@ -4860,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]] @@ -4951,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", @@ -6256,7 +6336,7 @@ dependencies = [ "openssl-probe", "rustls-pki-types", "schannel", - "security-framework", + "security-framework 3.7.0", ] [[package]] @@ -6425,6 +6505,38 @@ 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" +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 +9126,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" @@ -9409,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" @@ -9487,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" @@ -9517,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]] @@ -9529,7 +9698,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6998de05217a084b7578728a9443d04ea4cd80f2a0839b8d78770b76ccd45863" dependencies = [ "zbus_xml", - "zvariant", + "zvariant 5.10.0", ] [[package]] @@ -9543,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]] @@ -9556,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]] @@ -9569,7 +9762,7 @@ checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" dependencies = [ "serde", "winnow", - "zvariant", + "zvariant 5.10.0", ] [[package]] @@ -9580,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]] @@ -9932,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" @@ -9943,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]] @@ -9957,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 10c23433..ae6223b2 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -20,6 +20,15 @@ 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" } +# 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", + "linux-native-async-persistent", + "async-io", + "crypto-rust", +] } window-wrapper = { path = "../window-wrapper" } project-wizard = { path = "../project-wizard" } checks = { path = "../checks" } 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/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/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. /// 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, diff --git a/docs/SETUP.md b/docs/SETUP.md index 6c0260e1..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,24 @@ 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 +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. --- @@ -40,14 +56,14 @@ Workspace crates (versions are inherited from `[workspace.package].version`): 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 @@ -61,8 +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: + + | 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`). --- @@ -94,18 +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). -### `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. +### 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` 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 @@ -114,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 ``` --- diff --git a/docs/google-sync-handoff.md b/docs/google-sync-handoff.md index 993fad3e..ea69e081 100644 --- a/docs/google-sync-handoff.md +++ b/docs/google-sync-handoff.md @@ -6,6 +6,20 @@ 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, 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. @@ -19,9 +33,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 +113,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 +134,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 +160,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 +172,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` 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: + +```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. + +## 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: + +``` +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. + +## 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: + +- 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`. + +## 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 +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.