n naroドキュメント

バックエンド

プロジェクトごとにバックエンドを付けられます。専用のデータベース、サインアップとサインイン、そしてブラウザのコードから直接呼べる 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 enable
naro backend status

naro backend disable か、同じカードの「オフにする」でオフにします。最大 90 秒ほどでアプリのサインインとデータのリクエストが 404 になります。データベースとその中のデータ・ユーザー、サインイン設定、ポリシー、キーは残り、オンに戻すと同じキーでそのまま戻ります — アプリに置いた anon キーを変える必要はありません。オフの間、キーの入れ替えとサインイン設定の変更は 409 BACKEND_OFF で拒否され、ポリシーは変更できます。

naro backend disable

クイックスタート

createClient に naro backend が表示する URL と anon キーを渡し、supabase-js と同じ書き方でテーブルを扱います。呼び出しは例外を投げず、常に { data, error } を返します。

npm install naro-js
import { 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 に登録してください。プロバイダーはサインインを始めたホスト、つまり Cookie のあるホストへブラウザーを戻します。

naro backend auth email set --provider resend --from "App <no-reply@example.com>" --api-key-stdin < resend-key.txt
naro backend auth oauth set github --client-id <client-id> --client-secret-stdin < github-secret.txt
naro 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- Cookie を設定してからプロバイダーへ進めます — naro.sh の他のアプリはその Cookie を設定も上書きもできず、2 つのタブのサインインはそれぞれの Cookie を持ちます。サインインはその Cookie を持つブラウザーでだけ完了します(それ以外では OAUTH_BROWSER_MISMATCH)。結果はサーバーに送られない URL フラグメントに載ります — 60 秒間、一度だけ、このブラウザーがそのサインインのために保持した PKCE verifier でだけ使えるワンタイムコードです。createClient がページを開いたときに読み取ってセッションに交換し、アドレスバーから消します。getSession とすべてのクエリがそれを待つので、未ログインのまま先に出る要求はありません — 最大 15 秒です。それを過ぎると auth.ready() は TIMEOUT で解決し、クエリは未ログインのまま出ます。1 つのページで 2 回作られたクライアント(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 分に 1 通、1 時間に 5 通までです。リセットリンクは 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/ を許可し — そこにはサードパーティのスクリプトとオープンリダイレクトを置かないでください。プロバイダーが確認していないメールアドレスでの 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 off
naro backend users create --username admin --password-stdin
naro 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)でのみ受け付け、ブラウザ、Web Worker、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 --reveal
naro 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.sql
naro policy list

リクエストは、そのコマンドとロールに合う permissive ポリシーが一つ以上通り、restrictive ポリシーがすべて通るときに通ります。restrictive ポリシーは permissive ポリシーが通したものを狭めるだけで、単独では何も通しません。既定の FOR ALL はすべてのコマンド、既定の TO public は anon とサインイン済みユーザーの両方で、TO anon か TO authenticated で片方を選びます。コマンドごとに Postgres と同じように検査します。

リクエスト届く行書き込む行が通るべきもの
selectselect ポリシーの 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 の検査で、更新中のテーブル自身を読むサブクエリは、同じ文が先に変えた行を見ます。

名前付きポリシーより前に有効にしたバックエンドは、ポリシーを書き込むコマンド(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 は現在のバックエンドをアップロードし、保存済みのルールを名前付きポリシーに変換します。オフにしてあるバックエンドもオンに戻すので、オフのままにしたい場合は、終わってから 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 の拡張だからです。

ルール誰を通すか
noneservice キー以外は誰も。既定値です。
publicanon キーを持つ誰でも。サインインの有無は問いません。
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 の三値論理に従うので、グループにかけた 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() は条件 1 つかコールバックのまとまりを否定します。.match({ column: value }) はキーごとに eq を 1 つずつ加え、.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 つでも持つかを調べます。それぞれ文字列と数値を 1〜100 個受け付け、1 回のリクエストで 3 つ合わせて 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.ts
import { 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 変数を渡すと行全体になり、フィルターの値とフィルター文字列は検査しません。

上限

1 つの文でバインドできる値は 100 個まで、1 回の insert は 100 文までです。大きなまとまりは分けて送ってください。1 回の select で読めるのは .limit() か .range() で 1,000 行までです。1 回のリクエストのフィルター条件は 100 個まで、グループの入れ子は 4 段までです。like と ilike のパターンは UTF-8 で 50 バイトまでです(D1 の上限)。like では文字どおりの *、?、[ を 3 バイトと数えます。ilike が大文字小文字を同一視するのは ASCII の文字だけで、É と é は一致しません。update と delete にはフィルターが 1 つ以上必要で、テーブル全体を変える .all() は service キーでしか使えません。プロジェクトのデータベースが 1 日の書き込み上限(データベースのページを参照)を超えると、00:00 UTC まで insert・update・delete とサインアップは拒否されます。読み取りとサインインは引き続き使えます。

リクエストが失敗したら

呼び出しは例外を投げず、{ data, error } を返します。次のエラーはクエリではなく設定を指しています。

エラー対処
404 NOT_FOUND
There is no Naro backend at this host.
URL がバックエンドがオンのプロジェクトではありません。naro backend status が表示する URL を使い、オフと表示されたら naro backend enable でオンにしてください。オン・オフの切り替えがすべての場所に届くまで最大 90 秒ほどかかります。
503 BACKEND_UNAVAILABLE
The Naro backend could not be reached. Try again.
naro がいまこのホストのバックエンドを見つけられませんでした。もう一度試してください。
401 INVALID_API_KEY
The API key is missing or not valid for this project.
別のプロジェクトのキーか、入れ替え済みのキーです。naro backend status から anon キーを取り直してください。
401 INVALID_SESSION
The session is expired or not valid. Sign in again.
セッションが期限切れか、サインアウトされています。もう一度サインインしてください — SDK は古いトークンをすでに消しています。
403 PERMISSION_DENIED
This request is not allowed by the <action> policy of <table>.
この呼び出し元に、このテーブルでこのコマンドを許す permissive ポリシーがありません — update と delete には select ポリシーも必要です。naro policy list でテーブルのポリシーを確認し、マイグレーションの CREATE POLICY か naro policy create で追加してください。
403 PERMISSION_DENIED
New 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_INVALID
Policy <policy> on <table> cannot be applied.
ポリシーが、もうないカラムやテーブルを指しているか、生成カラムを検査しているか、一つの文でバインドできるより多くの値を使うか、naro が読めない形で保存されています。削除するか作り直すまで、そのポリシーがかかるリクエストは service キー以外すべて拒否されます。
500 POLICY_INVALID
Infinite recursion detected in policy for relation <table>.
ポリシーのサブクエリが読むテーブルの select ポリシーが、またそのポリシーへ戻ってきます。naro はそうしたポリシーを作成時に拒否します。それでもできてしまったら、その select ポリシーの一つがもう一方のテーブルを読まないようにして、循環を断ってください。
429 WRITE_QUOTA_EXCEEDED
This project has reached its daily database write limit. Reads still work; writes resume at 00:00 UTC.
プロジェクトのデータベースが今日(UTC)1 日の上限より多くの行を書き込みました。insert・update・delete とサインアップは 00:00 UTC に再開します — Retry-After ヘッダーと details.retryAfter があと何秒かを示し、naro db info で書き込み量を確認できます。読み取り、サインイン、サインアウトは引き続き使えます。service キーも拒否されます。この上限は権限ではなくコストの問題です。
507 DATABASE_FULL
The 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_CONFIGURED
Email 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_URL
redirectTo 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_REQUIRED
Naro sign-in works over https only. Use the https:// address of this project.
サインインはプロジェクトの https:// アドレスでのみ応答します(http は localhost のみ)。createClient とリンクには https の URL を使ってください。
403 EMAIL_NOT_VERIFIED
Email not verified
プロジェクトはメール確認を必須にしています。ユーザーに確認リンクを開いてもらうか、resendVerification で送り直してください。
403 SIGNUPS_DISABLED
Sign-ups are closed for this project.
プロジェクトのサインアップが閉じているため、誰も自分ではサインアップできません — メールでも、Google や GitHub の初回サインインでも。naro backend users create(または auth.admin.createUser)でアカウントを作るか、naro backend auth signups on でサインアップを開いてください。
403 PERMISSION_DENIED
Only the service key can manage users.
auth.admin はサーバー上で service キーから作ったクライアントでしか使えません。anon クライアントでは naro-js がリクエストを送らずにこのエラーを返します。

このリリースにないもの

メールアドレスかユーザー名とパスワード、メール確認とパスワードリセット、Google と GitHub でサインインできます。ほかのプロバイダー、マジックリンク、ワンタイムパスコードはまだありません。