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
bun beignet make upload todos.attachment --ui
bun installThe 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:
// 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:
17 unmodified lines181920212279 unmodified lines10210310310410510628 unmodified lines13513613713813914014117 unmodified lines} from "@/features/todos/contracts";import { cn } from "@/lib/utils";import { AttachmentUploader } from "./attachment-uploader";
const createTodoForm = rhf(createTodo);79 unmodified lines <ul className="divide-y"> {todos.map((todo) => ( <li key={todo.id} className="flex items-center gap-3 px-4 py-3"> <li key={todo.id} className="flex flex-wrap items-center gap-3 px-4 py-3"> <Checkbox id={`todo-${todo.id}`}28 unmodified lines <Trash2Icon /> </Button> <div className="w-full"> <AttachmentUploader resourceId={todo.id} /> </div> </li> ))}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:
// 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();
}
});bun run format
bun run test
bun beignet checkThe 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
bun beignet db migrate
bun run devSign 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
| Symptom | Check |
|---|---|
| Upload returns 403 | Sign in and use a Todo owned by that account. Check resourceId and the stored userId. |
| Upload returns a validation error | Use a PDF or plain-text file no larger than 5 MiB and keep the manifest and definition aligned. |
| The picker is missing | Import the component and render it inside each Todo row with resourceId={todo.id}. |
| The upload route is missing | Keep the generated route and central upload registry; run bun beignet doctor --strict. |
| Files disappear after deployment | Local 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
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.