Capabilities
Storage
storage@1capability contract
On this page
Introduction
storage@1 lets a participant put a file into the Rack: a photo, a drawing, a recorded reflection or a document. Your applet never sees a bucket or a key, only signed URLs to its own files, and a file has no URL at all until it has been scanned clean.
Declaring Upload Slots
An upload is a named slot with a scope and a MIME allowlist. Files always belong to a slot. You may declare your slots in the manifest's uploads array:
"uploads": [
{ "name": "artifacts", "scope": "room", "mime": ["image/png", "image/jpeg"], "max_bytes": 5242880, "max_files": 10 },
{ "name": "reflection", "scope": "attempt", "mime": ["audio/*"], "max_bytes": 10485760 }
]Each slot accepts the following fields:
| Field | Rules |
|---|---|
name |
Required. ^[a-z][a-z0-9_]*$, max 40, unique. |
scope |
Required. attempt, user, room or applet. Same meaning as db scopes, with one difference: an applet slot still belongs to the version, so its files do not carry over to the next one. |
mime |
Required, at least one. type/subtype or type/*. |
max_bytes |
Optional. Default 10 MB, ceiling 50 MB. |
max_files |
Optional. Default 20, ceiling 500. |
The attempt scope requires attempt@1, and the room scope requires broadcast@1.
Uploading
To upload a file, you may pass the slot name and a File to the put method. The optional third argument carries metadata, such as a caption:
input.addEventListener('change', async () => {
const file = input.files?.[0];
if (! file) return;
const result = await Rack.storage.put('artifacts', file, { caption: caption.value });
if (! result.ok) {
return showError(result.error === 'quota_exceeded'
? 'That slot is full.'
: 'We could not accept that file.');
}
render(result.data.file); // status is usually 'pending' at this point
});Pass the File itself. The SDK hands it to the Rack, so you never encode it as JSON or upload it with your own fetch. The ack timeout for storage:put is 120 seconds rather than the usual 5, because upload time is participant time.
A stored file looks like this:
interface StoredFile {
id: string;
upload: string; // the slot name
name: string;
mime: string;
bytes: number;
status: 'pending' | 'clean' | 'infected' | 'failed';
created_at: string;
}Reading Files Back
You may list a slot's files with the list method, fetch a URL for one with the url method, and remove one with the delete method:
const { data } = await Rack.storage.list('artifacts'); // omit the slot for all of them
const url = await Rack.storage.url(file.id);
if (url.ok) img.src = url.data.url; // expires; refetch rather than cache
await Rack.storage.delete(file.id);url() returns a short-lived signed URL, 10 minutes by default, and never an object key. Don't store it. Request it when you are about to render, and request it again if the participant leaves the page open.
Pending Files
url() fails for a file that is not yet clean. Render pending files as placeholders and refresh until they are ready:
if (file.status === 'pending') {
setTimeout(async () => {
const again = await Rack.storage.list('artifacts');
// …look for this id, re-render when it turns clean
}, 2000);
}Scanning
The Rack scans every upload. Until a file is marked clean, url() won't return a link for it, so render it as pending.
An infected file never gets a URL, and the participant sees a failure rather than a mystery.
Quotas and Limits
The Rack enforces these limits on the server, per slot and per scope, regardless of what the manifest asks for:
max_bytesper file, capped at 50 MB.max_filesper scope, capped at 500.- The MIME allowlist. A file whose type is not in the slot's list is refused with
validation.
The server-side MIME check is not a substitute for setting accept on your <input>. Do both, for the participant's sake.
Uploads count against the applet, so declare the smallest slot that does the job.