Course navigationPhase 27
Volume 07 · Phase 27

File Uploads & Supabase Storage

Design a secure file-upload pipeline with storage objects, metadata, validation, signed access, lifecycle cleanup, and authorization.

16lessons + labs
4klesson words
17source references
What you will ship

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.

Figure 27.1 — Where this phase lives in TaskFlow
upload intent
API authorization/validation
signed upload or storage request
object store
finalize metadata
signed/read access
Trace the flow left-to-right for implementation and right-to-left for debugging. At every arrow, ask what data crosses the boundary and what proves it is trustworthy.
Instructor mental model

Principles to keep while coding

01

Keep file bytes in object storage and metadata/authorization relationships in application data where appropriate.

02

Validate file type, size, path, and ownership before trusting an upload workflow.

03

Use signed or access-controlled URLs instead of making private files globally public.

04

Treat deletion as a coordinated workflow when both storage objects and database metadata must remain consistent.

Code-along path

Guided phase lab

Do these in order. Each step should end with evidence before the next one starts.

  1. 01

    Define file policy: allowed types, size, naming/path rules, ownership, retention.

  2. 02

    Create an upload-intent endpoint that checks authenticated user and target task access before granting upload capability.

  3. 03

    Upload bytes directly to storage where appropriate rather than proxying large files through the API by default.

  4. 04

    Finalize by verifying object existence and committing metadata transactionally as far as the architecture allows.

  5. 05

    Generate time-limited access for private objects and test outsider access denial.

  6. 06

    Implement delete/cleanup behavior for abandoned uploads and metadata/object mismatch.

Debugging studio

Failures you should know how to diagnose

Failure drill 1

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.

Failure drill 2

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.

Failure drill 3

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.

Failure drill 4

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.

Watch-order style

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.

01

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

2 min
02

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

2 min
03

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

2 min
04

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

2 min
05

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

2 min
06

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

2 min
07

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

2 min
08

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

2 min
09

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

2 min
10

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

2 min
11

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

2 min
12

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

2 min
LAB

Guided Build Lab

Purpose Design a secure file-upload pipeline with storage objects, metadata, validation, signed access, lifecycle cleanup, and authorization.

3 min
LAB

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.

2 min
LAB

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?

2 min
LAB

Mastery Capstone Lab

Integrate Phase 27 without following a transcript.

2 min
Practice ladder

Exercises that increase independence

Guided

Define file policy: allowed types, size, naming/path rules, ownership, retention.

Practice

Upload bytes directly to storage where appropriate rather than proxying large files through the API by default.

Debug

reproduce and diagnose — Trusting filename extension as MIME proof.

Independent

deliver Task attachment upload/download/delete workflow using Supabase Storage plus application metadata.

Explain-back

Can separate object bytes and metadata and Can validate/authorize upload intent.

Mastery gate

Do not leave the phase only because you reached the bottom

Phase exit criteria

Knowledge check

Scenario and concept quiz

These questions test engineering choices rather than whether you remember a lesson title.

0/0 correct · 0/8 answered
01Which practice best matches Phase 27's engineering goal?
02Which practice best matches Phase 27's engineering goal?
03Which practice best matches Phase 27's engineering goal?
04Which practice best matches Phase 27's engineering goal?
05You are working through the Phase 27 lab. Which next action is the most disciplined step?
06You are working through the Phase 27 lab. Which next action is the most disciplined step?
07You are working through the Phase 27 lab. Which next action is the most disciplined step?
08You are working through the Phase 27 lab. Which next action is the most disciplined step?
Source snapshot

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 const
reference/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.