Note (2026-04-24): After this document was written, legal_entity was renamed to tenant and the old tenant was renamed to organization. Read references to these terms with the pre-rename meaning.

Google Drive File Store Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Let users connect their Google Drive via OAuth on the settings page, select a folder via Google Picker, and read files from it for FP&A analysis.

Architecture: OAuth 2.0 flow for Google Drive with per-legal-entity connections stored in a new legal_entity_oauth_integration table. Existing legal_entity_integration renamed to legal_entity_datev_integration. Google Drive FileStore implementation behind the existing FileStore protocol. resolve-file-store replaces direct make-file-store calls to handle credential resolution.

Tech Stack: Clojure, Reitit, HTMX, Google Drive API v3, Google Picker API, KMS encryption, SSM Parameter Store

Design doc: docs/plans/2026-03-16-google-drive-file-store-design.md


Task 1: DB Migration — Rename table and create new table

Files:

Context:

Step 1: Create the up migration

Use migratus to generate the migration files:

clj -X:migratus :op :create :name '"rename-integration-add-oauth"'

Then write the up migration:

-- Step 1: Rename legal_entity_integration → legal_entity_datev_integration
ALTER TABLE legal_entity_integration RENAME TO legal_entity_datev_integration;

--;;

-- Rename constraints and indexes
ALTER TABLE legal_entity_datev_integration
    RENAME CONSTRAINT legal_entity_integration_unique
    TO legal_entity_datev_integration_unique;

--;;

ALTER TABLE legal_entity_datev_integration
    RENAME CONSTRAINT legal_entity_integration_legal_entity_id_fkey
    TO legal_entity_datev_integration_legal_entity_id_fkey;

--;;

ALTER INDEX idx_legal_entity_integration_legal_entity
    RENAME TO idx_legal_entity_datev_integration_legal_entity;

--;;

-- Step 2: Add connected_by_user_id to datev table (nullable for existing rows)
ALTER TABLE legal_entity_datev_integration
    ADD COLUMN connected_by_user_id UUID REFERENCES orcha_user(id);

--;;

-- Step 3: Create oauth_integration_type enum
CREATE TYPE oauth_integration_type AS ENUM ('google_drive');

--;;

-- Step 4: Create legal_entity_oauth_integration table
CREATE TABLE legal_entity_oauth_integration (
    id                       UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    legal_entity_id          UUID NOT NULL REFERENCES legal_entity(id) ON DELETE CASCADE,
    integration_type         oauth_integration_type NOT NULL,
    is_active                BOOLEAN NOT NULL DEFAULT true,
    connected_by_user_id     UUID NOT NULL REFERENCES orcha_user(id),
    refresh_token_encrypted  BYTEA,
    refresh_token_expires_at TIMESTAMPTZ,
    config                   JSONB NOT NULL DEFAULT '{}',
    metadata                 JSONB NOT NULL DEFAULT '{}',
    disconnect_reason        TEXT,
    created_at               TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at               TIMESTAMPTZ NOT NULL DEFAULT now(),
    UNIQUE(legal_entity_id, integration_type)
);

--;;

CREATE INDEX idx_legal_entity_oauth_integration_legal_entity
    ON legal_entity_oauth_integration(legal_entity_id);

Step 2: Write the down migration

DROP INDEX IF EXISTS idx_legal_entity_oauth_integration_legal_entity;

--;;

DROP TABLE IF EXISTS legal_entity_oauth_integration;

--;;

DROP TYPE IF EXISTS oauth_integration_type;

--;;

ALTER TABLE legal_entity_datev_integration
    DROP COLUMN IF EXISTS connected_by_user_id;

--;;

ALTER INDEX idx_legal_entity_datev_integration_legal_entity
    RENAME TO idx_legal_entity_integration_legal_entity;

--;;

ALTER TABLE legal_entity_datev_integration
    RENAME CONSTRAINT legal_entity_datev_integration_legal_entity_id_fkey
    TO legal_entity_integration_legal_entity_id_fkey;

--;;

ALTER TABLE legal_entity_datev_integration
    RENAME CONSTRAINT legal_entity_datev_integration_unique
    TO legal_entity_integration_unique;

--;;

ALTER TABLE legal_entity_datev_integration RENAME TO legal_entity_integration;

Step 3: Run migration locally

psql -h localhost -U postgres -d orcha -c "SELECT tablename FROM pg_tables WHERE tablename LIKE '%integration%';"

Verify: legal_entity_datev_integration exists, legal_entity_integration does not, legal_entity_oauth_integration exists.

Step 4: Commit

git add resources/migrations/*rename-integration-add-oauth*
git commit -m "migration: rename legal_entity_integration, create oauth_integration table"

Task 2: Update all DATEV references to use renamed table

Files:

Context:

Step 1: Find all references

rg "legal.entity.integration" --type clojure -l

This will find all files. For each file, replace all occurrences of legal-entity-integration (in HoneySQL/keyword context) with legal-entity-datev-integration.

Step 2: Update each file

In each file found, do a find-and-replace:

Don't change namespace names, requires, or anything not referencing the DB table.

Step 3: Run tests to verify nothing broke

clj -X:test:silent :nses '[com.getorcha.app.http.settings.integrations-test]'

If there are no tests for the integrations page yet, at minimum verify the system starts:

# In REPL
(reset)

Step 4: Lint

clj-kondo --lint src test dev

Step 5: Commit

git add <modified files>
git commit -m "refactor: rename legal_entity_integration references to legal_entity_datev_integration"

Task 3: SSM Parameters & Config — Infrastructure, dev, and test setup

Files:

Step 1: Add SSM parameters to CDK stack

In infra/stacks/foundation_stack.py, add to the params list (around line 774, before the closing bracket):

            ("google-drive-client-id", "GoogleDriveClientId", "Google Drive OAuth client ID"),
            ("google-drive-client-secret", "GoogleDriveClientSecret", "Google Drive OAuth client secret"),
            ("google-drive-state-secret", "GoogleDriveStateSecret", "Google Drive OAuth state signing secret"),
            ("google-drive-api-key", "GoogleDriveApiKey", "Google Picker API key"),

Step 2: Add dev SSM values to init_aws.clj

In scripts/init_aws.clj, add to the ssm-parameters map (after the Gmail section, around line 51):

   ;; Google Drive OAuth (file store)
   "/v1-orcha/google-drive-client-id"     "734247246418-uhksa9j2tut58oomf7oreqc3rto5pofk.apps.googleusercontent.com"
   "/v1-orcha/google-drive-client-secret" "GOCSPX-placeholder-for-drive"
   "/v1-orcha/google-drive-state-secret"  "Yp3N+qRvS8hT0wZz3uK6vG9dCbF5tM1o"
   "/v1-orcha/google-drive-api-key"       "AIzaSy-placeholder-for-picker"

Note: The client-id can be the same as the Gmail one if the same GCP project is used. The client-secret and api-key will be placeholders for now — they'll be filled with real values when the GCP project is configured.

Step 3: Add test SSM values to fixtures.clj

In test/com/getorcha/test/fixtures.clj, add to the ssm-params map (after Gmail entries, around line 132):

   "/v1-orcha/google-drive-client-id"     "test-google-drive-client"
   "/v1-orcha/google-drive-client-secret" "test-google-drive-secret"
   "/v1-orcha/google-drive-state-secret"  "test-google-drive-state"
   "/v1-orcha/google-drive-api-key"       "test-google-drive-api-key"

Step 4: Add Google Drive config to config.edn

In resources/com/getorcha/config.edn, add :google-drive under :com.getorcha/oauth-providers (after the :teams entry, around line 103):

  :google-drive {:client-id     #orcha/param "/v1-orcha/google-drive-client-id"
                 :client-secret #orcha/param "/v1-orcha/google-drive-client-secret"
                 :state-secret  #orcha/param "/v1-orcha/google-drive-state-secret"
                 :api-key       #orcha/param "/v1-orcha/google-drive-api-key"
                 :scopes        "https://www.googleapis.com/auth/drive.readonly"}

Also add a dev file store override under a new top-level key (before :integrant/config):

 :com.getorcha/dev-file-store
 #profile {:local-dev {:protocol "file" :path "/home/volrath/code/orcha/orcha/data/fpna/"}
           :default   nil}

And wire it into the app handler config in :integrant/config:com.getorcha.app.http/handler — no, actually it should go into the Link handler config since MCP tools run on the Link server. Add to :com.getorcha.link.http/handler:

  :com.getorcha.link.http/handler
  {:base-url       #ref [:com.getorcha/link-base-url]
   :db-pool        #integrant/ref :com.getorcha.db/pool
   :aws            #integrant/ref :com.getorcha.aws/state
   :key-arn        #orcha/param "/v1-orcha/oauth-signing-key-arn"
   :auth-providers #ref [:com.getorcha/auth-providers]
   :dev-file-store #ref [:com.getorcha/dev-file-store]}

Wait — MCP tools get their context from mcp-handler which only has {:db-pool :oauth-claims :identity-id :legal-entity-ids}. The dev-file-store config needs to reach resolve-file-store. Two options:

Option A: Add :dev-file-store to the MCP context map in mcp-handler. Option B: Use an atom or dynamic var set at system startup.

Go with Option A — add it to the MCP context. In src/com/getorcha/link/mcp/http.clj:274, add :dev-file-store to the context map. The Link handler config already has access to it via inject-config.

Modify src/com/getorcha/link/mcp/http.clj around line 274:

        context          {:db-pool          db-pool
                          :oauth-claims     oauth-claims
                          :identity-id      identity-id
                          :legal-entity-ids legal-entity-ids
                          :dev-file-store   (:dev-file-store _request)}

Also need to add :aws to the MCP context for resolve-file-store to decrypt tokens:

        context          {:db-pool          db-pool
                          :aws              (:aws _request)
                          :oauth-claims     oauth-claims
                          :identity-id      identity-id
                          :legal-entity-ids legal-entity-ids
                          :dev-file-store   (:dev-file-store _request)}

Step 5: Reinitialize LocalStack

bb dev:init-aws --force

Step 6: Lint

clj-kondo --lint src test dev

Step 7: Commit

git add infra/stacks/foundation_stack.py scripts/init_aws.clj test/com/getorcha/test/fixtures.clj resources/com/getorcha/config.edn src/com/getorcha/link/mcp/http.clj
git commit -m "feat: add Google Drive SSM params, config, and MCP context keys"

Task 4: resolve-file-store function

Files:

Context:

Step 1: Write the failing test

Create test/com/getorcha/link/mcp/file_store_test.clj:

(ns com.getorcha.link.mcp.file-store-test
  (:require [clojure.test :refer [deftest is testing]]
            [com.getorcha.link.mcp.file-store :as file-store]
            [com.getorcha.link.mcp.file-store.local]
            [com.getorcha.link.queries.documents :as queries]))


(deftest resolve-file-store-dev-override-test
  (testing "uses dev-file-store config when present and protocol is 'file'"
    (with-redefs [queries/get-legal-entity-data-source
                  (fn [_db _le-id] {:protocol "file" :path "/some/path"})]
      (let [context {:dev-file-store {:protocol "file" :path "/tmp"}
                     :db-pool        :fake-db}
            store   (file-store/resolve-file-store context (random-uuid))]
        (is (satisfies? file-store/FileStore store))))))


(deftest resolve-file-store-no-data-source-test
  (testing "returns nil when no data source configured"
    (with-redefs [queries/get-legal-entity-data-source
                  (fn [_db _le-id] nil)]
      (let [context {:db-pool :fake-db}]
        (is (nil? (file-store/resolve-file-store context (random-uuid))))))))

Step 2: Run tests to verify they fail

clj -X:test:silent :nses '[com.getorcha.link.mcp.file-store-test]'

Expected: FAIL — resolve-file-store doesn't exist yet.

Step 3: Implement resolve-file-store

Add to src/com/getorcha/link/mcp/file_store.clj:

(ns com.getorcha.link.mcp.file-store
  "FileStore protocol for abstract file system access.

  Different storage backends (local filesystem, S3, Google Drive, SFTP)
  implement this protocol. The `make-file-store` multimethod constructs
  the appropriate implementation from a data source config map."
  (:require [com.getorcha.link.queries.documents :as queries]))

Add the function after the existing multimethods:

(defn resolve-file-store
  "Resolves a FileStore for a legal entity from context.

  Checks for a dev file store override first (for local development),
  then reads the fpna_data_source from the DB and constructs the
  appropriate backend.

  Returns a FileStore instance, or nil if no data source is configured."
  [context legal-entity-id]
  (let [{:keys [db-pool dev-file-store]} context
        data-source (queries/get-legal-entity-data-source db-pool legal-entity-id)]
    (cond
      ;; Dev override for local file store
      (and dev-file-store (or (nil? data-source) (= "file" (:protocol data-source))))
      (make-file-store dev-file-store)

      (nil? data-source)
      nil

      ;; Google Drive — resolve credentials and construct store
      (= "google-drive" (:protocol data-source))
      nil ;; Placeholder — implemented in Task 7

      ;; Default — pass through to make-file-store
      :else
      (make-file-store data-source))))

Step 4: Run tests to verify they pass

clj -X:test:silent :nses '[com.getorcha.link.mcp.file-store-test]'

Step 5: Update MCP tools to use resolve-file-store

In src/com/getorcha/link/mcp/tools/fpna/list_files.clj, change the handler:

(defn handle-list-files
  "Handler for orcha-fpna-list-files tool."
  [args {:keys [legal-entity-ids] :as context}]
  (let [resolved (mcp.identity/resolve-legal-entity-from-args (:legal_entity_id args) legal-entity-ids)]
    (if (map? resolved)
      resolved
      (let [store (file-store/resolve-file-store context resolved)]
        (if-not store
          {:isError true
           :content [{:type "text"
                      :text (json/generate-string
                             {:error (str "No FP&A data source configured for legal entity " resolved)})}]}
          (try
            ...existing try body using `store` instead of calling make-file-store...

The key changes are:

  1. Destructure context instead of {:keys [db-pool legal-entity-ids]}
  2. Replace (queries/get-legal-entity-data-source db-pool resolved) + (file-store/make-file-store data-source) with (file-store/resolve-file-store context resolved)
  3. Remove the require of com.getorcha.link.mcp.file-store.local — that's now loaded by the system, not the tool
  4. Remove the require of com.getorcha.link.queries.documents

Do the same for src/com/getorcha/link/mcp/tools/fpna/excel.clj.

Step 6: Run existing MCP tests

clj -X:test:silent :nses '[com.getorcha.link.mcp-test]'

Step 7: Lint

clj-kondo --lint src test dev

Step 8: Commit

git add src/com/getorcha/link/mcp/file_store.clj src/com/getorcha/link/mcp/tools/fpna/list_files.clj src/com/getorcha/link/mcp/tools/fpna/excel.clj test/com/getorcha/link/mcp/file_store_test.clj
git commit -m "feat: add resolve-file-store with dev override, update MCP tools"

Task 5: Google Drive OAuth routes and handlers

Files:

Context:

:com.getorcha/integrations
{:base-url        #ref [:com.getorcha/app-base-url]
 :encryption-key-arn #ref [:com.getorcha/db-secrets-key-arn]
 :datev ...}

Step 1: Update config.edn to add encryption-key-arn at integrations level

In resources/com/getorcha/config.edn, add :encryption-key-arn to :com.getorcha/integrations:

:com.getorcha/integrations
{:base-url           #ref [:com.getorcha/app-base-url]
 :encryption-key-arn #ref [:com.getorcha/db-secrets-key-arn]
 :datev    #merge [...]}

The existing DATEV code uses (get-in integrations [:datev :account-key-encryption-key-arn]) — this still works because DATEV has its own copy. Google Drive handlers will use (:encryption-key-arn integrations).

Step 2: Create the Google Drive OAuth namespace

Create src/com/getorcha/app/http/settings/google_drive.clj:

(ns com.getorcha.app.http.settings.google-drive
  "Google Drive OAuth integration for FP&A file store.

  Handles the OAuth 2.0 flow for connecting Google Drive folders:
  - authorize: initiates Google OAuth consent
  - callback: exchanges code for tokens, stores refresh token
  - select-folder: stores the user's folder selection
  - disconnect: removes the connection"
  (:require [clojure.tools.logging :as log]
            [com.getorcha.aws :as aws]
            [com.getorcha.db.sql :as db.sql]
            [com.getorcha.app.http.identity :as app.http.identity]
            [com.getorcha.app.http.routes :as app.http.routes]
            [com.getorcha.email.oauth.state :as oauth.state]
            [reitit.core :as reitit]
            [ring.util.http-response :as ring.resp])
  (:import (java.net URLEncoder)
           (java.time Instant)))


;; Google OAuth endpoints
(def ^:private google-auth-endpoint "https://accounts.google.com/o/oauth2/v2/auth")
(def ^:private google-token-endpoint "https://oauth2.googleapis.com/token")
(def ^:private google-userinfo-endpoint "https://www.googleapis.com/oauth2/v3/userinfo")


(defn ^:private exchange-code-for-tokens!
  "Exchanges authorization code for access + refresh tokens."
  [{:keys [client-id client-secret]} redirect-uri code]
  ;; HTTP POST to google-token-endpoint
  ;; Returns {:access-token "..." :refresh-token "..." :expires-in 3600}
  ;; Use clj-http or hato for the HTTP call
  ;; Implementation details in step 3
  )


(defn ^:private get-user-email
  "Fetches the authenticated user's email from Google userinfo."
  [access-token]
  ;; GET google-userinfo-endpoint with Bearer token
  ;; Returns email string
  )


(defn ^:private authorize
  "Initiates Google Drive OAuth flow.

  Redirects user to Google consent screen with drive.readonly scope.
  State parameter carries legal-entity-id via HMAC-signed state."
  [{:keys [providers identity ::reitit/router] :as _request}
   respond
   _raise
   legal-entity-id]
  (let [google-drive-config (:google-drive providers)
        {:keys [client-id scopes state-secret]} google-drive-config
        base-url     (:base-url providers)
        redirect-uri (str base-url (app.http.routes/path-for router ::callback))
        state        (oauth.state/create-signed-state state-secret legal-entity-id)
        auth-url     (str google-auth-endpoint
                          "?client_id=" (URLEncoder/encode client-id "UTF-8")
                          "&redirect_uri=" (URLEncoder/encode redirect-uri "UTF-8")
                          "&response_type=code"
                          "&scope=" (URLEncoder/encode scopes "UTF-8")
                          "&access_type=offline"
                          "&prompt=consent"
                          "&state=" (URLEncoder/encode state "UTF-8"))]
    (log/info "Initiating Google Drive OAuth" {:legal-entity-id legal-entity-id})
    (respond (ring.resp/found auth-url))))


(defn ^:private callback
  "Handles Google Drive OAuth callback.

  Exchanges code for tokens, encrypts refresh token with KMS,
  stores in legal_entity_oauth_integration, sets fpna_data_source."
  [{:keys [aws db-pool identity integrations providers query-params ::reitit/router] :as _request}
   respond
   _raise]
  (let [code           (get query-params "code")
        state          (get query-params "state")
        error          (get query-params "error")
        settings-url   (app.http.routes/path-for router
                         :com.getorcha.app.http.settings.integrations/page)
        google-config  (:google-drive providers)
        state-secret   (:state-secret google-config)
        base-url       (:base-url providers)
        redirect-uri   (str base-url (app.http.routes/path-for router ::callback))]
    (cond
      error
      (do
        (log/warn "Google Drive OAuth error" {:error error})
        (respond (ring.resp/found (str settings-url "?error=google-drive-auth-failed"))))

      (or (nil? code) (nil? state))
      (respond (ring.resp/found (str settings-url "?error=google-drive-auth-failed")))

      :else
      (let [{:keys [legal-entity-id]} (oauth.state/verify-signed-state state-secret state)]
        (if-not legal-entity-id
          (do
            (log/warn "Invalid Google Drive OAuth state")
            (respond (ring.resp/found (str settings-url "?error=google-drive-auth-failed"))))

          (let [tokens         (exchange-code-for-tokens! google-config redirect-uri code)
                access-token   (:access-token tokens)
                refresh-token  (:refresh-token tokens)
                email          (get-user-email access-token)
                kms-client     (get-in aws [:clients :kms])
                key-arn        (:encryption-key-arn integrations)
                encrypted      (aws/kms-encrypt kms-client key-arn refresh-token)
                user-id        (:identity/id identity)]
            ;; Upsert into legal_entity_oauth_integration
            (db.sql/execute-one!
              db-pool
              {:insert-into   :legal-entity-oauth-integration
               :values        [{:legal-entity-id          legal-entity-id
                                :integration-type         (db.sql/->cast :google_drive
                                                                         :oauth-integration-type)
                                :is-active                true
                                :connected-by-user-id     user-id
                                :refresh-token-encrypted  encrypted
                                :metadata                 [:lift {:email email}]}]
               :on-conflict   [:legal-entity-id :integration-type]
               :do-update-set {:is-active                true
                               :connected-by-user-id     user-id
                               :refresh-token-encrypted  encrypted
                               :metadata                 [:lift {:email email}]
                               :disconnect-reason        nil
                               :updated-at               [:now]}})
            ;; Set fpna_data_source
            (db.sql/execute-one!
              db-pool
              {:update :legal-entity
               :set    {:fpna-data-source [:lift {:protocol "google-drive"}]}
               :where  [:= :id legal-entity-id]})
            (log/info "Google Drive connected" {:legal-entity-id legal-entity-id :email email})
            (respond (ring.resp/found (str settings-url "?success=google-drive-connected")))))))))


(defn ^:private select-folder
  "Stores the user's folder selection from Google Picker."
  [{:keys [db-pool form-params ::reitit/router] :as request}
   respond
   _raise]
  (let [legal-entity-id (some-> (get form-params "legal_entity_id") parse-uuid)
        folder-id       (get form-params "folder_id")
        folder-name     (get form-params "folder_name")]
    (when (and legal-entity-id folder-id)
      (db.sql/execute-one!
        db-pool
        {:update :legal-entity-oauth-integration
         :set    {:config     [:lift {:folder-id   folder-id
                                      :folder-name folder-name}]
                  :updated-at [:now]}
         :where  [:and
                  [:= :legal-entity-id legal-entity-id]
                  [:= :integration-type
                   (db.sql/->cast :google_drive :oauth-integration-type)]]}))
    (respond
      (ring.resp/ok
        (google-drive-section
          (::reitit/router request)
          request
          legal-entity-id)))))


(defn ^:private disconnect
  "Disconnects Google Drive for a legal entity."
  [{:keys [db-pool ::reitit/router] :as request}
   respond
   _raise]
  (let [legal-entity-id (some-> (get (:form-params request) "legal_entity_id") parse-uuid)]
    (when legal-entity-id
      ;; Clear the integration
      (db.sql/execute-one!
        db-pool
        {:update :legal-entity-oauth-integration
         :set    {:is-active                false
                  :refresh-token-encrypted  nil
                  :config                   [:lift {}]
                  :metadata                 [:lift {}]
                  :updated-at               [:now]}
         :where  [:and
                  [:= :legal-entity-id legal-entity-id]
                  [:= :integration-type
                   (db.sql/->cast :google_drive :oauth-integration-type)]]})
      ;; Clear fpna_data_source
      (db.sql/execute-one!
        db-pool
        {:update :legal-entity
         :set    {:fpna-data-source nil}
         :where  [:= :id legal-entity-id]}))
    (log/info "Google Drive disconnected" {:legal-entity-id legal-entity-id})
    (respond
      (ring.resp/ok
        (google-drive-section
          router
          request
          legal-entity-id)))))


;; UI rendering functions — see Task 6
(declare google-drive-section)


(defn routes [_config]
  ["/settings/integrations/google-drive"
   ["/authorize"
    {:name ::authorize
     :get  {:summary    "Initiate Google Drive OAuth"
            :parameters {:query [:map [:legal_entity_id :uuid]]}
            :handler    (fn [{:keys [query-params] :as req} resp raise]
                          (authorize req resp raise
                                     (parse-uuid (get query-params "legal_entity_id"))))}}]
   ["/callback"
    {:name ::callback
     :get  {:summary    "Handle Google Drive OAuth callback"
            :parameters {:query [:map
                                 [:code {:optional true} :string]
                                 [:state {:optional true} :string]
                                 [:error {:optional true} :string]]}
            :handler    #'callback}}]
   ["/select-folder"
    {:name ::select-folder
     :post {:summary "Store selected Google Drive folder"
            :handler #'select-folder}}]
   ["/disconnect"
    {:name ::disconnect
     :post {:summary "Disconnect Google Drive"
            :handler #'disconnect}}]])

Step 3: Implement the HTTP calls for token exchange and userinfo

The exchange-code-for-tokens! and get-user-email functions need HTTP client calls. Use hato (already a dependency — check deps.edn):

(ns com.getorcha.app.http.settings.google-drive
  (:require ...
            [hato.client :as hato]
            [cheshire.core :as json]
            ...))

(defn ^:private exchange-code-for-tokens!
  "Exchanges authorization code for access + refresh tokens."
  [{:keys [client-id client-secret]} redirect-uri code]
  (let [response (hato/request
                   {:method       :post
                    :url          google-token-endpoint
                    :content-type :application/x-www-form-urlencoded
                    :form-params  {"grant_type"    "authorization_code"
                                   "code"          code
                                   "client_id"     client-id
                                   "client_secret" client-secret
                                   "redirect_uri"  redirect-uri}})
        body     (json/parse-string (:body response) true)]
    {:access-token  (:access_token body)
     :refresh-token (:refresh_token body)
     :expires-in    (:expires_in body)}))


(defn ^:private get-user-email
  "Fetches the authenticated user's email from Google userinfo."
  [access-token]
  (let [response (hato/request
                   {:method  :get
                    :url     google-userinfo-endpoint
                    :headers {"Authorization" (str "Bearer " access-token)}})
        body     (json/parse-string (:body response) true)]
    (:email body)))

Check if hato is in deps.edn. If not, use clj-http or whatever HTTP client is already available. Search for existing HTTP client usage:

rg "hato|clj-http" deps.edn

Step 4: Register routes in app.http

In src/com/getorcha/app/http.clj:

Add require:

[com.getorcha.app.http.settings.google-drive :as app.http.settings.google-drive]

Add route inside the authenticated middleware block (after app.http.settings.integrations/routes):

(app.http.settings.google-drive/routes config)

Step 5: Lint

clj-kondo --lint src test dev

Step 6: Commit

git add src/com/getorcha/app/http/settings/google_drive.clj src/com/getorcha/app/http.clj resources/com/getorcha/config.edn
git commit -m "feat: add Google Drive OAuth routes and handlers"

Task 6: Settings page UI — Google Drive section

Files:

Context:

Step 1: Add google-drive-section rendering to google_drive.clj

(defn ^:private get-google-drive-status
  "Returns the Google Drive connection status for a legal entity."
  [db-pool legal-entity-id]
  (let [integration (db.sql/execute-one!
                      db-pool
                      {:select [:*]
                       :from   [:legal-entity-oauth-integration]
                       :where  [:and
                                [:= :legal-entity-id legal-entity-id]
                                [:= :integration-type
                                 (db.sql/->cast :google_drive :oauth-integration-type)]
                                [:= :is-active true]]})]
    (cond
      (nil? integration)
      {:status :not-connected}

      (nil? (get-in integration [:legal-entity-oauth-integration/config :folder-id]))
      {:status      :connected-no-folder
       :integration integration}

      :else
      {:status      :connected
       :integration integration})))


(defn google-drive-section
  "Renders the Google Drive connection section for a legal entity."
  [router request legal-entity-id]
  (let [{:keys [db-pool providers]} request
        {:keys [status integration]} (get-google-drive-status db-pool legal-entity-id)
        google-config (:google-drive providers)]
    [:div {:id (str "google-drive-section-" legal-entity-id)}
     (case status
       :not-connected
       [:div.connection-card.not-connected
        [:div.connection-icon [:iconify-icon {:icon :lucide:hard-drive}]]
        [:div.connection-info
         [:div.connection-title "Not connected"]
         [:div.connection-description "Connect your Google Drive to access financial data files."]]
        [:a.btn.btn-primary
         {:href (str (app.http.routes/path-for router ::authorize)
                     "?legal_entity_id=" legal-entity-id)}
         [:iconify-icon {:icon :simple-icons:googledrive}]
         "Connect Google Drive"]]

       :connected-no-folder
       (let [email (get-in integration [:legal-entity-oauth-integration/metadata :email])]
         [:div.connection-card.pending
          [:div.connection-icon [:iconify-icon {:icon :lucide:folder-search}]]
          [:div.connection-info
           [:div.connection-title "Select a folder"]
           (when email [:div.connection-company email])
           [:div.connection-description "Choose the Google Drive folder containing your financial data."]]
          ;; Google Picker trigger
          [:button.btn.btn-primary
           {:onclick (str "openGoogleDrivePicker('" legal-entity-id "')")
            :data-legal-entity-id (str legal-entity-id)}
           "Select Folder"]
          [:button.btn.btn-secondary
           {:hx-post   (app.http.routes/path-for router ::disconnect)
            :hx-target (str "#google-drive-section-" legal-entity-id)
            :hx-swap   "innerHTML"
            :hx-vals   (str "{\"legal_entity_id\":\"" legal-entity-id "\"}")
            :hx-confirm "Disconnect Google Drive?"}
           "Disconnect"]])

       :connected
       (let [email       (get-in integration [:legal-entity-oauth-integration/metadata :email])
             folder-name (get-in integration [:legal-entity-oauth-integration/config :folder-name])]
         [:div.connection-card.connected
          [:div.connection-icon [:iconify-icon {:icon :lucide:check-circle}]]
          [:div.connection-info
           [:div.connection-title "Connected"]
           (when email [:div.connection-company email])
           [:div.connection-description (str "Folder: " folder-name)]]
          [:div.connection-actions
           [:button.btn.btn-secondary
            {:onclick (str "openGoogleDrivePicker('" legal-entity-id "')")}
            "Change Folder"]
           [:button.btn.btn-secondary
            {:hx-post   (app.http.routes/path-for router ::disconnect)
             :hx-target (str "#google-drive-section-" legal-entity-id)
             :hx-swap   "innerHTML"
             :hx-vals   (str "{\"legal_entity_id\":\"" legal-entity-id "\"}")
             :hx-confirm "Disconnect Google Drive? Your data source will be removed."}
            "Disconnect"]]]))]))

Step 2: Add Google Drive section to the integrations page

In src/com/getorcha/app/http/settings/integrations.clj, modify the page function to include the Google Drive section. Add it after the existing integrations section:

;; After existing integrations (around line 497)
[:div#datev-rewe-section (datev-rewe-section router datev-rewe-status)]]

;; Add new FP&A Data section
[:section#fpna-data.settings-section
 [:h2.settings-section-title "FP&A Data Source"]
 (for [{:legal-entity/keys [id name]} legal-entities]
   [:div.master-data-section {:aria-labelledby (str "google-drive-heading-" id)}
    [:h3 {:id (str "google-drive-heading-" id)} name]
    (settings.google-drive/google-drive-section router request id)])]

Add the require:

[com.getorcha.app.http.settings.google-drive :as settings.google-drive]

Step 3: Create the Google Picker JS

Create resources/app/public/js/google-drive-picker.js:

// Google Drive Picker integration
// Loaded on the settings page when Google Drive integration is available

let pickerApiLoaded = false;
let gapiLoaded = false;

function loadGooglePickerApi() {
  if (gapiLoaded) return Promise.resolve();
  return new Promise(function(resolve) {
    const script = document.createElement('script');
    script.src = 'https://apis.google.com/js/api.js';
    script.onload = function() {
      gapi.load('picker', function() {
        pickerApiLoaded = true;
        gapiLoaded = true;
        resolve();
      });
    };
    document.head.appendChild(script);
  });
}

function openGoogleDrivePicker(legalEntityId) {
  const apiKey = document.querySelector('meta[name="google-drive-api-key"]').content;
  const clientId = document.querySelector('meta[name="google-drive-client-id"]').content;
  const selectFolderUrl = document.querySelector('meta[name="google-drive-select-folder-url"]').content;

  loadGooglePickerApi().then(function() {
    // Get a fresh access token via the token endpoint
    const tokenClient = google.accounts.oauth2.initTokenClient({
      client_id: clientId,
      scope: 'https://www.googleapis.com/auth/drive.readonly',
      callback: function(tokenResponse) {
        if (tokenResponse.error) {
          console.error('Token error:', tokenResponse.error);
          return;
        }
        createPicker(tokenResponse.access_token, apiKey, legalEntityId, selectFolderUrl);
      }
    });
    tokenClient.requestAccessToken();
  });
}

function createPicker(accessToken, apiKey, legalEntityId, selectFolderUrl) {
  const view = new google.picker.DocsView(google.picker.ViewId.FOLDERS);
  view.setSelectFolderEnabled(true);
  view.setMimeTypes('application/vnd.google-apps.folder');

  const picker = new google.picker.PickerBuilder()
    .addView(view)
    .setOAuthToken(accessToken)
    .setDeveloperKey(apiKey)
    .setCallback(function(data) {
      if (data.action === google.picker.Action.PICKED) {
        const folder = data.docs[0];
        // POST to select-folder endpoint
        const formData = new FormData();
        formData.append('legal_entity_id', legalEntityId);
        formData.append('folder_id', folder.id);
        formData.append('folder_name', folder.name);

        htmx.ajax('POST', selectFolderUrl, {
          target: '#google-drive-section-' + legalEntityId,
          swap: 'innerHTML',
          values: {
            legal_entity_id: legalEntityId,
            folder_id: folder.id,
            folder_name: folder.name
          }
        });
      }
    })
    .setTitle('Select a folder')
    .build();

  picker.setVisible(true);
}

Note: The Google Picker also needs the Google Identity Services library for the token client. Load it with:

<script src="https://accounts.google.com/gsi/client" async defer></script>

This should be added as a meta tag + script approach in the settings page. Add meta tags to the page head for the API key, client ID, and select-folder URL. The layout base function passes content as variadic args — so the meta tags need to be rendered in the page content, or better, pass them as data attributes on a container element.

Actually, simpler approach: render the config as data attributes on the Google Drive section container, and load the scripts inline on the settings page when Google Drive config is present.

Revise the approach: instead of meta tags, use data attributes on a container div and load the picker script from a <script> tag rendered by the settings page.

In integrations.clj, when rendering the FP&A section:

;; FP&A Data section — includes Google Picker scripts
[:section#fpna-data.settings-section
 [:h2.settings-section-title "FP&A Data Source"]
 ;; Config for Google Picker JS
 [:div#google-drive-config
  {:data-api-key         (:api-key google-drive-config)
   :data-client-id       (:client-id google-drive-config)
   :data-select-folder-url (app.http.routes/path-for router
                              :com.getorcha.app.http.settings.google-drive/select-folder)
   :style "display:none"}]
 (for [{:legal-entity/keys [id name]} legal-entities]
   [:div.master-data-section {:aria-labelledby (str "google-drive-heading-" id)}
    [:h3 {:id (str "google-drive-heading-" id)} name]
    (settings.google-drive/google-drive-section router request id)])
 ;; Load Google Picker scripts
 [:script {:src "https://apis.google.com/js/api.js" :async true :defer true}]
 [:script {:src "https://accounts.google.com/gsi/client" :async true :defer true}]
 [:script {:src (str "/public/js/google-drive-picker.js?v=" (com.getorcha.app.ui.layout/assets-version))
           :defer true}]]

Update the JS to read from #google-drive-config data attributes instead of meta tags.

Step 4: Toast messages

Add Google Drive toast messages to the toast-message function in integrations.clj:

"google-drive-connected"     {:type :success :message "Google Drive connected. Select a folder to continue."}
"google-drive-auth-failed"   {:type :error   :message "Google Drive authentication failed."}
"google-drive-disconnected"  {:type :success :message "Google Drive disconnected."}

Step 5: Lint and test manually

clj-kondo --lint src test dev

Start the system and verify the settings page renders.

Step 6: Commit

git add src/com/getorcha/app/http/settings/google_drive.clj src/com/getorcha/app/http/settings/integrations.clj resources/app/public/js/google-drive-picker.js
git commit -m "feat: add Google Drive connection UI to settings page with Picker"

Task 7: Google Drive FileStore implementation

Files:

Context:

Step 1: Write the failing test

Create test/com/getorcha/link/mcp/file_store/google_drive_test.clj:

(ns com.getorcha.link.mcp.file-store.google-drive-test
  (:require [clojure.test :refer [deftest is testing]]
            [com.getorcha.link.mcp.file-store :as file-store]
            [com.getorcha.link.mcp.file-store.google-drive :as gdrive]))


(deftest list-files-test
  (testing "maps Google Drive API response to FileStore format"
    (with-redefs [gdrive/drive-api-list-files
                  (fn [_token _folder-id]
                    [{:id "1" :name "Budget.xlsx" :size "1024"
                      :modifiedTime "2026-03-16T10:00:00.000Z"
                      :mimeType "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"}
                     {:id "2" :name "Reports" :size nil
                      :modifiedTime nil
                      :mimeType "application/vnd.google-apps.folder"}])]
      (let [store   (file-store/make-file-store
                      {:protocol     "google-drive"
                       :folder-id    "root-folder"
                       :access-token "test-token"})
            entries (file-store/list-files store "" {})]
        (is (= 2 (count entries)))
        (is (= "Budget.xlsx" (:name (first entries))))
        (is (= false (:directory? (first entries))))
        (is (= "xlsx" (:type (first entries))))
        (is (= true (:directory? (second entries))))
        (is (= "directory" (:type (second entries))))))))


(deftest list-files-filter-test
  (testing "filters by file type"
    (with-redefs [gdrive/drive-api-list-files
                  (fn [_token _folder-id]
                    [{:id "1" :name "Budget.xlsx" :size "1024"
                      :modifiedTime "2026-03-16T10:00:00.000Z"
                      :mimeType "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"}
                     {:id "2" :name "Notes.pdf" :size "2048"
                      :modifiedTime "2026-03-16T10:00:00.000Z"
                      :mimeType "application/pdf"}])]
      (let [store   (file-store/make-file-store
                      {:protocol     "google-drive"
                       :folder-id    "root-folder"
                       :access-token "test-token"})
            entries (file-store/list-files store "" {:file-type "xlsx"})]
        (is (= 1 (count entries)))
        (is (= "Budget.xlsx" (:name (first entries))))))))

Step 2: Run test to verify it fails

clj -X:test:silent :nses '[com.getorcha.link.mcp.file-store.google-drive-test]'

Step 3: Implement Google Drive FileStore

Create src/com/getorcha/link/mcp/file_store/google_drive.clj:

(ns com.getorcha.link.mcp.file-store.google-drive
  "Google Drive implementation of FileStore.

  Uses the Google Drive API v3 to list and read files from a specific
  folder. Requires a valid OAuth access token."
  (:require [cheshire.core :as json]
            [clojure.string :as str]
            [com.getorcha.link.mcp.file-store :as file-store]
            [hato.client :as hato])
  (:import (java.io InputStream)
           (java.net URLEncoder)))


(def ^:private drive-files-endpoint "https://www.googleapis.com/drive/v3/files")


(def ^:private mime-type->extension
  "Maps common Google Drive MIME types to file extensions."
  {"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"         "xlsx"
   "application/vnd.ms-excel"                                                   "xls"
   "application/pdf"                                                            "pdf"
   "text/csv"                                                                   "csv"
   "application/vnd.openxmlformats-officedocument.wordprocessingml.document"    "docx"
   "application/vnd.openxmlformats-officedocument.presentationml.presentation"  "pptx"
   "text/plain"                                                                 "txt"
   "application/json"                                                           "json"
   "application/xml"                                                            "xml"
   "image/png"                                                                  "png"
   "image/jpeg"                                                                 "jpg"})


(def ^:private google-folder-mime "application/vnd.google-apps.folder")


(defn ^:private file-extension-from-name
  "Extracts lowercase file extension from filename."
  [^String filename]
  (let [dot-idx (.lastIndexOf filename ".")]
    (when (pos? dot-idx)
      (.toLowerCase (.substring filename (inc dot-idx))))))


(defn drive-api-list-files
  "Lists files in a Google Drive folder via the API.

  Returns raw API response items."
  [access-token folder-id]
  (let [query    (str "'" folder-id "' in parents and trashed = false")
        url      (str drive-files-endpoint
                      "?q=" (URLEncoder/encode query "UTF-8")
                      "&fields=" (URLEncoder/encode
                                   "files(id,name,size,modifiedTime,mimeType)" "UTF-8")
                      "&pageSize=1000"
                      "&orderBy=name")
        response (hato/request
                   {:method  :get
                    :url     url
                    :headers {"Authorization" (str "Bearer " access-token)}})
        body     (json/parse-string (:body response) true)]
    (:files body)))


(defn ^:private find-subfolder-id
  "Finds a subfolder by name within a parent folder."
  [access-token parent-id subfolder-name]
  (let [query    (str "'" parent-id "' in parents"
                      " and name = '" subfolder-name "'"
                      " and mimeType = '" google-folder-mime "'"
                      " and trashed = false")
        url      (str drive-files-endpoint
                      "?q=" (URLEncoder/encode query "UTF-8")
                      "&fields=" (URLEncoder/encode "files(id)" "UTF-8"))
        response (hato/request
                   {:method  :get
                    :url     url
                    :headers {"Authorization" (str "Bearer " access-token)}})
        body     (json/parse-string (:body response) true)]
    (:id (first (:files body)))))


(defn ^:private resolve-folder-id
  "Resolves a relative path to a Google Drive folder ID.

  Walks the path components, looking up each subfolder."
  [access-token root-folder-id relative-path]
  (if (or (nil? relative-path) (= "" relative-path))
    root-folder-id
    (let [parts (remove str/blank? (str/split relative-path #"/"))]
      (reduce (fn [parent-id part]
                (or (find-subfolder-id access-token parent-id part)
                    (throw (ex-info "Folder not found"
                                    {:path relative-path :part part}))))
              root-folder-id
              parts))))


(defn ^:private api-file->entry
  "Converts a Google Drive API file to a FileStore entry."
  [relative-prefix {:keys [name size modifiedTime mimeType] :as _file}]
  (let [directory? (= google-folder-mime mimeType)
        path       (if (str/blank? relative-prefix)
                     name
                     (str relative-prefix "/" name))]
    (if directory?
      {:name       name
       :path       path
       :directory? true
       :type       "directory"
       :size       nil
       :modified   nil}
      {:name       name
       :path       path
       :directory? false
       :type       (or (file-extension-from-name name)
                       (get mime-type->extension mimeType))
       :size       (some-> size parse-long)
       :modified   modifiedTime})))


(defn ^:private find-file-id
  "Finds a file by path relative to the root folder.

  Walks parent directories, then finds the file by name in the last directory."
  [access-token root-folder-id relative-path]
  (let [parts     (remove str/blank? (str/split relative-path #"/"))
        dir-parts (butlast parts)
        file-name (last parts)
        parent-id (if (seq dir-parts)
                    (resolve-folder-id access-token root-folder-id
                                       (str/join "/" dir-parts))
                    root-folder-id)
        query     (str "'" parent-id "' in parents"
                       " and name = '" file-name "'"
                       " and trashed = false")
        url       (str drive-files-endpoint
                       "?q=" (URLEncoder/encode query "UTF-8")
                       "&fields=" (URLEncoder/encode "files(id)" "UTF-8"))
        response  (hato/request
                    {:method  :get
                     :url     url
                     :headers {"Authorization" (str "Bearer " access-token)}})
        body      (json/parse-string (:body response) true)]
    (or (:id (first (:files body)))
        (throw (java.io.FileNotFoundException.
                 (str "File not found: " relative-path))))))


(defmethod file-store/make-file-store "google-drive"
  [{:keys [folder-id access-token] :as _config}]
  (reify file-store/FileStore
    (list-files [_ relative-path opts]
      (let [target-folder (resolve-folder-id access-token folder-id relative-path)
            raw-files     (drive-api-list-files access-token target-folder)
            prefix        (if (or (nil? relative-path) (= "" relative-path))
                            ""
                            relative-path)
            entries       (map #(api-file->entry prefix %) raw-files)]
        (if-let [ft (:file-type opts)]
          (filter #(= ft (:type %)) entries)
          entries)))

    (read-file [_ relative-path]
      (let [file-id  (find-file-id access-token folder-id relative-path)
            url      (str drive-files-endpoint "/" file-id "?alt=media")
            response (hato/request
                       {:method  :get
                        :url     url
                        :headers {"Authorization" (str "Bearer " access-token)}
                        :as      :stream})]
        ^InputStream (:body response)))))

Step 4: Run tests to verify they pass

clj -X:test:silent :nses '[com.getorcha.link.mcp.file-store.google-drive-test]'

Step 5: Wire up resolve-file-store for Google Drive

Back in src/com/getorcha/link/mcp/file_store.clj, fill in the Google Drive branch. This needs to:

  1. Query legal_entity_oauth_integration for the refresh token
  2. Decrypt it with KMS
  3. Exchange for an access token
  4. Get the folder ID from config
  5. Call make-file-store

Add a require for the google-drive namespace (to register the multimethod):

(ns com.getorcha.link.mcp.file-store
  (:require [cheshire.core :as json]
            [com.getorcha.aws :as aws]
            [com.getorcha.db.sql :as db.sql]
            [com.getorcha.link.queries.documents :as queries]
            [hato.client :as hato]))

Add the token refresh function and update resolve-file-store:

(def ^:private google-token-endpoint "https://oauth2.googleapis.com/token")


(defn ^:private refresh-google-access-token
  "Exchanges a Google refresh token for a fresh access token."
  [client-id client-secret refresh-token]
  (let [response (hato/request
                   {:method       :post
                    :url          google-token-endpoint
                    :content-type :application/x-www-form-urlencoded
                    :form-params  {"grant_type"    "refresh_token"
                                   "refresh_token" refresh-token
                                   "client_id"     client-id
                                   "client_secret" client-secret}})
        body     (json/parse-string (:body response) true)]
    (:access_token body)))


(defn resolve-file-store
  "Resolves a FileStore for a legal entity from context.

  Checks for a dev file store override first (for local development),
  then reads the fpna_data_source from the DB and constructs the
  appropriate backend.

  Returns a FileStore instance, or nil if no data source is configured."
  [context legal-entity-id]
  (let [{:keys [db-pool aws dev-file-store]} context
        data-source (queries/get-legal-entity-data-source db-pool legal-entity-id)]
    (cond
      ;; Dev override for local file store
      (and dev-file-store (or (nil? data-source) (= "file" (:protocol data-source))))
      (make-file-store dev-file-store)

      (nil? data-source)
      nil

      ;; Google Drive — resolve credentials and construct store
      (= "google-drive" (:protocol data-source))
      (let [integration (db.sql/execute-one!
                          db-pool
                          {:select [:refresh-token-encrypted :config]
                           :from   [:legal-entity-oauth-integration]
                           :where  [:and
                                    [:= :legal-entity-id legal-entity-id]
                                    [:= :integration-type
                                     (db.sql/->cast :google_drive :oauth-integration-type)]
                                    [:= :is-active true]]})
            encrypted   (:legal-entity-oauth-integration/refresh-token-encrypted integration)
            folder-id   (get-in integration [:legal-entity-oauth-integration/config :folder-id])
            kms-client  (get-in aws [:clients :kms])]
        (when (and encrypted folder-id)
          (let [refresh-token (String. (aws/kms-decrypt kms-client encrypted) "UTF-8")
                ;; Get Google Drive client credentials from... we need them here.
                ;; They're in the oauth-providers config. Pass them through context.
                ;; For now, we'll need to add :providers to the MCP context.
                google-config (:google-drive (:providers context))
                access-token  (refresh-google-access-token
                                (:client-id google-config)
                                (:client-secret google-config)
                                refresh-token)]
            (make-file-store {:protocol     "google-drive"
                              :folder-id    folder-id
                              :access-token access-token}))))

      ;; Default — pass through to make-file-store
      :else
      (make-file-store data-source))))

Wait — the MCP context doesn't have :providers. The providers config is on the app server, not the Link server. We need to get the Google Drive client-id/secret to the Link server too.

Options:

  1. Add :providers (or at least :google-drive config) to the Link handler config in config.edn
  2. Store the client-id/secret separately accessible to both

Go with option 1: add the Google Drive config to the Link handler. In config.edn, under :com.getorcha.link.http/handler, add:

:google-drive {:client-id     #orcha/param "/v1-orcha/google-drive-client-id"
               :client-secret #orcha/param "/v1-orcha/google-drive-client-secret"}

Then in mcp-handler, add :google-drive to the context:

context {:db-pool          db-pool
         :aws              (:aws _request)
         :oauth-claims     oauth-claims
         :identity-id      identity-id
         :legal-entity-ids legal-entity-ids
         :dev-file-store   (:dev-file-store _request)
         :google-drive     (:google-drive _request)}

And update resolve-file-store to use (:google-drive context) instead of (:google-drive (:providers context)).

Step 6: Run all file store tests

clj -X:test:silent :nses '[com.getorcha.link.mcp.file-store-test com.getorcha.link.mcp.file-store.google-drive-test]'

Step 7: Lint

clj-kondo --lint src test dev

Step 8: Commit

git add src/com/getorcha/link/mcp/file_store/google_drive.clj src/com/getorcha/link/mcp/file_store.clj test/com/getorcha/link/mcp/file_store/google_drive_test.clj resources/com/getorcha/config.edn src/com/getorcha/link/mcp/http.clj
git commit -m "feat: add Google Drive FileStore implementation with token refresh"

Task 8: Integration testing and cleanup

Files:

Step 1: Clean up stale requires

The MCP tool files currently have [com.getorcha.link.mcp.file-store.local] in their requires (to register the multimethod). Since resolve-file-store handles everything, the local and google-drive namespace loading should happen at the system level. Add requires to the file-store namespace itself:

In src/com/getorcha/link/mcp/file_store.clj, add to requires:

;; Side-effect requires to register multimethod implementations
com.getorcha.link.mcp.file-store.local
com.getorcha.link.mcp.file-store.google-drive

Remove com.getorcha.link.mcp.file-store.local from list_files.clj and excel.clj requires.

Step 2: Run the full test suite

clj -X:test:silent 2>&1 | grep -A 5 -E "(FAIL in|ERROR in|Execution error|failed because|Ran .* tests)"

Step 3: Lint the full codebase

clj-kondo --lint src test dev

Step 4: Manual testing checklist

  1. Start the system: (reset) in REPL
  2. Navigate to Settings page: verify Google Drive section appears per legal entity
  3. Click "Connect Google Drive" — verify redirect to Google OAuth (will fail without real credentials, but verify the URL is correct)
  4. Test with dev-file-store override — verify MCP tools still work with local files

Step 5: Commit

git add <all modified files>
git commit -m "chore: integration test cleanup and stale require removal"