스토리지
사용자가 올리는 파일 — 아바타, 첨부, 문서 — 을 프로젝트 URL 뒤의 Cloudflare R2에 두고, 누가 읽고 쓸지는 Supabase처럼 행 수준 보안 정책으로 정한다. naro-js는 supabase-js와 같은 방식으로 부른다.
얼리 액세스. Storage는 프로젝트 백엔드 위에서 돈다: 먼저 naro backend enable로 백엔드를 켠다. 컨트롤 플레인에서 Storage가 켜지기 전까지 naro storage enable은 STORAGE_DISABLED로 답한다.
켜기
naro storage enable은 프로젝트의 R2 버킷을 만들고 백엔드를 그 버킷과 함께 다시 올린다. 다시 돌려도 안전하고, 팀 플랜이 바뀐 직후 다시 돌리면 다음 확인을 기다리지 않고 새 한도가 바로 적용된다. Storage가 꺼져 있으면 naro storage bucket create가 먼저 켠다.
naro backend enablenaro storage enable버킷
파일은 직접 만든 버킷에 들어간다: 프로젝트당 100개까지, 이름은 1–63자의 소문자·숫자·-·_이고 글자나 숫자로 시작하고 끝난다. authenticated·copy·info·list·move·public·render·sign·upload는 Storage 경로가 쓰는 말이라 이름으로 못 쓴다. 공개 버킷은 URL을 가진 누구에게나 파일을 내준다 — 목록·업로드와 그 밖의 호출은 여전히 정책을 거친다. 버킷마다 파일 크기 상한과 받는 형식을 정할 수 있다.
naro storage bucket create avatars --public --file-size-limit 5MiB --allowed-mime-types image/png,image/jpeg,image/webpnaro storage bucket create docs누가 읽고 쓰는가
Storage의 정책은 Supabase처럼 storage.objects에 거는 Postgres 행 수준 보안이고, 쓰는 법도 같다: 마이그레이션에 쓰고 naro db push로 적용한다. 정책이 없으면 service 키만 지나간다. storage.foldername(name)·storage.filename(name)·storage.extension(name)은 Supabase와 같이 동작하고, storage.objects의 정책은 서브쿼리로 내 테이블을 읽을 수 있다.
-- 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앱에서
naro.storage.from(bucket)이 파일을 올리고, 내려받고, 나열하고, 지운다. 모든 호출은 { data, error }를 돌려주고 던지지 않는다. 업로드는 파일의 바이트를 그대로, 50 MiB까지, 파일의 형식(없으면 application/octet-stream)으로 보낸다. upsert: true면 있는 파일을 바꾼다.
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);공개 URL과 서명 URL
getPublicUrl은 요청 없이 공개 버킷의 URL을 만든다. createSignedUrl은 호출자가 읽을 수 있는 파일의 링크를 expiresIn초(최대 7일) 동안 준다 — 파일을 바꾸거나 지우면 링크는 더 이상 열리지 않는다.
터미널에서
naro storage ls·cp·rm은 프로젝트의 service 키로 naro://<bucket>/<path>를 다룬다 — NARO_SERVICE_KEY, 없으면 경고와 함께 받아 온다. 바이트는 naro를 거치지 않고 프로젝트 백엔드로 바로 간다. cp는 양쪽 방향으로 되고 로컬 파일은 덮어쓴다. 이미 있는 객체를 바꾸려면 --upsert가 필요하다. rm은 먼저 묻고, --recursive는 폴더 아래 전부를 지운다. naro://avatars/u1/는 폴더만, naro://avatars/u1은 이름이 u1인 객체까지 지운다.
naro storage cp ./me.png naro://avatars/u1/me.pngnaro storage ls naro://avatars/u1naro storage rm naro://avatars/u1/old.pngSupabase에서 옮겨 올 때
메서드 이름·인자·결과는 supabase-js 그대로다(FileObject의 snake_case 필드까지). 다른 점:
- supabase-js의 upload(path, file)는 multipart/form-data 폼으로 보낸다. Naro는 파일 바이트를 본문 그대로 받고 폼은 400 INVALID_REQUEST로 거절하므로, 클라이언트를 naro-js로 바꾸거나 아래처럼 fetch로 바이트를 보낸다.
- 업로드의 형식은 파일의 형식(Blob.type)이다. 형식 없는 바이트는 supabase-js의 기본값 text/plain이 아니라 application/octet-stream이고, 문자열은 text/plain;charset=utf-8이다.
- info는 list와 같은 snake_case FileObject를 돌려준다(크기와 형식은 metadata.size·metadata.mimetype). supabase-js의 info는 camelCase 필드(bucketId·createdAt·size·contentType)다.
- 오류는 naro-js의 다른 호출과 같은 NaroError { code, message, details? }다.
- 버킷은 클라이언트가 아니라 naro storage bucket create나 MCP 서버로 만든다.
- move·copy·createSignedUrls·list의 search와 50 MiB 넘는 파일은 아직 없다.
- 경로는 쓴 글자 그대로다 — 한글·이모지 포함, 정규화하지 않는다. .·.. 세그먼트, 빈 세그먼트, 앞뒤의 /, 제어 문자, 역슬래시(\), 양방향 제어 문자(U+202E 등)는 거절한다.
- Supabase 문서의 정책 모양 둘은 마이그레이션을 push할 때 거절된다. (select auth.uid()::text) 대신 (select auth.uid())::text로 쓰고, storage.foldername(name)은 (storage.foldername(name))[n](n은 1–16)으로만 쓴다 — any(...) 안에는 쓸 수 없다. storage.allow_only_operation과 storage.allow_any_operation은 없다.
naro-js 없이 보낼 때는 파일 바이트를 본문에 그대로 싣는다:
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,
},
);한도
| 항목 | 한도 |
|---|---|
| 업로드 한 번 | 50 MiB |
| 프로젝트의 파일 전체 | Hobby 1 GiB, Pro 100 GiB, Enterprise 1 TiB |
| UTC 하루의 쓰기 연산 | Hobby 50,000, Pro 500,000, Enterprise 5,000,000 |
| 버킷 | 프로젝트당 100개 |
| 파일 경로 | UTF-8로 900바이트 |
| 서명 URL | 1초부터 7일까지 |
용량은 팀 플랜을 따르고 15분마다 다시 확인한다. 넘으면 파일을 지울 때까지 업로드가 507 STORAGE_QUOTA_EXCEEDED로 답한다. 업로드는 한 번마다 쓰기 연산으로 세고, 하루 한도를 넘으면 00:00 UTC까지 업로드가 429 STORAGE_OPS_QUOTA_EXCEEDED로 답한다. 크기는 R2가 직접 재서 30분에서 1시간 반쯤 늦게 알려 준다: 파일을 지운 뒤에는 새 크기가 보고되고 확인돼야 업로드가 다시 열린다. 다운로드와 삭제는 막히지 않는다. 50 MiB 넘는 파일은 바이트를 보내기 전에 413 OBJECT_TOO_LARGE다.
끄기와 삭제
naro backend disable은 백엔드와 함께 Storage도 멈춘다: 다시 켤 때까지 URL은 404로 답하고 파일은 남는다. 프로젝트를 지우면 버킷과 그 안의 모든 파일이 지워진다: 빈 버킷은 바로, 아니면 모든 파일이 하루 안에 만료되도록 정해지고 그 뒤에 버킷이 지워진다. 지운 파일은 되살릴 수 없다.