Building a folder browser on a bucket that has no folders
How prefix, delimiter and cursor turn a flat keyspace into a navigable tree on Infrai storage — plus the paging behaviour that will surprise you.
One parameter does almost all the work: delimiter. Call GET /v1/storage/object/list/{bucket} with prefix set to the directory you’re showing and delimiter=/, and Infrai splits the flat keyspace into exactly the two lists a file browser needs — items are the files sitting at that level, common_prefixes are the subfolders below it. Nothing recurses, nothing is materialised, and the call is free.
The mental model to keep is that u/42/Photos/ is a string, not a directory. Storage holds keys; the slash is a convention; every folder you render is inferred from keys that happen to share a leading substring. That inference is fast and cheap, and it has consequences a Dropbox clone meets within a week — so let’s build the browser first, then look at the sharp edges.
One directory, one call
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS "https://api.infrai.cc/v1/storage/object/list/kb-uploads-0726?prefix=u/42/&delimiter=/&limit=100" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"items": [
{
"bucket_id": "bkt_fccb89d444004fc6aa1ed1",
"key": "u/42/readme.txt",
"size_bytes": 11,
"etag": "5eb63bbbe01eeed093cb22bb8f5acdc3",
"content_type": "text/plain",
"metadata": null,
"created_at": "2026-07-27T00:58:09.340848Z",
"last_modified": "2026-07-27T00:58:06Z"
}
],
"next_cursor": null,
"common_prefixes": ["u/42/Documents/", "u/42/Photos/"]
}
}
Two folders and one file — that’s a directory listing. Navigating into Documents/ is the same call with prefix=u/42/Documents/; navigating up is prefix.split("/").slice(0, -2).join("/") + "/" with no network round trip at all. Breadcrumbs are free because the path is the state.
Drop the delimiter and you get the recursive view: every key under the prefix, flat. That’s what you want for “download this folder as a zip” and never what you want for a UI.
Same endpoint, different question.
The paging behaviour that will bite you
limit caps the underlying keys the server scans, not the rows you get back. So with a small limit and a delimiter, a page can legitimately return zero items and one prefix, and the same prefix can appear on several consecutive pages. Walking our test tree with limit=2 produced this:
page1: items [] common_prefixes ["u/42/Documents/"]
next_cursor "u/42/Documents/2026/q2-report.pdf"
page2: items [] common_prefixes ["u/42/Documents/", "u/42/Photos/"]
next_cursor "u/42/Photos/trip/img-1.jpg"
page3: items ["u/42/readme.txt"] common_prefixes ["u/42/Photos/"]
next_cursor null
Documents/ came back twice. Push straight into an array and your sidebar shows duplicates, so accumulate prefixes in a Set and keep calling until next_cursor is null — the cursor is an object key, and you pass it back verbatim.
// drive.mjs — Node 22 ESM
const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
const auth = { Authorization: `Bearer ${KEY}` };
export async function readDirectory(bucket, prefix, { pageSize = 200 } = {}) {
const folders = new Set();
const files = [];
let cursor = null;
do {
const qs = new URLSearchParams({ prefix, delimiter: "/", limit: String(pageSize) });
if (cursor) qs.set("cursor", cursor);
const res = await fetch(`${API}/v1/storage/object/list/${bucket}?${qs}`, { method: "GET", headers: auth });
if (!res.ok) throw new Error(`list failed: HTTP ${res.status} ${await res.text()}`);
const { data } = await res.json();
for (const p of data.common_prefixes ?? []) folders.add(p);
for (const item of data.items ?? []) {
files.push({
name: item.key.slice(prefix.length),
key: item.key,
contentType: item.content_type,
sizeBytes: item.size_bytes,
modified: item.last_modified,
});
}
cursor = data.next_cursor ?? null;
} while (cursor);
return {
path: prefix,
parent: prefix.split("/").filter(Boolean).slice(0, -1).join("/") + "/",
folders: [...folders].map((p) => ({ name: p.slice(prefix.length).replace(/\/$/, ""), prefix: p })),
files: files.sort((a, b) => a.name.localeCompare(b.name)),
};
}
Sorting happens inside that function for a reason, and it’s the reason most file managers eventually grow a database. The list endpoint returns keys in lexicographic order; it doesn’t support sorting by size or modified date, filtering by content type, or any notion of “recently opened”. Every column header your users expect to be clickable is work you do after the response arrives. For a folder of 50 files that’s free — sort the array and move on. For a tenant with 200,000 objects across 4,000 prefixes, “sort by newest” would mean paging the whole keyspace on every render, which is the point where you stop treating the bucket as a database and keep a metadata table beside it: key, size, mime type, owner, modified, with the bucket holding nothing but bytes.
What a filesystem gives you that a keyspace doesn’t
| Operation | Filesystem | Bucket + list endpoint | What you build |
|---|---|---|---|
| Open a directory | readdir | prefix + delimiter=/ | nothing, it’s one call |
| Empty directory | exists | can’t exist — no key, no prefix | a folder row in your DB |
| Rename a directory | one rename | copy each object, then delete each | a job, not a request |
| Sort by date or size | stat + sort | lexicographic keys only | your own index |
| Folder size | du | sum over a recursive list | a counter you maintain |
| Move across buckets | n/a | POST /v1/storage/object/copy per object | a queued batch |
The empty-folder row isn’t a nitpick. Creating one by writing a zero-byte marker object doesn’t work either, because the write route wants a body:
{
"ok": false,
"error": {
"code": "INVALID_ARGUMENT",
"http_status": 400,
"message": "storage.object.put needs 'data' (base64 body)",
"retryable": false
}
}
Write a single-byte .keep if you must. The honest answer is that “new folder” is a row in your database which the browser merges into common_prefixes before rendering — users create empty folders constantly, and storage has no idea what they mean.
Renaming a folder, properly
There’s no move operation, so a rename is copy-then-delete over every key under the prefix. POST /v1/storage/object/copy handles one object and preserves its content type; POST /v1/storage/object/delete_batch/{bucket} clears up to 1000 keys per call and reports per-key failures instead of aborting.
// rename.mjs — Node 22 ESM
import { readDirectory } from "./drive.mjs";
const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
const json = { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" };
async function everyKeyUnder(bucket, prefix, out = []) {
const dir = await readDirectory(bucket, prefix);
out.push(...dir.files.map((f) => f.key));
for (const folder of dir.folders) await everyKeyUnder(bucket, folder.prefix, out);
return out;
}
export async function renameFolder(bucket, from, to) {
const keys = await everyKeyUnder(bucket, from);
for (const key of keys) {
const move = { src_bucket: bucket, src_key: key, dst_bucket: bucket, dst_key: to + key.slice(from.length) };
const res = await fetch(`${API}/v1/storage/object/copy`, { method: "POST", headers: json, body: JSON.stringify(move) });
if (!res.ok) throw new Error(`copy ${key} failed: HTTP ${res.status}`);
}
const cleanup = await fetch(`${API}/v1/storage/object/delete_batch/${bucket}`, {
method: "POST",
headers: json,
body: JSON.stringify({ keys }),
});
if (!cleanup.ok) throw new Error(`delete_batch failed: HTTP ${cleanup.status}`);
return (await cleanup.json()).data;
}
Copies are per-object and billable, so renaming a 4,000-file folder is 4,000 calls — run it from a queue with the user seeing an optimistic name, not inside a request handler. Because the delete happens after the copies, a crash midway leaves both trees present rather than neither, which is the failure mode you want.
curl -sS -X POST "https://api.infrai.cc/v1/storage/object/delete_batch/kb-uploads-0726" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"keys":["u/42/old/notes.md","u/42/old/missing.md"]}'
{
"ok": true,
"data": {
"deleted": ["u/42/old/notes.md"],
"errors": [{ "key": "u/42/old/missing.md", "code": "STORAGE_OBJECT_NOT_FOUND" }]
}
}
Partial success is the normal case, not the exception. And the queue that runs those renames is POST /v1/queue/publish on the same key as the bucket, with POST /v1/cron/create sweeping the trash folder nightly — no second account, no second vendor, and per-tenant cost attribution stays one query instead of a reconciliation across three invoices.
Cost, and when to use something else
Verified 27 July 2026: listing is free, as are head, presign and single-object delete. The rename path is what costs — POST /v1/storage/object/copy bills $0.0001 per object.
Reads are metered differently. GET /v1/storage/object/get/{bucket}/{key} bills $0.104 per GB of response body rather than per request, so a browser that lists constantly and downloads occasionally costs close to nothing, while a “download this whole folder” button is an egress line item you can forecast from your own file sizes. New accounts start with $2 of credit. Read the live figures, because they trend downward:
curl -sS "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| python3 -c "import json,sys; [print(c['id'], c['billing'].get('price_usd','free'), c['billing'].get('unit','')) for c in json.load(sys.stdin)['capabilities'] if c['id'].startswith('storage.object')]"
The same semantics exist on Amazon S3, where ListObjectsV2 with a delimiter returns CommonPrefixes, so this design ports without a rewrite. Supabase Storage is the honest alternative if you’d rather be handed folders, per-file rows and row-level security than assemble them: its storage API keeps a database row per object, which is exactly the index this article tells you to build. Pick Supabase if the file manager is your product. If it’s a feature inside a larger app, the list endpoint plus a small metadata table is a couple of hundred lines.