Storage
Organize files into isolated buckets, upload from the browser or Node.js, and serve thumbnails straight from the CDN.
Buckets#
Buckets are isolated containers with their own visibility (public vs. private) and usage counters. Toggling isPubliccascades to every file's ACL.
List buckets#
/api/storage/companies/{slug}/bucketsconst buckets = await sentroy.buckets.list()Get bucket#
/api/storage/companies/{slug}/buckets/{slug}const bucket = await sentroy.buckets.get("product-assets")Create bucket#
/api/storage/companies/{slug}/bucketsconst created = await sentroy.buckets.create({
name: "User Uploads",
description: "Avatars and profile media",
isPublic: false,
})slug is auto-derived from name if omitted.
Update bucket#
/api/storage/companies/{slug}/buckets/{slug}await sentroy.buckets.update("product-assets", { isPublic: true })Delete bucket#
/api/storage/companies/{slug}/buckets/{slug}?force=trueawait sentroy.buckets.delete("product-assets", { force: true })Media#
Upload, list, download, and delete files inside a bucket. The same access token authorizes both mail and storage calls — no separate credential.
List files#
/api/storage/companies/{slug}/buckets/{slug}/media?type=image&limit=50const { items, total } = await sentroy.media.list("product-assets", {
type: "image",
limit: 50,
})Get media record#
/api/storage/companies/{slug}/buckets/{slug}/media/{id}const media = await sentroy.media.get("product-assets", mediaId)Upload#
Multipart form upload — the SDK builds the form for you. The cURL example below uses -Fto stand in for the SDK's FormData.
Browser — pass a File from an <input type="file">:
/api/storage/companies/{slug}/buckets/{slug}/mediaconst input = document.querySelector<HTMLInputElement>("input[type=file]")!
const file = input.files![0]
const uploaded = await sentroy.media.upload("product-assets", {
body: file,
folder: "products",
tags: ["v1", "cover"],
})
console.log(uploaded.url) // Public CDN URLNode.js — convert a file to a Blob via openAsBlob:
import { openAsBlob } from "node:fs"
const blob = await openAsBlob("./photo.jpg")
const uploaded = await sentroy.media.upload("product-assets", {
body: blob,
filename: "photo.jpg",
isPublic: true,
})Video processing — videos can opt into two server-side flags. compressVideo re-encodes the source at the same resolution (typically 30–60% smaller, no visible quality loss) and runs synchronously. transcodeVideo implies compressVideo and additionally generates a 144p / 480p / 720p / 1080p ladder. Ladder generation is asynchronous: the upload response returns immediately with processing.status === "queued", then ladder rungs stream into videoMeta.variants over the next few minutes. Ladder rungs are reachable at /f/<mediaId>/<height> (e.g. /f/abc/720).
/api/storage/companies/{slug}/buckets/{slug}/media (video, multi-quality)const uploaded = await sentroy.media.upload("clips", {
body: videoFile,
compressVideo: true,
transcodeVideo: true,
})
// Response is immediate; ladder fills in over time:
// uploaded.processing.status // "queued" | "processing" | "completed"
// uploaded.processing.variantsTotal // 4
// uploaded.videoMeta.variants // [] at first, fills with each rung
// Poll until done:
let media = uploaded
while (
media.processing &&
media.processing.status !== "completed" &&
media.processing.status !== "failed"
) {
await new Promise((r) => setTimeout(r, 4000))
media = await sentroy.media.get("clips", media.id)
}
console.log(media.videoMeta?.variants.map((v) => v.height))
// → [144, 480, 720, 1080]Latency budget: compressVideo roughly doubles the upload handler latency (the request waits for ffmpeg). transcodeVideo only adds a probe + bookkeeping on the request path; the heavy ladder work runs in the background. Plan-tier quotas still meter the final compressed bytes, not the raw upload — a sub-second probe response keeps client UIs snappy even on very long videos.
Download#
Streams from the storage backend; works for both public and private buckets (auth-gated for private). Pass quality to fetch a pre-generated thumbnail size.
/api/storage/companies/{slug}/buckets/{slug}/media/{id}/download?quality=500const blob = await sentroy.media.download("product-assets", mediaId)
const thumb = await sentroy.media.download("product-assets", mediaId, {
quality: 500,
})Delete#
/api/storage/companies/{slug}/buckets/{slug}/media/{id}await sentroy.media.delete("product-assets", mediaId)Removes the original S3 object, every generated thumbnail, and the media record in one call.
Thumbnails#
Image uploads automatically get several thumbnail sizes generated by the CDN. Use the helpers below to pick the right URL for your display target — never ship a 4000px JPG into a 56px avatar.
import {
pickThumbnailUrl,
pickPresetThumbnailUrl,
THUMBNAIL_PRESETS,
} from "@sentroy-co/client-sdk"
// Manual target (px) — pass display size * 2 for retina
const avatarUrl = pickThumbnailUrl(media, 56 * 2)
// Semantic preset
const cardUrl = pickPresetThumbnailUrl(media, "card") // ~500 px
const previewUrl = pickPresetThumbnailUrl(media, "preview") // ~960 pxHow it picks
The helper picks the smallest thumbnail that still covers the target (so you never upscale), then falls back through:
thumbnail.urlif the backend exposed it directly- CDN-prefix +
thumbnail.fileNamederived frommedia.url - proxy
media.downloadUrl?quality=Nfor private buckets media.url/media.downloadUrlif no thumbnails exist (non-image, or upload before processing finished)
Returns undefined only when the media has no public URL at all.
Presets
| Name | Type | Description |
|---|---|---|
| avatar | 128 px | Round chips, 28-64 px display @2x |
| card | 500 px | Grid / list cards, 200-300 px display |
| preview | 960 px | Modal / detail view |
| hero | 1600 px | Full-bleed hero, edge cases |