File uploads
filesUpload 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.
files-shareFree in your app, Apache-2.0Status 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.
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: 25MBimport { files } from "mainsdk";
const ref = await files.upload(file, { owner: user, label: "personal" });
const url = await files.url(ref, { for: user, expiresIn: "1h" });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" });
});
});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
Drivers
5 drivers. Select one to see its line in main.yaml.
files:
driver: getqrChange this one line to swap providers; your calls stay the same.getqr.id integration
Codes
| Code | Where it appears | Meaning |
|---|---|---|
MAIN-FILES-0010 | Checkmainsdk check, before deploy | A storage SDK used for user uploads outside a files driver, for example @vercel/blob in route code |
MAIN-FILES-0016 | Checkmainsdk check, before deploy | The 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-0002 | RuntimeError responses and logs | access denied this user a file URL (403), or someone other than the uploader called files.complete() |
MAIN-FILES-0005 | RuntimeError responses and logs | files.complete() ran the scan and found an infected file; the upload fails and the file is quarantined, with no readable URL |
MAIN-FILES-0003 | RuntimeError responses and logs | A per-call maxSize is above files.maxSize |
MAIN-FILES-0006 | RuntimeError responses and logs | The file is larger than the upload allows (413); it is removed and never readable |
MAIN-FILES-0004 | RuntimeError responses and logs | An upload target is invalid, already used or older than 15 minutes (403) |
MAIN-FILES-0007 | RuntimeError responses and logs | The file's type isn't one the upload allows (415); it is removed and never readable |
MAIN-FILES-0008 | RuntimeError responses and logs | files.complete() ran before any bytes reached the store (409) |
MAIN-FILES-0011 | RuntimeError responses and logs | The s3 driver found no credentials: no keys in main.yaml and no runtime role |
MAIN-FILES-0012 | RuntimeError responses and logs | files.upload() from the server isn't supported by the s3 driver yet |
MAIN-FILES-0013 | RuntimeError responses and logs | The store answered with an error (502) |
MAIN-FILES-0014 | RuntimeError responses and logs | files.driver names a driver that isn't built yet |
MAIN-FILES-0015 | RuntimeError responses and logs | The 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.
| Feature | Phase | Milestone | Spec ticket |
|---|---|---|---|
| files@1 contract on the getqr.id OpenAPI | Phase 1 | 0.3 | files-share#contract |
| Upload and signed URLs with owner scope and scanning | Phase 1 | 0.3 | files-share#upload |
| Resumable, idempotent uploads | Phase 1 | 0.3 | files-share#resumable |
| Upload component from @getqr/react | Phase 1 | 0.3 | files-share#react |
| Check against storage SDKs used directly | Phase 1 | 0.3 | files-share#check-rule |
| Local driver | Phase 1 | 0.3 | files-share#local-driver |
| Direct browser uploads on signed URLs | Phase 1 | 0.3 | files-share#direct-upload |
| S3 and S3-compatible driver | Phase 1 | 0.3 | files-share#driver-s3 |
| Uploadthing driver | Phase 1 | 0.3 | files-share#driver-uploadthing |
| Vercel Blob driver | Phase 1 | 0.3 | files-share#driver-vercel-blob |
Quick start
Point files at a store, then upload and read. This runs on the local driver with no account.
files:
driver: local
max-size: 5MBapp/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 });
}use mainsdk::prelude::*;
let app = App::test();
Context::new().tenant("tnt_a").user("u1").scope(async {
use mainsdk::files::Label;
let file = app.files().upload(b"hello".to_vec()).owner("u1").label(Label::Pii).await?;
assert_eq!(file.size, 5);
Ok::<_, MainError>(())
}).await?;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.
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| Key | Type | What it does |
|---|---|---|
driver | string | getqr, s3 or local. uploadthing and vercel-blob are planned |
key | secret reference | The driver's secret. A literal value fails validation with MAIN-CONFIG-0004 |
scan | boolean | On by default. files.complete() scans before the file is readable; an infected file is quarantined and the upload fails |
max-size | size | Largest upload accepted, default 25MB. A call may ask for less, never more (MAIN-FILES-0003) |
types | list of MIME types | Types an upload accepts unless a call passes its own types. image/* matches a family |
signing-key | secret reference | Signs upload tokens and read URLs. Set it when several instances serve the same files; otherwise each process has its own |
base-url | URL | getqr only: the getqr.id API origin, for a sandbox |
bucket, region, endpoint, prefix | string | s3 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-id | string | s3 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
files: { driver: getqr, key: secret:GETQR_API_KEY, scan: true, max-size: 25MB }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>files: { driver: local } # stores under .mainsdk/filesSetup for each store is on its page: getqr.id and Amazon S3.
Choosing a driver
| Driver | When to pick it | Notes |
|---|---|---|
getqr | The 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. |
s3 | You 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. |
local | Development and tests, with no account. | Not for production: mainsdk check --env prod fails with MAIN-CHECK-0026. |
uploadthing, vercel-blob | Planned. | 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:
files:
driver: localSee 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.
"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.
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);
}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.
"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.
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).
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.
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.
| Call | What 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.driver | The 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
| Code | When | Fix |
|---|---|---|
MAIN-FILES-0002 | files.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-0003 | A call's maxSize is above files.maxSize (400) | Lower it in the call, or raise files.maxSize on purpose |
MAIN-FILES-0004 | An upload target is invalid, already used or older than 15 minutes (403) | Ask the server for a new one with files.uploadUrl() |
MAIN-FILES-0005 | The scan found an infected file; the upload fails, the file is quarantined and no URL exists | Ask the user for a clean file |
MAIN-FILES-0006 / 0007 | The 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-0008 | files.complete() ran before any bytes reached the store (409) | Send the file to upload.url first |
MAIN-FILES-0010 | mainsdk check found a storage SDK imported directly in route code, for example @aws-sdk/client-s3 | Replace it with files.upload() or files.uploadUrl(), or set that store as the files driver |
MAIN-FILES-0011 to 0015 | Driver 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.
files:
driver: s3 # was getqrExisting 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.
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
accessdecision, 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-0005before 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
- Share links and QR codes: share a file or record as an expiring link.
- Images and video: resize and transcode uploads (planned).
- Permissions: decide who else can read a file.
Was this page helpful?