Reading Files

Visibility is set on the app, not on individual files. Public CDN URLs or short-lived presigned GETs.

Visibility is set on the app, not on individual files. An app is public or private, and every file in it inherits that. There is no per-file visibility. New apps default to public.

Public apps

Every file gets a stable, permanently valid, auth-free URL on cdn.sonuslab.dev. It comes back as file.url on any StorageFile.

const file = await storage.upload({
  name: 'avatar.png',
  contentType: 'image/png',
  data: bytes,
})

file.url // https://cdn.sonuslab.dev/<app-slug>/<random>-avatar.png

StorageFile is { id, key, name, size, contentType, url?, metadata, createdAt }.

The URL never expires and needs no signing round-trip, so store it directly:

await db.users.update(userId, { avatarUrl: file.url })

What the CDN serves

The URL path is the object key verbatim. Only GET and HEAD are answered (plus OPTIONS preflight); anything else is a 405. Range requests are honoured and return 206, so <video> seeking works.

access-control-allow-origin: *
accept-ranges: bytes
cache-control: public, max-age=3600, s-maxage=3600
etag: "..."
x-content-type-options: nosniff

Inline vs. download

Content-type comes from whoever uploaded the bytes, so the CDN does not replay it verbatim for renderable types. Images (png, jpeg, gif, webp, avif), video (mp4, webm), and audio (mpeg, wav, ogg, flac, m4a, aac) render inline. PDFs keep their content-type but are forced to content-disposition: attachment. Everything else — including text/*, image/svg+xml, and any XML — is rewritten to application/octet-stream and downloaded. Those types can execute script on a domain shared by every tenant.

Private apps

The CDN returns 404 for every object belonging to a private app. file.url is undefined; mint a short-lived presigned GET instead.

storage.getDownloadUrl(id, opts?)

Resolve a readable URL. Presigned GET on private apps, the stable CDN URL on public ones.

const url = await storage.getDownloadUrl(file.id, { expiresIn: 600 })
NameTypeRequiredDescription
idstringyesFile id.
expiresInnumbernoLifetime in seconds, 1–604800 (7 days). Default 300. Ignored on public apps — the CDN URL never expires.

storage.download(id)

Fetch the stored bytes plus the file record in one call.

const { data, file } = await storage.download(file.id)
// data : Uint8Array — ciphertext if the file was uploaded encrypted
// file : StorageFile

Over REST

Both methods are thin wrappers over two API-key endpoints. expiresAt is only present when the url actually expires.

GET /files/:id/url?expiresIn=600
Authorization: Bearer sl_...

200 { "url": "https://...", "expiresAt": "2026-08-05T15:10:00.000Z" }

GET /files/:id/download
Authorization: Bearer sl_...

302 Location: https://...

Encrypted files

A public URL to an encrypted file points at the AES-GCM container, not the original bytes. It will not display in an <img> or play in an <audio> — the browser sees an opaque blob. Fetch and decrypt client-side instead.
import { decryptDownload } from 'sonuslab-storage/client'

const url = await storage.getDownloadUrl(fileId)
const plaintext = await decryptDownload({ url, key })
// Uint8Array — the original bytes. Only now is it renderable.

Need the sealed filename and content-type as well? Use decryptContainer — see Encryption.

What a public URL actually guarantees

On a public app, anyone holding the URL can read the file — there is no authorization check. Keys carry 96 bits of entropy so they are not guessable or enumerable, but they are not access-controlled either. Treat a leaked URL as a leaked file.
Flipping an app to private does not immediately revoke reads. Browser and edge caches hold objects for up to an hour (max-age=3600), on top of a 300-second visibility cache in the CDN worker. Anything already fetched stays readable through that window. If you need the file gone now, delete it.

The public key also contains the original filename verbatim (sanitised to [A-Za-z0-9._-]), so the filename is visible to anyone who sees the URL. The encryption path avoids this by storing under an opaque random name.

Copyright © 2026