PR: #614: feat(import): Dropbox Chooser door in the Import drawer [Story-2395] by @ankitmaheshwari-com
View PR →

Story-2395 · pramaan-app · 14 Jul 2026

The second door

Story-2386 opened Google Drive. But a firm’s case file lives wherever that firm happens to keep it, and for a large share of them that is Dropbox. A door that only opens for one cloud is not an import feature — it is a preference. This Story adds the second door, and it is deliberately the same door: same button row, same gate, same rows, same queue.

The one idea

A door, not a pipeline — again. A file picked from Dropbox 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 which cloud the bytes came from, or that they came from a cloud at all. Zero backend change, zero contract change, no src/gen regeneration, no new npm runtime dependency.

Why this door is SIMPLER than the Drive one

The Drive door had to carry OAuth: mint a token, cache it, notice it died early, re-issue it silently on a 401. The Dropbox Chooser carries all of that itself, inside its own popup, and hands back short-lived (~4 hour) direct links that are already authorized.

So PRAMAAN never sees a Dropbox token, never stores one, and has no OAuth code path here to get wrong. Fetching a picked file is one plain fetch(link) with no Authorization header. The security surface that does not exist cannot leak.

What shipped

Piece What it does
“Dropbox” button The fourth member of the existing row — Browse files · Choose folder · Google Drive · Dropbox — under the SAME gate: disabled until a source is armed. Privileged armed replaces the whole zone with the refusal, so there is no Dropbox door to reach at all.
dropins.js by script tag Loaded lazily on the first click, cached for the page’s life, carrying data-app-key on the #dropboxjs tag (Dropbox reads its key from that attribute and refuses without it). No npm SDK, no bundle weight.
Browser fetch → the existing queue Dropbox.choose({ linkType: "direct", multiselect: true }) → direct links → fetch each → new File(...) → onAddFiles. 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 by the time the queue sees it.
Rows that name their own cloud A fetching row carries an origin and reads “Fetching from Dropbox…”. Reusing Drive’s copy for a Dropbox file would have been a one-word lie in the one place counsel is watching.

The flow

armed source (Client's / Opponent's / Other / Strategy) │ ▼ "Dropbox" │ ▼ Dropbox Chooser (its OWN popup — Dropbox owns the auth; we never see a token) │ ├── cancel ─────────────▶ quiet return (no toast, no noise) │ ├── script blocked ─────▶ quiet return │ ▼ one "Fetching from Dropbox…" row per picked file │ ├── > 200 MB ──────────▶ row: "Too large for browser import" (terminal, no retry) │ ├── fetch failed ──────▶ row: "Couldn't fetch from Dropbox" (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 file failing never stops the others: the survivors of a pick go to the queue together, and the failures stay on screen as honest rows. Too large for a browser import is terminal — no retry button, because a refetch cannot shrink it. A broken or expired direct link IS retryable, and its retry re-fetches from Dropbox rather than pretending to retry an upload that never started.

The ceiling neither door gets to invent

The 200 MB browser-import ceiling used to live inside the Drive door (DRIVE_MAX_BROWSER_BYTES). A second door is exactly the moment a constant like that drifts: two doors, two ceilings, two different answers to the same question. So it moved to src/lib/matters/browser-import.ts, which both doors now import. A file too large from Drive is exactly as too large from Dropbox — the limit is a property of the browser holding the bytes, not of the cloud that sent them.

Degrading honestly when Dropbox is not configured

The door needs one value: NEXT_PUBLIC_DROPBOX_APP_KEY. With it unset (as in most local .env.local files today), the button is not rendered at all — the drawer simply has three buttons instead of four, and nothing else in it changes. A dead button that opens a broken popup is worse than no button.

Security surface

Concern Where it is answered
Tokens None exist. The Chooser authenticates inside its own popup and returns links, not credentials. Nothing is persisted, nothing is sent to a PRAMAAN server, and there is no refresh token by construction.
Reach The direct links cover exactly the files counsel picked, and they expire on their own (~4h). PRAMAAN never gains standing access to a Dropbox account.
Third-party JSON The Chooser’s file payload is runtime-parsed with a Zod schema before we trust it (HR-21 in spirit; assertContract is server-only, and this is Dropbox’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 file must not lose the rest of counsel’s selection.
CSP Three directives had to move together or the door fails silently: script-src (www.dropbox.com, for dropins.js), frame-src (the Chooser’s iframe fallback), and connect-src (dl.dropboxusercontent.com, where the direct links serve the bytes). Edited inside buildContentSecurityPolicy() — the served header (LI-5).

What to look at

File Why
src/lib/dropbox/dropbox-config.ts The typed config gate. Returns null — not a half-config — when Dropbox cannot work.
src/lib/dropbox/dropbox-chooser.ts Script-tag loading (cached), the Chooser call, and the Zod parse of what it hands back. Unit-tested.
src/lib/dropbox/dropbox-fetch.ts Ten lines: fetch the direct link, wrap the blob as a File. No token, no 401 dance. Unit-tested.
src/lib/matters/browser-import.ts The shared 200 MB ceiling both remote doors obey.
src/components/matters/use-dropbox-chooser.ts The pre-queue rows and their fate. The only place that knows Dropbox exists at all.
src/components/matters/import-into-matter-sheet-v2.tsx One more button, one more row source, one more outside-click veto. The drawer’s grammar is otherwise untouched.

Out of scope, on purpose

OneDrive stays deferred (Epic-2384: publisher-verification friction; it waits for a pilot M365 firm to ask). Folder pick is not a feature the Chooser has — it is files-only, exactly as Drive’s drive.file scope makes folder import a picker gesture rather than a capability.

Files changed

14 file(s), +749 / -28 lines

Path Role + -
messages/en.jsonConfig+5-1
messages/hi.jsonConfig+5-1
public/brand/dropbox.svgSource+3-0
src/components/matters/import-into-matter-sheet-v2.stories.tsxComponent+120-0
src/components/matters/import-into-matter-sheet-v2.tsxComponent+74-12
src/components/matters/use-dropbox-chooser.tsComponent+191-0
src/lib/csp.tsLibrary+13-0
src/lib/dropbox/dropbox-chooser.test.tsLibrary+99-0
src/lib/dropbox/dropbox-chooser.tsLibrary+107-0
src/lib/dropbox/dropbox-config.tsLibrary+26-0
src/lib/dropbox/dropbox-fetch.test.tsLibrary+47-0
src/lib/dropbox/dropbox-fetch.tsLibrary+32-0
src/lib/google/drive-fetch.tsLibrary+6-14
src/lib/matters/browser-import.tsLibrary+21-0