Skip to content

feat: first-party Google Drive integration — search and read Drive files from chat (stacked on #285) - #290

Closed
amal66 wants to merge 6 commits into
Open-Legal-Products:mainfrom
amal66:olp-pr/google-drive-native
Closed

feat: first-party Google Drive integration — search and read Drive files from chat (stacked on #285)#290
amal66 wants to merge 6 commits into
Open-Legal-Products:mainfrom
amal66:olp-pr/google-drive-native

Conversation

@amal66

@amal66 amal66 commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #285 (shares its branch history — review only the last commit here until #285 lands).

What this adds

"Search my Google Drive for X and summarize it" as a one-click-connect experience: a Google Drive card on Account → Connectors runs a first-party OAuth flow, and three read-only chat tools (google_drive_search, google_drive_read_file, google_drive_list_recent) call the GA Drive REST API directly. Google Docs/Sheets/Slides export as text; PDF and Word convert via the existing extractors; results carry the same untrusted-context framing as MCP tool output, and the chat UI renders the calls with the existing connector treatment (no event-pipeline changes).

Why not the Drive MCP server

Google's hosted Drive MCP (drivemcp.googleapis.com) is gated behind the Workspace Developer Preview Program. Verified live during #285's testing: with both APIs enabled and the full advertised scope set, OAuth succeeds and tools/list works, but every tools/call returns a bare PERMISSION_DENIED for non-enrolled accounts — while the same token lists files over plain Drive REST without complaint. The REST API has no such gate, so the reliable path for stock deployments is first-party. (#285's MCP connector support still works for enrolled accounts.)

Design notes

  • OAuth: PKCE (S256) + access_type=offline&prompt=consent (Google issues refresh tokens only with these — same lesson as fix: Google Workspace MCP connectors — durable refresh tokens + reliable OAuth popup #185). Tokens AES-GCM-encrypted at rest, hashed + TTL'd state rows, transparent refresh with 60s leeway; invalid_grant deletes the row so status stays honest.
  • Safety: read-only drive.readonly scope; Drive q values escape quotes/backslashes so user input can't break out of the query literal; 60k-char cap per file read.
  • Setup UX: missing OAuth client fails fast with the exact Console steps and this deployment's redirect URI in the message; env vars fall back to GOOGLE_MCP_OAUTH_* so one Cloud client serves both features. README gains a full self-hosting section (APIs to enable, redirect URI, test-mode vs restricted-scope verification).

Testing

  • 8 new unit tests (PKCE URL construction, fail-fast setup error, tool gating, q-escaping, Doc export, invalid_grant cleanup); backend suite 460 passing; frontend tsc + suite clean.
  • Live end-to-end verification (real Google account, consent popup, real Drive files → chat summary) in progress; results will be posted as a comment.

🤖 Generated with Claude Code

@amal66
amal66 marked this pull request as draft August 4, 2026 14:47
amal66 and others added 6 commits August 5, 2026 20:17
…les from chat

WHY THIS MATTERS
"Search my Google Drive for X and summarize it" should be one Connect
click away. The MCP route to that experience is blocked for most
deployments: Google's hosted Drive MCP server (drivemcp.googleapis.com)
sits behind the Workspace Developer Preview Program, so OAuth succeeds,
tools list — and every tools/call returns a bare PERMISSION_DENIED
(verified live: same token, same scopes, REST works, MCP refuses). The
plain Drive REST API is GA and ungated. So the integration goes first
party: run OAuth ourselves, call REST directly.

HOW IT WORKS
- lib/integrations/googleDrive.ts owns the whole surface:
  * OAuth: PKCE (S256) against accounts.google.com with
    access_type=offline & prompt=consent — the two parameters without
    which Google never issues a refresh token (the same lesson the MCP
    connector flow learned in Open-Legal-Products#185). Tokens AES-GCM-encrypted at rest
    (one row per user), state rows hashed + TTL'd, refresh handled
    transparently with a 60s expiry leeway; a revoked grant
    (invalid_grant) deletes the row so the UI honestly shows
    disconnected instead of failing every call.
  * Tools: google_drive_search (name + fullText, q-escaping for quotes
    and backslashes so user input cannot break out of the query
    literal), google_drive_list_recent, google_drive_read_file
    (Docs/Sheets/Slides exported as text/CSV; PDF via the existing
    pdfjs extractor; .docx via mammoth; 60k-char cap). Read-only scope
    (drive.readonly) — write tools would need broader consent and
    confirmation policy, deliberately out of scope.
  * The tools reuse the MCP event shape (connector_name "Google
    Drive"), so the chat UI renders them with the existing connector
    treatment — zero changes to the event pipeline or frontend chat.
- Tool registry offers the tools only when the user has a token row;
  the dispatcher routes the google_drive_ prefix ahead of mcp_.
- Routes: /user/integrations/google-drive (status), /oauth/start,
  /oauth/callback (reuses the MCP popup renderer), DELETE disconnect
  (best-effort Google-side revocation, then row deletion).
- Frontend: a dedicated card on Account → Connectors — no server URL to
  type, just Connect → Google consent popup → status-polling (COOP
  severs window.opener on Google's consent page, so polling our own
  status endpoint is the source of truth) → tools active. Disconnect
  next to it.
- Env: GOOGLE_DRIVE_OAUTH_CLIENT_ID/_SECRET, falling back to
  GOOGLE_MCP_OAUTH_* so one Cloud Console client serves both features.
  README gains a self-hosting section with the exact Console steps
  (enable drive.googleapis.com, the redirect URI to add, test-user vs
  verification implications of the restricted scope).

TESTING
- 8 new unit tests: PKCE/offline URL construction, fail-fast setup
  error with copy-pasteable redirect URI, tool gating on connection,
  q-escaping, Google Doc export path, invalid_grant row cleanup.
- Full backend suite: 460 passing. Frontend tsc + suite clean.
…ge's MFA machinery

WHY THIS MATTERS
The backend gates POST /user/integrations/google-drive/oauth/start and
DELETE /user/integrations/google-drive behind requireMfaIfEnrolled: a
user who has enrolled a second factor but whose current session has not
verified it gets a 403 with code `mfa_verification_required`. Every
other sensitive action on the Connectors page (create/save/delete a
connector, toggle tools, refresh) already handles that answer by
opening the MFA verification popup and retrying once the user has
verified. The new Google Drive card did not: its connect() and
disconnect() called the API directly, so an MFA-enrolled user clicking
"Connect" saw a dead-end red error string quoting the backend's 403 —
with no way to proceed short of re-logging-in.

WHAT IS THE MFA "STEP-UP" PATTERN HERE
This page implements step-up authentication: routine reads work with a
normal session, but state-changing actions on credential-bearing
resources demand a fresh second-factor proof. The client half of the
pattern is `runSensitiveAction(action, fn)`:

    1. pre-check: `needsMfaVerification()` — if the session is known to
       need verification, remember `action` and open the popup instead
       of even attempting `fn`;
    2. catch: if `fn` still hits the backend gate, `isMfaRequiredError`
       recognises the 403 and the same popup opens;
    3. resume: once the popup reports "verified", `handleMfaVerified`
       re-dispatches the remembered `action` descriptor.

Step 3 is why the wrapper works on *descriptors* (`{ type: "delete",
connectorId }`) rather than closures: the retry must run after an
arbitrary delay, from the popup's callback, against fresh state.

HOW THE FIX WORKS
- `PendingMfaAction` gains two descriptors: `{ type: "drive-connect" }`
  and `{ type: "drive-disconnect" }`.
- The card's connect()/disconnect() bodies now run inside
  `runSensitiveAction`, passed down as a prop. Their internal
  try/catch keeps genuine failures local to the card (the card owns its
  own error line), but rethrows MFA challenges so the wrapper can see
  them:

      } catch (e) {
          if (isMfaRequiredError(e)) throw e;   // page machinery's job
          setError(...);                        // card-local failure
      }

- Resumption needs the page to call *into* the card (the actions close
  over card-local state like `setStatus`), so the card registers a
  `GoogleDriveCardHandle` — `{ connect, disconnect }` — into a ref the
  parent owns, via an every-render effect so the handle never captures
  stale state. `handleMfaVerified` re-invokes the interrupted action
  through that ref, exactly as it re-dispatches every other descriptor.
- One browser subtlety: the OAuth popup must be opened synchronously in
  the click handler (an `await` before `window.open` loses the user-
  activation token and the popup gets blocked), so the card still opens
  it before entering the wrapper. On the resumed, post-verification run
  there is no click; if the browser then refuses the popup, the
  existing `window.location.assign(authorizationUrl)` fallback engages.

TESTING
frontend: npx tsc --noEmit clean; connectors page vitest suite (OAuth
poll cancellation) passes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…just success

WHY THIS MATTERS
The Google Drive card's connect() opened its OAuth popup and closed it
on exactly one path: successful authorization. Every other way out of
the flow — startGoogleDriveOAuth() failing (backend down, OAuth client
unconfigured, MFA challenge), the five-minute authorization timeout,
and the user pressing the card's Cancel affordance — returned with the
window still open. The user is left staring at an orphaned blank
"about:blank" popup they have to hunt down and close by hand, and
repeated attempts stack them up. The pre-existing MCP flow in this same
file (connectConnectorOAuth) already solved this; the Drive card just
didn't follow it.

WHAT IS THE PATTERN: RESOURCE CLEANUP IN try/finally
A popup window is a resource exactly like a file handle or a lock: once
acquired, it must be released on *every* control-flow path, and the
number of exit paths only grows as code evolves (this function has at
least five). Enumerating them by hand — a close() sprinkled on each
early return — is how leaks happen; the next contributor adds a sixth
path and forgets. The robust shape is the one the language gives you:

    const popup = window.open(...);   // acquire
    try {
        ...any number of returns/throws...
    } finally {
        try { popup?.close(); } catch {}   // release, unconditionally
    }

The inner try/catch around close() matters too: Google serves its
consent page with Cross-Origin-Opener-Policy: same-origin, which severs
the opener relationship; some browsers then throw on cross-origin
window operations. A failed close must not mask the real error that got
us into the finally.

HOW THE FIX WORKS
The success-path popup.close() moves into a finally block wrapping the
whole connect() body — mirroring connectConnectorOAuth's cleanup a few
hundred lines down. The finally also covers the MFA detour introduced
by the previous commit (runSensitiveAction may park the action and open
the verification popup before the OAuth flow even starts; the blank
popup opened with the click still gets closed). `popup?.close()` keeps
the popup-blocked fallback path (popup === null, full-page redirect)
safe.

TESTING
frontend: npx tsc --noEmit clean; connectors page vitest suite passes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
WHY THIS MATTERS
backend/schema.sql has a "Direct client grant hardening" section that
does two things, in order: (1) `revoke all ... from anon,
authenticated` per table, because the browser talks to Supabase
directly with those roles and backend-owned tables must not be readable
or writable from the client; (2) one bulk
`grant select, insert, update, delete on all tables in schema public to
service_role`, because the backend connects as service_role and the
tables are owned by the bootstrap role.

The new user_google_drive_tokens and google_drive_oauth_states tables
were appended AFTER that section, so on a fresh schema.sql bootstrap
both halves miss them:

- the bulk service_role grant executed before the tables existed
  ("on all tables" is expanded at execution time, not tracked
  afterwards), so the backend has no privileges on its own token
  store — every Drive status/connect call fails with a permission
  error on a clean deployment;
- they are absent from the per-table revoke list, so whatever default
  privileges the environment applies to new public tables (hosted
  Supabase grants anon/authenticated on everything by default) stay in
  place on the tables holding encrypted OAuth tokens.

WHAT IS DEFENSE IN DEPTH HERE
RLS is already enabled on both tables with no user policies, which
blocks anon/authenticated row access even where grants slip through —
that is why this ships as a hardening fix rather than a live token
leak. But the file's own convention is belt and braces: RLS guards
rows, revoked grants guard the table (including metadata like row
counts via ANALYZE-visible stats, and any future ALTER that disables
RLS), and the two fail independently.

HOW THE FIX WORKS
Both files gain the exact statements the sibling MCP OAuth tables use
(user_mcp_oauth_tokens / user_mcp_oauth_states in the hardening list):

    revoke all on public.user_google_drive_tokens from anon, authenticated;
    revoke all on public.google_drive_oauth_states from anon, authenticated;
    grant select, insert, update, delete on ... to service_role;

- backend/schema.sql: appended inside the Drive section with a comment
  explaining why the statements are repeated after the hardening
  section (fresh-bootstrap correctness).
- backend/migrations/20260804_01_google_drive_integration.sql: the same
  statements, because on an existing hosted database the migration —
  not schema.sql — creates the tables, and Supabase default privileges
  would otherwise grant the browser roles access (the same reason
  20260724_02_tabular_folder_rows.sql carries its own revoke/grant
  pair). No sequences are involved (both PKs are uuid), so no sequence
  grants are needed.

TESTING
Statements mirror the existing, exercised pattern verbatim. The
Supabase stack suite (test:stack) that applies schema.sql needs a
running local Supabase Docker stack, which is not available in this
environment; backend unit suite passes (it does not exercise SQL
bootstrap).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… that cannot work

WHY THIS MATTERS
The Drive status endpoint already reports whether the deployment can do
this at all: `getGoogleDriveStatus()` returns `configured: false` when
the server has no GOOGLE_DRIVE_OAUTH_CLIENT_ID/_SECRET (and no
GOOGLE_MCP_OAUTH_* fallback). But no UI read the flag. On an
unconfigured self-hosted deployment the card rendered a perfectly
ordinary, enabled Connect button; clicking it opened a blank popup
(spawned optimistically, before the backend is asked for the
authorization URL), then the /oauth/start call failed and the user was
left with an error string and an empty window — for a condition that is
not an error at all, just a server that hasn't been set up.

WHAT IS THE PRINCIPLE: DON'T OFFER ACTIONS THAT CANNOT SUCCEED
A control's enabled state is a promise that the action can work. When
the client already knows an action is impossible — the capability flag
is sitting in state it fetched — showing a live button converts a
static configuration fact into a runtime failure the user has to
diagnose. The kind fix is to (a) disable the control and (b) say what
would make it available, so a self-hoster reads the next step off the
card instead of off a stack trace.

HOW THE FIX WORKS
- The Connect button's `disabled` becomes `busy || !status.configured`.
  The shared account button style already renders a proper disabled
  look (`disabled:cursor-not-allowed disabled:opacity-45`), so no new
  styling is needed.
- When the card is neither loading nor connected and `configured` is
  false, a one-line hint appears under the row pointing the
  administrator at the "Google Drive Integration" section of the
  README, which walks through the Cloud Console setup and the env vars.
- The status-fetch error fallback already fabricates
  `{ connected: false, configured: false }`, so a backend that cannot
  even answer the status probe now degrades to the same honest disabled
  state instead of an enabled button that would fail identically.

TESTING
frontend: npx tsc --noEmit clean; connectors page vitest suite passes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
WHY THIS MATTERS
This branch was originally stacked on the Google MCP connector work
(PR Open-Legal-Products#285), which introduced GOOGLE_MCP_OAUTH_CLIENT_ID/_SECRET as the
OAuth client for Google-hosted MCP servers. The Drive integration
politely fell back to those variables so one Cloud Console client could
serve both features. PR Open-Legal-Products#285 was closed unmerged, so on main those
variables are not read by anything else, are not documented anywhere,
and are not part of any setup flow. Keeping the fallback would ship
configuration surface that references a feature this codebase does not
have: an operator grepping for GOOGLE_MCP_OAUTH_CLIENT_ID would find a
consumer but no producer, and the error message would advertise "MCP
equivalents" that no README explains.

WHAT IS THE FALLBACK BEING REMOVED
googleDriveOAuthEnv() resolved the client as
GOOGLE_DRIVE_OAUTH_CLIENT_ID || GOOGLE_MCP_OAUTH_CLIENT_ID (and the
same for the secret), and the fail-fast setup error suggested setting
either pair. The test suite's env hygiene saved, deleted, and restored
the MCP pair around every test so the fallback could not leak in.

HOW THE FIX WORKS
googleDriveOAuthEnv() now reads only GOOGLE_DRIVE_OAUTH_CLIENT_ID and
GOOGLE_DRIVE_OAUTH_CLIENT_SECRET, the setup error names only that pair,
and the test env bookkeeping for the MCP variables is deleted. This is
config-surface-only: no OAuth, token, or tool behavior changes. If a
Google MCP connector feature lands later, reintroducing a shared-client
fallback is a one-line change made with both features in view.

TESTING
backend: npx tsc --noEmit clean; vitest suite passes including the
googleDrive unit tests that assert the fail-fast setup error.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant