ハコヅミを作る #29:管理者の権限と「凍結」、登録数の上限を作る
#27で、Google アカウントでログインできるようになりました。一般に公開すると、自分以外の人が登録できるようになります。
その前に要るのが、迷惑な使い方に気づいて、止める手段です。
- 管理者の権限と、管理画面の最小限(#138)
- 会員区分と、登録数の上限(#139)
管理者に見えるのは、件数まで
管理画面に出すのは、ユーザーの一覧です。メールアドレス、名前、登録した日、最後に使った日、そして件数(ギア・消耗品・コンテナ・プラン・キャンプ・画像)を出します。

管理画面(データは架空)。出すのは件数まで。自分自身と管理者には、「凍結する」のボタンを出さない
先に決めたのは、管理者にも、ほかの人のデータの中身は見せないことです。ギアの名前、キャンプ場、メモ、画像は、管理画面に出しません。サーバーの問い合わせも、件数を数えるだけで、中身は取っていません。
迷惑な使い方(画像を大量に置く、など)は、件数を見れば気づけます。中身まで見られる画面は、作らなければ、漏れることもありません。
管理画面は、ないものとして扱う
管理者でない人が /admin を開くと、404 を返します。「権限がありません」ではなく、「そのページはありません」です。管理画面があること自体を、見せないためです。
最初の管理者は、D1 のコンソールで決める
権限は、users のテーブルに role(user/admin)の列を足して持ちます。では、最初の管理者は、どう決めるのでしょうか。
画面から管理者にできる機能を作ると、その機能を守る仕組みも要ります。管理者は自分1人なので、作らないことにしました。Cloudflare のダッシュボードの D1 のコンソールで、SQL を1行流します。
UPDATE users SET role = 'admin' WHERE email = '<自分のメールアドレス>';
Better Auth の「管理者」プラグインは、使わない
Better Auth には、管理者向けの機能をまとめたプラグインがあります。権限、利用の停止、なりすまし(ほかの人としてログインする)などです。
要るのは、権限と停止だけです。なりすましは、「ほかの人のデータを見せない」方針と合いません。プラグインは使わず、自分で列を2つ足すことにしました。動きは、hooks.server.ts を読めば分かる形にしています。
「停止」ではなく「凍結」と呼ぶ
管理者がアカウントを止める機能を、最初は「停止」と書いていました。Issue の下書きを読んでいて、引っかかりました。
#27で、有料会員が冬に休む「休止」を考えています。休止しても、登録したデータは見られるようにしたいところです。見られなくなると、フリー会員より機能が落ちてしまいます。では、「停止」は、どういう扱いなのでしょうか。
整理すると、2つは別のものでした。
| 誰が | できること | |
|---|---|---|
| 休止 | 有料会員が、自分で選ぶ | 見る・編集するはできる(フリーと同じ扱い) |
| 凍結 | 管理者が、迷惑な使い方を止める | 見ることも含めて、全部できない |
言葉が似ていると、あとで混ざります。管理者が止めるほうは「凍結」と呼ぶことにして、Issue にも CLAUDE.md にも、そう書きました。
凍結された人は、どこにも届かない

凍結された人は、どの画面を開いても、ここに移る。できるのは、ログアウトだけ
凍結された人は、ログインはできます。ただし、どの画面を開いても、「このアカウントは凍結されています」の画面に移ります。データは消しません。凍結を解けば、もとどおり使えます。
止める場所は、ここでも hooks.server.ts の1か所です。画面だけでなく、画面のデータ、フォームの送信、画像の配信も、すべてここを通ります。ページごとに「凍結されていないか」を書かなくて済みます。
本人が、自分の権限を変えられないこと
気をつけたのは、本人が自分の role を書き換えられないことです。Better Auth には、名前などを本人が変える処理(update-user)があります。ここに role: 'admin' を付けて送られたら、どうなるでしょうか。
Better Auth には、「この列は、本人からは受け付けない」と伝える設定があります。
user: {
additionalFields: {
role: { type: 'string', required: true, defaultValue: 'user', input: false },
suspendedAt: { type: 'date', required: false, input: false }
}
}
設定しただけで安心せず、E2E で、実際に送って確かめています。role や凍結の日時を付けて送っても、DB の値が変わらないことを見ました。
登録数に、上限を付ける
次は、上限です。課金はまだ先ですが、上限の仕組みは、公開の前に要ります。1人が大量に登録して、D1 や R2 を使い切ることを防ぐためです。

一覧の見出しの下に、今の数と上限を出す
上限の数は、テーブルに持つ
開発を始めたときから、CLAUDE.md の共通のルールに「プランごとの上限は、コードに直書きしない」と書いていました。plan_limits というテーブルを作り、区分ごとに1行で持ちます。
| 対象 | フリーの上限(仮) |
|---|---|
| ギア | 300 |
| 消耗品 | 100 |
| コンテナ | 50 |
| プラン | 20 |
| キャンプ | 100 |
| 画像 | 300 |
料金の話がまだなので、数は仮です。自分のデータ(ギア 56 件)より十分に多く、乱用は止まる数にしました。有料の行は、料金を決めるときに入れます。
実際に効く上限の決め方
その人に効く上限は、1つの関数で決めます。
- 管理者は、無制限
- ほかは、その人の区分の行
- 区分の行がなければ、フリーの行を使う
- フリーの行も無ければ、何も登録できない
下の2つは、入れ忘れへの備えです。有料の行を入れ忘れたまま、誰かを有料にしても、無制限にはなりません。「分からないときは、厳しいほうに倒す」としました。
上限に達したら、新しい登録だけを止める

上限に達しているときの、登録の画面(撮るために、上限を一時的に 2 にしている)
上限に達したときは、登録の画面で、入力欄の代わりに案内を出します。入力し終わってから断られるより、親切だと考えました。もちろん、サーバーでも確かめています。画面を通さずに送っても、登録されません。
止めるのは、新しい登録だけです。見る・チェックする・編集する・削除するは、今までどおりできます。上限をあとで下げて、超えた状態になっても、データは消しません。
画像だけは、少し細かくなりました。止めるのは、「画像のないギアやキャンプに、新しく画像を付けるとき」だけです。すでにある画像の差し替えは、枚数が増えないので、上限に達していてもできます。
テスト用のユーザーが、上限に当たる
実装の前に、1つ困ることが分かっていました。
E2E は、テスト用のユーザー1人で、全部のテストを流します。ローカルの D1 は、テストのデータを消しません。前回(#28)に書いたとおり、ギアは、すぐに 1,000 件を超えます。フリーの上限(300 件)を効かせると、ほとんどのテストが落ちてしまいます。
かといって、「テストのときは上限を見ない」という分岐を、アプリのコードに入れたくはありません。
そこで、テストの準備で、ローカルの D1 にだけ、とても大きい上限の行を入れることにしました。テスト用のユーザーは、その区分にします。開発環境や本番の D1 には、この行は入りません。アプリのコードは、本番と同じものが動きます。
上限そのもののテストは、テストの中で別のユーザー(フリー)を作って行います。300 件を入れる代わりに、フリーの上限を一時的に小さい数に書き換え、終わったら戻します。
受入確認も、同じやり方で
人が画面で確かめるときも、300 件を手で登録するわけにはいきません。開発用の D1 のコンソールで、上限を一時的に小さくして確かめました。
管理者は無制限になるので、自分のアカウントでは確かめられません。ここで役に立ったのが、もう1つの Google アカウントです。凍結の確認でも、上限の確認でも、一般のユーザーとして使いました。公開の前に、「自分ではない人」として触ってみる、よい機会になっています。
おわりに
- 管理者にも、ほかの人のデータの中身は見せない。見せる画面を作らなければ、漏れない
- 似た言葉は、早めに分ける。「休止」は本人が選ぶ、「凍結」は管理者が止める
- 権限や区分は、本人が書き換えられないことを、実際に送って確かめる
- 上限の数は、テーブルに持つ。行がないときは、厳しいほうに倒す
- 止めるのは、新しい登録だけ。超えたデータは消さない
- テストのための分岐を、アプリに入れない。テストの側で、データを用意する
次回は、アカウントの画面と、エクスポート、退会です。