백엔드
프로젝트마다 백엔드를 붙일 수 있다. 자기 데이터베이스, 가입과 로그인, 그리고 브라우저 코드가 직접 부르는 Data API다 — 사이에 직접 만든 서버가 없다. naro 워커가 프로젝트 URL 뒤에서 따로 돌기 때문에 호스트를 하나 더 설정할 일도, 커넥션 스트링을 들고 다닐 일도 없다.
얼리 액세스. 클라이언트는 npm install naro-js로 설치하고, 백엔드는 쓰는 프로젝트마다 켠다.
무엇이 생기나
앱에 env.DB로 묶인 Cloudflare D1 데이터베이스, 같은 데이터베이스에 저장되고 이메일이나 아이디와 비밀번호로 로그인하는 계정, 그리고 프로젝트 URL의 /_naro/v1에 있는 Data API다. 모든 요청은 SQL에 닿기 전에 API 키, 로그인한 사용자, 테이블 정책을 거치고, SQL은 naro가 직접 만든다. 값은 언제나 바인딩되고 테이블·컬럼 이름은 스키마에 실제로 있어야 한다. 계정과 정책은 _naro_ 테이블에 있고, Data API는 그 테이블을 열지 않는다.
사용자가 올리는 파일(아바타, 첨부)은 같은 URL 뒤, 같은 방식으로 쓰는 정책 아래 Storage에 둔다: 스토리지
켜기
백엔드는 켜기 전까지 꺼져 있고, 프로젝트마다 따로 켠다. 프로젝트 Database 페이지의 백엔드 카드에서 켜기를 누르거나 naro backend enable을 실행한다(처음 이름인 provision도 그대로 된다). 켜면 데이터베이스, 키, Naro 워커가 만들어진다. 다시 실행해도 안전하다. 멈춘 실행은 실패한 단계부터 이어 가고, 이미 돌고 있는 백엔드를 다시 켜면 수리가 된다 — 백엔드로 가는 라우트를 다시 쓰고 migrate와 상태 확인을 한 번 더 한다. naro backend status는 켜져 있는지, 상태, 코드가 부를 URL, anon 키를 보여주고, 도중에 죽은 실행은 stalled라고 알린다 — 같은 enable 명령이 이어 간다.
naro backend enablenaro backend statusnaro backend disable이나 같은 카드의 끄기로 끈다. 최대 90초쯤 안에 앱의 로그인과 데이터 요청이 404를 받는다. 데이터베이스와 그 안의 데이터·사용자, 로그인 설정, 정책, 키는 남고, 다시 켜면 같은 키로 그대로 돌아온다 — 앱에 넣은 anon 키를 바꿀 필요가 없다. 꺼진 동안 키 재발급과 로그인 설정 변경은 409 BACKEND_OFF로 거절되고, 정책은 그대로 바꿀 수 있다.
naro backend disable빠른 시작
createClient에 naro backend가 찍는 URL과 anon 키를 넘기고, supabase-js와 같은 방식으로 테이블을 다룬다. 호출은 던지지 않는다. 언제나 { data, error }를 돌려준다.
npm install naro-jsimport { createClient } from "naro-js";
const naro = createClient({
url: "https://my-app.naro.sh",
anonKey: "naro_pk_…",
});
await naro.auth.signIn({ email, password });
const {
data: { user },
} = await naro.auth.getUser();
const { data, error } = await naro
.from("posts")
.select("*")
.eq("user_id", user.id)
.order("created_at", { ascending: false })
.limit(20);
await naro.from("posts").insert({ title: "Hello", content: "World" });
await naro.from("posts").update({ title: "Updated" }).eq("id", 10);
await naro.from("posts").delete().eq("id", 10);메일·소셜 로그인
이메일·비밀번호 가입과 로그인은 설정이 필요 없다: 백엔드를 켜면 바로 된다. 인증 메일, 비밀번호 재설정, Google·GitHub 로그인은 프로젝트 자신의 계정으로 돈다: 그 인증·재설정 메일을 보낼 Resend API 키, 공급자마다 직접 만든 OAuth 앱. 이것들은 설정하기 전에는 켜지지 않는다. 비밀은 명령줄이 아니라 stdin으로만 넣고 다시는 보여 주지 않는다 — 메일 키의 앞 몇 글자나 set만. naro backend auth는 프로젝트의 프로덕션 호스트마다 — naro.sh 주소와 커스텀 도메인마다 — 콜백 URL을 보여 준다. createClient({ url })에 넘기는 출처의 것을 공급자 앱의 리다이렉트 URI로 등록한다: 공급자는 로그인을 시작한 호스트로, 쿠키가 있는 곳으로 브라우저를 돌려보낸다.
naro backend auth email set --provider resend --from "App <no-reply@example.com>" --api-key-stdin < resend-key.txtnaro backend auth oauth set github --client-id <client-id> --client-secret-stdin < github-secret.txtnaro backend auth redirects add https://app.example.com/auth/로그인은 브라우저를 앱으로 돌려보낸다. 프로젝트의 프로덕션 호스트 — https://<name>.naro.sh 주소와 커스텀 도메인 — 나 naro backend auth redirects add로 허용한 항목으로만 간다 — 출처(https://app.example.com, 모든 페이지) 또는 출처와 경로(https://app.example.com/auth/, 그 아래 페이지만). https, localhost만 http. 프리뷰 호스트(<name>-preview.naro.sh)는 누가 푸시한 브랜치든 같은 계정으로 돌린다: 추가해야만 허용되고, 인증·재설정 메일의 링크는 언제나 프로덕션 호스트로 간다. signInWithOAuth는 브라우저를 프로젝트의 /_naro/v1/auth/authorize/<provider>로 보내고, 그곳이 자기가 도는 호스트에 그 로그인 하나만의 10분짜리 __Host- 쿠키를 심은 뒤 공급자로 보낸다 — naro.sh의 다른 앱은 그 쿠키를 심거나 덮어쓸 수 없고, 두 탭의 로그인은 각자의 쿠키를 가진다. 로그인은 그 쿠키를 가진 브라우저에서만 끝난다(다른 곳에서는 OAUTH_BROWSER_MISMATCH). 결과는 서버로 가지 않는 URL 프래그먼트에 실린다 — 60초 동안 한 번만, 그리고 이 브라우저가 그 로그인을 위해 간직한 PKCE verifier로만 쓸 수 있는 일회용 코드. createClient가 페이지를 열 때 읽어 세션으로 바꾸고 주소창에서 지우며, getSession과 모든 쿼리가 그걸 기다려서, 로그인 전 상태로 먼저 나가는 요청이 없다 — 최대 15초: 그 뒤에는 auth.ready()가 TIMEOUT으로 풀리고 쿼리는 로그인 전 상태로 나간다. 한 페이지에서 두 번 만든 클라이언트(React StrictMode)는 같은 교환을 기다린다. 인증 링크는 어느 브라우저에서 열든 주소를 인증만 한다: 인증한 뒤 로그인한다.
// Google or GitHub: the browser goes to the project's /authorize, then to
// the provider, and comes back signed in.
await naro.auth.signInWithOAuth({
provider: "github",
redirectTo: "https://app.example.com/auth/done",
});
// On the page it comes back to, createClient finishes the sign-in itself.
const {
data: { user },
} = await naro.auth.getUser();
// Or read what came back: a failure, a confirmation or a reset link.
const { data, error } = await naro.auth.getSessionFromUrl();
// data.type: "oauth" | "email_verification" | "recovery" | null
// Email confirmation: the link only confirms. After you confirm, sign in.
await naro.auth.signUp({ email, password, redirectTo: "https://app.example.com/auth/confirmed" });
await naro.auth.resendVerification(email, { redirectTo: "https://app.example.com/auth/confirmed" });
// Password reset
await naro.auth.resetPasswordForEmail(email, { redirectTo: "https://app.example.com/auth/reset" });
// On /auth/reset, after the link — within 15 minutes, while nobody is signed
// in here (opening the link signed out whoever was):
await naro.auth.updateUser({ password: newPassword });resetPasswordForEmail은 계정이 있든 없든 같은 답을 주고, 한 주소에는 1분에 한 통, 1시간에 다섯 통까지만 보낸다. 재설정 링크는 naro_type=recovery로 돌아온다: 그 페이지에서 updateUser({ password })를 부른다. supabase-js의 복구 세션처럼, 링크를 열면 그 브라우저에 로그인해 있던 사람은 로그아웃된다. updateUser는 아무도 로그인해 있지 않을 때만, 15분 동안만 그 토큰을 쓰고, 로그인·가입·로그아웃은 토큰을 지운다 — 공용 컴퓨터에 열어 둔 링크를 다른 사람이 쓸 수 없다. 재설정은 그 계정의 세션을 모두 끝내고, 그 전에 연결된 Google·GitHub 로그인을 모두 떼어 내며(그 뒤 그 공급자로 다시 로그인하면 다시 연결된다), 주소를 인증된 것으로 한다. 그 뒤 새 비밀번호로 로그인한다. naro backend auth require-verification on이면 가입은 세션 없이 답하고, 주소를 인증하기 전의 로그인은 403 EMAIL_NOT_VERIFIED다.
로그인이 도착한 페이지의 스크립트는 누구든 프래그먼트를 읽을 수 있고, 열린 리다이렉트는 그것을 넘겨준다: 허용한 출처에서 다른 곳으로 보내는 페이지(/logout?next=…)가 있으면 코드나 재설정 토큰이 그 사이트로 간다. 로그인에는 전용 페이지를 두고 — 출처 전체 대신 https://app.example.com/auth/를 허용하고 — 거기에는 제3자 스크립트와 열린 리다이렉트를 두지 않는다. 공급자가 인증하지 않은 이메일의 Google·GitHub 로그인은 거절된다(OAUTH_PROVIDER_EMAIL_NOT_VERIFIED): 먼저 그 공급자에서 이메일을 인증한다. 주인이 인증하기 전에 다른 사람이 그 주소로 가입해 두었다면 — 또는 그 미인증 계정 때문에 주인의 Google·GitHub 로그인이 거절됐다면(OAUTH_ACCOUNT_NOT_LINKED) — 인증할 때 그 비밀번호가 지워진다(PASSWORD_RESET_REQUIRED): 비밀번호 재설정으로 새로 정한다.
관리자 계정과 가입 닫기
로그인은 프로젝트 자신의 naro Auth다(Supabase Auth에 해당). 나나 관리자만 로그인하는 앱이라면 가입을 닫고 계정을 직접 만든다. 가입이 닫히면 모든 가입 — 이메일 가입이든 Google·GitHub 첫 로그인이든 — 이 403 SIGNUPS_DISABLED("Sign-ups are closed for this project.")로 거절되고, 정책의 authenticated는 직접 만든 계정만 뜻한다. 이미 있는 사용자는 계속 로그인한다. naro backend users create는 --username, --email 중 하나 이상과 stdin의 비밀번호(--password-stdin: 파이프 또는 숨김 입력. --password도 되지만 셸 기록에 남는다)를 받는다. 계정은 인증된 상태로 만들어져 가입이 닫혀 있어도 바로 로그인되고, 아이디만 있는 계정은 email: null이다. naro backend users는 모든 사용자를 보여 주고, naro backend users delete <id|username|email>은 한 명을 지우고 그 세션을 끝낸다.
naro backend auth signups offnaro backend users create --username admin --password-stdinnaro backend auth password --min-length 10// In the app: sign in by username, or by email.
await naro.auth.signIn({ username: "admin", password });
// On a server, with the service key: supabase-js's auth.admin.
const admin = createClient({
url: "https://my-app.naro.sh",
serviceKey: process.env.NARO_SERVICE_KEY,
});
await admin.auth.admin.createUser({ username: "editor", password });
const {
data: { users, total },
} = await admin.auth.admin.listUsers({ page: 1, perPage: 50 });
await admin.auth.admin.deleteUser(users[0].id);아이디는 영문·숫자·_·. 3–30자이고 대소문자를 가리지 않는다. 가입이 열려 있는 동안 이미 있는 아이디로 가입하면 409 USERNAME_TAKEN("That username is already taken.")이 돌아와서, 누구나 어떤 아이디가 있는지 알 수 있다 — 주소마다 걸린 가입 속도 제한만큼. 가입이 닫혀 있으면 모든 가입이 먼저 403 SIGNUPS_DISABLED다. 서버에서는 service 키의 auth.admin.createUser, listUsers, deleteUser가 supabase-js와 같은 이름으로 같은 일을 한다. anon 클라이언트에서는 요청 없이 403 PERMISSION_DENIED다. 비밀번호는 6자 이상이다. naro backend auth password --min-length로 6–128을 정하고, 가입·비밀번호 재설정·생성에서 더 짧으면 400 PASSWORD_TOO_SHORT("Use at least 10 characters.")다. 로그인은 길이를 보지 않으므로 전에 정한 비밀번호는 계속 된다.
이 설정 전에 켠 백엔드는 다음 업로드 때 받는다: naro backend enable을 한 번 실행한다. 그 전에는 naro backend users가 409 BACKEND_OUTDATED, signIn({ username })과 auth.admin이 404 NOT_FOUND다.
키
anon 키는 프로젝트를 식별할 뿐 그 자체로는 아무 권한도 주지 않는다. 이 키로 온 요청도 전부 정책을 거치므로 브라우저 코드에 넣어도 된다. service 키는 모든 정책을 건너뛴다. 서버에 NARO_SERVICE_KEY로 두어야 한다. createClient는 service 키를 서버(Node, Deno, Bun, Cloudflare Workers)에서만 받고 브라우저, 웹 워커, React Native 앱에서는 거부한다. naro backend keys --reveal이 service 키를 찍는다(데이터베이스에 쓸 수 있는 역할만). naro backend keys rotate는 준비가 끝난 백엔드의 키를 겹치는 기간 없이 바꾼다. 새 키가 살아나면 옛 키를 보내는 클라이언트는 곧바로 거부되고, 재발급 도중 몇 초 동안은 옛 키가 먼저 거부될 수도 있다. Naro 워커가 데이터베이스보다 먼저 새 키를 받으므로 실패한 재발급은 대개 옛 키를 그대로 살려 둔다. 확실하지 않으면 — 5xx 응답, 시간 초과, 워커가 새 키를 기대하고 있을 수 있다는 문구 — naro backend keys --reveal로 어느 키가 살아 있는지 보고 naro backend enable을 실행한다.
// On a server only (Node, Deno, Bun, Workers). Anywhere else createClient throws.
const admin = createClient({
url: "https://my-app.naro.sh",
serviceKey: process.env.NARO_SERVICE_KEY,
});naro backend keys --revealnaro backend keys rotate --anon행 수준 보안
정책은 Supabase와 같은 Postgres의 행 수준 보안(RLS)이고, 늘 켜져 있다. 정책이 없는 테이블은 anon과 로그인한 사용자의 요청을 모두 거부하고, service 키는 정책을 건너뛴다. 테이블의 정책은 그 테이블을 만드는 마이그레이션에, 같은 파일의 CREATE TABLE 뒤에 CREATE POLICY로 쓴다. naro db push, naro migration up, naro db query, 그리고 MCP 툴 apply_migration·execute_sql이 정책을 테이블과 함께 통째로 적용하거나 통째로 되돌린다. 세 가지 꼴이 대부분의 앱을 덮는다 — 사용자 자기 행, 누구나 읽는 테이블, 관리자만 로그인하는 앱(먼저 naro backend auth signups off를 하고 관리자 계정을 만들어 두면 authenticated가 정확히 그 계정들이다):
-- migrations/0002_todos.sql: each signed-in user reads and changes their own rows.
create table todos (
id integer primary key,
user_id uuid not null,
title text not null
);
create policy "own rows" on todos for all to authenticated
using ((select auth.uid()) = user_id)
with check ((select auth.uid()) = user_id);
-- migrations/0003_posts.sql: anyone reads, signed in or not.
create table posts (id integer primary key, title text not null);
create policy "read" on posts for select to anon, authenticated using (true);
-- migrations/0004_reports.sql: an app only its admins sign in to. First run
-- naro backend auth signups off and create the admin accounts: authenticated
-- then means exactly those accounts.
create table reports (id integer primary key, body text not null);
create policy "admins" on reports for all to authenticated using (true) with check (true);마이그레이션과 쿼리는 CREATE POLICY, DROP POLICY(IF EXISTS는 없는 테이블도 건너뛴다, Postgres와 같다), COMMENT ON POLICY p ON t IS 'naro:owner <컬럼>'(p를 naro policy set --owner가 쓰는 owner 규칙으로 만든다. 다른 주석은 무시된다), ALTER TABLE t ENABLE·FORCE·NO FORCE ROW LEVEL SECURITY(늘 켜져 있으니 아무 일도 하지 않는다)를 받는다. 다음은 아무것도 실행하기 전에 400 VALIDATION_FAILED "Statement <i> of <n>: …"로 거절한다: ALTER TABLE … DISABLE ROW LEVEL SECURITY(모두에게 열려면 using (true)인 정책을 만든다), GRANT·REVOKE(모든 테이블이 행 수준 보안 뒤에 있으니 정책을 쓴다), ALTER POLICY(DROP POLICY 뒤 CREATE POLICY), 그리고 CREATE TABLE·DROP TABLE·이름 변경에 쓴 public.x 테이블('SQLite has no schema "public": write the table name without "public.".'). 한 요청의 문장은 통째로 적용되거나 통째로 되돌려진다: 없는 테이블의 정책, 이미 있는 이름, 한 테이블의 33번째 정책은 요청 전체를 400 DATABASE_QUERY_FAILED로 실패시키고, 어느 문장인지 알려 주며, 앞의 CREATE TABLE까지 아무것도 적용하지 않는다. 정책 문장은 params를 받지 않는다. 값은 SQL에 직접 쓴다.
정책은 Postgres처럼 테이블을 따라간다. DROP TABLE이 정책을 지우고, 새 테이블은 같은 이름이던 앞 테이블의 남은 정책을 물려받지 않는다(백엔드 없는 프로젝트에서 테이블 에디터로 바꾼 테이블은 예외). SQLite는 컬럼을 바꿀 때 테이블을 다시 만든다 — __new_x를 만들고, 행을 복사하고, x를 지우고, __new_x를 x로 바꾼다. 그래서 그 DROP이 x의 정책을 지우고 이름 변경은 되돌려 주지 않는다. 이름을 바꾼 뒤 같은 마이그레이션에서 CREATE POLICY를 다시 쓴다. 단순한 ALTER TABLE a RENAME TO b도 정책을 옮기지 않는다. 정책은 a 아래 남고 b에는 정책이 없으니 b에 다시 만든다. 응답의 warnings는 마이그레이션이 지운 정책을 붙여 넣을 수 있는 SQL과 함께 나열한다. 프로젝트에 아직 백엔드가 없을 때(정책은 저장되고 naro backend enable로 켜면 적용된다), 그리고 적용은 됐지만 Data API가 거절하는 정책이 있을 때(이유와 함께)도 알려 준다. CLI는 경고를 ⚠ …로 찍고 MCP 툴은 그대로 돌려준다.
naro db push --dry-run은 아직 적용되지 않은 파일의 정책 문장을 내 컴퓨터에서 파싱해 거절할 문장을 <파일>: Statement <i> of <n>: …로 찍는다. 테이블이나 정책이 있는지는 파일을 적용할 때만 안다. naro db pull은 지금 있는 정책을 기준 파일의 끝에 ALTER TABLE … ENABLE ROW LEVEL SECURITY와 CREATE POLICY 문으로 쓴다(owner 규칙의 컬럼은 COMMENT ON POLICY로). naro db reset은 정책을 스키마와 함께 비우고 마이그레이션 파일로 다시 만든다. 그래서 데이터베이스에만 있는 정책은 사라지는데, 묻기 전에 파일이 만들지 않는 정책을 다시 만드는 SQL과 함께 나열해 주니, 그 SQL을 마이그레이션에 넣으면 남길 수 있다(마이그레이션 이력이 있으면 naro db pull은 거절된다). 파일이 만들지 않는 테이블의 정책은 테이블과 함께 사라지고, 서버가 거절할 파일이 있으면 naro db reset은 시작하지 않는다. 백엔드가 있는 프로젝트에서 naro db lint는 rls_no_policy(Data API가 통째로 거절하는 테이블), rls_policy_text_default(정책이 불리언으로 읽는 수 컬럼의 기본값이 글자인 것, BOOLEAN DEFAULT 'true' 등), rls_policy_unindexed를 경고로, 백엔드가 적용하지 못하는 정책(텍스트 컬럼을 참·거짓으로 읽는 정책 등)인 rls_policy_invalid를 오류로 알린다.
더 갖춘 정책의 예다. 아래 정책은 사용자마다 자기 todos를, 누구에게나 공개된 글을, 팀원에게 그 팀의 문서를 보여 주고, 지운 문서는 모두에게서 숨긴다. 마이그레이션에 쓰거나 naro policy create로 보낸다 — 따옴표로 감싼 문장 하나, 또는 여러 문장과 DROP POLICY를 담은 --file. naro policy list는 그것을 create가 다시 받는 SQL로 찍는다. 바꾼 정책은 10초 안쪽으로 반영된다. 다만 naro policy create로 만든 정책은 ./migrations 어디에도 기록되지 않아서 naro db reset이 지운다.
-- Each signed-in user reads and changes their own todos.
create policy "own todos" on todos to authenticated
using (user_id = auth.uid())
with check (user_id = auth.uid());
-- Anyone reads published posts; authors add their own.
create policy "published posts" on posts for select
using (published);
create policy "authors insert" on posts for insert to authenticated
with check (author_id = auth.uid());
-- Team members read their team's docs. The subquery reads members through
-- members' own select policies, so members needs one too. It runs once per
-- doc, so it needs an index to find its rows by — create it first:
-- naro db index create members team_id
create policy "own memberships" on members for select to authenticated
using (user_id = auth.uid());
create policy "team docs" on docs for select to authenticated
using (exists (
select 1 from members m
where m.team_id = docs.team_id and m.user_id = auth.uid()
));
-- Deleted docs stay hidden, whatever else lets them through.
create policy "hide deleted" on docs as restrictive for select
using (deleted_at is null);naro policy create --file policies.sqlnaro policy list요청은 그 명령과 역할에 맞는 permissive 정책이 하나 이상 통과하고 restrictive 정책이 모두 통과할 때 지나간다. 그래서 restrictive 정책은 permissive 정책이 들인 것을 좁힐 뿐이고, 혼자서는 아무것도 들이지 않는다. 기본값 FOR ALL은 모든 명령이고, 기본값 TO public은 anon과 로그인한 사용자 둘 다다. TO anon이나 TO authenticated로 하나만 고른다. 명령마다 Postgres가 검사하는 대로 검사한다.
| 요청 | 닿는 행 | 쓰는 행이 통과해야 하는 것 |
|---|---|---|
select | select 정책들의 USING을 통과하는 행. | — |
insert | — | WITH CHECK. .select()가 있으면 select 정책들의 USING도. |
update | 자기 USING과 select 정책들의 USING을 통과하는 행. | WITH CHECK(없는 정책은 USING)와 select 정책들의 USING. |
delete | 자기 USING과 select 정책들의 USING을 통과하는 행. | — |
USING을 통과하지 못한 행은 오류 없이 빠진다. insert나 update가 쓰려는 행이 검사에 어긋나면 아무것도 쓰지 않고 — 요청 전체가 되돌려진다 — 403 PERMISSION_DENIED "New row violates row-level security policy for table <table>."로 답한다. restrictive 정책에 어긋났으면 "policy" 뒤에 따옴표로 그 이름이 붙는다. 그래서 행을 select가 보여 주는 범위 밖으로 옮기는 update는 Supabase처럼 거부된다. 위 정책에 docs의 update 정책을 더하면 deleted_at을 채우는 update는 "hide deleted"에 막힌다. 명령과 역할에 맞는 permissive 정책이 하나도 없으면 naro는 요청을 거부한다 — 403 PERMISSION_DENIED "This request is not allowed by the <action> policy of <table>." Postgres라면 행 0개로 답하는 자리다. 검사는 행을 저장될 모습으로 읽고, 컬럼이 비교하는 방식 — COLLATE NOCASE·RTRIM까지 — 으로 비교한다. 그래서 검사가 읽는 숫자 컬럼에는 문자열이 아니라 수·불리언·null을 보내야 하고, 그 컬럼은 LIKE로 맞추거나 글자로 바꿀 수 없다. 검사가 읽는 기본 키 컬럼은 null이 아닌 값으로 보내야 한다. 검사가 읽는 다른 컬럼은 기본값이 없거나 단순한 기본값 — NULL, TRUE, FALSE, 수, 숫자 컬럼이 아닌 컬럼의 따옴표 문자열 — 일 때만 빼도 되므로, 기본값이 CURRENT_TIMESTAMP 같은 식인 컬럼은 보내야 한다. 생성 컬럼과 가상 테이블의 컬럼은 읽을 수 없다.
USING과 WITH CHECK는 정책에 필요한 만큼의 Postgres 식을 받는다: 컬럼, 문자열·수·true·false·null, =, <>, <, <=, >, >=, AND·OR·NOT, IS [NOT] NULL, IS [NOT] DISTINCT FROM, [NOT] IN (…), [NOT] LIKE·ILIKE, BETWEEN, 다른 테이블을 보는 EXISTS (SELECT …)와 IN (SELECT …), lower·upper·coalesce·length·now(), 그리고 auth.uid()·auth.role()·auth.jwt() ->> 'sub'·'role'·'email'·'email_verified'. Supabase의 (select auth.uid())는 auth.uid()로 읽는다. anon에게 auth.uid()는 null이고 auth.jwt()에는 Supabase처럼 anon 키의 role만 있으며, NOT·IS NULL·NOT EXISTS도 Postgres처럼 읽는다: anon에게 auth.uid() IS NULL은 참이고, user_id = auth.uid()와 그 NOT은 어떤 행에도 맞지 않는다. anon을 위한 정책은 TO anon이나 auth.role() = 'anon'으로 쓴다. auth.jwt() ->> 'email'은 로그인한 사용자의 주소가 확인된 뒤에만 있고 그 전에는 null이다 — 가입은 주소를 확인하기 전에 로그인시키므로 확인되지 않은 주소는 적어 낸 글자일 뿐이고, 어느 쪽인지는 email_verified가 말한다. 서브쿼리는 Postgres처럼 그 테이블을 그 테이블의 select 정책을 거쳐 본다. 그래서 정책이 들여다보는 테이블에는 같은 사용자에게 맞는 select 정책이 있어야 한다 — 위에서는 members에 있다. 서브쿼리 안에서 테이블 이름 없는 컬럼은 서브쿼리 테이블의 것이 먼저이므로, 바깥 행은 docs.team_id처럼 쓴다. 바깥 행을 읽는 서브쿼리는 문장이 보는 행마다 한 번 돌므로 인덱스가 있어야 한다: WHERE가 =로 비교하는 컬럼으로 시작하는 인덱스가 생기기 전까지 naro는 그 정책을 거부하고 만들 인덱스를 알려 준다 — 위에서는 naro db index create members team_id. naro policy create --allow-unindexed로 그래도 만들 수 있지만, 그러면 돌 때마다 테이블 전체를 읽는다. 자기 자신으로 다시 펼쳐지는 정책은 만들 때 Postgres의 문구로 거부된다: "Infinite recursion detected in policy for relation members". 한 문장에 걸리는 정책은 값을 50개까지 바인딩하고 SQL을 50,000바이트까지 쓴다. 한 문장이 바인딩할 수 있는 값과 담을 수 있는 SQL의 나머지 절반은 요청 몫이다.
Postgres와 다른 점. 행 수준 보안은 늘 켜져 있고 끌 수 없다. 테이블을 모두에게 열려면 create policy "open" on t using (true) with check (true). permissive 정책이 없는 명령은 행 0개가 아니라 403이고, 호출자에게 select 정책이 없는 update·delete는 select 문구로 거부된다. 역할은 anon과 authenticated뿐이고 security definer 함수는 없다. 비교는 SQLite를 따른다: 컬럼의 친화성과 콜레이션이 비교를 정하고, 타입 오류는 없으며, lower·upper·ILIKE는 ASCII 글자만 접고, now()는 CURRENT_TIMESTAMP가 쓰는 UTC 글자이므로 같은 꼴로 저장한 컬럼과 비교한다. 컬럼은 선언한 타입으로 읽는다: 수·불리언 컬럼의 행에 글자 'true'가 들어 있으면 거짓으로 읽히고 NOT is_private가 그 행을 돌려준다. 그래서 Data API는 어느 테이블의 정책이든 읽는 컬럼(owner 규칙의 id 컬럼은 빼고)에 문자열을 넣지 못하게 한다(returning 유무와 무관, 400 INVALID_QUERY). 선언한 타입에 INT·REAL·FLOA·DOUB·NUM·DEC·BOOL·BIT가 든 컬럼에는 모든 문자열이 그렇고, 그 컬럼을 읽는 정책이 없어도 SQLite가 글자로 둘 문자열('abc'·'true', '7'은 7로 저장된다)은 거절한다 — Postgres는 타입 오류로 거절하고, 나중에 쓴 정책이 그 글자를 거꾸로 읽기 때문이다. 다른 타입으로 선언한 컬럼(uuid·date·timestamptz·json)은 문자열을 글자 그대로 두므로 Supabase의 user_id uuid와 auth.uid() = user_id가 동작하고, 거절하는 것은 SQLite가 수로 저장할 문자열('12')과 NUL이 든 문자열, 그리고 정책이 그 컬럼을 참·거짓으로 읽거나 수와 비교할 때의 모든 문자열뿐이다. 그런 컬럼을 빼고 쓰는 insert도, is_private BOOLEAN DEFAULT 'true'처럼 SQLite가 글자로 두는 기본값이면 같이 거절된다(must include is_private) — 값을 보낸다. service 키, raw SQL, 가져오기는 문자열을 넣을 수 있으니 'true'가 아니라 true를 보낸다. 그런 uuid 꼴 컬럼을 WITH CHECK가 수와 크기로 비교(<, >, BETWEEN)하면 새 글자를 저장될 행과 다르게 판단할 수 있고, LIKE나 글자 함수는 수 컬럼처럼 거절된다. update의 검사에서 고치는 테이블 자신을 읽는 서브쿼리는 같은 문장이 먼저 바꾼 행을 본다.
named 정책이 생기기 전에 켠 백엔드는 정책을 쓰는 명령 — naro policy create, naro policy drop, naro policy set — 에 409 BACKEND_OUTDATED로 답한다: "The project backend is older than named policies. Run `naro backend enable` to update it, then try again." naro backend enable은 지금의 백엔드를 올리고 저장된 규칙을 named 정책으로 바꾼다. 꺼 둔 백엔드도 다시 켜므로, 꺼 둬야 한다면 끝난 뒤 naro backend disable을 다시 실행한다. 그동안에도 naro policy list와 naro policy remove는 동작한다. CREATE POLICY나 DROP POLICY가 든 마이그레이션이나 쿼리도 아무것도 실행하기 전에 같은 답을 받고, 그런 문장이 없는 SQL은 전과 같이 실행된다.
줄임말: naro policy set
naro policy set은 명령마다 규칙이나 조건 하나로 테이블의 정책 naro_select, naro_insert, naro_update, naro_delete를 쓰고, 테이블의 다른 정책은 건드리지 않는다. none은 그 명령의 정책을 지운다. naro policy list는 그것을 CREATE POLICY로 보여 준다. owner 정책에는 -- naro: owner 주석이 붙는다. insert에서 owner 컬럼을 채우는 것은 SQL로 말할 수 없는 naro 확장이기 때문이다.
| 규칙 | 누구를 통과시키나 |
|---|---|
none | service 키 말고는 아무도. 기본값이다. |
public | anon 키를 가진 누구나. 로그인 여부와 상관없다. |
authenticated | 로그인한 사용자 누구나. |
owner | 로그인한 사용자, 그리고 owner 컬럼에 자기 사용자 id가 있는 행만. insert에서는 naro가 그 컬럼을 채우고, 다른 값을 보내면 거부한다. |
아래 명령은 로그인한 사용자마다 자기 todos만 읽고 바꾸게 한다. naro policy set에서 빠진 값은 저장된 값이 그대로 남는다. naro policy remove는 테이블의 정책을 모두 지워 테이블을 다시 닫는다.
naro policy set todos --select owner --insert owner --update owner --delete owner --owner user_id명령마다 행에 대한 조건을 걸 수도 있다. .or()의 필터 문법으로 쓰고 — 쉼표로 나눈 조건은 AND다 — 로그인한 사용자의 id 자리에 auth.uid()를 쓴다. members.cs.{auth.uid()}는 멤버 id가 든 JSON 배열에 맞는다. --select-when과 --delete-when은 USING, --insert-when은 WITH CHECK가 된다. --update-when은 update의 USING이 되고, 바뀐 행은 모두 --update-check를 만족해야 한다. --update-check를 주지 않으면 --update-when이다. anon에게 auth.uid()는 값이 없다: 그것과의 비교 하나는 어떤 행에도 맞지 않고, 그 not도 맞지 않는다. 묶음 안에서는 SQL의 3값 논리를 따르므로 묶음에 건 not은 anon을 들일 수 있다 — not(and(is_public.eq.1,user_id.eq.auth.uid()))는 공개가 아닌 행 모두에 맞는다. 조건은 막을 것의 not이 아니라 들일 것으로 쓴다. --signed-in은 그 명령에서 건 조건에서 anon을 돌려보낸다. --from-file은 정책을 JSON으로 받고, auth.uid()는 {"auth":"uid"}로 쓴다. naro policy set은 조건을 필터 문법으로 다시 찍고, --json은 정확한 JSON을 준다. using과 check는 각각 조건 100개, 중첩 4단까지이고 그중 contains·containedBy·overlaps는 4개까지다. naro policy set 한 번에 보내는 JSON은 16 KB까지다.
# Public posts for everyone, the rest for their author; authors write their own.
naro policy set posts \
--select-when 'or(is_public.eq.true,user_id.eq.auth.uid())' \
--insert-when 'user_id.eq.auth.uid()' \
--update-when 'user_id.eq.auth.uid()' \
--delete-when 'user_id.eq.auth.uid()'
# The same select condition as JSON, for naro policy set posts --from-file policy.json
{
"select": {
"using": {
"or": [
{ "operator": "eq", "column": "is_public", "value": true },
{ "operator": "eq", "column": "user_id", "value": { "auth": "uid" } }
]
}
}
}--update-check를 주면 기본값을 대신한다. 그래서 select 정책이 행을 그 주인에게 묶어 두지 않는 한, user_id.eq.auth.uid()가 없는 check로는 사용자가 user_id를 바꿔 행을 남에게 넘길 수 있다. 넣어 둔다: --update-when 'user_id.eq.auth.uid()' --update-check 'user_id.eq.auth.uid(),status.eq.draft'.
필터
이어 붙인 필터는 AND로 묶인다. .or()와 .and()는 조건을 묶고 — 아래처럼 콜백을 넘기거나 Supabase 필터 문자열을 넘긴다 — .not()은 조건 하나나 콜백의 묶음을 부정한다. .match({ column: value })는 키마다 eq를 하나씩 더하고, .filter(column, operator, value)는 이름으로 어떤 연산자든 받는다. NULL인 컬럼에 대한 조건은 참도 거짓도 아니고, 그 not도 마찬가지다. .not("status", "eq", "draft")는 status가 NULL인 행을 빼놓는다 — SQL, Supabase와 같다. 그런 행도 넣으려면 .or()에 status.is.null을 더한다.
import { quoteFilterValue } from "naro-js";
// Chained filters are ANDed. Group them with a callback…
await naro
.from("posts")
.select()
.or((q) => q.eq("status", "draft").lt("price", 10))
.not("deleted_at", "is", null)
.match({ user_id: user.id })
.filter("views", "gte", 100);
// …or paste Supabase's filter string.
await naro
.from("posts")
.select()
.or("status.eq.draft,and(price.lt.10,tags.cs.{sale})");
// Input from users goes in quoted, as one value.
await naro
.from("posts")
.select()
.or(`title.eq.${quoteFilterValue(q)},body.eq.${quoteFilterValue(q)}`);
// A TEXT column holding a JSON array, like ["a","b"].
await naro.from("posts").select().contains("tags", ["a", "b"]);필터 문자열의 값은 모두 텍스트다. id.eq.10은 "10"을 보낸다. 타입이 선언된 컬럼(INTEGER, REAL…)은 비교할 때 이를 수로 되돌리지만 타입이 없는 컬럼은 되돌리지 않고, SQLite는 모든 수를 모든 텍스트보다 앞에 두므로 타입 없는 n에서 n.lt.10은 모든 수에 맞는다 — 그런 컬럼에는 콜백 형식이나 .lt("n", 10)을 쓴다. 따옴표가 없으면 텍스트가 아닌 값이 몇 있다. is.null, is.true, is.false는 그 값 그대로다. eq·neq 뒤와 in.(…) 목록의 true·false는 불리언이다. done.eq.true는 1이 든 BOOLEAN·INTEGER 컬럼에 맞고 archived.neq.true는 0이 든 행에만 맞으며, done.eq."true"는 텍스트와 비교한다. cs·cd·ov 목록에서는 쓴 그대로 나가는 십진수가 수다 — 안전한 정수이거나 지수 없는 소수 — 그래서 {1e5}와 9,007,199,254,740,991보다 큰 id는 텍스트로 남는다. .like()와 .ilike()를 포함한 모든 like·ilike에서 *는 %를 뜻하므로(PostgREST와 같다) 글자 그대로의 *는 맞출 수 없다. 사용자 입력에는 콜백 형식이나 .filter()를 쓰고, 문자열을 만들어야 하면 값마다 quoteFilterValue()를 거친다. 필터는 테이블 정책이 허용하는 범위 안에서 좁힐 뿐이지만, 주입된 조건은 쿼리를 그 경계까지 넓힐 수 있다 — 입력을 그대로 넣어 만든 .delete().or()는 호출자가 지울 수 있는 행을 전부 지울 수 있다.
.contains(), .containedBy(), .overlaps()는 JSON 배열이 든 TEXT 컬럼에 쓴다. 값을 모두 가졌는지, 그 값들만 가졌는지, 하나라도 가졌는지를 본다. 각각 문자열과 수를 1~100개 받고, 요청 하나에 이 셋을 합쳐 4개까지 쓸 수 있다. JSON 배열이 아닌 행은 오류 없이 맞지 않는다. 65,536자를 넘는 값은 들여다보지 않아 조건에도 그 not에도 맞지 않고, JSONB 값은 언제나 맞지 않는다.
타입
naro gen types가 스키마의 TypeScript를 써 준다. 그 Database를 createClient<Database>()에 넘기면 클라이언트가 테이블을 안다: from()은 테이블 이름을 받고, select("id, title")은 그 컬럼만 돌려주며 .single()도 따라간다 — 테이블에 없는 컬럼은 쓴 자리에서 타입 오류다 — insert()와 update()는 테이블이 저장할 수 있는 것을 받고, 필터와 order()는 그 테이블의 컬럼 이름을 받는다. --lang typescript가 기본값이자 유일한 언어다. 타입 인자가 없으면 클라이언트는 전처럼 타입이 없다.
naro gen types --output src/database.types.tsimport { createClient } from "naro-js";
import type { Database, Tables } from "./database.types";
const naro = createClient<Database>({
url: "https://my-app.naro.sh",
anonKey: "naro_pk_…",
});
// { id: number; title: string }[] | null
const { data } = await naro.from("posts").select("id, title");
// Does not compile: Argument of type '"id, nope"' is not assignable
// to parameter of type '"Unknown column 'nope'"'.
await naro.from("posts").select("id, nope");
await naro.from("posts").insert({ title: "Hello", user_id: user.id });
const post: Tables<"posts"> | null = (
await naro.from("posts").select().eq("id", 1).maybeSingle()
).data;Insert는 생성 컬럼을 빼고, 기본값이 있거나 NULL을 허용하거나 INTEGER PRIMARY KEY이거나 insert 규칙이 owner인 테이블의 소유 컬럼이면 선택으로 둔다 — 소유 컬럼은 백엔드가 채우고, naro gen types가 정책을 읽어 안다(naro policy set 뒤에는 다시 돌린다). Update는 모든 컬럼이 선택이다. 타입은 SQLite의 친화도 규칙과 D1이 돌려주는 값을 따른다: INTEGER·REAL은 number, TEXT는 string, BOOLEAN은 number — 0이나 1이고 insert에는 true·false를 넣어도 된다 — NUMERIC·DATE·DATETIME은 number | string(CURRENT_TIMESTAMP는 텍스트다), BLOB은 number[]이고 Data API로는 쓸 수 없다, 타입이 없는 컬럼과 STRICT 테이블의 ANY 컬럼은 number | string | number[]다. TEXT 컬럼에는 배열이나 객체를 그대로 쓸 수 있다 — insert({ tags: ["a", "b"] }) — Data API가 JSON.stringify와 같은 JSON 텍스트로 저장하고 .contains()가 그것을 읽는다. 읽으면 그 문자열로 돌아오므로 JSON.parse한다. 뷰는 Tables<"view">용으로 Database.views에 Row가 있지만 from()은 받지 않는다: Data API는 뷰를 읽지 않고 404 TABLE_NOT_FOUND로 답한다. select()에 따옴표 친 이름이나 string 변수를 넘기면 행 전체가 나오고, 필터 값과 필터 문자열은 검사하지 않는다.
한도
문장 하나는 값을 100개까지 바인딩하고, insert 하나는 100문장까지다. 큰 묶음은 나눠 보낸다. select 한 번은 .limit()이나 .range()로 1,000행까지 읽는다. 요청 하나의 필터 조건은 100개까지이고, 묶음은 4단까지 겹칠 수 있다. like·ilike 패턴은 UTF-8로 50바이트까지다(D1의 한도). like에서 글자 그대로의 *, ?, [는 3바이트로 센다. ilike는 ASCII 글자만 대소문자를 접는다. É와 é는 서로 맞지 않는다. update와 delete에는 필터가 하나 이상 있어야 하고, 테이블 전체를 바꾸는 .all()은 service 키만 쓸 수 있다. 프로젝트 데이터베이스가 하루 쓰기 한도(데이터베이스 문서 참고)를 넘으면 00:00 UTC까지 insert·update·delete와 가입이 거절된다. 읽기와 로그인은 계속 된다.
요청이 실패하면
호출은 던지지 않고 { data, error }를 돌려준다. 아래 오류는 쿼리가 아니라 설정을 가리킨다.
| 오류 | 할 일 |
|---|---|
404 NOT_FOUNDThere is no Naro backend at this host. | URL이 백엔드가 켜진 프로젝트가 아니다. naro backend status가 찍는 URL을 쓰고, 꺼져 있다고 나오면 naro backend enable로 켠다. 켜고 끈 것이 모든 위치에 닿기까지 최대 90초쯤 걸린다. |
503 BACKEND_UNAVAILABLEThe Naro backend could not be reached. Try again. | naro가 지금 이 호스트의 백엔드를 찾지 못했다. 다시 시도한다. |
401 INVALID_API_KEYThe API key is missing or not valid for this project. | 다른 프로젝트의 키이거나 바뀐 키다. naro backend status에서 anon 키를 다시 가져온다. |
401 INVALID_SESSIONThe session is expired or not valid. Sign in again. | 세션이 만료됐거나 로그아웃됐다. 다시 로그인한다 — SDK가 옛 토큰은 이미 지웠다. |
403 PERMISSION_DENIEDThis request is not allowed by the <action> policy of <table>. | 이 호출자에게 이 테이블의 이 명령을 허락하는 permissive 정책이 없다 — update와 delete에는 select 정책도 있어야 한다. naro policy list로 테이블의 정책을 보고, 마이그레이션의 CREATE POLICY나 naro policy create로 더한다. |
403 PERMISSION_DENIEDNew row violates row-level security policy for table <table>. | 이 insert나 update가 쓰는 행이 WITH CHECK에, 또는 update나 .select()를 붙인 insert라면 select 정책에 어긋나 아무것도 쓰지 않았다. 문구에 이름이 있으면 어긋난 restrictive 정책이다. 보낸 값 — 예를 들어 user_id — 을 naro policy list가 찍는 것과 비교한다. |
500 POLICY_INVALIDPolicy <policy> on <table> cannot be applied. | 정책이 이제 없는 컬럼이나 테이블을 가리키거나, 생성 컬럼을 검사하거나, 한 문장이 바인딩할 수 있는 것보다 많은 값을 쓰거나, naro가 읽을 수 없는 꼴로 저장됐다. 지우거나 다시 만들 때까지 그 정책이 걸리는 요청은 service 키 말고는 모두 거부된다. |
500 POLICY_INVALIDInfinite recursion detected in policy for relation <table>. | 정책의 서브쿼리가 읽는 테이블의 select 정책이 다시 그 정책으로 돌아온다. naro는 그런 정책을 만들 때 거부한다. 그래도 생겼다면 그 select 정책 중 하나가 다른 테이블을 읽지 않게 해서 고리를 끊는다. |
429 WRITE_QUOTA_EXCEEDEDThis project has reached its daily database write limit. Reads still work; writes resume at 00:00 UTC. | 프로젝트 데이터베이스가 오늘(UTC) 하루 한도보다 많은 행을 썼다. insert·update·delete와 가입은 00:00 UTC에 다시 된다 — Retry-After 헤더와 details.retryAfter가 몇 초 남았는지 알려 주고, naro db info가 쓴 양을 보여준다. 읽기, 로그인, 로그아웃은 계속 된다. service 키도 막힌다. 이 한도는 권한이 아니라 비용 문제다. |
507 DATABASE_FULLThe database has reached D1's 10 GB limit, which cannot be raised. Delete data to free space; writes fail until it is under the limit. | 프로젝트 데이터베이스가 D1의 10 GB 한도에 닿았다. 필요 없는 데이터를 지우거나 큰 바이너리를 스토리지(R2)로 옮길 때까지 쓰기가 실패하고, 다시 시도해도 풀리지 않는다. 읽기는 계속 된다. naro db info가 크기를 보여 주고 8 GB부터 경고한다. |
409 EMAIL_NOT_CONFIGUREDEmail is not set up for this project. Run `naro backend auth email set` first. | 프로젝트에 메일이 설정돼 있지 않아 인증·재설정 링크를 보낼 수 없다. naro backend auth email set으로 설정한다. |
409 PROVIDER_NOT_CONFIGURED<Provider> sign-in is not set up for this project. Run `naro backend auth oauth set <provider>` first. | naro backend auth oauth set으로 그 공급자의 OAuth 앱을 설정하고, naro backend auth가 보여 주는 콜백 URL 중 앱이 createClient에 넘기는 출처의 것을 앱의 리다이렉트 URI로 등록한다. |
403 INVALID_REDIRECT_URLredirectTo is not an allowed redirect for this project. Allow its origin, or a path under it, with `naro backend auth redirects add <url>`. | naro backend auth redirects add로 앱의 출처를 — 더 좋게는 https://app.example.com/auth/처럼 로그인 페이지만 — 허용하거나, 프로젝트 프로덕션 호스트의 페이지로 돌려보낸다. 프리뷰 호스트는 추가해야만 허용된다. |
400 HTTPS_REQUIREDNaro sign-in works over https only. Use the https:// address of this project. | 로그인은 프로젝트의 https:// 주소에서만 답한다(http는 localhost만). createClient와 링크에 https URL을 쓴다. |
403 EMAIL_NOT_VERIFIEDEmail not verified | 프로젝트가 메일 인증을 요구한다. 사용자에게 인증 링크를 열게 하거나 resendVerification으로 다시 보낸다. |
403 SIGNUPS_DISABLEDSign-ups are closed for this project. | 프로젝트의 가입이 닫혀 있어서 누구도 스스로 가입할 수 없다 — 이메일로도, Google·GitHub 첫 로그인으로도. naro backend users create(또는 auth.admin.createUser)로 계정을 만들거나 naro backend auth signups on으로 가입을 연다. |
403 PERMISSION_DENIEDOnly the service key can manage users. | auth.admin은 서버에서 service 키로 만든 클라이언트에서만 된다. anon 클라이언트에서는 naro-js가 요청 없이 이 오류를 돌려준다. |
이번 릴리스에 없는 것
이메일이나 아이디와 비밀번호, 메일 인증과 비밀번호 재설정, Google·GitHub로 로그인할 수 있다. 다른 공급자, 매직 링크, 일회용 코드 로그인은 아직 없다.