2026-08-10 画面のサインイン状態をサーバーのセッションから決める(Issue #40)
- 課題:
components/SignInWithEthereum.tsxがサインイン済みかどうかをReactの状態(signedInAs)だけで持ち、サーバーのセッションを読んでいなかった。GET /api/auth/meは実装済みだが呼び出し元がゼロだった。結果として、(1) 再読み込みすると有効なセッションがあっても「署名してサインイン」に戻る、(2) 期限切れ後もタブを開いたままなら「サインイン済み」と表示し続ける、(3) ヘッダーの「運営」リンクが全員に見える(/adminはnotFound()で存在を隠す設計なのに導線だけ出ている)、という3つのずれがあった。また、利用者が明示的にサインアウトする手段がなかった。
決定
1. セッションはクライアントから /api/auth/me で読む。Server Component では読まない
/setup とヘッダーのどちらも、cookies() を読むのではなく GET /api/auth/me の結果を使う。
- ヘッダーはルートレイアウトにあるため、ここで
cookies()を読むと全ページが動的になる。 トップページが静的でなくなるのは、外部から見える公開ビュー(#16)を考えると避けたい。実際、この変更後も/と/setupは静的なまま(npm run buildの出力で確認)。 /setupの他のカード(ConnectWallet/WalletSetup)は元々クライアント側でウォレットの状態を見ている。サインイン表示だけサーバー由来にすると、同じ画面に真実の源が2つ混ざる。- 代償は、初回描画で軽量なリクエストが1回増えることと、確定するまでの短い未確定状態。未確定の間は「サインインしていない」表示を出さず、何も出さないか確認中と表示する。サインイン済みの人に状態が揺れて見えるのを避けるため。
取得は lib/useSession.ts へ集約し、react-query の同じキー(["session"])を共有する。ヘッダーと /setup が同時に使っても取得は1回。
2. サインアウトはヘッダーに常設する
/setup だけに置く案は採らない。サインアウトしたくなるのは /setup にいるときとは限らず、共用端末での利用を考えるとどのページからでも終われるほうがよい。
ヘッダーがサインアウトのためにセッションを知る必要がある以上、「運営」リンクの出し分けは追加コストなしで付いてくる。 逆に、リンクの出し分けだけのために全ページで取得を1回増やすのは割に合わない。この2つは一体の判断として決めた。
3. 「運営」リンクは管理者にだけ表示する
GET /api/auth/me の応答に isAdmin を加えた。返すのは要求者自身の権限だけで、管理者の一覧は返さない。
これはアクセス制御ではない。 アクセス制御は従来どおりサーバー側の requireAdmin() で、/admin は認可に失敗すれば404を返す。リンクを隠す価値は「権限のない人に、押しても404になる導線を見せない」ことであって、/admin というパスの存在は隠せない(notFound() による否認であり、URLの秘匿ではない)。この点は architecture.md にも明記した。
4. ウォレット未接続のときはセッションを破棄しない
アカウント・チェーンの変更を検知してサインアウトする既存の挙動は残す。ただし判定を次のように変えた。
- 表示の根拠がサーバー側になったため、ウォレットが未接続(
addressがundefined)の状態でも比較が走るようになった。 wagmi の再接続が終わる前はこの状態を通るので、そのまま破棄すると有効なセッションを消してしまう。未接続のときは何もしない。 - セッションのアドレスは正規化済みの小文字、wagmi 側はチェックサム表記なので、比較の前に小文字へ揃える。揃えないと常に不一致となり、サインインした直後にサインアウトされる。
含めなかったもの
- 自動サインイン(ページを開いただけで署名を求める挙動)
- ウォレット接続状態の表示の作り直し
- セッションの有効期限の変更(#41 で14日として文書化した)
- 検知を全ページへ広げること。 検知の
useEffectはSignInWithEthereumの中にあり、このコンポーネントを置いているのは/setupだけなので、自動破棄が効くのは/setupを開いている間に限られる。ここで直したのは「再読み込みすると検知が効かなくなる」ほうで、「どのページでも効く」にはなっていない。SessionStatusがルートレイアウト経由で全ページに乗ったので置き場所としてはそちらが自然だが、範囲が広がるため #44 で扱う
残る重複
shortenAddress を lib/domain/address.ts に置いた。#39(監査ログ閲覧)が lib/applicationEventLabels.ts に同じ処理を持っていたため、#39 の側で lib/domain/address.ts へ寄せた(#34 / PR #39)。applicationEventLabels.ts に残るのは fieldLabel だけになる。