n naroDocs

Storage

Store the files your users upload — avatars, attachments, documents — in Cloudflare R2 behind your project's URL, and decide who may read and write them with row-level security policies, as in Supabase. naro-js calls it the way supabase-js does.

Early access. Storage runs on the project's backend: turn that on first with naro backend enable. Until Storage is switched on for the control plane, naro storage enable answers STORAGE_DISABLED.

Turn it on

naro storage enable makes the project's R2 bucket and uploads the backend again with it. It is safe to run again, and running it right after your team's plan changes applies the new limits at once, without waiting for the next check. naro storage bucket create turns Storage on first when it is off.

naro backend enable
naro storage enable

Buckets

Files live in buckets you make: up to 100 per project, named with 1–63 lowercase letters, digits, - and _, starting and ending with a letter or a number; authenticated, copy, info, list, move, public, render, sign and upload are taken by the storage routes. A public bucket serves its files to anyone who has the URL; listing, uploading and every other call still go through the policies. A bucket can cap the size of a file and the types it takes.

naro storage bucket create avatars --public --file-size-limit 5MiB --allowed-mime-types image/png,image/jpeg,image/webp
naro storage bucket create docs

Who may read and write

Storage's policies are Postgres row-level security on storage.objects, as in Supabase, and they are written the same way: in a migration, applied with naro db push. Without a policy only the service key gets through. storage.foldername(name), storage.filename(name) and storage.extension(name) work as in Supabase, and a policy on storage.objects can read your tables in a subquery.

-- migrations/0005_avatars.sql
create policy "avatars are readable" on storage.objects
  for select to public using (bucket_id = 'avatars');
create policy "upload into own folder" on storage.objects
  for insert to authenticated
  with check (bucket_id = 'avatars' and (storage.foldername(name))[1] = auth.uid()::text);
create policy "replace own files" on storage.objects
  for update to authenticated
  using (bucket_id = 'avatars' and (storage.foldername(name))[1] = auth.uid()::text);
create policy "remove own files" on storage.objects
  for delete to authenticated
  using (bucket_id = 'avatars' and (storage.foldername(name))[1] = auth.uid()::text);
naro db push

From your app

naro.storage.from(bucket) uploads, downloads, lists and removes files. Every call returns { data, error } and never throws. An upload sends the file's own bytes, up to 50 MiB, typed as the file is (or application/octet-stream); upsert: true replaces a file that is there.

import { createClient } from "naro-js";

const naro = createClient({
  url: "https://my-app.naro.sh",
  anonKey: "naro_pk_…",
});
const {
  data: { user },
} = await naro.auth.getUser();

const avatars = naro.storage.from("avatars");
await avatars.upload(`${user.id}/me.png`, file, { upsert: true });
const { data: image } = avatars.getPublicUrl(`${user.id}/me.png`); // no request
await avatars.list(user.id, { limit: 20 });
await avatars.remove([`${user.id}/old.png`]);

// A private bucket: a link for ten minutes.
const { data: link } = await naro.storage
  .from("docs")
  .createSignedUrl("team-1/plan.pdf", 600);

Public and signed URLs

getPublicUrl builds a public bucket's URL without a request. createSignedUrl links to a file the caller can read, for expiresIn seconds (up to 7 days); the link stops working once the file is replaced or removed.

From the terminal

naro storage ls, cp and rm work on naro://<bucket>/<path> with the project's service key — NARO_SERVICE_KEY, or fetched for you with a warning. The bytes go straight to your project's backend, not through naro. cp goes both ways and overwrites a local file; replacing an object that is already there takes --upsert. rm asks first, and --recursive removes everything under a folder: naro://avatars/u1/ is the folder alone, naro://avatars/u1 takes an object named u1 as well.

naro storage cp ./me.png naro://avatars/u1/me.png
naro storage ls naro://avatars/u1
naro storage rm naro://avatars/u1/old.png

Coming from Supabase

The method names, arguments and results are supabase-js's, FileObject's snake_case fields included. What is different:

  • supabase-js's upload(path, file) sends a multipart/form-data form. Naro takes the file's own bytes as the body and refuses a form with 400 INVALID_REQUEST, so switch the client to naro-js, or send the bytes with fetch as below.
  • An upload is typed as the file is (Blob.type). Bytes with no type are application/octet-stream, not supabase-js's default text/plain, and a string is text/plain;charset=utf-8.
  • info answers the same snake_case FileObject as list, with the size and type in metadata.size and metadata.mimetype; supabase-js's info answers camelCase fields (bucketId, createdAt, size, contentType).
  • Errors are NaroError { code, message, details? }, like the rest of naro-js.
  • Buckets are made with naro storage bucket create or the MCP server, not from the client.
  • move, copy, createSignedUrls, list's search and files over 50 MiB are not here yet.
  • A path keeps every character as written — Korean and emoji included, nothing normalized; . and .. segments, empty segments, a / at either end, control characters, a backslash (\) and bidirectional control characters (U+202E and the like) are refused.
  • Two policy shapes from Supabase's docs are refused when the migration is pushed: write (select auth.uid())::text, not (select auth.uid()::text), and use storage.foldername(name) only as (storage.foldername(name))[n] with n from 1 to 16 — not inside any(...). storage.allow_only_operation and storage.allow_any_operation are not available.

Without naro-js, send the file's bytes as the body:

await fetch(
  `https://my-app.naro.sh/_naro/v1/storage/object/avatars/${user.id}/me.png`,
  {
    method: "POST",
    headers: {
      apikey: "naro_pk_…",
      authorization: `Bearer ${token}`, // a signed-in user's token
      "content-type": file.type,
      "x-upsert": "true",
    },
    body: file,
  },
);

Limits

WhatLimit
One upload50 MiB
All files of a project1 GiB on Hobby, 100 GiB on Pro, 1 TiB on Enterprise
Write operations a day (UTC)50,000 on Hobby, 500,000 on Pro, 5,000,000 on Enterprise
Buckets100 per project
A file's path900 bytes of UTF-8
A signed URL1 second to 7 days

The capacity follows your team's plan, checked every 15 minutes; past it, uploads answer 507 STORAGE_QUOTA_EXCEEDED until you delete files. Every upload counts as a write operation, and past the day's limit uploads answer 429 STORAGE_OPS_QUOTA_EXCEEDED until 00:00 UTC. The size comes from R2 itself, which reports it 30 minutes to about an hour and a half late: after you delete files, uploads reopen only once the new size has been reported and checked. Neither limit stops downloads or deletes. A file over 50 MiB answers 413 OBJECT_TOO_LARGE before any byte is sent.

Off and deleted

naro backend disable stops Storage with the rest of the backend: its URLs answer 404 until it is on again, and the files are kept. Deleting the project deletes its bucket and every file in it: an empty bucket at once; otherwise every file is set to expire within a day, and the bucket is deleted after that. Deleted files cannot be restored.