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
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
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.json | Config | +5 | -1 |
| messages/hi.json | Config | +5 | -1 |
| public/brand/dropbox.svg | Source | +3 | -0 |
| src/components/matters/import-into-matter-sheet-v2.stories.tsx | Component | +120 | -0 |
| src/components/matters/import-into-matter-sheet-v2.tsx | Component | +74 | -12 |
| src/components/matters/use-dropbox-chooser.ts | Component | +191 | -0 |
| src/lib/csp.ts | Library | +13 | -0 |
| src/lib/dropbox/dropbox-chooser.test.ts | Library | +99 | -0 |
| src/lib/dropbox/dropbox-chooser.ts | Library | +107 | -0 |
| src/lib/dropbox/dropbox-config.ts | Library | +26 | -0 |
| src/lib/dropbox/dropbox-fetch.test.ts | Library | +47 | -0 |
| src/lib/dropbox/dropbox-fetch.ts | Library | +32 | -0 |
| src/lib/google/drive-fetch.ts | Library | +6 | -14 |
| src/lib/matters/browser-import.ts | Library | +21 | -0 |