Cloudflare D1 GUI が欲しくなる場面は決まっている。D1 のデータを見たいだけなのに、選択肢が意外と少ないときだ。wrangler d1 execute で SQL を打つか、Workers 側にエンドポイントを生やすか。ローカルの Miniflare が書いた SQLite ファイルは .wrangler/state/ の奥でハッシュ名になっていて、どれが目的のデータベースかも一目では分からない。

iyansr/d1-studio はそこに Cloudflare D1 GUI を一発で立てるツールだ。npx d1-studio だけで、プロジェクトの Wrangler 設定を読んで該当する SQLite ファイルを見つけ、ブラウザに表を出す。ORM もスキーマファイルも要らない。

この手の「ローカルに管理画面を立てる」道具で本当に見るべきなのは機能ではなく既定値だと思っている。待受はどこか、認証はあるか、読み取り専用は本当に書き込みを止めるか。止めると言っているだけで止まっていない実装は珍しくない。そこで拒否されたあとにデータベース側から数え直すところまでやった。ついでに、確認が入らない経路を自分で踏み抜いて201行消した。その話も書く。

d1-studio 1.0.1 の実測。ランタイム依存パッケージは0件、読み取り専用はINSERT・UPDATE・DELETE・DROPの4種を403で拒否しSELECTは200、URLトークン43文字とcookieで無し401・誤り401・正しい302・別オリジン403、AIのコードパスは0件。リポジトリに置かれた設計文書の行数はマイルストーン計画7本で970行・PRDが297行・決定ログと実測事実が126行・CLAUDE.mdが63行
@iyansr/d1-studio 1.0.1 を npm から導入して計測。数値はいずれも 2026-10-06 の実測

30秒でわかる

・導入は 1パッケージ・1.7MB・脆弱性0件。dependencies が空オブジェクトで、全依存が dist/ にバンドルされている
・待受は 127.0.0.1 のみ。起動URLに43文字のトークンが付き、無し・誤りはどちらも401
・CSRF対策は Origin ヘッダ無しも拒否(別オリジン403・ヘッダ無し403・同一オリジン200)
・読み取り専用は4種の文を403で止め、拒否後にDBを数え直して無改変を確認した
・ただしローカルの書込モードには確認が一切無い。既定が書込可なので、貼り付けミスが即座に効く
・本体にAI的な切り口は無い(AI/MCPのコードパス0件)。一方リポジトリには実測が仕様書を訂正する層が置いてある

Cloudflare D1 GUI として何をしてくれるか

まず正体の確認から。README の冒頭は「A zero-config browser studio for Cloudflare D1, local or remote. One command, no ORM, no schema file.」で、要するに D1 専用のブラウザ管理画面だ。SQLite 汎用ツールではなく、Wrangler の設定ファイルを読むところから始まるのが特徴になる。

導入を実測した。

npm i @iyansr/d1-studio
npx d1-studio --local path/to/db.sqlite --no-open -p 4199

結果は 1パッケージ・1.7MB・18ファイル・所要1秒・脆弱性0件。package.json を読むと dependencies が {}、engines が node >=22.16 だった。

項目 実測値
バージョン 1.0.1(npm @iyansr/d1-studio)
ライセンス MIT
インストールされるパッケージ 1件
展開サイズ 1.7MB / 18ファイル
ランタイム依存 0件(dependencies: {})
必要 Node 22.16 以上
CLIオプション 16個
star / fork / open issue 16 / 1 / 0

依存0件が効くのは npx 経由のときだ。依存ツリーを解決しないので起動が速く、監査対象も自分のパッケージだけになる。代償としてバンドルサイズを自分で抱えるが、1.7MB なら問題にならない。

起動すると、こういうバナーが出る。

d1-studio v1.0.1
  mode      local (read-write)
  database  test.sqlite
  file      ./test.sqlite
  studio    http://127.0.0.1:4199/?t=BqBNSmYSsehK70-i1YdTx3GuY_29YsziH0FT7HeaDBQ
  note      writes here can make concurrent `wrangler dev` writes fail (SQLITE_BUSY)

注目したいのは3つ。モードが明示されること(local (read-write))、URLにトークンが付くこと、そしてwrangler dev との同時書き込みが SQLITE_BUSY を起こしうると警告していることだ。3つめは実際に踏む事故で、起動時に言ってくれるのは親切だと思う。

--help のオプションは16個。--local / --remote、--port、--host、--db、--config、--env、--persist-to、--write / --no-write、--open / --no-open、-y、-h、-v。多すぎず、Wrangler のフラグ名に寄せてあるので迷わない。

ゼロコンフィグは本当にゼロか

「zero-config」と名乗る道具は多いが、実際には環境変数を1本要求したりする。ここも確かめた。

wrangler.json だけを置いたプロジェクトを作り、引数なしで起動する。

# wrangler.json に d1_databases を1件書いただけのディレクトリで
npx d1-studio --no-open

Miniflare の SQLite ファイルは、こちらでは何も指定していない。結果はこうなった。

d1-studio v1.0.1
  mode      local (read-write)
  database  demo-db (binding DB, id 3f2a…)
  config    ./wrangler.json

設定ファイルを見つけ、D1 のバインディングを読み、ハッシュ名の SQLite ファイルを特定するところまで引数なしで通った。 /api/tables を叩くと中の表が出てきて、SELECT も通る。

細かいが面白かったのが2点ある。

ひとつは、特定されたファイル名が b42e24f1… で始まるハッシュで、これはリポジトリの Verified facts に固定値として載っているものと同じだった。後述するが、この文書は作者が実際の Wrangler で確かめた値をテスト用の固定値として書き残したもので、出荷されたツールがその通りの名前を導き出している。文書と実装が噛み合っている。

もうひとつは、同じディレクトリに metadata.sqlite を置いておいたのに、それを掴まずに正しいほうを開いたこと。これも Verified facts の第3項に「状態ディレクトリには DB ではない metadata.sqlite も入るので、選択時に除外する」と書かれている内容そのままだ。

Cloudflare D1 GUI の既定値がどれだけ安全側に寄っているか

ここからが本題だ。ブラウザを使わず curl だけで確認した。

まず待受アドレス。--host の既定は 127.0.0.1 と help に書いてあるが、書いてあることと実態は別なので /proc/net/tcp を直接読んだ。

・ポート 4196 の待受アドレス → 127.0.0.1(0.0.0.0 ではない)

ローカル専用で立つ。同一LAN内の別マシンから覗かれる構成にはなっていない。

次にトークン。起動URLの ?t= は43文字だった。3条件で叩く。

リクエスト ステータス
トークン無しで / 401
正しいトークンで / 302(cookie を発行してリダイレクト)
誤ったトークン(同じ長さ)で / 401

陽性対照(正しいトークン)と陰性対照(誤ったトークン・無し)の両方を取った。発行される cookie の名前は d1s_4199 で、待受ポートが名前に入っている。

そしてCSRF。ローカルで動く管理画面は、利用者が別のタブで開いた悪意あるページから叩かれうる。ここが素通しだと、ローカル専用であることが守りにならない。

POST /api/query の条件 ステータス
Origin: http://127.0.0.1:4199(同一オリジン) 200
Origin: https://evil.example(別オリジン) 403
Origin ヘッダ無し 403

3つめが重要だ。Origin を省けば通る実装は実在する。ここは省いても403で、既定として正しい。

拒否したあとDBを数え直して無改変を確認した。403で止まったのはINSERT・UPDATE・DELETE・DROPの4種で、文の種別と対処が本文に入りRestart with --write to enable editsと返る。DB側から数え直した結果は行数201のまま増減なし、追加を試みた行は0件、orders表は存在したまま
拒否の返り値だけでなく、データベースの中身を直接開いて検証した

最後に読み取り専用ガード。--no-write で起動して5種の文を投げた。

投げた文 ステータス 返ってきたメッセージ
SELECT COUNT(*) FROM orders 200 {"rows":[[201]]}
INSERT INTO orders(...) VALUES(...) 403 Read-only mode: INSERT is not allowed. Restart with --write to enable edits.
UPDATE orders SET amount=0 403 Read-only mode: UPDATE is not allowed.(以下同じ)
DELETE FROM orders 403 Read-only mode: DELETE is not allowed.(以下同じ)
DROP TABLE orders 403 Read-only mode: DROP is not allowed.(以下同じ)

文の種別を名指しして、対処まで書くエラーメッセージは良い。「拒否されました」だけの実装より、原因と次の手が一度に分かる。

ただし403が返ることと、書き込みが起きていないことは別の話だ。そこでサーバーを止めてから node:sqlite で直接開き、数え直した。

・行数 → 201(開始時と同じ)
・INSERT を試みた customer='mallory' の行 → 0件
・UPDATE で0にしようとした amount=0 の行 → 0件
・orders 表の存在 → 1(DROP されていない)

ガードは実在していた。4種すべてについて、拒否の返り値と実際の無改変が一致している。

行編集のAPIは実行予定のSQLを返す

グリッドから行を編集したときに何が走るか。ここは /api/batch という別のエンドポイントになっていて、?dryRun=1 を付けると実行せずに SQL だけ返す。

実際に叩いた結果がこれだ。

{"statements":[{"sql":"UPDATE \"orders\" SET \"amount\" = ? WHERE \"id\" = ?",
                "params":[99.9,1],"dangerous":false}],
 "dangerous":false,"requiresConfirm":"none"}

識別子は二重引用符で囲まれ、値はプレースホルダで渡る。dangerous と requiresConfirm という判定結果も一緒に返ってくるので、UI 側はこれをそのまま確認ダイアログに出せる。

識別子の扱いも試した。

・表名に orders"; DROP TABLE orders; -- を入れる → 404「No such table: orders”; DROP TABLE orders; –」
・列名に amount"=0, "customer を入れる → 400「No such column: orders.amount”=0, “customer」

エスケープして通すのではなく、実スキーマと照合して存在しない識別子を弾いている。許可リスト方式なので、エスケープ漏れという失敗の仕方がそもそも無い。この設計は後述の決定ログに D6 として明記されていて、実装と文書が一致していた。

ローカル書込モードには確認が無い(201行消した)

ここで正直に書いておくことがある。筆者はこの検証中にテスト用の201行を全部消した。

確認ダイアログはリモートだけの機能。ローカルは確認なしでlocalは既定で書込可でDROPも即時実行、リモートのエディタで安全な文はnoneで自分で書いたSQLはそのまま走る、リモートのグリッド編集はクリック確認で実行予定のSQLをプレビューしてから、リモートの危険な文はDB名の入力が必要でDROPやWHERE無しのUPDATEとDELETEが該当
確認の強さはモードと操作元で変わる。ローカルは最も緩い

経緯はこうだ。/api/batch に ?dryRun=1 があったので、/api/query にも同じ仕組みがあるだろうと考えて、本文に {"sql":"DELETE FROM orders","dryRun":true} を入れて投げた。返ってきたのは 200 と "changes":201。dryRun は /api/query が受け付けるフィールドではなく、黙って無視されて本番実行された。

これは2つのことを示している。

1つめ、/api/query は知らないフィールドを拒否せず無視する。sql と params と confirm 以外は検証の対象外で、タイポや思い込みがそのまま実行に化ける。

2つめ、そしてこちらが本質だが、ローカルの書込モードには確認が一切無い。サーバー側のコードを読むと、確認の強さを決める関数の先頭にこう書いてある。

if (req.mode === 'local') return 'none';
if (req.dangerous) return 'type-name';
return req.source === 'grid' ? 'click' : 'none';

整理するとこうなる。

モード 操作元 危険な文か 必要な確認
ローカル すべて すべて なし
リモート SQLエディタ いいえ なし
リモート グリッド編集 いいえ クリック
リモート すべて はい データベース名を入力

--local の既定は --write(help にも「on for local, off for remote」と書いてある)。つまり何もオプションを付けずに開いた時点で、SQLコンソールから DROP TABLE が確認なしで通る状態になっている。

これは設計上の割り切りとして理解できる。ローカルの Miniflare ファイルは開発用の使い捨てで、壊れても wrangler dev を回せば作り直せる、という前提だろう。実際 Cloudflare の課金もリモートにしか効かないので、危険度の重みづけとしては筋が通っている。

それでも実務的な助言としては、消えて困るローカルDBを開くときは --no-write を付けるほうがいい。読むだけなら読み取り専用で足りるし、そちらのガードは先に見たとおり実在する。リモートを --write で開く場合は、危険な文にデータベース名の入力が入る(筆者は Cloudflare アカウントが無いためこの経路は未検証)。

graph TD A["npx d1-studio"] --> B{"--local か --remote か"} B -->|"--local(既定)"| C["Wrangler設定を読む
SQLiteファイルを特定"] B -->|"--remote"| D["Cloudflare REST API
本記事では未検証"] C --> E["127.0.0.1 で待受
URLトークン43文字"] D --> E E --> F{"書込モードか"} F -->|"--no-write"| G["INSERT/UPDATE/DELETE/DROP
を403で拒否"] F -->|"--write(localの既定)"| H{"モードは"} H -->|"local"| I["確認なしで即実行"] H -->|"remote・危険な文"| J["DB名の入力を要求"] H -->|"remote・グリッド"| K["SQLをプレビューして
クリック確認"]

本体にAIは無いが、リポジトリの進め方は見る価値がある

当サイトは AI 関連の OSS と開発者向けセキュリティを扱っている。d1-studio はそのどちらでもない。念のため機械的に確認した。

・src/ と ui/ の .ts / .tsx を mcp・anthropic・openai・llm で検索 → ヒット0件

この製品にAI的な切り口は無い。 無理に見つけようとすると捏造になるので、そこははっきり書く。今回は読者からの明示的な依頼で取り上げている。

仕様書を実測が訂正する層が置いてある。PRDは297行で作りたいものの仕様書、Verified factsは実際のWranglerで確かめた6項目、決定ログD1からD14は4件がPRDから逸脱と明示、スパイクS1からS5は未解決の問いとどの工程を止めるか
リポジトリに置かれた設計文書の構造。コードを書く前に読む側の分量が本体に匹敵する

ただしリポジトリの作り方のほうは別だ。CLAUDE.md が63行あり、冒頭でこう指示している——「機能に取りかかる前に該当する計画を読め。各計画の末尾に状況と逸脱の節がある」。その先にあるのが docs/plans/README.md(126行)で、ここに3つの層が置いてある。

Verified facts(実測で確かめた事実)。見出しは「Verified facts (spike, 2026-09-28, Wrangler 4.142.0)」で、前置きに「これらは実物の Wrangler に対して確認した。一部は PRD を訂正する」とある。中身は6項目で、たとえば、

・Miniflare のファイル名を決めるハッシュを実際に突き合わせ、一致したファイル名をテスト用の固定値として載せている
・派生元は database_id とは限らず、preview_database_id → database_id → バインディング名の順に試す必要がある
・状態ディレクトリには DB ではない metadata.sqlite も入るので、選択時に除外する
・「PRD の設定ファイル優先順位は間違っている」——実際は wrangler.json → wrangler.jsonc → wrangler.toml で、.wrangler/deploy/config.json のリダイレクトも追う

自分の仕様書を名指しで訂正する節が、仕様書と並べて置いてある。

決定ログ D1〜D14。各行に決定と理由が並び、PRD から逸脱するものには Δ、オーナーの承認が要るものには (sign-off) が付く。Δ が4件、sign-off が5件あった。

スパイク S1〜S5。まだ答えが出ていない問いと、それがどの工程を止めるかが書いてある。たとえば S2 は「wrangler auth token の出力形式・ログアウト時の終了コード・期限切れトークンを更新するか」で、工程03の初日にやる、credential の手順2を止める、と明記されている。

面白いのは、この決定ログの主張を公開物の側から検証できることだ。実際にやってみた。

決定 書かれている内容 実測
D1 パッケージを3MB未満に保つ・Node 22.16以上 1.7MB・engines: >=22.16 ✅
D7 全依存を dist/ にバンドルし dependencies は {} dependencies: {} ✅
D8 セッションcookieは d1s_<port>(ブラウザはポートで分離しないため) d1s_4199 ✅
D6 ?dryRun=1 が実行予定のSQLを返す・識別子はサーバー側で検証 返った・404と400で弾かれた ✅
D12 リモートの危険な文はDB名の入力を要求 コードで確認・実行は未検証

5件中4件が、出荷された成果物を触るだけで裏取りできた。計画に書いたことが実装に残っているというのは、当たり前のようで珍しい。

コミットの書き方にも同じ癖が出ている。本記事が測った2つの挙動は、それぞれ計画のタスク番号を持ったコミットに対応していた。

・c24e4519 feat(server): Hono app with host, origin and token security (01-T8) — 待受アドレス・Origin 検査・トークンを入れた回
・1b6b162e feat(server): read-only guard, auto-LIMIT and lazy remote counts (03-T4, T5, T6) — 読み取り専用ガードを入れた回

01-T8 や 03-T4 は docs/plans/01-core-cli.md や 03-remote.md に書かれたタスク番号で、計画 → コミット → 出荷物の挙動が一本の線でたどれる。先ほどの表で D1・D7・D8・D6 を成果物側から裏取りできたのは、この線が切れていないからだ。

起動時に出る SQLITE_BUSY の警告も、計画側に対応する項目があった。スパイク S3 が「wrangler dev が動いている状態での同時書き込み——破損のリスク、workerd がこちらの書き込みを見るか、dev サーバーが動いているかをどう検出するか」を工程01の未解決の問いとして挙げている。問いが残ったまま出荷して、代わりに起動時の警告でしのいでいるわけで、これはこれで正直な処理だと思う(筆者は wrangler dev を併走させていないので、競合そのものは未検証)。

同梱テストも回した。vitest の結果は 29ファイル・661テストが全通過・3.88秒・失敗0件。star 16・コミット55本の小さなリポジトリにしては、テストの密度がかなり高い。CI は Node と Bun の2ジョブ構成になっている。ソースは src/ と ui/src/ を合わせて TypeScript 118ファイル・13,415行で、設計文書1,456行に対してコード13,415行という比率だ。

導入するかの判断材料

導入して数えた結果。通過したテストは661、CLIのオプションは16、導入サイズは1.7MB、AIのコードパスは0件
数値はいずれも 2026-10-06 の実測

向いている場面

・Cloudflare D1 を使っていて、ローカルの Miniflare ファイルを毎回探すのが面倒な場合。設定ファイルから自動で見つけてくれるのがそのまま価値になる
・ORM を入れていないプロジェクト。スキーマ定義を要求しないので、素の D1 でも使える
・チームに配る前に既定値を自分で確かめたい場合。本記事で測った範囲(待受・トークン・CSRF・読み取り専用)はすべて curl だけで再現できる

向いていない場面

・Node 22.16 未満の環境。node:sqlite 前提なので動かない
・D1 以外の SQLite を日常的に触る用途。Wrangler 設定を起点にする設計なので、汎用の SQLite クライアントのほうが素直
・チームの共有環境に常駐させる用途。ローカル書込モードに確認が無く、--host を変えれば待受も広がる。そもそも開発者の手元で短時間動かす前提の道具だ

何を代替するかは明確で、wrangler d1 execute と自前の管理エンドポイントだ。前者は結果が端末に流れるだけで表として読みにくく、後者は作るのも守るのも自分の仕事になる。d1-studio はその中間を、依存0件の1.7MB で埋める。

star 16・公開から日が浅く、当サイトが普段使っている採用ラインには届いていない。それでも取り上げたのは、既定値の検証がそのまま記事になるくらい素直に作られていたからだ。403を返すだけでなく実際に書き込みを止めていること、Origin ヘッダ無しも落とすこと、識別子を許可リストで扱うこと。どれも「やっているつもり」で終わりやすい箇所で、全部実測で裏が取れた。

そのうえで、ローカル書込モードの確認の無さは知らずに使うと効く。筆者は201行で済んだが、--no-write を付ける習慣だけは持っておいたほうがいい。

参照ソース

・iyansr/d1-studio(GitHub) — 本記事の計測対象。@iyansr/d1-studio 1.0.1
・docs/plans/README.md(決定ログと Verified facts) — D1〜D14、S1〜S5、PRD を訂正する6項目の出典
・@iyansr/d1-studio(npm) — 配布パッケージ
・Cloudflare D1 公式ドキュメント — D1 そのものの仕様

計測値は data/measurements/runs/2026-10-06-d1-studio-cloudflare-d1.json に記録した。環境は Ubuntu 24.04 / x86_64 / node v22.22.2。リモートモード、データベース名入力による確認、ブラウザUI、Bun ランタイムはいずれも未検証。