-- RLS: users can upload to their own avatar path
CREATE POLICY "avatar_upload" ON storage.objects FOR INSERT
WITH CHECK (
bucket_id = 'avatars'
AND (storage.foldername(name))[1] = (select auth.uid())::text
);
-- RLS: users can read own transcripts + transcripts for challenges they entered
CREATE POLICY "transcript_read" ON storage.objects FOR SELECT
USING (
bucket_id = 'transcripts'
AND EXISTS (
SELECT 1 FROM entries
WHERE entries.agent_id IN (
SELECT id FROM agents WHERE user_id = (select auth.uid())
)
AND (storage.foldername(name))[1] = entries.challenge_id::text
)
);
Signed Download URLs (Private Files)
// Generate temporary access URL (expires in 1 hour)
const { data } = await supabase.storage
.from('transcripts')
.createSignedUrl(`${challengeId}/${entryId}.json.gz`, 3600)
// Return signed URL to client — works for 1 hour, then expires
Cache-Control Headers
Bucket
Cache-Control
Why
avatars
max-age=86400 (24h)
Changes rarely
transcripts
no-cache during judging, immutable after
Changes during challenge, permanent after
submissions
immutable
Never changes after submission
Sources
Supabase Storage documentation
OWASP File Upload Cheat Sheet
Supabase storage RLS patterns
Changelog
2026-03-21: Initial skill — file upload and storage
1---2name: file-upload-and-storage3description: File upload security, Supabase Storage patterns, presigned URLs, file validation, CDN caching, and storage architecture for Arena/MathMind/OUTBOUND.4---56# File Upload & Storage78## Review Checklist9101. [ ] File type validated by magic bytes (not extension)112. [ ] File size limit enforced server-side123. [ ] Large uploads use presigned URLs (not through API)134. [ ] Private files use signed download URLs with expiration145. [ ] Filenames sanitized (no special chars, no path traversal)156. [ ] RLS policies on storage bucket match business rules167. [ ] Retention policy exists for old files1718---1920## File Validation (CRITICAL)2122```ts23// NEVER trust file extensions — validate magic bytes24const MAGIC_BYTES: Record<string, number[]> = {25 'image/jpeg': [0xFF, 0xD8, 0xFF],26 'image/png': [0x89, 0x50, 0x4E, 0x47],27 'image/webp': [0x52, 0x49, 0x46, 0x46], // RIFF header28 'application/pdf': [0x25, 0x50, 0x44, 0x46], // %PDF29 'application/gzip': [0x1F, 0x8B],30}3132function validateFileType(buffer: ArrayBuffer, allowedTypes: string[]): string | null {33 const bytes = new Uint8Array(buffer.slice(0, 8))3435 for (const type of allowedTypes) {36 const magic = MAGIC_BYTES[type]37 if (magic && magic.every((b, i) => bytes[i] === b)) {38 return type39 }40 }41 return null // Invalid file type42}4344// Filename sanitization45function sanitizeFilename(name: string): string {46 return name47 .replace(/[^a-zA-Z0-9._-]/g, '_') // strip special chars48 .replace(/\.{2,}/g, '.') // no double dots (path traversal)49 .slice(0, 100) // max length50}51```5253## Presigned Upload URLs5455```ts56// Server: generate presigned upload URL57'use server'58export async function getUploadUrl(filename: string, contentType: string) {59 const supabase = await createClient()60 const { data: { user } } = await supabase.auth.getUser()61 if (!user) return { error: 'Unauthorized' }6263 const safeName = sanitizeFilename(filename)64 const path = `${user.id}/${crypto.randomUUID()}-${safeName}`6566 const { data, error } = await supabase.storage67 .from('uploads')68 .createSignedUploadUrl(path)6970 if (error) return { error: 'Upload failed' }71 return { signedUrl: data.signedUrl, path }72}7374// Client: upload directly to storage (bypasses your server)75const { signedUrl, path } = await getUploadUrl(file.name, file.type)76await fetch(signedUrl, {77 method: 'PUT',78 body: file,79 headers: { 'Content-Type': file.type },80})81```8283## Supabase Storage Architecture (Arena)8485```86Buckets:87├── avatars/ (public) — agent profile images88│ └── {agent_id}.webp89├── transcripts/ (private) — challenge session transcripts90│ └── {challenge_id}/{entry_id}.json.gz91├── submissions/ (private) — challenge submission files92│ └── {challenge_id}/{entry_id}/93└── exports/ (private) — generated reports, replays94 └── {user_id}/{export_id}.json95```9697```sql98-- RLS: users can upload to their own avatar path99CREATE POLICY "avatar_upload" ON storage.objects FOR INSERT100WITH CHECK (101 bucket_id = 'avatars'102 AND (storage.foldername(name))[1] = (select auth.uid())::text103);104105-- RLS: users can read own transcripts + transcripts for challenges they entered106CREATE POLICY "transcript_read" ON storage.objects FOR SELECT107USING (108 bucket_id = 'transcripts'109 AND EXISTS (110 SELECT 1 FROM entries111 WHERE entries.agent_id IN (112 SELECT id FROM agents WHERE user_id = (select auth.uid())113 )114 AND (storage.foldername(name))[1] = entries.challenge_id::text115 )116);117```118119## Signed Download URLs (Private Files)120121```ts122// Generate temporary access URL (expires in 1 hour)123const { data } = await supabase.storage124 .from('transcripts')125 .createSignedUrl(`${challengeId}/${entryId}.json.gz`, 3600)126127// Return signed URL to client — works for 1 hour, then expires128```129130## Cache-Control Headers131132| Bucket | Cache-Control | Why |133|--------|--------------|-----|134| avatars | `max-age=86400` (24h) | Changes rarely |135| transcripts | `no-cache` during judging, `immutable` after | Changes during challenge, permanent after |136| submissions | `immutable` | Never changes after submission |137138## Sources139- Supabase Storage documentation140- OWASP File Upload Cheat Sheet141- Supabase storage RLS patterns142143## Changelog144- 2026-03-21: Initial skill — file upload and storage
Run npx skillmds add nickgallick/file-upload-and-storage in your terminal (requires Node.js), paste this page's agent-chat prompt into Claude, Cursor, or any MCP-connected agent, or download the SKILL.md file and copy it into your agent's skills directory.
File upload security, Supabase Storage patterns, presigned URLs, file validation, CDN caching, and storage architecture for Arena/MathMind/OUTBOUND. It is listed under Security on SkillMD.
This skill has not completed SkillMD's automated safety review yet. Capability flags: makes network calls. SkillMD never runs a skill's scripts for you; review the SKILL.md before installing.
This skill is tagged as working with Claude Code, Claude.ai, OpenAI Codex. SKILL.md is an open format, so most agents that read a skills directory can load it too.
Yes. Installing skills from SkillMD is free, and the skill stays under its author's original license.
nickgallick (@nickgallick) published this skill. Their other Agent Skills are listed on their SkillMD profile.