MainSDK
Search⌘K
Interfaces

Files, sharing and media

File uploads

files

Upload files through one interface over getqr.id, S3 or local disk. Every file is scanned, private to its owner and read through signed, short-lived URLs.

Building for 0.3ships as a stable APIPhase 1 · Core, public betaSpec files-shareFree in your app, Apache-2.0

Status Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.

Stability The promise an interface ships with.

  • Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
  • Beta Usable in production; may change before it becomes stable.
  • Experimental May change or go away; needs an explicit opt-in in main.yaml.
Stability levels
Updated 2026-10-28

files stores what your users upload and returns it through short-lived signed URLs. Every file is scanned and private to its owner. Reach for it whenever a user attaches a photo or a document.

At a glance

files:
  driver: s3
  bucket: acme-uploads
  region: eu-west-1
  key: secret:S3_SECRET_ACCESS_KEY
  scan: true
  maxSize: 25MB

Calls

  • files.upload()
  • files.uploadUrl()
  • files.complete(id, { owner })
  • files.url()
  • files.get(id)
  • handleFilesRoute(files, req)
  • signatureScan(bytes)
  • files.accept(idOrKey, token, { bytes, type, signal? })
  • files.checkReadUrl(url)
  • files.read(path)
  • files.scanResult(id, verdict)
  • files.sign(s)
  • files.verify(s, sig)
  • files.delete(ref, { owner })
  • files.driver

Guarantees

Whatever the driver.

  • Files readable only by their owner or whom access allows
  • Scanned before they become readable
  • Resumable, idempotent uploads
  • Browser uploads go straight to the store on a signed, single-use URL
  • Every upload goes through files, never a storage SDK in your route code

Drivers

5 drivers. Select one to see its line in main.yaml.

main.yamlgetqr.id (default)
files:
  driver: getqr

Change this one line to swap providers; your calls stay the same.getqr.id integration

Codes

CodeWhere it appearsMeaning
MAIN-FILES-0010Checkmainsdk check, before deployA storage SDK used for user uploads outside a files driver, for example @vercel/blob in route code
MAIN-FILES-0016Checkmainsdk check, before deployThe configured files driver cannot meet a guarantee, named by its id in the message (a warning, so the run still passes): uploadthing declares upload-single-use and data-region
MAIN-FILES-0002RuntimeError responses and logsaccess denied this user a file URL (403), or someone other than the uploader called files.complete()
MAIN-FILES-0005RuntimeError responses and logsfiles.complete() ran the scan and found an infected file; the upload fails and the file is quarantined, with no readable URL
MAIN-FILES-0003RuntimeError responses and logsA per-call maxSize is above files.maxSize
MAIN-FILES-0006RuntimeError responses and logsThe file is larger than the upload allows (413); it is removed and never readable
MAIN-FILES-0004RuntimeError responses and logsAn upload target is invalid, already used or older than 15 minutes (403)
MAIN-FILES-0007RuntimeError responses and logsThe file's type isn't one the upload allows (415); it is removed and never readable
MAIN-FILES-0008RuntimeError responses and logsfiles.complete() ran before any bytes reached the store (409)
MAIN-FILES-0011RuntimeError responses and logsThe s3 driver found no credentials: no keys in main.yaml and no runtime role
MAIN-FILES-0012RuntimeError responses and logsfiles.upload() from the server isn't supported by the s3 driver yet
MAIN-FILES-0013RuntimeError responses and logsThe store answered with an error (502)
MAIN-FILES-0014RuntimeError responses and logsfiles.driver names a driver that isn't built yet
MAIN-FILES-0015RuntimeError responses and logsThe getqr.id API has no endpoint for this step, such as downloading or deleting stored bytes (501)

Features

Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.

FeaturePhaseMilestoneSpec ticket
files@1 contract on the getqr.id OpenAPIPhase 10.3files-share#contract
Upload and signed URLs with owner scope and scanningPhase 10.3files-share#upload
Resumable, idempotent uploadsPhase 10.3files-share#resumable
Upload component from @getqr/reactPhase 10.3files-share#react
Check against storage SDKs used directlyPhase 10.3files-share#check-rule
Local driverPhase 10.3files-share#local-driver
Direct browser uploads on signed URLsPhase 10.3files-share#direct-upload
S3 and S3-compatible driverPhase 10.3files-share#driver-s3
Uploadthing driverPhase 10.3files-share#driver-uploadthing
Vercel Blob driverPhase 10.3files-share#driver-vercel-blob

Quick start

Point files at a store, then upload and read. This runs on the local driver with no account.

main.yaml
files:
  driver: local
  max-size: 5MB

app/api/avatar/route.ts

import { auth, files } from "mainsdk";

export async function POST(req: Request) {
  const session = await auth.require(req);
  const file = (await req.formData()).get("file") as File;

  const ref = await files.upload(new Uint8Array(await file.arrayBuffer()), {
    owner: session.user,
    type: file.type,
    label: "personal",
  });
  const url = await files.url(ref, { for: session.user, expiresIn: "1h" });
  return Response.json({ ref, url });
}

files.upload() takes the bytes and their MIME type, scans them, and returns a FileRef with status: "ready". The URL is signed and stops working after an hour. For anything large, let the browser send the file with files.uploadUrl() (below).

Configuration

Every key lives under files:. With no driver, the app uses local.

main.yaml
files:
  driver: getqr                  # getqr | s3 | local   (uploadthing, vercel-blob: planned)
  key: secret:GETQR_API_KEY      # the driver's secret, always a secret reference
  scan: true                     # scan every upload before it becomes readable
  max-size: 25MB                  # largest file accepted
  types: [image/*, application/pdf]   # allowed MIME types; empty allows any
KeyTypeWhat it does
driverstringgetqr, s3 or local. uploadthing and vercel-blob are planned
keysecret referenceThe driver's secret. A literal value fails validation with MAIN-CONFIG-0004
scanbooleanOn by default. files.complete() scans before the file is readable; an infected file is quarantined and the upload fails
max-sizesizeLargest upload accepted, default 25MB. A call may ask for less, never more (MAIN-FILES-0003)
typeslist of MIME typesTypes an upload accepts unless a call passes its own types. image/* matches a family
signing-keysecret referenceSigns upload tokens and read URLs. Set it when several instances serve the same files; otherwise each process has its own
base-urlURLgetqr only: the getqr.id API origin, for a sandbox
bucket, region, endpoint, prefixstrings3 only: the bucket, its region (default us-east-1), the endpoint of an S3-compatible store ({bucket} is replaced) and a key prefix
access-key-idstrings3 only: the access key identifier (not a secret). Set it with key. With both unset, the driver uses the runtime's IAM role

Secret references match secret:[A-Z0-9_]+ and resolve through secrets on the server.

Config for each driver

getqr.id (default)
files: { driver: getqr, key: secret:GETQR_API_KEY, scan: true, max-size: 25MB }
Amazon S3 or an S3-compatible store (R2, MinIO, B2)
files: { driver: s3, bucket: acme-uploads, region: eu-west-1, access-key-id: AKIA…, key: secret:S3_SECRET_ACCESS_KEY, scan: true }
# on AWS, omit access-key-id and key to use the runtime's IAM role
# R2, MinIO and B2 also set: endpoint: https://<their-endpoint>
Local disk (development and tests)
files: { driver: local }   # stores under .mainsdk/files

Setup for each store is on its page: getqr.id and Amazon S3.

Choosing a driver

DriverWhen to pick itNotes
getqrThe default, and the one HubXero products use. You want uploads, share links and QR codes in one place.Needs a getqr.id API key. getqr.id scans server side, so files.complete() reads its verdict. The server cannot read the bytes back (files.read() gives MAIN-FILES-0015); serve them with files.url(). The upload target is a tus endpoint (PATCH), so the browser needs a tus-aware client.
s3You already have a bucket, need its region, or want files in your own cloud account. Works with R2, MinIO and B2.Keep Block all public access on. Browser uploads need a CORS rule on the bucket. Send files with files.uploadUrl(); a server-side files.upload() gives MAIN-FILES-0012.
localDevelopment and tests, with no account.Not for production: mainsdk check --env prod fails with MAIN-CHECK-0026.
uploadthing, vercel-blobPlanned.Not built: MAIN-FILES-0014.

Every driver keeps the same guarantees (ADR-0013): the SDK enforces owner scope, signed single-use upload targets, the scan and short-lived read URLs; drivers only move bytes.

Overlays by environment

Use the local driver in development so you need no account with any store:

main.env.dev.yaml
files:
  driver: local

See main.yaml for overlay order and merge rules.

What you get

files stores the files your users upload and gives them back through short-lived signed URLs. You choose the store in main.yaml: getqr.id (the default), your own S3 bucket or an S3-compatible store, or local disk for development. Whichever you choose, uploads go through files, are scanned before anyone can read them, and belong to their owner: only the owner, or someone access allows, can read one. An idempotency key makes a retried upload return the same file. Reach for it whenever a user attaches a photo, a document or a proof video. mainsdk check fails if route code uses a storage SDK directly.

Examples

Resumable upload with an idempotency key

Pass an idempotency key. A retry with the same key and owner returns the same file instead of a second one.

app/claims/actions.ts
"use server";
import { auth, files } from "mainsdk";
import type { FileRef } from "mainsdk/files";

declare function saveProof(user: string, claimId: string, proof: FileRef): Promise<void>;   // your own record

export async function attachProof(req: Request, claimId: string, video: Uint8Array) {
  const session = await auth.require(req);
  const ref = await files.upload(video, {
    owner: session.user,
    type: "video/mp4",
    label: "personal",
    idempotencyKey: `claim:${claimId}:proof`,   // same key on retry
  });
  await saveProof(session.user, claimId, ref);
  return ref;
}

Upload straight from the browser

For large files, let the browser send the bytes to the store and keep them off your server. files.uploadUrl() returns an Upload: method (PUT or POST), a signed single-use url, headers or fields, and a pending FileRef in upload.ref. The file is not readable until files.complete() has scanned it. files.complete() takes the owner too, and refuses anyone but the uploader with MAIN-FILES-0002. An upload target works once and expires in 15 minutes.

app/api/uploads/route.ts
import { auth, files } from "mainsdk";

export async function POST(req: Request) {
  const session = await auth.require(req);
  const upload = await files.uploadUrl({
    owner: session.user,
    maxSize: "5MB",
    types: ["image/png", "image/jpeg"],
  });
  return Response.json(upload);
}
app/api/uploads/[id]/complete/route.ts
import { auth, files } from "mainsdk";

export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
  const session = await auth.require(req);
  const { id } = await params;                                   // upload.ref.id
  const ref = await files.complete(id, { owner: session.user }); // only the uploader can finish it
  const url = await files.url(ref, { for: session.user, expiresIn: "1h" });
  return Response.json({ url });
}

The browser sends the file to upload.url with upload.method: for PUT, the bytes as the body with upload.headers; for POST, a form with upload.fields first and the file last. Then it calls the complete route with upload.ref.id. The store refuses a file over maxSize (MAIN-FILES-0006, 413) or outside types (MAIN-FILES-0007, 415), and a used or expired target fails with MAIN-FILES-0004. The full browser function is in Upload files with S3 or getqr.id.

Upload from a form with the Upload component

With the getqr.id driver, <Upload/> from @getqr/react handles the browser side (drop zone, paste, camera, progress and the scan) and shows "Powered by getqr.id". Its props belong to @getqr/react.

app/claims/new/upload-proof.tsx
"use client";
import { Upload } from "@getqr/react";

export function UploadProof() {
  return <Upload accept={["video/*", "image/*"]} onError={(err) => console.error(err)} />;
}

Let a reviewer read a file

files.url() checks access for the reader. If the decision denies them, it throws MAIN-FILES-0002 with status 403. The action is files.read, so a role rule such as support: [files.read] lets support staff read any file.

app/api/claims/[id]/proof/route.ts
import { auth, files } from "mainsdk";
import type { FileRef } from "mainsdk/files";

declare function proofOf(user: string, claimId: string): Promise<FileRef>;   // your own record

export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const session = await auth.require(req, { role: "reviewer" });
  const proof = await proofOf(session.user, id);
  const url = await files.url(proof, { for: session.user, expiresIn: "1h" });
  return Response.redirect(url, 302);
}

Read the bytes on the server

files.read(ref, { for }) returns { bytes, type } for the owner or an allowed reader, for server code that processes a file (for example documents). It needs a store that serves bytes to the server: local and s3 do, getqr does not (MAIN-FILES-0015).

lib/read-file.ts
import { files } from "mainsdk";

export async function textOf(fileId: string, userId: string) {
  const { bytes, type } = await files.read(fileId, { for: { id: userId } });
  return { type, text: new TextDecoder().decode(bytes) };
}

Look up a file

files.get(id) returns a file's current record (status, owner, size, type) or undefined for an unknown id. It is synchronous and never returns the bytes.

lib/file-status.ts
import { files } from "mainsdk";

export function isReady(fileId: string) {
  return files.get(fileId)?.status === "ready";
}

Reference: driver and route calls

These calls sit under the upload and read flow. Your routes seldom need them, but drivers, adapters and tests do.

CallWhat it does
files.accept(idOrKey, token, { bytes, type, signal? })Accept the bytes for an upload target from files.uploadUrl(). It checks the token, the expiry, single use, the size and the type, and rejects an aborted signal. The positional form accept(id, token, bytes, type) is deprecated and goes in 1.0.
files.scanResult(id, verdict)Record a store's scan verdict that arrived by webhook (getqr.id upload.scanned), so files.complete() needn't poll. { status: "ready", size?, type? } makes the file readable when its size and type fit. { status: "blocked" } quarantines it. Returns the file's tenant, or undefined when no such file is pending.
files.checkReadUrl(url)Check a local read URL's signature and expiry. Returns the file id when it is valid, else null.
files.sign(s)Sign a string with the files key (HMAC-SHA256, base64url).
files.verify(s, sig)Compare a signature made by files.sign in constant time.
files.delete(ref, { owner })Delete a file and its stored bytes. ref is a FileRef or an id, and only the file's owner may delete it. The decision is written to the audit log.
files.driverThe driver object behind the interface, read-only. Its id is the driver name. For adapters and tests.
handleFilesRoute(files, req)Serve the local and memory drivers' routes under /_mainsdk/files: PUT .../upload accepts bytes, and GET .../read returns them for a valid signed URL. Any other request gets 404. Adapters mount it for you.
signatureScan(bytes)The built-in scan. It resolves "infected" for the EICAR test signature and "clean" otherwise. Pass a real engine as scanner to replace it.

Errors and checks

CodeWhenFix
MAIN-FILES-0002files.url() or files.read() for a reader access denies, or files.complete() by someone other than the uploader (403)Read as the owner, or add an access rule for the reader
MAIN-FILES-0003A call's maxSize is above files.maxSize (400)Lower it in the call, or raise files.maxSize on purpose
MAIN-FILES-0004An upload target is invalid, already used or older than 15 minutes (403)Ask the server for a new one with files.uploadUrl()
MAIN-FILES-0005The scan found an infected file; the upload fails, the file is quarantined and no URL existsAsk the user for a clean file
MAIN-FILES-0006 / 0007The file is larger than the upload allows (413), or its type is not allowed (415)Send a smaller file, or one of the allowed types
MAIN-FILES-0008files.complete() ran before any bytes reached the store (409)Send the file to upload.url first
MAIN-FILES-0010mainsdk check found a storage SDK imported directly in route code, for example @aws-sdk/client-s3Replace it with files.upload() or files.uploadUrl(), or set that store as the files driver
MAIN-FILES-0011 to 0015Driver problems: no S3 credentials (0011), server upload on S3 (0012), the store answered with an error (0013), a driver not built (0014), a step the getqr.id API lacks (0015)The fix in the problem names the change

The full list is in check codes. Run mainsdk explain MAIN-FILES-0005 for the cause and fix offline.

Swapping drivers

Switch one line; files.upload(), files.uploadUrl(), files.complete(), files.url() and files.read() stay the same.

main.yaml
files:
  driver: s3   # was getqr

Existing files stay where they are until you migrate them. Sharing is separate from storage: share(), links and QR codes always use getqr.id, whichever files driver holds the file (see Share links and QR codes).

Testing

createTestApp() puts files on its in-memory fake. The fake enforces the same owner scope and scanning as production, and testApp.files.put(upload, bytes, { type }) plays the browser's part in the direct flow. The built-in scan refuses the EICAR test file.

app/api/uploads/uploads.test.ts
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { createTestApp } from "@mainsdk/testing";
import { files } from "mainsdk";

describe("files", () => {
  it("files-share AC1 refuses a reader access denies", async () => {
    const testApp = createTestApp({ main: "./main.yaml" });
    const upload = await files.uploadUrl({ owner: { id: "u1" }, maxSize: "5MB", types: ["image/png"] });
    await testApp.files.put(upload, new Uint8Array([0x89, 0x50, 0x4e, 0x47]), { type: "image/png" });
    const ref = await files.complete(upload.ref.id, { owner: { id: "u1" } });
    await assert.rejects(files.url(ref, { for: { id: "u2" } }), { code: "MAIN-FILES-0002" });
  });
});

Use clock.advance("61m") to expire a one-hour signed URL without waiting. The infected-file test is in the upload guide.

Security and privacy

  • Files are private to their owner. Readers go through an access decision, and URLs are signed and short-lived.
  • files.complete() scans each upload before it becomes readable; infected files are quarantined and audited.
  • Label personal files (label: "personal") so logs, errors and exports treat them as personal data.
  • With tenancy, a file of another tenant is refused with MAIN-TENANT-0005 before any owner check, and its object key carries the tenant's region.
  • Browser upload targets are single-use and limited in size and type, so a client cannot send a larger or different file than you allowed.
  • With the default driver you also get first-party tracking from getqr.id.

FAQ

Who scans the file, getqr.id or the SDK? With the getqr.id driver, getqr.id scans server side and files.complete() trusts its verdict. With the other drivers the SDK scans in files.complete(): the built-in scan matches the EICAR test signature, and a real engine plugs in through the scanner option when you build the app in code.

Can I keep using S3? Yes, as the files driver. What fails mainsdk check with MAIN-FILES-0010 is importing its SDK in route code instead of going through files.

How do I share a file with someone outside the app? Use share() to make a private, expiring getqr.id link.

Next

Was this page helpful?

↑↓ moveEnter openEsc close

Not decided yet