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.

Define an upload

Put feature-owned upload definitions under features/<feature>/uploads/:

// features/posts/uploads/attachment.ts
import { defineUpload } from "@beignet/core/uploads";
import { z } from "zod";
import type { AppContext } from "@/app-context";

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

export const PostAttachmentUpload = defineUpload<
  "posts.attachment",
  typeof Metadata,
  AppContext,
  { attachmentIds: string[] }
>("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 extension = file.name.split(".").pop();
    return `posts/${tenantId}/${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.create({
          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.

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,
});

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 { server } from "@/server";

const uploadRouter = createUploadRouter({
  uploads: uploadsFromRegistry(postUploads),
  ctx: () => server.createContextFromNext(),
  storage: server.ports.storage,
  instrumentation: server.ports.devtools,
});

export const { POST } = createUploadRoute(uploadRouter);

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, then run onComplete.

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/uploads.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.

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 { createS3UploadSigner } from "@beignet/provider-storage-s3";

const uploadRouter = 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.

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, and size, then writes accepted files through ctx.ports.storage.

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. Model them as app-owned attachment status, jobs, events, or provider adapters that run after the object exists. The upload primitive keeps the boundary focused on validated object creation and completion hooks.