Skip to content

Upload a file

Add a private attachment uploader to each Todo. Accept one PDF or plain-text file up to 5 MiB, verify that the signed-in user owns the Todo, and test that another user cannot upload to it.

Before you start

Use the Next.js and SQLite app from the quickstart. Run commands from its root with Bun. The generated local storage provider writes files under storage/app; this walkthrough needs no cloud storage account.

The result is a working upload flow. It stores the file and returns its object key. Listing attachments, downloading them, and removing them with a Todo need application-owned records and authorization; they are not added by this guide.

Generate the upload workflow

bash
bun beignet make upload todos.attachment --ui
bun install

The generator adds an upload definition, a browser-safe manifest, an upload route, storage wiring, and AttachmentUploader. Keep the generated registrations and the manifest's limits: application/pdf or text/plain, one file, 5 MiB maximum, and private visibility.

If your app already has a storage provider, inspect the existing configuration before changing it. The Uploads reference explains the generated files and alternative delivery modes.

Check ownership on the server

The browser supplies a Todo ID. Treat it as a lookup key, then check ownership against the stored Todo before accepting the file. Replace the generated definition:

typescript
// features/todos/uploads/attachment.ts
import { z } from "zod";
import { defineUpload } from "@/lib/uploads";
import { attachmentUploadFile } from "../attachment-upload-manifest";

export const AttachmentUploadMetadataSchema = z.object({
	resourceId: z.uuid(),
});

export type AttachmentUploadMetadata = z.infer<typeof AttachmentUploadMetadataSchema>;

export const AttachmentUpload = defineUpload("todos.attachment", {
	metadata: AttachmentUploadMetadataSchema,
	file: attachmentUploadFile,
	async authorize({ ctx, metadata }) {
		if (ctx.actor.type !== "user") return false;
		const todo = await ctx.ports.todos.findById(metadata.resourceId);
		return todo?.userId === ctx.actor.id;
	},
	key({ metadata, uploadId }) {
		return "todos/" + metadata.resourceId + "/" + uploadId;
	},
	storageMetadata({ metadata }) {
		return { resourceId: metadata.resourceId };
	},
	async onComplete({ ctx, metadata, files }) {
		const objectKeys = files.map((file) => file.key);
		ctx.ports.logger.info("Todo attachment uploaded", {
			resourceId: metadata.resourceId,
			objectKeys,
		});
		return { objectKeys };
	},
});

The object key uses the validated Todo ID and a generated upload ID, not a filename supplied by the browser. Returning { objectKeys } keeps the result compatible with the generated uploader.

Add the uploader to each Todo

Import AttachmentUploader and give it the row's Todo ID. Allow the row to wrap so the uploader sits beneath the existing controls:

features/todos/components/todo-app.tsx

The generated component supplies the file picker, progress, cancellation, validation errors, and an Upload complete. confirmation.

Test ownership and file limits

Add this test. It uses the starter's Todo repository test helper and memory storage, so it neither boots the server nor writes files to disk:

typescript
// features/todos/tests/attachment.test.ts
import { createMemoryStorage, createUserActor } from "@beignet/core/ports";
import { createTestContext } from "@beignet/core/testing";
import { createUploadRouter, uploadsFromRegistry } from "@beignet/core/uploads";
import type { AppContext } from "@/app-context";
import { expect, test } from "@/lib/beignet-test";
import { todoUploads } from "../uploads";
import { createTestTodoRepository } from "./helpers";

test("only the Todo owner can upload an allowed private file", async () => {
	const todos = createTestTodoRepository();
	const todo = await todos.create({ userId: "owner", title: "Attach a note" });
	const storage = createMemoryStorage();
	const makeContext = createTestContext<AppContext>();
	const owner = makeContext({
		actor: createUserActor("owner"), ports: { todos, storage }, ambient: false,
	});
	const stranger = makeContext({
		actor: createUserActor("stranger"), ports: { todos, storage }, ambient: false,
	});
	const routerFor = (ctx: AppContext) => createUploadRouter({
		uploads: uploadsFromRegistry(todoUploads), ctx, storage, id: () => "upload-1",
	});
	const formData = new FormData();
	formData.set("metadata", JSON.stringify({ resourceId: todo.id }));
	formData.append("file", new File(["hello"], "note.txt", { type: "text/plain" }));
	try {
		await expect(routerFor(stranger.ctx).upload("todos.attachment", {
			formData,
		})).rejects.toMatchObject({ code: "UNAUTHORIZED_UPLOAD" });
		const key = `todos/${todo.id}/upload-1`;
		expect(await storage.get(key)).toBeNull();

		const router = routerFor(owner.ctx);
		const completed = await router.upload("todos.attachment", { formData });
		expect(completed.result).toEqual({ objectKeys: [key] });
		const object = await storage.get(key);
		expect(object?.visibility).toBe("private");
		expect(await object?.text()).toBe("hello");

		for (const file of [
			{ name: "image.png", contentType: "image/png", size: 5 },
			{ name: "large.txt", contentType: "text/plain", size: 5 * 1024 * 1024 + 1 },
		]) {
			await expect(router.prepare("todos.attachment", {
				metadata: { resourceId: todo.id }, files: [file],
			})).rejects.toMatchObject({ code: "INVALID_UPLOAD_FILE" });
		}
	} finally {
		await owner.dispose();
		await stranger.dispose();
	}
});
bash
bun run format
bun run test
bun beignet check

The test proves that a second account cannot store a file for this Todo, an owner can store the original bytes privately, and unsupported types or oversized files are rejected. The server enforces these rules independently of the picker.

Try it in the app

bash
bun beignet db migrate
bun run dev

Sign in, create a Todo, and choose a small .txt file in its uploader. Expect Upload complete. and a new object under storage/app. The server log includes its object key. Refreshing the page keeps the stored file, but does not display an attachment list because this guide does not add attachment records.

Private objects have no public download URL. To offer downloads, save the object key against the Todo in onComplete, then check the current user's ownership in your download endpoint. See Storage for reading objects and signed URLs. Plan cleanup when an attachment or its Todo is deleted.

Troubleshooting

SymptomCheck
Upload returns 403Sign in and use a Todo owned by that account. Check resourceId and the stored userId.
Upload returns a validation errorUse a PDF or plain-text file no larger than 5 MiB and keep the manifest and definition aligned.
The picker is missingImport the component and render it inside each Todo row with resourceId={todo.id}.
The upload route is missingKeep the generated route and central upload registry; run bun beignet doctor --strict.
Files disappear after deploymentLocal storage requires persistent disk. Use a hosted storage provider on hosts such as Vercel.

For a hosted app, configure durable storage before accepting uploads. Your host's request-size limits also apply to the generated multipart route; use direct uploads when appropriate for the provider and file sizes you support.

Work with your agent

text
Follow the upload-file guide in this Beignet app. Read the app instructions and
installed package guidance, inspect current uploads and storage, and preserve
existing configuration. Add a private PDF/text attachment uploader to Todos,
limited to one file up to 5 MiB. Authorize against the saved Todo's owner on the
server. Test owner success, stranger rejection, private storage, file type, and
size limits. Run format, tests, and beignet check. Explain where files live and
what remains to add an attachment list, authorized downloads, and deletion.