Uploads

Uploads are typed application workflows above StoragePort. Use them when a route needs file constraints, authorization, metadata validation, storage keys, and completion behavior in one predictable place.

StoragePort stores objects. Upload definitions decide who may upload, what files are accepted, where objects are written, and what app records or audit events are created after upload completion.

Generate an upload workflow

Generate the backend definition, registry, route, local storage wiring, and a client-safe constraint manifest:

beignet make upload posts.attachment

In a full-stack app, add --ui to generate the connected browser workflow as well:

beignet make upload posts.attachment --ui

The UI option adds features/posts/client/attachment-upload.ts, features/posts/components/attachment-uploader.tsx, and features/posts/tests/attachment-uploader.test.tsx. The uploader derives its file input hints from the same feature-root manifest used by the server definition, and includes progress, cancellation, error, reset, and completion states. API-only apps support the backend command but reject --ui before writing files.

Define an upload

Create the app-bound defineUpload builder once in lib/uploads.ts with createUploads<AppContext>() (see app-bound builders), then put feature-owned upload definitions under features/<feature>/uploads/:

// features/posts/uploads/attachment.ts
import { z } from "zod";
import { defineUpload } from "@/lib/uploads";

const Metadata = z.object({
  postSlug: z.string().min(1),
});

export const PostAttachmentUpload = defineUpload("posts.attachment", {
  metadata: Metadata,
  file: {
    contentTypes: ["application/pdf", "text/plain"],
    maxSizeBytes: 5 * 1024 * 1024,
    maxFiles: 3,
    visibility: "private",
    cacheControl: "private, max-age=0",
  },
  authorize({ ctx }) {
    return ctx.actor.type === "user";
  },
  key({ ctx, metadata, uploadId, file }) {
    const tenantId = ctx.tenant?.id ?? "default";
    const actorId = ctx.actor.type === "user" ? ctx.actor.id : "anonymous";
    const extension = file.name.split(".").pop();
    return `posts/${tenantId}/${actorId}/${metadata.postSlug}/${uploadId}.${extension}`;
  },
  storageMetadata({ ctx, metadata }) {
    return {
      tenantId: ctx.tenant?.id ?? "default",
      postSlug: metadata.postSlug,
    };
  },
  async onComplete({ ctx, metadata, files }) {
    const attachments = await Promise.all(
      files.map((file) =>
        ctx.ports.postAttachments.upsertByUploadId({
          id: file.uploadId,
          tenantId: ctx.tenant?.id ?? "default",
          postSlug: metadata.postSlug,
          key: file.key,
          fileName: file.name,
          contentType: file.contentType,
          size: file.object.size,
        }),
      ),
    );

    await ctx.ports.audit.record({
      action: "posts.attachment.upload",
      actor: ctx.actor,
      tenant: ctx.tenant,
      requestId: ctx.requestId,
      resource: { type: "post", id: metadata.postSlug },
      metadata: { attachmentCount: attachments.length },
    });

    return { attachmentIds: attachments.map((item) => item.id) };
  },
});

The completion hook is where app-owned database records, audit entries, domain events, jobs, notifications, and scanning state belong. Beignet does not create a framework upload table.

Direct-upload completion is intentionally stateless: Beignet does not retain an issuance record or single-use marker between prepare and complete. Authorize every request, include the relevant actor, tenant, or resource owner in key(...), and make onComplete(...) idempotent by upload ID or object key. Back the app record with a unique constraint or upsert so replaying complete does not duplicate records or side effects. Apps that require single-use issuance or revocation should store that state in an app-owned table.

Uploads are protected by default. A definition must either provide authorize(...) or explicitly opt into public preparation with access: "public". Use public access only for workflows where anonymous callers are allowed to write the configured object keys.

Collect feature uploads in a registry:

// features/posts/uploads/index.ts
import { defineUploads } from "@beignet/core/uploads";
import { PostAttachmentUpload } from "./attachment";

export const postUploads = defineUploads({
  postAttachment: PostAttachmentUpload,
});

Names vs registry keys

Upload routes and clients resolve the defineUpload(...) name, such as "posts.attachment" — not the defineUploads({...}) registry key, such as postAttachment. Registry keys only organize the registry object. Requesting an unknown name returns UPLOAD_NOT_FOUND with the registered names listed, and createUploadRouter(...) throws at construction when two definitions share the same name.

Expose the route

Use a focused Next.js route for uploads:

// app/api/uploads/[uploadName]/[action]/route.ts
import { createUploadRouter, uploadsFromRegistry } from "@beignet/core/uploads";
import { createUploadRoute } from "@beignet/next";
import { postUploads } from "@/features/posts/uploads";
import { getServer } from "@/server";

export const { POST } = createUploadRoute(async () => {
  const server = await getServer();

  return createUploadRouter({
    uploads: uploadsFromRegistry(postUploads),
    ctx: () => server.createContextFromNext(),
    storage: server.ports.storage,
    limits: {
      jsonMaxBytes: 256 * 1024,
      multipartMaxBytes: 25 * 1024 * 1024,
    },
    instrumentation: server.ports.devtools,
  });
});

The action segment is one of:

ActionPurpose
prepareValidate metadata and file intent, authorize the upload, compute keys, and return direct-upload instructions when a signer is configured.
uploadAccept a server-handled multipart upload and write files through StoragePort.
completeVerify direct-uploaded objects exist and match the prepared file before running verifyFile and onComplete.

Verification

Upload definitions always validate file count, content type, size, and authorization before writing app records. For supported binary types, Beignet also checks the declared content type against the file signature instead of trusting only browser metadata. Signature verification currently covers PDF, ZIP, GIF, JPEG, PNG, SVG, and WebP. Set contentTypeVerification: false only when a workflow intentionally accepts one of those media types without matching bytes.

export const PostImageUpload = defineUpload("posts.image", {
  metadata: Metadata,
  file: {
    contentTypes: ["image/png", "image/jpeg", "image/webp"],
    maxSizeBytes: 5 * 1024 * 1024,
    contentTypeVerification: "signature",
  },
  authorize({ ctx }) {
    return ctx.actor.type === "user";
  },
  key({ ctx, metadata, uploadId }) {
    const actorId = ctx.actor.type === "user" ? ctx.actor.id : "anonymous";
    return `posts/${actorId}/${metadata.postSlug}/images/${uploadId}`;
  },
});

Direct uploads can require a browser-computed SHA-256 checksum. When a signer is configured and checksum.required is not false, prepare and complete require the checksum, and completion reads the stored object bytes before running onComplete.

export const PostAttachmentUpload = defineUpload("posts.attachment", {
  metadata: Metadata,
  file: {
    contentTypes: ["application/pdf", "text/plain"],
    maxSizeBytes: 5 * 1024 * 1024,
    checksum: { algorithm: "sha256" },
  },
  authorize({ ctx }) {
    return ctx.actor.type === "user";
  },
  key({ ctx, metadata, uploadId }) {
    const actorId = ctx.actor.type === "user" ? ctx.actor.id : "anonymous";
    return `posts/${actorId}/${metadata.postSlug}/attachments/${uploadId}`;
  },
});

Checksum-enabled browser clients need a client-safe manifest so Beignet can know which uploads require a digest before prepare:

// client/upload-manifest.ts
import type { UploadManifestEntry } from "@beignet/core/uploads";

export const uploadManifest = [
  {
    name: "posts.attachment",
    file: {
      contentTypes: ["application/pdf", "text/plain"],
      maxSizeBytes: 5 * 1024 * 1024,
      checksum: { algorithm: "sha256" },
    },
  },
] satisfies UploadManifestEntry[];
// client/index.ts
import { createUploadClient } from "@beignet/core/uploads/client";
import type { postUploads } from "@/features/posts/uploads";
import { uploadManifest } from "./upload-manifest";

type AppUploads = typeof postUploads;

export const uploads = createUploadClient<AppUploads>({
  baseUrl: "/api/uploads",
  manifest: uploadManifest,
});

Use verifyFile(...) for app-owned scanning, moderation, or quarantine decisions that need the object to exist in storage before records are created. The hook runs after Beignet's built-in object verification and before onComplete.

export const PostAttachmentUpload = defineUpload("posts.attachment", {
  metadata: Metadata,
  file: {
    contentTypes: ["application/pdf"],
    maxSizeBytes: 5 * 1024 * 1024,
  },
  authorize({ ctx }) {
    return ctx.actor.type === "user";
  },
  key({ ctx, metadata, uploadId }) {
    const actorId = ctx.actor.type === "user" ? ctx.actor.id : "anonymous";
    return `posts/${actorId}/${metadata.postSlug}/attachments/${uploadId}`;
  },
  async verifyFile({ ctx, file }) {
    const scan = await ctx.ports.fileScanner.scanObject(file.key);

    return scan.clean
      ? true
      : {
          valid: false,
          reason: "Upload did not pass scanning.",
          details: { scanner: scan.provider, finding: scan.finding },
        };
  },
  async onComplete({ ctx, files }) {
    await ctx.ports.postAttachments.upsertByUploadId({
      id: files[0]!.uploadId,
      key: files[0]!.key,
    });
  },
});

Use the upload client

Create a browser client typed by the upload registry. Import the registry as a type so client code does not bundle server-only upload hooks:

// client/index.ts
import { createUploadClient } from "@beignet/core/uploads/client";
import type { postUploads } from "@/features/posts/uploads";

type AppUploads = typeof postUploads;

export const uploads = createUploadClient<AppUploads>({
  baseUrl: "/api/uploads",
});

Upload by route name:

const result = await uploads.upload("posts.attachment", {
  metadata: { postSlug: "hello-world" },
  files: [file],
  strategy: "auto",
  onProgress({ progress }) {
    console.log(Math.round(progress * 100));
  },
});

upload(...) uses direct upload instructions when the route returns them and falls back to server-handled multipart upload otherwise. Use direct(...) when direct upload is required, or server(...) when a form should always stream through the app server.

Progress reporting depends on the transport. Direct uploads report real per-file progress from the browser's XMLHttpRequest upload events. Server-handled uploads stream the whole multipart request through the app server and only report request completion, so onProgress fires once with progress: 1 when the request finishes.

React components

React apps can wrap the typed upload client with @beignet/react-uploads to track status, progress, errors, aborts, and completion results:

// client/uploads.ts
import { createUploadClient } from "@beignet/core/uploads/client";
import { createReactUploads } from "@beignet/react-uploads";
import type { postUploads } from "@/features/posts/uploads";

type AppUploads = typeof postUploads;

export const uploads = createUploadClient<AppUploads>({
  baseUrl: "/api/uploads",
});

export const reactUploads = createReactUploads({
  uploads,
});
const attachment = reactUploads.useUpload("posts.attachment");

attachment.upload({
  metadata: { postSlug: "hello-world" },
  files,
});

upload(...) is fire-and-forget and never rejects; failures land in hook state and onError. Use uploadAsync(...) when the caller needs the completion result or a rejecting promise.

See React uploads for hook state and callback details.

Direct uploads

Direct uploads use an UploadSignerPort. The S3-compatible provider includes a signer for AWS S3, Cloudflare R2, MinIO, Spaces, and similar services:

import { createUploadRouter, uploadsFromRegistry } from "@beignet/core/uploads";
import { createUploadRoute } from "@beignet/next";
import { createS3UploadSigner } from "@beignet/provider-storage-s3";
import { postUploads } from "@/features/posts/uploads";
import { getServer } from "@/server";

export const { POST } = createUploadRoute(async () => {
  const server = await getServer();

  return createUploadRouter({
    uploads: uploadsFromRegistry(postUploads),
    ctx: () => server.createContextFromNext(),
    storage: server.ports.storage,
    signer: createS3UploadSigner({
      bucket: env.STORAGE_S3_BUCKET,
      region: env.STORAGE_S3_REGION,
      endpoint: env.STORAGE_S3_ENDPOINT,
      credentials: {
        accessKeyId: env.STORAGE_S3_ACCESS_KEY_ID,
        secretAccessKey: env.STORAGE_S3_SECRET_ACCESS_KEY,
      },
      keyPrefix: env.STORAGE_S3_KEY_PREFIX,
    }),
  });
});

The upload client handles the direct flow for browser code: it calls prepare, PUTs each file to the returned provider URL with the returned headers, then calls complete with the prepared file metadata. Completion re-checks the object key, content type, declared size, maxSizeBytes, supported content signatures, and configured checksums before running verifyFile and onComplete.

The upload router limits prepare and complete JSON bodies to 256 KiB by default. Override limits.jsonMaxBytes if metadata is larger.

Server uploads

Server uploads use multipart/form-data and are useful for small forms, local development, and tests:

await uploads.server("posts.attachment", {
  metadata: { postSlug: "hello-world" },
  files: [file],
});

The router parses metadata, validates file count, content type, size, supported content signatures, and configured checksums, then writes accepted files through ctx.ports.storage. Server-handled multipart uploads reject declared Content-Length values over limits.multipartMaxBytes, then enforce the same limit while reading the actual request stream before calling formData(). The limit therefore applies to chunked requests and requests without a length header. The default is 25 MiB.

Error codes

On the server, upload failures throw condition-specific subclasses of UploadErrorUploadNotFoundError, InvalidUploadActionError, InvalidUploadMetadataError, InvalidUploadFileError, UnauthorizedUploadError, UploadObjectNotFoundError, UploadBodyTooLargeError, and InvalidUploadBodyError — each carrying its code literal and HTTP status. Catch the base UploadError for blanket handling or a subclass for one condition; UploadError itself is not constructed directly.

Upload routes use the standard flat Beignet error body with an optional details value:

{
  "code": "INVALID_UPLOAD_METADATA",
  "message": "Invalid metadata for upload \"posts.attachment\".",
  "details": { "issues": [] }
}
CodeStatusMeaning
UPLOAD_NOT_FOUND404No upload is registered under the requested defineUpload(...) name. The message lists the registered names.
INVALID_UPLOAD_ACTION400The route action segment is not prepare, upload, or complete.
INVALID_UPLOAD_BODY400The request body is not valid JSON, is missing a files array, contains non-object file entries, omits uploadId or key on completed files, or a multipart upload has no file field. Shape problems include details.issues.
INVALID_UPLOAD_METADATA422Metadata failed the upload's schema. details.issues carries the schema issues.
INVALID_UPLOAD_FILE413, 415, or 422File count, size (413), content type/signature (415), checksum, scanner, or completed-object verification failed (422).
UNAUTHORIZED_UPLOAD403The upload omitted authorization, or its authorize hook denied the request.
UPLOAD_OBJECT_NOT_FOUND404Completion could not find the direct-uploaded object in storage.
UPLOAD_BODY_TOO_LARGE413The upload route body exceeded the configured JSON or multipart body limit.

The typed upload client and @beignet/react-uploads surface these as UploadClientError values with the same code, status, and details.

Testing

Use memory storage and the memory signer in tests:

import {
  createMemoryUploadSigner,
  createUploadRouter,
  uploadsFromRegistry,
} from "@beignet/core/uploads";
import { createMemoryStorage } from "@beignet/core/ports";
import { postUploads } from "@/features/posts/uploads";

const router = createUploadRouter({
  uploads: uploadsFromRegistry(postUploads),
  ctx,
  storage: createMemoryStorage(),
  signer: createMemoryUploadSigner(),
  id: () => "upload_1",
});

const prepared = await router.prepare("posts.attachment", {
  metadata: { postSlug: "hello-world" },
  files: [{ name: "note.txt", contentType: "text/plain", size: 5 }],
});

Use app-owned fake repositories to assert attachment rows, audit entries, and events created by onComplete.

Scanning and quarantine

Virus scanning, malware detection, moderation, and quarantine are app or provider concerns. Use verifyFile(...) to block completion synchronously when that is appropriate. If verification rejects a server-handled multipart upload, Beignet deletes all objects stored by that request before returning the error; cleanup failures appear as upload.server.cleanup.failed instrumentation. Direct-upload completion does not delete pre-existing objects, so lifecycle expiration remains necessary for abandoned or rejected direct uploads. For asynchronous scanners, create an attachment row with a pending or quarantined status in onComplete, dispatch a job, and publish the object only after the app-owned scanner marks it clean.