CoreMVP

Private files and account pictures

Set up private personal files, change your account picture, and verify owner-only access.

Start locally

From the template root, install dependencies with bun install, then run:

bunx supabase start
bunx supabase migration list --local

All checked-in migrations must appear as applied. If checked-in versions are pending, apply them without replacing your local data:

bunx supabase db push --local
bunx supabase migration list --local

Stop on a migration mismatch or error; do not reset retained data. Once every version is applied, sync local settings and start the app:

./coremvp env sync
bun run dev

The Storage migration creates user-files as a private bucket with a 4 MiB limit (4,194,304 bytes), accepting PDF, PNG, and JPEG. It adds five storage.objects policies: upload, read, delete, avatar replacement, and a restrictive ownership guard. A pre-existing bucket with different privacy, size, or MIME settings makes the migration fail. Inspect that bucket and its contents before deciding how to reconcile it; the migration does not change its settings or delete objects.

Change your picture

Sign in and open Dashboard → Account. Choose a PNG or JPEG under Upload or replace picture to preview it locally, then click Save profile picture. After Profile picture saved, the new image appears in the Account page and account menu. Reset discards the unsaved selection. To return to initials, choose Remove picture, then Save profile picture. Refresh the page to confirm the stored state. An upload or read failure shows an error instead of a success. The public demo uses the same form but never persists picture changes.

Your picture is private to your account. Other users, including members of your Organization, cannot view it. The object has one fixed path per user; replacing it does not add a profile URL or another application table.

Use personal files

Call these same-origin routes from a signed-in browser. Upload one file field using FormData; let the browser set the multipart boundary:

const form = new FormData();
form.append("file", file); // A PDF, PNG, or JPEG selected by your user.
const uploaded = await fetch("/api/files", { method: "POST", body: form });
const result = await uploaded.json();
if (!uploaded.ok || !result.ok) throw new Error(result.message);
const fileKey = result.data.fileKey;

const downloaded = await fetch(`/api/files/${fileKey}`, { cache: "no-store" });
if (!downloaded.ok) throw new Error("Download failed.");
const bytes = await downloaded.blob();

const removed = await fetch(`/api/files/${fileKey}`, { method: "DELETE" });
if (!removed.ok) throw new Error("Removal failed.");

Keep the returned fileKey in your product's own state if you need to retrieve that file later. There is no listing API or file-manager page. The server creates an opaque key and a sanitized filename beneath the verified user's folder. A file key never authorizes another user's folder. General files cannot be overwritten; upload a new file, then remove the old one when appropriate.

RouteResult
POST /api/filesUpload a personal file; 201 with data.fileKey
GET /api/files/:fileKeyDownload the owner's bytes as an attachment
DELETE /api/files/:fileKeyRemove the owner's file
POST /api/files/avatarUpload or replace a PNG/JPEG picture; 200
GET /api/files/avatarRead the owner's raster picture inline
DELETE /api/files/avatarRemove the picture

The avatar upload also takes one multipart file field. No session or an anonymous Auth session receives 401. Foreign or missing objects receive 404 without disclosing another user's file; malformed keys receive 400. Cross-site mutations receive 403, oversized files 413, and unsupported or mismatched types 415. Provider/setup failures remain errors. Reads use private, no-store and nosniff; avatar replacement bypasses cached bytes.

The service uses the request's Supabase user JWT, so Storage policies also protect direct SDK requests. The secret/admin client does not upload, read, or remove these user files. Never make this bucket public or return public or signed URLs as an alternative to authenticated reads.

Test the file service

From the template root, run:

bun test src/tests/storage.test.ts

These focused tests check file types and limits, safe paths, authenticated same-origin API behavior, and provider failure handling. They use controlled Storage responses; they do not require a browser or running Supabase instance and do not prove your deployed bucket policies.

With local Supabase and the app running, try the Account upload, replacement, and removal steps above. For the provider boundary, use two test accounts and check that B cannot read, overwrite, or remove A's objects through the Storage SDK. Always verify A's bytes remain after a denied mutation.

Deploy and check the hosted path

Follow the Vercel deployment guide. Apply migrations to the selected linked Supabase project with bunx supabase db push before using Storage. In Supabase Storage, confirm the bucket is private, has the 4 MiB limit and the three allowed MIME types. Stop if settings or migrations differ from the shipped contract.

On your normal Production deployment, sign in with two test accounts. Upload, replace, refresh, and remove account A's picture; A's account menu must show the replacement bytes without a stale image. Repeat a personal file upload, download, and removal. With B's session, direct Storage SDK download, remove, and overwrite attempts against A's object paths must fail to expose or change A's bytes. An unauthorized remove can return an empty result rather than an error, so verify A's original bytes remain.

Check a 4 MiB upload through the deployed Hono route and verify that a larger file fails. Vercel's request limit includes multipart overhead; the 4 MiB file limit leaves room beneath its 4.5 MB function payload limit. Local Playwright does not prove Vercel payload handling or hosted Storage/CDN behavior.

There is no aggregate quota, malware scanning, public sharing, resumable large upload, or automatic cleanup on account deletion. Remove a user's objects through the Storage API before deleting their Auth account when your product needs that cleanup. Never delete storage.objects rows with SQL: that does not remove the stored bytes.

What's next

Use the API reference to extend your product's same-origin API, or review Authentication for session handling.

Was this page helpful?

On this page