{"id":48482,"plugin_id":"plugins_6ac57dac927c81919e4e14ef134d04e2","kind":"skill","collection_source":"plugin_package","comparison_source":null,"observed_at":"2026-10-24T13:36:32.142Z","digest":"97e5a110817eb52b4a2484069bd7f080c9b9dfe9072adda0b1c167c4154cded0","against":null,"payload":{"description":"Use for file and object storage in Volcano apps, including uploads, downloads, photo galleries, file sharing, buckets, ownership-aware paths, public or private files, visibility, and resumable uploads.","included_files":[],"name":"volcano-storage","skill_md_contents":"---\nname: volcano-storage\ndescription: Use for file and object storage in Volcano apps, including uploads, downloads, photo galleries, file sharing, buckets, ownership-aware paths, public or private files, visibility, and resumable uploads.\n---\n# Volcano Storage Skill\n\n## Role\nImplement secure storage flows with explicit bucket, path, and access-control intent. This skill is self-contained: bucket selection, every file operation, public/private semantics, resumable uploads, and error handling are embedded.\n\n## Workflow\n1. Select a bucket and design ownership-aware paths.\n2. Implement upload/download/list/remove using canonical APIs.\n3. Keep files private by default; explicitly justify any public flag.\n4. For files >100 MB or unreliable connections, use resumable uploads.\n\n## Buckets and Paths\n\n### Bucket selection\n```ts\nconst avatars = volcano.storage.from('avatars');\nconst documents = volcano.storage.from('documents');\n```\nCreate buckets with the CLI before referencing them — `volcano storage bucket create <name> [--allowed-mime-type <type>] [--file-size-limit <bytes>]` (local) / `volcano cloud storage bucket create ...` (cloud); `volcano storage bucket list|get` to check what already exists. `volcano-config.yaml`'s `buckets` section (see `volcano-platform`) only manages policies on buckets that already exist — it never creates or deletes them.\n\n**A new bucket already has working owner-scoped RLS policies — do NOT hand-write them for the common case.** `bucket create` installs defaults (`default-auth-insert`, `default-auth-select-own`, `default-auth-update-own`, `default-auth-delete-own`): any authenticated user can upload, and each user can read/update/delete only their own objects. So a private, per-user upload/list app needs **zero** policy setup — just create the bucket and upload under owner-scoped paths (below). Reach for `volcano storage policy create` only for *non-default* access (public read, cross-user sharing) — see \"Custom policies\". Creating owner-scoped policies by hand is the most common storage mistake: they already exist, and a wrong definition denies the very uploads it was meant to allow.\n\n### Path conventions (recommended)\n```\navatars/<userId>/profile.jpg\ndocuments/<userId>/reports/<year>/<filename>.pdf\nprojects/<projectId>/assets/<assetType>/<filename>\n```\nAvoid: random/timestamp-only paths (`file-${Date.now()}.pdf`).\n\n## Upload\n\n### Basic\n```ts\nconst fileInput = document.querySelector('input[type=\"file\"]') as HTMLInputElement;\nconst file = fileInput.files![0];\n\nconst { data, error } = await volcano.storage\n  .from('avatars')\n  .upload(`${user.id}/profile.jpg`, file);\n// data: { name, size, mime_type, ... }\n```\n\n### With content type\n```ts\nawait volcano.storage\n  .from('documents')\n  .upload('reports/annual-2024.pdf', file, { contentType: 'application/pdf' });\n```\n\n### From Blob / ArrayBuffer\n`upload()` accepts a **`File`, `Blob`, or `ArrayBuffer`** only. A Node `Buffer`\n(or any `Uint8Array`) is **rejected** with `Invalid file body type. Expected\nFile, Blob, or ArrayBuffer` — the common trap in server Functions, where an\nimage/PDF/QR generator hands you a Node `Buffer`. Wrap it in a `Blob`.\n```ts\nconst blob = new Blob(['hello'], { type: 'text/plain' });\nawait volcano.storage.from('uploads').upload('notes/hello.txt', blob);\n\n// In a Function: generators return a Node Buffer — wrap it, do NOT pass it directly.\n// (QRCode here is the third-party `qrcode` npm package — add it to package.json so it bundles into the function.)\nconst png = await QRCode.toBuffer('https://example.com'); // Node Buffer\nawait volcano.storage.from('uploads').upload('codes/qr.png', new Blob([png]), {\n  contentType: 'image/png',\n});\n```\n\n## Download\n\n### Basic\n```ts\nconst { data: blob, error } = await volcano.storage\n  .from('documents')\n  .download('reports/annual-2024.pdf');\n\nconst url = URL.createObjectURL(blob);\nconst a = document.createElement('a');\na.href = url;\na.download = 'annual-2024.pdf';\na.click();\nURL.revokeObjectURL(url);\n```\n\n### Display in browser (image)\n```ts\nconst { data: blob } = await volcano.storage.from('avatars').download(`${userId}/profile.jpg`);\nimgEl.src = URL.createObjectURL(blob);\n```\n\n### Partial download (range)\n```ts\nconst { data: blob } = await volcano.storage\n  .from('documents')\n  .download('large-file.zip', { range: 'bytes=0-1023' });\n```\n\n## List\n\n### Whole bucket\n```ts\nconst { data: files, error } = await volcano.storage.from('uploads').list();\n// file fields: name, size, mime_type, is_public, created_at, owner_id, public_url?\n```\n\n### With prefix\n```ts\nconst { data: files } = await volcano.storage.from('uploads').list('images/');\nconst { data: mine } = await volcano.storage.from('documents').list(`${userId}/`);\n```\n\n### Paginated\n```ts\nconst { data: files, nextCursor } = await volcano.storage\n  .from('uploads')\n  .list('', { limit: 100 });\n\nif (nextCursor) {\n  const { data: more } = await volcano.storage\n    .from('uploads')\n    .list('', { limit: 100, cursor: nextCursor });\n}\n```\n\n## Delete\n```ts\n// Single\nawait volcano.storage.from('uploads').remove('old-file.txt');\n\n// Multiple\nconst { data } = await volcano.storage.from('uploads').remove([\n  'temp/file1.txt',\n  'temp/file2.txt',\n  'temp/file3.txt',\n]);\n// data.deleted is the list of removed paths\n```\n\n## Move and Copy\n```ts\nawait volcano.storage.from('documents').move('drafts/report.pdf', 'published/report.pdf');\nawait volcano.storage.from('documents').copy('templates/invoice.pdf', 'invoices/2024-001.pdf');\n```\n\n## Public vs Private\n\n### Default: private\nFiles require an authenticated session to download.\n\n### Toggle visibility\n```ts\nconst { data } = await volcano.storage\n  .from('avatars')\n  .updateVisibility(`${userId}/profile.jpg`, true);\n// data.public_url present when true\n\nawait volcano.storage\n  .from('avatars')\n  .updateVisibility(`${userId}/profile.jpg`, false);\n```\n\n### Get public URL (does NOT verify file is actually public)\n```ts\nconst { data } = volcano.storage.from('avatars').getPublicUrl(`${userId}/profile.jpg`);\n// data.publicUrl: 'https://api.<project>.volcano.dev/public/<project-id>/avatars/<path>'\n```\nPublic URLs require no auth, work in any browser, are CDN-friendly, and 403 if the file is later made private.\n\n## Resumable Uploads\nUse for files >100 MB or unreliable networks.\n\n### Simple resumable upload\n```ts\nconst { data, error } = await volcano.storage\n  .from('uploads')\n  .uploadResumable('large-video.mp4', file, {\n    onProgress: (uploaded, total) => {\n      const pct = Math.round((uploaded / total) * 100);\n      progressBar.value = pct;\n    },\n  });\n```\n\n### Manual session control\n```ts\n// 1. Create session\nconst { data: session } = await volcano.storage\n  .from('uploads')\n  .createUploadSession('large-video.mp4', {\n    totalSize: file.size,\n    contentType: 'video/mp4',\n    partSize: 10 * 1024 * 1024, // 10 MB parts\n  });\n// session: { session_id, total_parts, part_size }\n\n// 2. Upload each part\nfor (let i = 1; i <= session.total_parts; i++) {\n  const start = (i - 1) * session.part_size;\n  const end = Math.min(start + session.part_size, file.size);\n  const partData = file.slice(start, end);\n  const { error } = await volcano.storage\n    .from('uploads')\n    .uploadPart('large-video.mp4', session.session_id, i, partData);\n  if (error) break; // can retry this part\n}\n\n// 3. Complete\nawait volcano.storage\n  .from('uploads')\n  .completeUploadSession('large-video.mp4', session.session_id);\n```\n\n### Resume after interruption\n```ts\nconst { data: status } = await volcano.storage\n  .from('uploads')\n  .getUploadSession('large-video.mp4', sessionId);\n// status: { uploaded_parts, total_parts, uploaded_bytes, total_size, missing_parts, part_size }\n\nfor (const partNumber of status.missing_parts) {\n  const start = (partNumber - 1) * status.part_size;\n  const end = Math.min(start + status.part_size, file.size);\n  await volcano.storage\n    .from('uploads')\n    .uploadPart('large-video.mp4', sessionId, partNumber, file.slice(start, end));\n}\nawait volcano.storage.from('uploads').completeUploadSession('large-video.mp4', sessionId);\n```\n\n### Abort\n```ts\nawait volcano.storage.from('uploads').abortUploadSession('large-video.mp4', sessionId);\n```\n\n### Limits\n- Min part size: 5 MB\n- Max part size: 25 MB (default)\n- Max parts: 10,000\n- Session expiry: 7 days\n\n## Error Handling\nCommon error messages:\n- `No active session` — sign in first.\n- `File not found` — wrong path or bucket.\n- `Permission denied` — access policy violation.\n- `Bucket not found` — invalid bucket name.\n- `File too large` — exceeds size limit.\n\n```ts\nconst { data, error } = await volcano.storage.from('uploads').upload('file.txt', file);\nif (error) {\n  console.error('Upload failed:', error.message);\n}\n```\n\n## Access Patterns\n\n### User-scoped paths\n```ts\n// Allowed by typical \"path starts with user's id\" policy\nawait volcano.storage.from('uploads').upload(`${user.id}/avatar.jpg`, file);\n\n// Denied\nawait volcano.storage.from('uploads').upload(`other-user-id/avatar.jpg`, file);\n```\n\n### Public read, authenticated write\n- Download policy allows anyone (when `is_public`).\n- Upload policy requires an authenticated session.\n\n### Custom policies (only for non-default access)\nThe auto-applied defaults already cover private per-user storage — don't recreate\nthem. For anything else, attach policies with the CLI (or `volcano-config.yaml`'s\n`buckets.<name>.policies`, which fully syncs declared policies). A `--definition`\nis an RLS expression over the object row (columns include `owner_id`, `name`,\n`bucket_id`, `is_public`):\n```sh\nvolcano storage policy create avatars --operation SELECT --name public-read  --definition \"is_public = true\"\nvolcano storage policy create avatars --operation INSERT --name authed-write  --definition \"auth.uid() IS NOT NULL\"\nvolcano storage policy create docs    --operation SELECT --name owner-read    --definition \"auth.uid() = owner_id\"\n```\n`volcano storage policy list|get|delete <bucket>` to inspect/manage. `--operation`\nis one of `SELECT|INSERT|UPDATE|DELETE`.\n\n### Role-based\nPolicies inspect the user's role from the JWT (admin/user/etc.) — same client API, different rows visible.\n\n## Best Practices\n- **Validate before upload** (size and MIME type) for better UX:\n```ts\nconst MAX = 10 * 1024 * 1024;\nconst ALLOWED = ['image/jpeg', 'image/png', 'image/gif'];\nif (file.size > MAX) return showError('File too large');\nif (!ALLOWED.includes(file.type)) return showError('Invalid file type');\n```\n- **Choose resumable above 100 MB** automatically:\n```ts\nconst useResumable = file.size > 100 * 1024 * 1024;\n```\n- **Revoke object URLs** when no longer displayed:\n```ts\nconst url = URL.createObjectURL(blob);\nimg.src = url;\n// later:\nURL.revokeObjectURL(url);\n```\n- **Show progress** for large uploads using `onProgress`.\n\n## Verification Checklist\n- Bucket and path strategy is explicit and ownership-aware.\n- Upload/download/list/remove errors are handled.\n- Private/public expectations are explicit and justified.\n- Resumable upload is used for large files; cleanup-on-cancel is wired.\n- No path uses random tokens without semantic prefix.\n\n## Optional Fallback Reference\n"},"changes":[],"summary":"First saved snapshot. No earlier version is available for comparison.","summary_kind":"deterministic","summary_metadata":{}}