Repository navigation
feat(google): Google Sheets sync — credentials, keychain, opt-in gate, existing-sheet push - #111
Merged
Merged
Conversation
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
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
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.
…roke 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.
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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Four tracker tasks that are really one body of work: they all touch
AppSettings, the sameSettings section and the same OAuth code, so doing them in one pass is cheaper than four. Plus the
release→site pipeline, which the Cloudflare cutover for this feature quietly broke.
What changes
Credentials are resolved at runtime (ASNT-94). The client id and secret were
option_env!constants, so rotating the Cloud project meant cutting a release. They are now a
ClientCredspassed in, resolved by a ladder in
crates/app/src/google.rs: the persisted copy while it isfresh → a conditional
If-None-Matchre-check → the compiled-in pair. No background timer and nostartup fetch — the check rides the first Google action that needs it, and a network failure keeps
what is stored and logs at
warn. Working offline is the normal case for this audience, so theendpoint is never on the critical path of a sign-in.
The refresh token moves to the OS credential store (ASNT-95). It never expires on its own, so
anything holding it can mint Drive tokens indefinitely — and
AppSettingsserialises to a filethat travels in backups and roaming profiles. No migration and no fallback read: there are no
existing installs, so writing a plaintext path to support nobody would just keep the risk alive.
Google sync ships off, behind a consent dialog (ASNT-96). 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, not greyed out — an unexplained
disabled control invites a support question. Switching off also forgets the stored sign-in, or
"off" would leave a live grant on the machine.
Push into a spreadsheet the user already owns (ASNT-93).
create_sheetno longer discards thespreadsheet id and the write half is now
write_values, which a new sheet and an existing one bothgo through. Reaching a sheet the user already has needs Google's own chooser:
drive.filegrantsper 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 that 404 is mapped to a message
that names what actually happened.
The release→site pipeline fires the Cloudflare deploy hook. Hosting
/oauth/configand/pickermeant moving the site to a Cloudflare Worker, which Cloudflare rebuilds on a commit tosite. Publishing a release makes no commit anywhere, so nothing rebuilt — whileredeploy-site-on-release.yml, the workflow whose entire job is preventing 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 loudly instead of repeating the same trick.Decisions worth reviewing
drive.file+ hosting the Picker, rather thanwidening to the
spreadsheetsscope. Route B would honour "we already take a URL", but it is asensitive-tier scope: brand-verification review before Production, the token reaches every
spreadsheet in the account, and Drive
modifiedTimepolling stops working. The cost of A is ahosted page, which is now live.
pre-set the setting without skipping consent — but the setting is the consent record, so
pre-setting it would be skipping consent. The Settings toggle is now the only route on both
platforms, which is the asymmetry that task already accepted for macOS.
/oauth/configis not access control and is not described as such anywhere.It ships inside the binary; it stops casual scraping and drive-by indexing. What it guards isn't
confidential either — Google treats an installed app's secret as non-confidential (RFC 8252
§8.5), and loopback + PKCE is what secures the exchange.
and nothing else; Google's redirect has to reach the app on the user's own machine.
Verification
cargo fmt --all --check,cargo clippy --workspace --all-targets -- -D warnings -A dead_codeand
cargo test --workspaceall pass locally (Windows). Three new tests cover the credentialladder including the offline branch; two cover the fragment encoding and a secretless client.
The live endpoints were checked independently of the site agent's report:
/oauth/config→ 401 without a bearer,/picker→ 200.DEFAULT_CONFIG_ENDPOINTandDEFAULT_PICKER_PAGEalready point at them, so no code change was needed.Not verified end to end, and cannot be until the Cloud console work below lands: consent,
sheet creation, and the Picker round trip have never run against real Google APIs. The deploy hook
is likewise unexercised — the first release publish after merge is its real test, so watch that one
rather than assuming.
Closes
Closes #105 (Notion ID: 3bd21d32-b13b-819b-b6b5-c32789cfa3e1)
Closes #106 (Notion ID: 3bd21d32-b13b-8151-ba16-c5d74d01330d)
Closes #107 (Notion ID: 3bd21d32-b13b-8195-bc04-de8c57b91b58)
Closes #108 (Notion ID: 3bd21d32-b13b-8115-bd20-cb2ebf8f4c35)
#84 (ASNT-80) stays open deliberately. Its scope is the account setup, and only the OAuth
client itself exists. Everything still blocking a working Google sync now collapses into that one
task:
805791669854qrate.dvnl.workGOOGLE_CONFIG_TOKEN— not currently set;gh secret listshows onlyCLOUDFLARE_DEPLOY_HOOK. Without it a shipped binary sends an empty bearer, gets a 401, and afresh install cannot sign in at all.
GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRETare optional —they are only the last rung, for a first sign-in while our own endpoint is down.
One loose end belongs to neither this PR nor #84: rotate the config token and the client
secret. Both were pasted into agent transcripts while setting this up. Neither is confidential by
design, but rotation costs one Cloudflare secret each until the first binary bakes them in — so
do it before setting the Actions secret above, not after.
Also still on the
sitebranch, not here:deploy-site.ymlpublishes a second, unvisited copy toGitHub Pages, and its header comment still calls the removed
gh workflow rundispatch"load-bearing".