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
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
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.json | Config | +5 | -1 |
| messages/hi.json | Config | +5 | -1 |
| src/components/matters/import-into-matter-sheet-v2.stories.tsx | Component | +99 | -0 |
| src/components/matters/import-into-matter-sheet-v2.tsx | Component | +79 | -11 |
| src/components/matters/use-drive-picker.ts | Component | +194 | -0 |
| src/lib/csp.ts | Library | +23 | -0 |
| src/lib/google/drive-config.ts | Library | +34 | -0 |
| src/lib/google/drive-fetch.test.ts | Library | +66 | -0 |
| src/lib/google/drive-fetch.ts | Library | +75 | -0 |
| src/lib/google/drive-picker.ts | Library | +202 | -0 |