@nextyugai/drive-file-picker
v0.2.0
Published
Thin iframe wrapper for the NextYug Drive file picker. The only thing a client app installs.
Maintainers
Readme
@nextyugai/drive-file-picker
One npm package. Your app gets a file picker; NextYug Drive keeps the files.
Client apps implement no storage, no upload logic, and hold no storage credentials. The package is a thin iframe wrapper around the drive's own media manager, so there is one UI codebase and one place to fix bugs.
npm install @nextyugai/drive-file-pickerPublic on npmjs. No registry configuration, no token.
Release candidates go to the nextyug feed on Azure Artifacts first. Point the
@nextyugai scope at it in your .npmrc — safe to commit, because the token
comes from the environment:
@nextyugai:registry=https://pkgs.dev.azure.com/nextyug/_packaging/nextyug/npm/registry/
//pkgs.dev.azure.com/nextyug/_packaging/nextyug/npm/registry/:username=nextyug
//pkgs.dev.azure.com/nextyug/_packaging/nextyug/npm/registry/:_password=${AZURE_ARTIFACTS_NPM_TOKEN}
//pkgs.dev.azure.com/nextyug/_packaging/nextyug/npm/registry/:[email protected]AZURE_ARTIFACTS_NPM_TOKEN is an Azure DevOps PAT with Packaging (Read) scope,
base64-encoded.
Peer dependency: React 18+. ~3.3 KB gzipped. ESM + CJS, types included.
Setup, end to end
1. One env var on your backend
DRIVE_URL=http://localhost:4000 # dev — no cloud dependency
DRIVE_URL=https://drive.nextyug.ai # prod
NEXTYUG_DRIVE_APP_KEY=nyd_... # server-side only, never in a bundleGet the app key from a drive admin: Admin → Apps → New app. You give them the
origins your app runs on; they hand back an appId and a key shown exactly once.
2. One backend route
It exists so the app key never reaches a browser. Roughly fifteen lines:
// app/api/drive/session/route.ts (Next.js App Router)
export async function POST() {
const user = await getCurrentUser() // your existing auth
const res = await fetch(`${process.env.DRIVE_URL}/api/v1/connect`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.NEXTYUG_DRIVE_APP_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
externalTenantId: user.schoolId, // stable org id in YOUR app
tenantName: user.schoolName,
externalUserId: user.id,
userEmail: user.email,
userName: user.name,
scopes: ['read', 'write'],
pathPrefix: `${user.schoolId}/`, // optional hard scope
}),
})
// Return ONLY the session token to the browser.
return Response.json(await res.json())
}/api/v1/connect is idempotent. Call it on every login — the first call
provisions the org and its bucket, later calls return the same one. There is no
separate "already connected" path to maintain.
3. Wrap your app once
import { DriveProvider } from '@nextyugai/drive-file-picker'
<DriveProvider getSession={() => fetch('/api/drive/session', { method: 'POST' }).then(r => r.json())}>
{children}
</DriveProvider>The frontend needs zero drive config. The iframe origin comes from the connect response, so the iframe can never point somewhere the token didn't come from.
4. Pick files anywhere
import { FilePicker } from '@nextyugai/drive-file-picker'
<FilePicker
open={open}
onClose={() => setOpen(false)}
appId="app_a91f"
accept={['image/*', 'application/pdf']}
multiple
maxSize={25 * 1024 * 1024}
folder="students/photos"
onSelect={(files) => saveAvatarId(files[0].id)}
/>5. Render stored files
import { DriveImage, DriveFile } from '@nextyugai/drive-file-picker'
<DriveImage fileId={student.avatarId} alt="" />
<DriveFile fileId={doc.id}>
{({ url, missing }) =>
missing ? <span>Removed</span> : <a href={url ?? '#'}>Download</a>
}
</DriveFile>Store the id, never a URL
onSelect gives you:
type DriveFile = {
id: string // ← store THIS
name: string
mimeType: string
size: number
width?: number
height?: number
provider: 'nextyug' | 's3' | 'azure' | 'gdrive'
createdAt: string
}There is deliberately no url field. Signed URLs expire. If one were exposed
here, someone would write INSERT INTO students (avatar) VALUES (file.url), it
would pass review, work in testing, and break a week later — by which point it is
a data migration.
<DriveImage> and <DriveFile> fetch a fresh signed URL and refresh it before it
expires. Server-side, call GET /api/v1/files/:id/url.
A quick way to check you got this right:
grep -rE "signedUrl|drive.*\.url" src/ # should find no persisted URLsAPI
| Export | Purpose |
|---|---|
| <DriveProvider> | Holds the session token, refreshes before expiry, resolves the embed origin |
| <FilePicker> | Modal picker |
| <DriveImage> | Renders a stored fileId, auto-refreshing its signed URL |
| <DriveFile> | Render-prop version for non-images (downloads, video, PDF) |
| useDrive() | Session, embed origin, and getToken() for your own calls |
<FilePicker> props
| Prop | Type | Notes |
|---|---|---|
| open / onClose | boolean / () => void | You own the open state |
| onSelect | (files: DriveFile[]) => void | Fires once, then the modal closes |
| appId | string | Your registered app id — goes in the iframe URL |
| accept | string[] | e.g. ['image/*']. UX only — the drive re-validates on upload |
| multiple | boolean | Default false |
| maxSize | number | Bytes. UX only — enforced server-side too |
| folder | string | Opens the picker at this path |
| zIndex | number | Default 1000. Raise only if your app stacks higher |
| onError | (msg: string) => void | Session and upload failures |
Security model
- App key is server-side only. If a build can put it in a browser bundle, the design is wrong.
- Session tokens are short-lived (15 min), bound to one tenant, one user,
explicit scopes and an optional path prefix. The drive re-validates scope on
every call — nothing the iframe claims is trusted. A
readsession cannot upload; awritesession cannot delete. - The token is delivered by
postMessage, never as a URL query param. Query params leak into referrers, history and server logs. - Both sides check
event.originand reject silently otherwise. frame-ancestorsis an exact-match allowlist per app. No wildcards. An unregistered origin gets nothing — the embed refuses to render.- The iframe runs with
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"and nothing broader.
Deleted files
Deletes make GET /files/:id/url return 404, and <DriveImage> renders a
placeholder rather than a broken image. Broken references are expected and fail
gracefully — there is deliberately no cross-app reference counting, which would
be a distributed GC problem.
Local development
Point DRIVE_URL at a local drive instance. No cloud dependency, and no
frontend config change — the embed origin follows the connect response.
Not in v0
<DriveEmbed> (full manager iframe), useFilePicker() headless trigger,
connector/trash views, and Vue/Svelte bindings. Ask when a real project needs
one; DriveEmbed in particular will be designed better after one real
integration exists.
