event-manual / chapter 37

Admin シェルとナビ登録

@event/shell

defineRealms — N-named-realm 共有パスワード認証

xdeal は admin / roster の2 realm を別々に手書きしていた。defineRealms は realm を宣言的に定義し、cookie 名・パスワード env・ガードを一括生成する:

import { defineRealms } from "@event/shell";

export const realms = defineRealms({
  admin:  { cookieName: "myevent_admin_session" },                          // ADMIN_PASSWORD
  roster: { cookieName: "myevent_roster_session", passwordEnv: "ROSTER_PASSWORD" },
});

// server action 冒頭で必ず:
realms.admin.requireFromValue((await cookies()).get(realms.admin.cookieName)?.value);

照合は constantTimeEqual(文字単位 XOR・Edge 互換)。spikes の平文 === をここで是正した。cookie の読み書きはアプリ側が next/headers で行い、package は next 非依存を保つ。統治原則は第9章と同じ — middleware は「境界」であって「認可」ではない。route / action は必ず requireFromValue() で再検査する。

createNavRegistry — self-register 方式

3アプリの NAV_GROUPS はいずれも中央のハードコード配列で、ページ追加のたびに編集され、既にドリフトしていた。registry 方式では各機能が NavItem を宣言し、shell が収集・整列・権限フィルタする。ページ追加 = nav 定義1つ追加であり、中央配列の編集は発生しない。

export interface NavItem {
  id: string;          // 重複 register は無害化(HMR の二重登録対策)
  group: string;       // グループ見出し
  label: string;
  href: string;
  order?: number;      // グループ内の並び。既定 100
  code?: string;       // 短い略号バッジ(例 "GEN")
  external?: boolean;  // 新規タブ + noopener
  requires?: string;   // この realm を持つユーザーにだけ表示
}

nav.register({ id: "surveys", group: "運用", label: "サーベイ", href: "/admin/surveys", requires: "admin" });
const groups = nav.groups((realm) => hasRealm(realm)); // NavGroup[]

AdminNav

@event/shell/ui<AdminNav groups={...}> が registry の出力を描画する。折りたたみグループ + query-aware active 判定href?tab=x まで一致して初めて active になるため、同一パスにタブ違いの項目が並ぶ nav でも正しくハイライトされる。useSearchParams を使うため、mount 側で <Suspense> boundary が必要である:

<Suspense>
  <AdminNav groups={nav.groups(() => true)} />
</Suspense>

ChromeGate — ISR-safe な chrome 分岐

公開 chrome(Header / Footer)を /embed/admin のような bare ページで外すゲート。実装上の要点は判定手段にある: root layout で headers() を読むと全ページが force-dynamic 化してエッジキャッシュが死ぬ(前日トラフィックで 15 秒スパイクの実績がある)。ChromeGate は静的レンダリングと両立する usePathname() で判定する。

<ChromeGate barePatterns={[/^\/embed/, /^\/admin/]} header={<Header />} footer={<Footer />}>
  {children}
</ChromeGate>

header / footer は server component をスロット(props)として受け取り、配置だけを行う。

devLogin — 三重ガード付きワンクリックログイン

devhub の「開く」ボタンが GET /login/dev?from=/admin を叩いて自動ログインする導線。createDevLoginHandler({ cookieName }) を mount する。安全不変条件は3つ:

  1. 本番(NODE_ENV=production)は通常 /login へ倒す — 本番に dev バックドアは存在しない
  2. localhost 直アクセスのみ — host が localhost / 127.0.0.1 に一致し、かつ cf-connecting-ip ヘッダが無いこと(Cloudflare トンネル経由 = 外部到達は拒否)
  3. open-redirect 防止from は自サイト内絶対パス(/ 始まり・// 拒否)のみ許可

パスワードは URL を通らない。サーバが env の ADMIN_PASSWORD を読んで cookie を張るだけである。

createAuthGateMiddleware

/admin /api/admin の境界ゲート。dev は素通し(auth 側の dev bypass と二重で安全)、bypassPrefixes(例 /api/auth/)は素通し、API パスには 401 JSON、ページには /login?from=<path> redirect を返す。edge runtime で動くため、@event/core barrel ではなく @event/core/auth subpath から constantTimeEqual を import している — barrel 経由だと contentStore の node:fs / node:crypto が edge bundle に混入する(第46章)。