Note (2026-04-24): After this document was written,
legal_entitywas renamed totenantand the oldtenantwas renamed toorganization. Read references to these terms with the pre-rename meaning.
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
Files:
resources/migrations/YYYYMMDDHHMMSS-rename-integration-add-oauth.up.sqlresources/migrations/YYYYMMDDHHMMSS-rename-integration-add-oauth.down.sqlContext:
legal_entity_integration with enum integration_type values datev and datev_rewelegal_entity_integration_unique on (legal_entity_id, integration_type)legal_entity_integration_legal_entity_id_fkeyidx_legal_entity_integration_legal_entityorcha_user with id UUID PKmigratusStep 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"
Files:
src/com/getorcha/app/http/settings/integrations.clj — all :legal-entity-integration → :legal-entity-datev-integrationsrc/com/getorcha/app/http/settings/notifications.clj — search for :legal-entity-integrationsrc/com/getorcha/app/http/connect/datev_rewe.clj — search for :legal-entity-integrationContext:
:legal-entity-integration → :legal-entity-datev-integration:legal-entity-integration/is-active → :legal-entity-datev-integration/is-activelegal-entity-integration and legal_entity_integration to catch all referencesStep 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:
:legal-entity-integration → :legal-entity-datev-integration (table references in HoneySQL):legal-entity-integration/ → :legal-entity-datev-integration/ (qualified key access)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"
Files:
infra/stacks/foundation_stack.py:747-775 — add Google Drive params to the listscripts/init_aws.clj:29-73 — add Google Drive dev values to ssm-parameterstest/com/getorcha/test/fixtures.clj:116-150 — add Google Drive test values to ssm-paramsresources/com/getorcha/config.edn — add :google-drive provider configStep 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"
resolve-file-store functionFiles:
src/com/getorcha/link/mcp/file_store.clj — add resolve-file-storesrc/com/getorcha/link/mcp/tools/fpna/list_files.clj — use resolve-file-storesrc/com/getorcha/link/mcp/tools/fpna/excel.clj — use resolve-file-storetest/com/getorcha/link/mcp/file_store_test.clj (create if needed)Context:
resolve-file-store receives the full MCP context mapfpna_data_source from legal entity via queries/get-legal-entity-data-source"file" protocol: checks :dev-file-store in context first"google-drive" protocol: queries legal_entity_oauth_integration, decrypts refresh token, exchanges for access token, constructs file store(file-store/make-file-store data-source) directly — change to (file-store/resolve-file-store context legal-entity-id)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:
context instead of {:keys [db-pool legal-entity-ids]}(queries/get-legal-entity-data-source db-pool resolved) + (file-store/make-file-store data-source) with (file-store/resolve-file-store context resolved)require of com.getorcha.link.mcp.file-store.local — that's now loaded by the system, not the toolrequire of com.getorcha.link.queries.documentsDo 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"
Files:
src/com/getorcha/app/http/settings/google_drive.cljsrc/com/getorcha/app/http.clj — add require + route registrationsrc/com/getorcha/app/http/settings/integrations.clj — add Google Drive section to pageContext:
settings/notifications.clj:714-760com.getorcha.email.oauth.state/create-signed-state and verify-signed-state(:google-drive (:providers request))com.getorcha.aws/kms-encrypt and kms-decrypt(:com.getorcha/db-secrets-key-arn integrations) — wait, check how DATEV gets it. It comes from (get-in integrations [:datev :account-key-encryption-key-arn]). For Google Drive, we should use the same KMS key. Add it to the Google Drive provider config or access it via the :integrations key on the request.:com.getorcha/db-secrets-key-arn which is referenced by the integrations config. The app handler receives :integrations in its config. So access it as (get-in request [:integrations :datev :account-key-encryption-key-arn]). But that's awkward — it's not DATEV-specific. Better: add a top-level :encryption-key-arn to the :com.getorcha/integrations config, or just reference the db-secrets-key-arn directly. Let's add it to config.edn under :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 ...}
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"
Files:
src/com/getorcha/app/http/settings/google_drive.clj — add UI renderingsrc/com/getorcha/app/http/settings/integrations.clj — add Google Drive section to pageresources/app/public/js/google-drive-picker.js — Google Picker integrationresources/app/public/css/style.css — styles for Google Drive section (if needed)Context:
https://apis.google.com/js/api.js, API key, OAuth access token, app ID (numeric part of client ID)hx-post with hx-target and hx-swap "innerHTML"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"
Files:
src/com/getorcha/link/mcp/file_store/google_drive.cljsrc/com/getorcha/link/mcp/file_store.clj — fill in resolve-file-store Google Drive branchtest/com/getorcha/link/mcp/file_store/google_drive_test.cljContext:
https://www.googleapis.com/drive/v3/filesGET /drive/v3/files?q='FOLDER_ID' in parents&fields=files(id,name,size,modifiedTime,mimeType)GET /drive/v3/files/FILE_ID?alt=mediaapplication/vnd.google-apps.folder. To list subdirectory contents, find the folder ID first, then list with that as parent.POST https://oauth2.googleapis.com/token with grant_type=refresh_tokenStep 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:
legal_entity_oauth_integration for the refresh tokenmake-file-storeAdd 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:
:providers (or at least :google-drive config) to the Link handler config in config.ednGo 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"
Files:
src/com/getorcha/link/mcp/tools/fpna/list_files.clj — ensure file-store.local require is removed (loaded by system)src/com/getorcha/link/mcp/tools/fpna/excel.clj — samelegal-entity-integration references are updatedStep 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
(reset) in REPLStep 5: Commit
git add <all modified files>
git commit -m "chore: integration test cleanup and stale require removal"