Keep file bytes in object storage and metadata/authorization relationships in application data where appropriate.
Course navigationPhase 27⌄
File Uploads & Supabase Storage
Design a secure file-upload pipeline with storage objects, metadata, validation, signed access, lifecycle cleanup, and authorization.
Phase outcome and project increment
Learning outcome
Design a secure file-upload pipeline with storage objects, metadata, validation, signed access, lifecycle cleanup, and authorization.
Concrete deliverable
Task attachment upload/download/delete workflow using Supabase Storage plus application metadata.
Principles to keep while coding
Validate file type, size, path, and ownership before trusting an upload workflow.
Use signed or access-controlled URLs instead of making private files globally public.
Treat deletion as a coordinated workflow when both storage objects and database metadata must remain consistent.
Guided phase lab
Do these in order. Each step should end with evidence before the next one starts.
- 01
Define file policy: allowed types, size, naming/path rules, ownership, retention.
- 02
Create an upload-intent endpoint that checks authenticated user and target task access before granting upload capability.
- 03
Upload bytes directly to storage where appropriate rather than proxying large files through the API by default.
- 04
Finalize by verifying object existence and committing metadata transactionally as far as the architecture allows.
- 05
Generate time-limited access for private objects and test outsider access denial.
- 06
Implement delete/cleanup behavior for abandoned uploads and metadata/object mismatch.
Failures you should know how to diagnose
Trusting filename extension as MIME proof
Predict the symptom, capture evidence at the nearest boundary, change one variable, and verify the same reproduction after the fix.
Public bucket for private tenant files
Predict the symptom, capture evidence at the nearest boundary, change one variable, and verify the same reproduction after the fix.
User-controlled storage path enabling cross-tenant overwrite
Predict the symptom, capture evidence at the nearest boundary, change one variable, and verify the same reproduction after the fix.
Database metadata created before upload succeeds with no cleanup
Predict the symptom, capture evidence at the nearest boundary, change one variable, and verify the same reproduction after the fix.
Lessons
The source chapters come first. Every phase now ends with a guided build, a debugging workshop, and an independent challenge so reading turns into transferable skill.
Storage Mental Model Public Private and Source of Truth
Storage Mental Model: Public, Private and Source of Truth TaskFlow - Full Stack Developer 2026 Storage is object storage, not a relational domain model Two systems, different responsibilitie
Signed Upload Intent Architecture
Signed Upload Intent Architecture TaskFlow - Full Stack Developer 2026 Why not proxy every byte through NestJS A backend proxy can work, but it makes the API server carry file bandwidth, mem
Attachment Data Model and Prisma Migration
Attachment Data Model and Prisma Migration TaskFlow - Full Stack Developer 2026 Domain model enum AttachmentStatus { PENDING READY DELETING } model TaskAttachment { id String @id @default(uu
Private Bucket Configuration and Server Storage Client
Private Bucket Configuration and Server Storage Client TaskFlow - Full Stack Developer 2026 Bucket policy await supabase.storage.createBucket( "task-attachments", { public: false, allowedMim
File Policy Safe Object Paths and Validation
File Policy, Safe Object Paths and Validation TaskFlow - Full Stack Developer 2026 Client metadata is untrusted • file.name may contain awkward characters or misleading extensions. • file.ty
Signed Standard Uploads for Small Files
Signed Standard Uploads for Small Files TaskFlow - Full Stack Developer 2026 When to use it Supabase recommends standard uploads for small files and TUS resumable uploads when files exceed r
Resumable TUS Uploads and Progress
Resumable TUS Uploads and Progress TaskFlow - Full Stack Developer 2026 When TaskFlow switches to TUS • File is larger than 6MB. • Users frequently upload over unstable networks. • Product r
Finalize Verify and Commit Attachment Metadata
Finalize, Verify and Commit Attachment Metadata TaskFlow - Full Stack Developer 2026 Never trust “upload finished” from the browser The browser can call finalize even if it uploaded nothing
Private Downloads and Short Lived Signed URLs
Private Downloads and Short-Lived Signed URLs TaskFlow - Full Stack Developer 2026 Download authorization A browser should not receive a permanent public object URL for Task documents. It fi
Replacement Deletion and Orphan Cleanup
Replacement, Deletion and Orphan Cleanup TaskFlow - Full Stack Developer 2026 Replacement strategy Do not overwrite the same object path by default. Upload a new UUID-named object, verify it
Frontend UX TanStack Query and Form Integration
Frontend UX, TanStack Query and Form Integration TaskFlow - Full Stack Developer 2026 UI responsibilities • Validate obvious type/size issues before requesting an intent. • Show selected fil
Testing Security Troubleshooting and Phase Exit
Testing, Security, Troubleshooting and Phase Exit TaskFlow - Full Stack Developer 2026 Security test matrix • Unauthenticated intent request -> 401. • Authenticated non-member attaches to an
Guided Build Lab
Purpose Design a secure file-upload pipeline with storage objects, metadata, validation, signed access, lifecycle cleanup, and authorization.
Debugging Workshop
Goal Learn to diagnose this phase's failures systematically instead of reinstalling packages, changing random configuration, or copying a fix without understanding it.
Phase Synthesis & Independent Challenge
Mission Rebuild the capability of Phase 27 without following the exact code line-by-line. The goal is transfer: can you use the ideas when the variable names, UI, or route shape changes?
Mastery Capstone Lab
Integrate Phase 27 without following a transcript.
Exercises that increase independence
Define file policy: allowed types, size, naming/path rules, ownership, retention.
Upload bytes directly to storage where appropriate rather than proxying large files through the API by default.
reproduce and diagnose — Trusting filename extension as MIME proof.
deliver Task attachment upload/download/delete workflow using Supabase Storage plus application metadata.
Can separate object bytes and metadata and Can validate/authorize upload intent.
Do not leave the phase only because you reached the bottom
Phase exit criteria
Scenario and concept quiz
These questions test engineering choices rather than whether you remember a lesson title.
Reference files
Attempt the work first. Use snapshots to compare a checkpoint, not as a substitute for understanding.
reference/TaskFlow_Storage_Snapshot/ATTACHMENT_SECURITY_CHECKLIST.md
# Attachment security checklist - Private bucket by default. - Service-role key only in NestJS/server environment. - NestJS authorizes Task membership before issuing upload/download capabilities. - Server generates object paths; never trust client-supplied paths. - Allowlist MIME types and cap size in API + bucket. - Original name is display metadata only. - Finalize verifies object existence, size and content type. - Signed download URLs are short-lived. - Do not store signed URLs in PostgreSQL. - Do not mutate `storage.objects` directly. - Add malware/quarantine scanning for higher-risk file classes. - Clean abandoned PENDING uploads and failed DELETING records.
reference/TaskFlow_Storage_Snapshot/README.md
# TaskFlow Phase 27 - File Uploads and Supabase Storage Reference snapshot for a signed-upload-intent architecture. NestJS authorizes attachment operations, Supabase Storage holds bytes, and PostgreSQL stores application metadata. Default bucket: `task-attachments` (private). Default app limit: 20 MB.
reference/TaskFlow_Storage_Snapshot/SOURCES.md
# Official sources - Supabase Storage overview: https://supabase.com/docs/guides/storage - Supabase Standard Uploads: https://supabase.com/docs/guides/storage/uploads/standard-uploads - Supabase Resumable Uploads: https://supabase.com/docs/guides/storage/uploads/resumable-uploads - Supabase Storage Access Control: https://supabase.com/docs/guides/storage/security/access-control - Supabase Storage Ownership: https://supabase.com/docs/guides/storage/security/ownership - Supabase Storage Schema: https://supabase.com/docs/guides/storage/schema/design - Supabase File Limits: https://supabase.com/docs/guides/storage/uploads/file-limits - Supabase createSignedUploadUrl: https://supabase.com/docs/reference/javascript/file-buckets-createsigneduploadurl - Supabase uploadToSignedUrl: https://supabase.com/docs/reference/javascript/file-buckets-uploadtosignedurl - Supabase createSignedUrl: https://supabase.com/docs/reference/javascript/file-buckets-createsignedurl - Supabase info: https://supabase.com/docs/reference/javascript/file-buckets-info - Supabase createBucket: https://supabase.com/docs/reference/javascript/file-buckets-createbucket - tus-js-client npm: https://www.npmjs.com/package/tus-js-client
reference/TaskFlow_Storage_Snapshot/apps/api/.env.example
SUPABASE_URL=https://YOUR_PROJECT.supabase.co SUPABASE_SERVICE_ROLE_KEY=SERVER_ONLY_SECRET TASK_ATTACHMENT_BUCKET=task-attachments
reference/TaskFlow_Storage_Snapshot/apps/api/prisma/schema.phase27.prisma
enum AttachmentStatus {
PENDING
READY
DELETING
}
model TaskAttachment {
id String @id @default(uuid())
taskId String
uploadedById String
bucket String @default("task-attachments")
objectPath String @unique
originalName String
mimeType String
sizeBytes Int
status AttachmentStatus @default(PENDING)
createdAt DateTime @default(now())
readyAt DateTime?
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)reference/TaskFlow_Storage_Snapshot/apps/api/src/attachments/attachment-path.ts
import { ATTACHMENT_EXTENSION_BY_MIME } from "./attachment.constants"
export function buildAttachmentObjectPath(input: {
workspaceId: string
taskId: string
attachmentId: string
mimeType: keyof typeof ATTACHMENT_EXTENSION_BY_MIME
}) {
const ext = ATTACHMENT_EXTENSION_BY_MIME[input.mimeType]
return `workspaces/${input.workspaceId}/tasks/${input.taskId}/${input.attachmentId}.${ext}`
}reference/TaskFlow_Storage_Snapshot/apps/api/src/attachments/attachment.constants.ts
export const ATTACHMENT_BUCKET = "task-attachments"
export const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024
export const ALLOWED_ATTACHMENT_MIME_TYPES = [
"application/pdf",
"image/png",
"image/jpeg",
"text/plain",
] as const
export const ATTACHMENT_EXTENSION_BY_MIME = {
"application/pdf": "pdf",
"image/png": "png",
"image/jpeg": "jpg",
"text/plain": "txt",
} as constreference/TaskFlow_Storage_Snapshot/apps/api/src/attachments/attachment.schemas.ts
import { z } from "zod"
import { ALLOWED_ATTACHMENT_MIME_TYPES, MAX_ATTACHMENT_BYTES } from "./attachment.constants"
export const createAttachmentIntentSchema = z.strictObject({
originalName: z.string().trim().min(1).max(255),
mimeType: z.enum(ALLOWED_ATTACHMENT_MIME_TYPES),
sizeBytes: z.number().int().positive().max(MAX_ATTACHMENT_BYTES),
})
export type CreateAttachmentIntent = z.infer<typeof createAttachmentIntentSchema>reference/TaskFlow_Storage_Snapshot/apps/api/src/attachments/attachments.controller.ts
import { Body, Controller, Param, Post, UseGuards } from "@nestjs/common"
import { StandardSchemaValidationPipe } from "@nestjs/common"
import { SupabaseAuthGuard } from "../auth/supabase-auth.guard"
import { CurrentUser } from "../common/decorators/current-user.decorator"
import type { AuthenticatedUser } from "../auth/authenticated-user"
import { createAttachmentIntentSchema, type CreateAttachmentIntent } from "./attachment.schemas"
import { AttachmentsService } from "./attachments.service"
@Controller()
@UseGuards(SupabaseAuthGuard)
export class AttachmentsController {
constructor(private readonly attachments: AttachmentsService) {}
@Post("tasks/:taskId/attachments/upload-intents")
createIntent(
@Param("taskId") taskId: string,
@CurrentUser() user: AuthenticatedUser,
@Body(new StandardSchemaValidationPipe(createAttachmentIntentSchema))
body: CreateAttachmentIntent,
) {reference/TaskFlow_Storage_Snapshot/apps/api/src/attachments/attachments.module.ts
import { Module } from "@nestjs/common"
import { AttachmentsController } from "./attachments.controller"
import { AttachmentsService } from "./attachments.service"
import { SupabaseStorageService } from "../storage/supabase-storage.service"
@Module({
controllers: [AttachmentsController],
providers: [AttachmentsService, SupabaseStorageService],
})
export class AttachmentsModule {}reference/TaskFlow_Storage_Snapshot/apps/api/src/attachments/attachments.service.ts
import { randomUUID } from "node:crypto"
import { Injectable } from "@nestjs/common"
import { ATTACHMENT_BUCKET } from "./attachment.constants"
import { buildAttachmentObjectPath } from "./attachment-path"
import type { CreateAttachmentIntent } from "./attachment.schemas"
import type { AuthenticatedUser } from "../auth/authenticated-user"
import { SupabaseStorageService } from "../storage/supabase-storage.service"
@Injectable()
export class AttachmentsService {
constructor(
private readonly storage: SupabaseStorageService,
// inject Task/Attachment repositories used by the real application
) {}
async createIntent(taskId: string, user: AuthenticatedUser, input: CreateAttachmentIntent) {
// 1. Load Task inside the caller's authorized scope.
// 2. Generate attachmentId + trusted objectPath.
// 3. Insert PENDING TaskAttachment metadata row.
// 4. Ask Storage for a signed upload token.reference/TaskFlow_Storage_Snapshot/apps/api/src/storage/supabase-storage.service.ts
import { Injectable } from "@nestjs/common"
import { ConfigService } from "@nestjs/config"
import { createClient, SupabaseClient } from "@supabase/supabase-js"
@Injectable()
export class SupabaseStorageService {
private readonly client: SupabaseClient
constructor(config: ConfigService) {
this.client = createClient(
config.getOrThrow("SUPABASE_URL"),
config.getOrThrow("SUPABASE_SERVICE_ROLE_KEY"),
{ auth: { persistSession: false, autoRefreshToken: false } },
)
}
async createSignedUploadUrl(bucket: string, path: string) {
const { data, error } = await this.client.storage
.from(bucket)
.createSignedUploadUrl(path, { upsert: false })reference/TaskFlow_Storage_Snapshot/apps/web/src/components/tasks/task-attachment-uploader.tsx
"use client"
import { useState } from "react"
import { useMutation, useQueryClient } from "@tanstack/react-query"
export function TaskAttachmentUploader({ taskId }: { taskId: string }) {
const [progress, setProgress] = useState(0)
const queryClient = useQueryClient()
const mutation = useMutation({
mutationFn: async (file: File) => {
// create intent -> upload bytes -> complete attachment
// use standard upload for small files; TUS when progress/resume is needed
return { fileName: file.name }
},
onSuccess: async () => {
setProgress(0)
await queryClient.invalidateQueries({
queryKey: ["tasks", taskId, "attachments"],
})reference/TaskFlow_Storage_Snapshot/apps/web/src/lib/api/task-attachments.ts
import { createBrowserSupabaseClient } from "@/lib/supabase/client"
export async function uploadAttachmentFile(intent: {
bucket: string
objectPath: string
token: string
}, file: File) {
const supabase = createBrowserSupabaseClient()
const { error } = await supabase.storage
.from(intent.bucket)
.uploadToSignedUrl(intent.objectPath, intent.token, file, {
contentType: file.type,
})
if (error) throw error
}reference/TaskFlow_Storage_Snapshot/apps/web/src/lib/storage/upload-file-tus.ts
import * as tus from "tus-js-client"
export function uploadFileTus(input: {
projectRef: string
signedUploadToken: string
bucket: string
objectPath: string
file: File
onProgress: (ratio: number) => void
}) {
return new Promise<void>((resolve, reject) => {
const upload = new tus.Upload(input.file, {
endpoint: `https://${input.projectRef}.storage.supabase.co/storage/v1/upload/resumable`,
headers: { "x-signature": input.signedUploadToken },
metadata: {
bucketName: input.bucket,
objectName: input.objectPath,
contentType: input.file.type,
},
chunkSize: 6 * 1024 * 1024,reference/TaskFlow_Storage_Snapshot/setup/create-private-bucket.ts
import { createClient } from "@supabase/supabase-js"
const supabase = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{ auth: { persistSession: false, autoRefreshToken: false } },
)
const { error } = await supabase.storage.createBucket("task-attachments", {
public: false,
allowedMimeTypes: [
"application/pdf",
"image/png",
"image/jpeg",
"text/plain",
],
fileSizeLimit: "20MB",
})
if (error && !String(error.message).toLowerCase().includes("already")) {reference/TaskFlow_Storage_Snapshot/tests/storage-test-matrix.md
# Phase 27 test matrix ## Authorization - No token cannot create an upload intent. - Member outside the Task Workspace cannot attach/download/delete. - Valid member can create an intent for an authorized Task. ## Validation - Zero-byte, >20MB and disallowed MIME are rejected. - Client cannot choose an object path. - Generated paths are unique and Workspace/Task scoped. ## Lifecycle - Intent creates PENDING metadata. - Successful upload + matching Storage info becomes READY. - Missing object cannot finalize. - Metadata mismatch cannot finalize. - Signed download expires. - Delete removes bytes then metadata or leaves a recoverable DELETING state. - Stale PENDING intents are cleaned.