PR: #608: feat(import): Google Drive picker in the Import drawer [Story-2386] by @ankitmaheshwari-com
View PR →

Story-2386 · pramaan-app · 14 Jul 2026

Choose from Drive

Counsel’s case file already lives in Google Drive. Until now, importing it meant downloading every document to a Downloads folder and dragging it back into PRAMAAN — a round trip through the desktop that exists only because we never opened the door. This Story opens it, and the pipeline behind the door does not change by a single line.

The one idea

Drive is a door, not a pipeline. A file picked from Drive is fetched by the BROWSER, wrapped as a File, and handed to the existing upload queue at exactly the same entry point a local drop uses (onAddFiles(files, armed)). Presign, S3, finalize, the import ledger, declared_side, the Record — none of them learn where the bytes came from. Zero backend change. Zero contract change. No src/gen regeneration.

What shipped

Piece What it does
“Choose from Drive” button A secondary button beside “Browse files” in the Import drawer, under the SAME gate: disabled until a source is armed. Privileged armed replaces the whole zone with the refusal — so there is no Drive door to reach at all.
Google Identity Services + Picker Both loaded by script tag, lazily, on the first click. No npm SDK, no bundle weight, no new runtime dependency. Scope is drive.file — PRAMAAN can only ever read the files counsel hands it in our own picker, never their Drive.
Browser fetch → the existing queue Each picked doc is fetched with the short-lived token, wrapped as a File (name + mime preserved), and dropped into the queue. The armed side stamps it, journey rows show its progress, registered rows auto-clear. Identical to a local drop, because it IS a local drop.
Native Google formats A Google Doc / Sheet / Slide has no bytes to download. It is exported to PDF (files/{id}/export) and its filename gains .pdf, so what lands in the Record is a document the pipeline can actually read.

The gap nobody thinks about: the seconds before the queue

A local drop is instant — the browser already holds the bytes. A Drive pick does not: the bytes still have to come down. If those seconds had no UI, a slow 40 MB fetch would look exactly like a drawer that ignored the click.

So a Drive pick becomes a row immediately, in the same list, reading “Fetching from Drive…” — a fourth stage that sits before the three-step journey (no dot is lit yet, because none has been earned). The moment its bytes land, the fetch row retires and the queue’s own row takes over. Its side chip is display-only while it is pre-queue: there is no server document to correct yet, and it becomes correctable the instant there is.

The flow

armed source (Client's / Opponent's / Other / Strategy) │ ▼ "Choose from Drive" │ ▼ GIS token client (popup, scope: drive.file) │ ├── denied / popup closed ──▶ quiet return (no toast, no noise) │ ▼ Google Picker (multi-select, folder navigation) │ ├── cancel ──▶ quiet return │ ▼ one "Fetching from Drive…" row per picked doc │ ├── > 200 MB ──────────▶ row: "Too large for browser import" (terminal, no retry) │ ├── fetch failed ──────▶ row: "Couldn't fetch from Drive" (retry re-fetches) │ └── bytes land ──▶ new File(bytes, name, mime) │ ▼ onAddFiles(files, armed) ← the SAME door a local drop uses │ ▼ enqueue → mint ledger → presign → S3 → finalize → In the Record

One doc failing never stops the others: the survivors of a pick go to the queue together, and the failures stay on screen as honest rows. A file too large for a browser import is terminal — no retry button, because refetching cannot shrink it. A broken fetch IS retryable, and its retry re-fetches from Drive rather than pretending to retry an upload that never started.

Degrading honestly when Drive is not configured

The door needs a client id and an app id. The API key (developer key) is optional — the Picker runs on the OAuth token alone, and we pass the key only when it is set. With no client id or app id, the button is not rendered at all. A dead button that opens a broken popup is worse than no button; an unconfigured environment simply has no Drive door.

Security surface

Concern Where it is answered
Scope drive.file only — per-file consent, granted by the pick itself. PRAMAAN never gains read access to a Drive.
Tokens The access token lives in a closure for the length of one pick and is never persisted, never sent to a PRAMAAN server, never stored. No refresh token exists, by construction (implicit token client).
Third-party JSON The Picker’s doc payload is runtime-parsed with a Zod schema before we trust it (HR-21 in spirit; assertContract is server-only, and this is Google’s shape, not a PRAMAAN wire contract — HR-22 permits browser-only schemas outside src/gen). A malformed entry is dropped, not thrown: one bad doc must not lose the rest of counsel’s selection.
CSP Four directives had to move together or the door fails silently: script-src (accounts + apis), frame-src (the picker renders in a docs.google.com iframe), and connect-src (the browser fetches the bytes from the Drive API). Edited inside buildContentSecurityPolicy() — the served header (LI-5).

What to look at

File Why
src/lib/google/drive-config.ts The typed config gate. Returns null — not a half-config — when Drive cannot work.
src/lib/google/drive-picker.ts Script-tag loading (cached per session), the GIS token client, the Picker, and the Zod parse of what it hands back.
src/lib/google/drive-fetch.ts Pure: media download vs PDF export, the filename it lands under, the 200 MB browser ceiling. Unit-tested.
src/components/matters/use-drive-picker.ts The pre-queue rows and their fate. The only place that knows Drive exists at all.
src/components/matters/import-into-matter-sheet-v2.tsx One button, one merged row list. The drawer’s existing grammar is untouched.

Files changed

10 file(s), +782 / -13 lines

Path Role + -
messages/en.jsonConfig+5-1
messages/hi.jsonConfig+5-1
src/components/matters/import-into-matter-sheet-v2.stories.tsxComponent+99-0
src/components/matters/import-into-matter-sheet-v2.tsxComponent+79-11
src/components/matters/use-drive-picker.tsComponent+194-0
src/lib/csp.tsLibrary+23-0
src/lib/google/drive-config.tsLibrary+34-0
src/lib/google/drive-fetch.test.tsLibrary+66-0
src/lib/google/drive-fetch.tsLibrary+75-0
src/lib/google/drive-picker.tsLibrary+202-0