Skip to content

feat(google): Google Sheets sync — credentials, keychain, opt-in gate, existing-sheet push - #111

Merged
devnull03 merged 6 commits into
mainfrom
google-sheets-sync
Aug 15, 2026
Merged

devnull03 merged 6 commits into
mainfrom
google-sheets-sync

Conversation

@devnull03

@devnull03 devnull03 commented Aug 15, 2026 •

Copy link
Copy Markdown
Owner

Four tracker tasks that are really one body of work: they all touch AppSettings, the same
Settings 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 ClientCreds
passed in, resolved by a ladder in crates/app/src/google.rs: the persisted copy while it is
fresh → a conditional If-None-Match re-check → the compiled-in pair. No background timer and no
startup 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 the
endpoint 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 AppSettings serialises to a file
that 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_sheet no longer discards the
spreadsheet id and the write half is now write_values, which a new sheet and an existing one both
go through. Reaching a sheet the user already has 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 that 404 is mapped to a message
that names what actually happened.

The release→site pipeline fires the Cloudflare deploy hook. Hosting /oauth/config and
/picker meant moving the site to a Cloudflare Worker, which Cloudflare rebuilds on a commit to
site. Publishing a release makes no commit anywhere, so nothing rebuilt — while
redeploy-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

  • Route A over Route B on ASNT-93. Keeping drive.file + hosting the Picker, rather than
    widening to the spreadsheets scope. Route B would honour "we already take a URL", but it is a
    sensitive-tier scope: brand-verification review before Production, the token reaches every
    spreadsheet in the account, and Drive modifiedTime polling stops working. The cost of A is a
    hosted page, which is now live.
  • No installer component, contrary to ASNT-96's task notes. That asked the component to
    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.
  • The bearer on /oauth/config is 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.
  • This must never become a token-exchange proxy. The Worker serves application credentials
    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_code
and cargo test --workspace all pass locally (Windows). Three new tests cover the credential
ladder 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_ENDPOINT and
DEFAULT_PICKER_PAGE already 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:

  • Enable the Sheets, Drive and Picker APIs on Cloud project 805791669854
  • Create a browser Picker API key, referrer-restricted to qrate.dvnl.work
  • Actions secret GOOGLE_CONFIG_TOKEN — not currently set; gh secret list shows only
    CLOUDFLARE_DEPLOY_HOOK. Without it a shipped binary sends an empty bearer, gets a 401, and a
    fresh install cannot sign in at all. GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET are optional —
    they are only the last rung, for a first sign-in while our own endpoint is down.
  • Publish the consent screen out of Testing, or refresh tokens expire after 7 days

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 site branch, not here: deploy-site.yml publishes a second, unvisited copy to
GitHub Pages, and its header comment still calls the removed gh workflow run dispatch
"load-bearing".

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.
@devnull03 devnull03 self-assigned this Aug 15, 2026
Copilot AI lite review requested due to automatic review settings August 15, 2026 16:54

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@devnull03 devnull03 changed the title Google sheets sync feat(google): Google Sheets sync — credentials, keychain, opt-in gate, existing-sheet push Aug 15, 2026
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.
@devnull03
devnull03 merged commit a43235d into main Aug 15, 2026
3 checks passed
@devnull03
devnull03 deleted the google-sheets-sync branch August 15, 2026 19:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants