# 指示書I — 認証（`proxy.ts` + cookie + IP制限）と相関IDの同居

| 項目 | 内容 |
|---|---|
| 実行環境 | ★**Cursor 内の Claude が実装します**★（`.claude/commands/d-watch.md` の役割分担表を参照） |
| 実行状態 | **未実行** |
| 取得する決定番号 | ★**D-080**★（★D-079 まで発番済み★。2026-09-08 時点。**着手直前に `grep -n "^### D-0" docs/decisions.md` で最終番号を再確認すること** — 指示書H は D-073 で**完了済み**。**他の指示書と同時に走らせないこと**） |
| 前提 | `docs/decisions.md` の **D-072 の項6**（方式と地雷の一覧）、★**D-077 の項5**（`X-Forwarded-For` は nginx が追記）★、★**D-079 の項1**（LB と nginx は同一＝**プロキシは1段**）★ を先に全部読むこと |
| ★着手前確認★ | ★**なし（全件解決済み）**★。★**この指示書は着手前確認なしで、段階0 から段階6 まで通して実行できます**★（2026-09-08 / 根拠 ★D-079 項1★） |
| 着手前の基準 | `npx tsc --noEmit` エラー0 / `npm test` 全パス / `npm run lint` 0 |
| 段階数 | ★**8段階**★ = 段階0〜6 の7段階 ＋ ★**段階5-B**★（2026-09-10 のユーザー判断を受けて後から挿入。★**既存の段階番号は繰り下げていない**★ — 他文書からの参照がズレるため。★指示書K の段階7-B と同じやり方★）。**実行順は 段階5 → 段階5-B → 段階6 です** |
| 改訂 | ★**2026-09-10**★ — ★**`ALLOW_DIRECT_ACCESS` を TypeScript 定数から環境変数に移した**★（ユーザー判断）。あわせて ★**段階5-B（環境変数のセットアップ手順と検証スクリプト）を追加**★。★段階0〜6 の番号・実装内容そのものは変更していない★ |

---

## ★このファイル名は `middleware.ts` ではありません★

**この `next` は 16.3.3 で、`middleware.ts` は非推奨です。** 正解は ★**`proxy.ts`**★ です。

`node_modules/next/dist/docs/01-app/03-api-reference/03-file-conventions/middleware.md` の原文:

> The `middleware.js` file convention has been **deprecated** in Next.js 16 and renamed to `proxy.js`.
> All functionality remains the same — only the file and export names have changed.

**`middleware.ts` を置くと dev サーバが警告を出します。** 訓練データの記憶で `middleware.ts` を
作らないでください。★**着手前に `node_modules/next/dist/docs/01-app/03-api-reference/03-file-conventions/proxy.md`
を実際に開いて読むこと**★（`CLAUDE.md` 末尾の「This is NOT the Next.js you know」）。

### 「proxy」という語は2つの意味で使われています。混同しないこと

| 語 | 意味 | この指示書での扱い |
|---|---|---|
| TLS を終端するリバースプロキシ | nginx / ロードバランサ | ★**LB が担当。アプリでは作りません**★ |
| ★**Next.js の `proxy.ts`**★ | フレームワークのファイル規約 | ★**これを作ります**★ |

---

## 何を作るのか

★**現状、URL を知っていれば全画面が誰でも開きます。**★ 認証は完全に未実装です
（`app/login/` も `proxy.ts` も存在せず、13本のAPIルートに認証の分岐が1つもありません）。

**方式は D-072 の項6 で決まっています。**

| 項目 | 決めたこと |
|---|---|
| 保護 | `proxy.ts` で全ルート |
| 認証 | cookie 方式。共有トークンを `app/login/` で入力 → 検証して cookie を発行 |
| IP制限 | `X-Forwarded-For` の ★**末尾から `TRUSTED_PROXY_HOPS` 番目だけ**★ を信じる（★**`TRUSTED_PROXY_HOPS = 1` ＝ 最後の1ホップで確定**★。D-079 項1 でプロキシは1段と判明） |
| トークン | `.env.local` の ★**`AUTH_TOKEN`（既に存在します）**★ |
| トークンの期限 | ★**実装しない**★（`CLAUDE.md` §7 で禁止） |

★**`proxy.ts` には相関IDの発行も同居させます。**★
`docs/logging-design.md:408` の申し送りどおり ★**相関IDの発行を先、認証を後**★ に置きます
（認証失敗もログで追跡できるようにするため）。これで `logging-design.md` のフェーズ2 が解除されます。

**1ファイル制約の解き方はガイドが示しています** — 「機能ごとに別モジュールへ分け、
`proxy.ts` から import する」のが公式推奨です。`proxy.ts` 本体は**薄い順序制御だけ**にしてください。

---

## ★着手前の確認事項 — 全件解決★

★**この節に残っていた未確認事項は 2026-09-08 にすべて解決しました。着手をブロックする確認はもうありません。**★
以下は「なぜこの実装にするのか」の根拠として残してあります。

### 【解決済み】nginx は `X-Forwarded-For` を「追記」する（2026-09-08 / 根拠 ★D-077 項5★）

ユーザー回答の原文:

> ★ロードバランサが X-Forwarded-For を追記するか上書きするか★はnginxで追記して各サービスに割り振ります

- ★**「追記（append）」で確定**★。クライアントが `X-Forwarded-For: 1.2.3.4` を送ってきた場合、
  nginx は自分が見た接続元IPを**末尾に足して** `1.2.3.4, <実際の接続元>` にします
- したがって `X-Forwarded-For` は ★**`<自称>, <実際の接続元>` のように複数要素になるのが正常系**★です。
  「上書きされるので要素は1つだけ」という想定は ★**捨ててください**★
  （★**ただし「要素が1つ＝直接アクセス」とも断定できません。**★ クライアントが XFF を送らなければ
  nginx が1個足しただけの「要素1個」になります。段階1 の期待値表の直後の注を参照）
- → ★**「末尾を取る」が唯一の正解として確定しました**★

★**先頭を取る実装にすると、クライアントが `X-Forwarded-For: <許可IP>` を自分で付けるだけで
IP制限を素通りできます。**★ **絶対に先頭を使わないこと。**

### 【解決済み】LB と nginx は同一 ＝ ★**プロキシは1段**★（2026-09-08 / 根拠 ★D-079 項1★）

前版ではここが未確認で、★**「この確認が取れるまで段階1・段階2 に着手しないこと」**★ と書いていました。
★**その制約は解除されました。**★ ユーザー回答の原文:

> ★1★は同一です

（★1★ ＝ 前版が挙げた確認事項「LB と nginx は同一か、LB → nginx の2段か」を指します）

- ★**LB と nginx は同一のもの。追記するプロキシは1段で確定**★
- したがって ★**`TRUSTED_PROXY_HOPS = 1` で確定**★（`X-Forwarded-For` の ★**末尾**★ を取る）
- ★**段階1・段階2 を含め、この指示書は着手前確認なしで実行できます**★

### 参考 — 2段だった場合に何が起きるか（★設定値を持たせる理由の説明★）

★**現時点では1段で確定しています。以下は `TRUSTED_PROXY_HOPS` という設定値を持たせる理由の説明であり、
将来 LB 構成が変わったときに読み返すためのものです。**★（削除しないこと）

「末尾を取る」は ★**追記するプロキシがちょうど1段であること**★ を前提にした話です。

| 構成 | 届く `X-Forwarded-For` | 末尾を取ると |
|---|---|---|
| ★**1段（nginx = LB）**★ ← ★**今回はこれ（D-079 項1）**★ | `<自称>, <nginx が見た接続元>` | ★**正しい**★（本当のクライアントIP） |
| ★**2段（LB → nginx が両方追記）**★ | `<自称>, <本当のクライアント>, <LB の内部IP>` | ★**LB の内部IPになる**★ |

2段だった場合、全リクエストが同一IP（LB の内部IP）に見えます。
その結果 allowlist は「（LB の内部IPを許可して）**全部通す**」か「（許可しないので）**全部弾く**」の
どちらかにしかならず、★**IP制限が完全に無意味化します。**★

正しい一般則は ★**「末尾から N 番目を取る」**★ です
（N = 自分の前段にある**信頼済みプロキシの段数**。1段なら N=1 で従来どおり末尾）。
だから段数を ★**設定値 `TRUSTED_PROXY_HOPS`**★ にします。

### ★リスク評価 — 実装後に「実は2段だった」と発覚した場合★

ユーザーからの質問「もし実装後に同一でないと発覚した場合は問題ありますか？」への回答です。

★**結論: fail-closed（安全側）に倒れます。危険な方向へは倒れません。**★

| 観点 | 実際に起きること |
|---|---|
| 何が起きるか | 2段なら `X-Forwarded-For` の末尾は ★**LB の内部IP**★ になり、**全リクエストが同一IPに見える** |
| allowlist との関係 | `ALLOWED_IPS` は ★**社内IPの列挙**★ なので、LB の内部IPは入っていない |
| 結果 | ★**全員が 403 になり、即座に気づきます**★（誰もログインできないので運用開始と同時に発覚する） |
| ★逆パターン（気づかず素通り）★ | ★**起きません**★。「弾かれる」方向にしか倒れないため、静かに穴が空くことはない |
| ★唯一の例外★ | `ALLOWED_IPS` に ★**LB の内部IPが偶然入っていた**★ 場合のみ、逆に全員素通りになります。社内IPの列挙では**通常起きません**（`10.x` / `172.16-31.x` / `192.168.x` のようなプライベートIPを allowlist に書かないこと） |
| ★修正コスト★ | ★**ほぼゼロ。`lib/config/allowed-ips.ts` の `TRUSTED_PROXY_HOPS` を `1` → `2` に変えるだけ**★。★**コード変更は不要**★（`clientIp()` は最初から「末尾から N 番目」で一般化してあるため） |

★**これが `TRUSTED_PROXY_HOPS` をハードコードせず設定値にした理由です。**★
1段で確定していても設定値のまま残してください（定数に畳み込まないこと）。

- 実測での確認手順は ★**段階5-2**★ にあります。★**実測結果と決めた値は段階6 に記録すること**★

### 実装への反映 — `clientIp()` は「末尾から N 番目」で一般化する

★**段数は1段で確定（D-079 項1）ですが、`clientIp()` は「末尾から N 番目」で一般化してください。**★
（1段/2段のどちらでも設定値1つで直せる形にしておくのが目的。上のリスク評価を参照）

- `lib/config/allowed-ips.ts` に ★**`TRUSTED_PROXY_HOPS`（`1` で確定。D-079 項1）**★ を置く
- `clientIp(headerValue, hops)` は ★**末尾から `hops` 番目の要素**★ を返す
  （`hops = 1` なら末尾＝**従来どおりの挙動**。`hops = 2` なら末尾から2番目）
- 要素数が `hops` に満たない場合は ★**`null`（取れない扱い）**★ にする。
  **足りないぶんを先頭で埋めない**こと（自称IPを掴んでしまいます）
- `TRUSTED_PROXY_HOPS` を ★**ハードコードしないこと**★（`CLAUDE.md` §7）

**ヘッダが1つも無い場合**（nginx を通っていない = 直接アクセス）は ★**拒否**★ が既定です。
ローカル開発で困るので、**開発時だけ通す条件は `NODE_ENV` ではなく設定値で明示的に切る**こと
（★`ALLOW_DIRECT_ACCESS`。★**環境変数**★です。段階1-1 で定義します★）。

---

## 作業の順序（★崩さないこと★）

| 段階 | 内容 | コミット |
|---|---|---|
| 段階0 | `d-watch-api.yaml` の `securitySchemes` を cookie 方式に直す | 段階0で1コミット |
| 段階1 | `lib/config/allowed-ips.ts` と `lib/auth/`（純粋関数）+ テスト | 段階1で1コミット |
| 段階2 | `proxy.ts` と分割モジュール（相関ID → 認証） | 段階2で1コミット |
| 段階3 | `app/login/` と `app/api/auth/login` `logout` | 段階3で1コミット |
| 段階4 | `lib/logger/redact.ts` に cookie 名を追加（★既存テスト2件が落ちるので同時に直す★） | 段階4で1コミット |
| 段階5 | 認証イベントのログ追加 ＋ ★`X-Forwarded-For` の段数を実測して確定★ | 段階5で1コミット |
| ★**段階5-B**★ | ★**環境変数のセットアップ手順と検証スクリプト**★（`.env.production.example` / `npm run check:env` / `docs/deploy.md`） | 段階5-Bで1コミット |
| 段階6 | 文書の更新と記録 | 段階6で1コミット |

★**段階5-B の番号について**★: この指示書は段階0〜6 で番号が確定しており、
★**繰り下げると他文書からの参照がズレる**★ので、★**段階5 と段階6 の間に「段階5-B」として挿入しました**★
（★指示書K が段階7-B を挿入したのと同じやり方★）。**実行順は 段階5 → 段階5-B → 段階6 です。**

★**段階ごとに `npx tsc --noEmit` と `npm test` を実行し、通ってからコミットすること**★（D-050）。

---

## 段階0 — `d-watch-api.yaml` の `securitySchemes` を直す

**現在の `securitySchemes` は `bearerAuth` / Laravel Sanctum の残骸です**（`docs/architecture.md:49`）。
`architecture.md:50-52` が「認証方式は cookie 方式と決まっているが、HTTPS の終端場所が回答待ちで
`proxy.ts` も未着手のため、ここを今確定させると決まっていないことを決めたことになる」として
**保留していた箇所**です。★**D-072（HTTPS の終端は LB）／ D-077 項5（nginx が `X-Forwarded-For` を追記）
／ D-079 項1（LB と nginx は同一＝1段）ですべて解決したので、ここを直します。**★

`CLAUDE.md` §6-1「API契約を変えたくなったら、先に `d-watch-api.yaml` を直す」に従い**最初に行います**。

- `securitySchemes` を ★**cookie 方式（`type: apiKey` / `in: cookie` / `name: dwatch_session`）**★ に書き換える
- ★**`bearerAuth` と Sanctum への言及を残さないこと**★
- 認証が要る操作に `security` を付ける。**`/api/auth/login` は認証不要**なので付けない
- ★**トークンの期限・残日数に関する記述を書かないこと**★（`CLAUDE.md` §7 で禁止）
- 401（未認証）と 403（IP拒否）のレスポンスを **`{ error: { code, message } }`**（`CLAUDE.md` §9.3）で定義する

---

## 段階1 — 設定値と純粋関数（★テストを先に書く★）

### 1-1. `lib/config/allowed-ips.ts`（★新規。`index.ts` に足さないこと★）

★**`lib/config/index.ts` に追加してはいけません。**★
`index.ts` は `lib/judge/` から広く import されており、**`proxy` のバンドルに judge 系を
引き込む恐れ**があります。ガイドも「proxy は shared modules に依存すべきでない」と警告しています。
`lib/config/advice-rules.ts` が独立ファイルになっている前例に倣ってください。

```ts
/**
 * IP制限の許可リスト（D-072 / D-077 項5 / D-079 項1）。
 * ★ハードコード禁止（CLAUDE.md §7）のため、判定側から直接IPを書かずここを参照する★
 * 値は運用で変わるので、変えるときはこのファイルだけを直す。
 *
 * ★書き方の注意★
 * - 判定は「文字列の完全一致」だけ。CIDR / レンジ / ワイルドカードには対応していない（P1 の割り切り）
 * - ★nginx が実際に付ける表記をそのまま書くこと。ログで実際の値を確認してから登録する★
 *   （`::1` / `::ffff:203.0.113.10` / `[2001:db8::1]:54321` のような表記ゆれは
 *    正規化しないので、書き方が違うだけで拒否される）
 */
export const ALLOWED_IPS = [
  // 例: "203.0.113.10",
] as const;

/** 許可リストが空のとき、IP制限を「かけない」か「全拒否」か（★誤って全公開しないため明示する★） */
export const ALLOW_ALL_WHEN_EMPTY = true;

/**
 * ★X-Forwarded-For が無い／末尾から N 番目が取れない ＝ nginx を経由していない直接アクセス★
 * を通すかどうか。★環境変数から読む（2026-09-10 のユーザー判断）★。
 * ローカル開発でだけ `.env.local` に `ALLOW_DIRECT_ACCESS=true` を書く。
 * ★本番では設定しない（未設定 ＝ false ＝ 拒否）★
 * ★ALLOW_ALL_WHEN_EMPTY で代用しないこと（後述）★
 *
 * ★`=== "true"` であること。`!== "false"` にしないこと（理由は指示書I の 1-1-c）★
 */
export const ALLOW_DIRECT_ACCESS = process.env.ALLOW_DIRECT_ACCESS === "true";

/**
 * 自分の前段にある「信頼済みプロキシ」の段数（D-077 項5 / ★D-079 項1★）。
 * clientIp() は X-Forwarded-For の ★末尾から この数だけ遡った要素★ を採用する。
 *
 * ★1 で確定★ — LB と nginx は同一（プロキシは1段）。2026-09-08 のユーザー回答（D-079 項1）。
 * ★定数に畳み込まず設定値のまま残すこと★: 将来 LB → nginx の2段構成になった場合、
 * ここを 2 にするだけで直る（コード変更不要）。指示書I の「リスク評価」節を参照。
 */
export const TRUSTED_PROXY_HOPS = 1;
```

★**`ALLOW_ALL_WHEN_EMPTY` を必ず持たせること。**★ 空配列を「制限なし」と暗黙に扱うと、
**設定ミスで全公開になったことに気づけません。** 既定は `true`（開発を止めないため）にし、
**本番では `false` にすることを段階6の記録に書いてください。**

### 1-1-a. ★「ヘッダの有無」と「allowlist が空か」は別の判定にすること★

★**この2つを1つの設定値でまとめて扱うと、初期状態のまま本番に穴が残ります。**★

- 「XFF が無い／末尾から N 番目が取れない ＝ 直接アクセス」を通すか → ★`ALLOW_DIRECT_ACCESS`（★**環境変数**★。未設定 ＝ `false`。1-1-c）★
- 「allowlist が空のとき制限をかけないか」 → `ALLOW_ALL_WHEN_EMPTY`（★TypeScript 定数★。既定 `true`）

**なぜ分けるのか（★この指示書に元々あった矛盾★）:**
`ALLOW_ALL_WHEN_EMPTY` の既定が `true` で、かつ `ip === null` を「allowlist が空なら通す」と
扱ってしまうと、★**初期状態（allowlist 空）では nginx を経由しない直接アクセスがそのまま通ります。**★
これは「ヘッダが1つも無い場合は拒否」という上の指示と真っ向から矛盾します。
`ALLOW_DIRECT_ACCESS` を独立させ、★**`ip === null` は `ALLOW_DIRECT_ACCESS` が `true` のときだけ通す**★
と定義してください（`ALLOW_ALL_WHEN_EMPTY` は `ip` が取れているときの話に限定する）。

### 1-1-b. ★CIDR には対応しない（P1 の割り切り）★

`ALLOWED_IPS` の判定は ★**文字列の完全一致のみ**★ です。`192.0.2.0/24` のような
**CIDR / レンジ / ワイルドカードは解釈されません**（そう書いても、その文字列と完全一致する
IPは存在しないので**常に拒否**になります）。

- ★**P1 では「単一IPの列挙のみ」と割り切ります。**★ 拠点ネットワーク全体を許可したくなった時点で
  `lib/config/allowed-ips.ts` と `isAllowedIp()` を**作り直すことになります**
- この割り切りを ★**段階6 の記録にも書き、後任に伝わるようにしてください**★

### 1-1-c. ★`ALLOW_DIRECT_ACCESS` だけを環境変数にする理由★（2026-09-10 のユーザー判断）

**ユーザー判断の原文:**

> 環境変数でお願いします、その代わりサーバーに載せ替えた際にすぐに環境変数を
> 設定できるようにバッチか手順など作成してください

（後半の「バッチか手順など」への対応が ★**段階5-B**★ です）

**経緯 — なぜ TypeScript 定数のままではまずいのか:**

- 旧版の指示書は `lib/config/allowed-ips.ts` に ★**TypeScript 定数として `ALLOW_DIRECT_ACCESS = false`**★
  を置く設計でした
- ★**問題**★: ★**開発中は `true` にしないとローカル（`localhost:3000` への直アクセス）が全部 403 になります**★。
  つまり **開発者は必ず一度この行を `true` に書き換える**ことになります。
  ★**TypeScript ファイルなので、`true` のままコミットする事故が起こりえます**★
- ★**そうなると本番で「nginx を通らない直接アクセスが素通り」します。例外もエラーもテスト失敗も出ません**★
  （`CLAUDE.md` §4.6 の `contract` と同じ「静かに壊れる」型の事故です）
- ★**対策**★: ★**環境変数に移す。`.env*` は `.gitignore` 対象（`.gitignore:34`。`:35` の `!.env.example` だけが例外）
  なので、`.env.local` に書いた `true` は構造的にコミットできません**★

#### ★★`=== "true"` にすること。`!== "false"` にしないこと★★

```ts
// ✅ 正しい
export const ALLOW_DIRECT_ACCESS = process.env.ALLOW_DIRECT_ACCESS === "true";

// ❌ 間違い（未設定が true になる ＝ 本番で穴が空く）
export const ALLOW_DIRECT_ACCESS = process.env.ALLOW_DIRECT_ACCESS !== "false";
```

- ★**`=== "true"` なら、未設定・空文字・タイポ・`1`・`TRUE` — すべて `false`（拒否）になります**★
- ★**つまり本番で設定し忘れても安全側（fail-closed）に倒れます**★。
  「設定を忘れたら穴が空く」ではなく「設定を忘れたら閉じる」が正しい向きです

★**既存の `USE_FIXTURES` は逆向きなので、真似しないこと**★:

| 設定値 | 実装 | 未設定のとき | 設計意図 |
|---|---|---|---|
| `USE_FIXTURES`（`lib/db/use-fixtures.ts:17`） | `process.env.USE_FIXTURES !== "false"` | ★**`true`**★（fixtures で動く） | ★**開発しやすさ優先**★。clone しただけで動くことが目的 |
| ★**`ALLOW_DIRECT_ACCESS`（本指示書）**★ | ★**`process.env.ALLOW_DIRECT_ACCESS === "true"`**★ | ★**`false`**★（拒否） | ★**セキュリティ優先**★。設定漏れが穴にならないことが目的 |

`lib/db/use-fixtures.ts:17` の実物:

```ts
export const USE_FIXTURES = process.env.USE_FIXTURES !== "false";
```

★**「既存に合わせて `!== "false"` にする」は誤りです。**★ 向きが逆なのは意図的で、
★**セキュリティに関わる設定は未設定を「閉じる」側に倒す**★というのがここでの規則です。
★**この対比を段階6 の記録に残してください**★（次に誰かが「統一しよう」として戻すのを防ぐため）。

### 1-1-d. ★他の3つは TypeScript 定数のままにすること★

★**`ALLOWED_IPS` / `TRUSTED_PROXY_HOPS` / `ALLOW_ALL_WHEN_EMPTY` は環境変数に移しません。**★

| 設定値 | 置き場所 | 理由 |
|---|---|---|
| `ALLOWED_IPS` | ★**TypeScript のまま**★ | ★**本番で意味のある値**★。社内IPの列挙はレビュー対象にすべき情報なので、コードで管理し履歴を残す |
| `TRUSTED_PROXY_HOPS` | ★**TypeScript のまま**★ | ★**本番で意味のある値**★（`1` で確定。D-079 項1）。構成が変わったときにコードレビューを通したい |
| `ALLOW_ALL_WHEN_EMPTY` | ★**TypeScript のまま**★ | ★**本番で意味のある値**★（本番では `false` にする。段階6 の記録参照） |
| ★**`ALLOW_DIRECT_ACCESS`**★ | ★**環境変数**★ | ★**本番では常に `false`。`true` にするのは開発中だけ**★ という、他の3つと性質が違う唯一の設定値だから |

★**判定基準はこれです: 「本番で値を入れるもの」＝コード。「開発中だけ立てるもの」＝環境変数。**★
`ALLOW_DIRECT_ACCESS` だけが後者に当たります。

### 1-2. `lib/auth/`（★純粋関数。DBもファイルも触らない★）

`docs/architecture.md:127` が `lib/auth/` を置き場所として予告しています（「**予定**（認証方式の決定待ち）」）。

| ファイル | 中身 |
|---|---|
| `lib/auth/token.ts` | トークンの検証。`AUTH_TOKEN` の読み取りと ★**定数時間比較**★ |
| `lib/auth/client-ip.ts` | `X-Forwarded-For` の ★**末尾から `TRUSTED_PROXY_HOPS` 番目**★ を取る + 許可判定（★段数・直接アクセス可否は `lib/config/allowed-ips.ts` から受け取る。ハードコード禁止★） |
| `lib/auth/__tests__/` | 上2つのテスト |

★**トークン比較は必ず定数時間で行うこと。**★ `a === b` は先頭から比較して違えば即 false を返すため、
**応答時間の差から1文字ずつ推測される**（タイミング攻撃）。`node:crypto` の `timingSafeEqual` を使い、
**長さが違う場合も同じ時間で false を返す**ようにしてください。

`lib/logger/index.ts:22` の `resolveLevel()` が `process.env` を読む薄い関数の**前例**です。同じ形にしてください。

### ★期待値表（先にテストへ写すこと）★

#### `clientIp(headerValue, hops)` — `X-Forwarded-For` から接続元を取る

★`hops` は `TRUSTED_PROXY_HOPS`（★**`1` で確定**★ ＝ 末尾。D-079 項1）。以下は特記の無い限り `hops = 1`。★

| 入力（`x-forwarded-for` の値） | hops | 期待 | 何を固定するか |
|---|---|---|---|
| `"1.2.3.4, 203.0.113.10"` | 1 | ★**`"203.0.113.10"`**★ | ★**これが正常系のメインケース**★（nginx が追記 = D-077 項5。末尾を取る。先頭の `1.2.3.4` はクライアントの自称なので信じない） |
| `"203.0.113.10"` | 1 | `"203.0.113.10"` | ★**追記が確定したので「LB が上書きした場合」という説明は破棄**★。単一要素は ★**「クライアントが XFF を送らず nginx が1個足した正常系」でもあり得ます**★（★直下の注のとおり、要素数だけでは直接アクセスと断定できません★）。ここでは**値の取得だけを固定する** |
| `"1.2.3.4, 5.6.7.8, 203.0.113.10"` | 1 | `"203.0.113.10"` | 多段でも末尾 |
| ★`"1.2.3.4, 203.0.113.10, 10.0.0.5"`★ | ★**2**★ | ★**`"203.0.113.10"`**★ | ★**将来 LB → nginx の2段構成になった場合**★（`10.0.0.5` は LB の内部IP。末尾から2番目が本当のクライアント）。★**現構成は1段（D-079 項1）だが、このテストは残すこと**★ — `hops` の一般化が壊れていないことを固定する |
| `"1.2.3.4, 203.0.113.10"` | ★**3**★ | ★**`null`**★ | ★**要素数が hops に満たない → 取れない扱い。先頭で埋めないこと**★ |
| `"  203.0.113.10  "` | 1 | `"203.0.113.10"` | 前後の空白を落とす |
| `"1.2.3.4,"` | 1 | ★**`null`**★ | 末尾が空 → 取れない扱い |
| `""` | 1 | `null` | 空文字 |
| `null`（ヘッダ無し） | 1 | `null` | ★**nginx を通っていない（直接アクセス）**★ |
| ★`"1.2.3.4, 5.6.7.8, 203.0.113.10"`★<br>（★`X-Forwarded-For` ヘッダ行が複数来た場合★） | 1 | `"203.0.113.10"` | ★**`Headers.get()` は同名ヘッダを `", "` で連結して1つの文字列で返す**★。したがって**分割して末尾を取れば正しく動く**。`getSetCookie` のような個別取得は ★**不要**★ |
| `"1.2.3.4, ::1"` | 1 | `"::1"` | ★**IPv6。正規化しない（後述）**★ |
| `"1.2.3.4, ::ffff:203.0.113.10"` | 1 | `"::ffff:203.0.113.10"` | ★**IPv4射影アドレス。`203.0.113.10` に展開しない**★ |
| `"1.2.3.4, [2001:db8::1]:54321"` | 1 | `"[2001:db8::1]:54321"` | ★**ポート付き。ポートを剥がさない**★ |

> ★**注 — 「要素が1個＝直接アクセス」と断定はできません。**★
> nginx の追記が `$proxy_add_x_forwarded_for` 相当なら、★**クライアントが `X-Forwarded-For` を送ってこない
> 普通のリクエストは、nginx が1個足した結果「要素1個」になります**★。つまり要素数だけでは
> 「nginx 経由の正常系」と「nginx を経由しない直接アクセス」を**区別できません**。
> - `clientIp()` は ★**要素数で分岐せず、末尾から N 番目を取るだけ**★ にしてください（上表のとおり）
> - 直接アクセスを本当に塞ぐのは ★**ネットワーク側（アプリのポートに nginx 以外が到達できないこと）**★ です。
>   `ALLOW_DIRECT_ACCESS` はあくまで「XFF が取れないとき」の保険であって、**偽装 XFF は防げません**
> - ★**「何段か」は D-079 項1 で解決済み（1段）です。**★ 残っているのは
>   ★**アプリのポートが nginx からのみ到達可能かどうか**★ の1点で、これは**着手をブロックしません**
>   （ネットワーク側の設定であり、アプリの実装は変わらない）。★**段階6 の記録に「未確認のまま」と
>   明記して申し送ってください**★

★**表記の正規化（IPv6の短縮形の展開・IPv4射影の展開・ポート除去）は P1 では行いません。**★
`isAllowedIp()` は ★**完全一致の文字列比較**★（次表）なので、★**表記ゆれはそのまま「拒否」になります。**★

- 割り切りの内容: ★**正規化しない。`ALLOWED_IPS` には nginx が実際に付ける表記をそのまま書く**★
- そのため `lib/config/allowed-ips.ts` のコメントに
  ★**「nginx が実際に付ける表記をそのまま書くこと。ログで確認してから登録する」**★ と必ず書くこと（段階1-1 のコード例参照）
- `clientIp()` がやってよいのは ★**`,` での分割と前後の空白除去まで**★

#### `isAllowedIp(ip, allowedIps, allowAllWhenEmpty, allowDirectAccess)`

★**3軸（ip が `null` か / allowlist が空か / 直接アクセスを許すか）で網羅します。**★
判定の順序は ★**「ip が null かどうか」が先、「allowlist が空かどうか」は後**★ です
（`ip === null` は `allowAllWhenEmpty` の影響を**一切受けません**）。

> ★**注 — `allowDirectAccess` は環境変数由来です（1-1-c）。**★
> 呼び出し側が `lib/config/allowed-ips.ts` の `ALLOW_DIRECT_ACCESS`
> （＝ `process.env.ALLOW_DIRECT_ACCESS === "true"`）を渡します。
> ★**`isAllowedIp()` 自身は `process.env` を読まないこと**★ — 引数で受け取る純粋関数のままにしてください
> （そうしないと下の表の12ケースをテストできません）。
> ★**したがって下表の `allowDirectAccess = true` の行（11・12）は、本番では発生しない組み合わせです。**★
> 環境変数が未設定・空文字・`1`・`TRUE` のいずれでも `false` になるため、
> ★**本番で通るのは行1〜10 の経路だけ**★ になります。

| # | ip | allowedIps | allowAllWhenEmpty | allowDirectAccess | 期待 | 何を固定するか |
|---|---|---|---|---|---|---|
| 1 | `"203.0.113.10"` | `["203.0.113.10"]` | `false` | `false` | **true** | 一致 |
| 2 | `"203.0.113.11"` | `["203.0.113.10"]` | `false` | `false` | **false** | 不一致 |
| 3 | `"203.0.113.10"` | `["203.0.113.10"]` | `true` | `false` | **true** | 一致（`allowAllWhenEmpty` は空でないので無関係） |
| 4 | `"203.0.113.11"` | `["203.0.113.10"]` | `true` | `false` | ★**false**★ | ★**allowlist が空でなければ `allowAllWhenEmpty` は効かない**★ |
| 5 | `"203.0.113.10"` | `[]` | `true` | `false` | **true**（制限なし） | 開発時の既定 |
| 6 | `"203.0.113.10"` | `[]` | `false` | `false` | ★**false（全拒否）**★ | 本番想定の設定ミス検知 |
| 7 | ★**`null`**★ | `["203.0.113.10"]` | `false` | `false` | ★**false**★ | 直接アクセスは拒否 |
| 8 | ★**`null`**★ | `["203.0.113.10"]` | `true` | `false` | ★**false**★ | 同上 |
| 9 | ★**`null`**★ | `[]` | ★**`true`**★ | ★**`false`**★ | ★**false**★ | ★**元の指示書が `true` としていた矛盾行。既定状態（allowlist 空）でも直接アクセスは通さない**★ |
| 10 | ★**`null`**★ | `[]` | `false` | `false` | ★**false**★ | 同上 |
| 11 | ★**`null`**★ | `[]` | `true` | ★**`true`**★ | ★**true**★ | ★**ローカル開発。`.env.local` に `ALLOW_DIRECT_ACCESS=true` を明示的に書いたときだけ通る**★ |
| 12 | ★**`null`**★ | `["203.0.113.10"]` | `false` | ★**`true`**★ | ★**true**★ | ★**直接アクセスを許した以上、照合すべきIPが無いので通す**★（★この環境変数を本番で設定しないこと。段階5-B の検証スクリプトが機械的に検出します★） |

★**行9 が今回の修正点です。**★ 旧版は `isAllowedIp(null, [], true)` → `true`（制限なしなので通す）と
していましたが、それだと ★**初期状態のまま本番へ出た瞬間、nginx を経由しない直接アクセスが素通り**★ し、
「ヘッダが1つも無い場合は拒否」という指示と矛盾します。★**`null` の可否は `ALLOW_DIRECT_ACCESS` だけが決める**★
と覚えてください。

#### `verifyToken(input, expected)`

| input | expected | 期待 | 何を固定するか |
|---|---|---|---|
| `"abc"` | `"abc"` | **true** | 一致 |
| `"abc"` | `"abd"` | **false** | 不一致 |
| `"abc"` | `"abcd"` | ★**false**★ | ★**長さ違いで例外を投げないこと**★（`timingSafeEqual` は長さが違うと throw する） |
| `""` | `"abc"` | **false** | 空入力 |
| `"abc"` | `""` | ★**false**★ | ★**`AUTH_TOKEN` が未設定なら誰も通さない**★（空を「何でも通す」にしないこと） |
| `"abc"` | `undefined` | ★**false**★ | 同上 |

★**`AUTH_TOKEN` が未設定のときに「認証を無効化して全部通す」実装をしないこと。**★
設定漏れが**そのまま全公開**になります。**未設定なら誰も入れない**が正しい挙動です。

---

## 段階2 — `proxy.ts` と分割モジュール

### 2-1. 構成

```
proxy.ts                    ← 薄い順序制御だけ。ロジックを書かない
lib/proxy/request-id.ts     ← 相関IDの発行（★先に走る★）
lib/proxy/authenticate.ts   ← 認証とIP制限（★後に走る★）
```

★**`proxy.ts` 本体にロジックを書かないこと。**★ 1ファイルしか置けないので、
**中身を膨らませると次に横断処理を足すときに壊します。** ガイドの推奨どおり分割してください。

### 2-2. 順序（★逆にしないこと★）

1. ★**相関IDの発行**★ — `x-request-id` を生成する。
   ★**クライアントが送ってきた `x-request-id` は信頼せず、常に上書き生成する**★
   （`docs/logging-design.md:372-373`）。ヘッダ名は `x-request-id` で確定（`logging-design.md:448`）
2. **IP制限の判定** — 拒否なら ★**403**★
3. **認証の判定** — 未認証なら、画面は `/login` へリダイレクト、APIは ★**401**★

**この順序の理由**（`docs/logging-design.md:408`）: **認証失敗もログで追跡できるようにするため**。
相関IDを後にすると、弾いたリクエストのログに ID が付きません。

★**既存の `withRequestId()` は変更不要です。**★ ヘッダがあればそれを使い、無ければ生成する作りで
（`docs/decisions.md:273-274`）、`proxy.ts` 投入後もそのまま動きます。**触らないでください。**

★**リクエストヘッダを後段へ渡す方法は、必ずガイドで確認してください。**★
`NextResponse.next()` に渡す形が版によって違います。**記憶で書かないこと。**

### 2-3. `matcher`

★**`matcher` を書かないと静的ファイルにも全部かかります。**★ ガイドの警告:

> Without a `matcher`, Proxy runs on **every request**, including static files (`_next/static`),
> image optimizations (`_next/image`), and assets in the `public/` folder. ...
> otherwise auth logic or redirects can unintentionally block CSS, JS, or images from loading.

- 静的アセット（`_next/static` / `_next/image` / `favicon.ico` / `public/`）を**除外する**
- ★**`/login` と `/api/auth/login` は `matcher` で除外せず、関数の中で分岐して通すこと**★
  （公式の認証例がその形。matcher で削ると保護漏れの発見が難しくなります）
- ★**`matcher` に変数は使えません**★（ビルド時に静的解析されるため）。
  **許可IPリストを `matcher` に書こうとしないこと**

### 2-4. ★踏むと痛い地雷★

| # | 内容 |
|---|---|
| 1 | ★**ランタイムは Node.js 固定。`runtime` を指定するとエラーになります**★。Edge は `proxy` では非対応。**`fs` は使えます** |
| 2 | ★**`_next/data` は `matcher` で除外しても必ず `proxy` が走ります**★。「ページを保護したのにデータルートを保護し忘れる」事故を防ぐ意図的な挙動です。**除外できないものとして扱ってください** |
| 3 | ★**Server Functions は `matcher` の除外を受けても実行されます**★（使用ページへの POST として扱われる）。ガイドは「**各 Server Function の中で認証・認可を検証せよ。`proxy` だけに頼るな**」と明記。**P1 では Server Actions を使っていないので `proxy` 単独で足ります。段階6 の記録に「Server Actions を使い始めたら各関数内で検証が必要」と書いてください** |
| 4 | **LB 配下では `serverActions.allowedOrigins` の設定が必要になる可能性があります**（`next.config.ts` は現在未設定）。**この指示書では触らず、段階6 の記録に申し送りだけ書いてください** |

---

## 段階3 — `app/login/` と `app/api/auth/`

### 3-1. cookie の仕様

| 項目 | 値 | 理由 |
|---|---|---|
| 名前 | ★**`dwatch_session`**★ | `lib/logger/redact.ts` のコメントとテストデータが既にこの名前を想定している |
| `httpOnly` | **true** | クライアントの JS から読めなくする |
| `secure` | **true** | ブラウザ〜LB 間は HTTPS なので有効（アプリが HTTP で応答しても**ブラウザは受け入れます**） |
| `sameSite` | **`lax`** | D-072 |
| `path` | `/` | |
| ★`expires` / `maxAge`★ | ★**付けない**★ | `CLAUDE.md` §7「トークン期限/残日数の実装」は**禁止**。**公式例には載っていますが、ここでは入れません** |

### 3-2. cookie に入れる値

★**`AUTH_TOKEN` と同じ値を入れます**（署名トークンは作りません）。★

`docs/architecture.md:191` が「cookie の失効方式 → 署名トークンか設定値との突き合わせか」を
未決として予告していましたが、**設定値との突き合わせ**を採ります。理由:

- **期限を実装しないので、署名に載せる情報（発行時刻・有効期限）がありません**
- P1 は**社内利用のみ・共有トークン1本**で、利用者ごとの識別をしません
- cookie が漏れても、漏れるのは**その人が自分で入力した値**です（新しい秘密が増えません）

★**ただし比較は必ず `lib/auth/token.ts` の定数時間比較を通すこと。**★
`proxy` の中で `===` を書かないでください。

### 3-3. 画面とルート

- `app/login/` — トークンの入力欄と送信。★**入力値をログに出さないこと**★
- `app/api/auth/login` — 検証して cookie を発行。**失敗しても「トークンが違う」以上の情報を返さない**
- `app/api/auth/logout` — cookie を削除
- ★**`/login` は認証をかけない**★（段階2-3 の関数内分岐で通す）

**リダイレクトや絶対URLを自前で組まないこと。** LB が HTTPS を終端するため、
アプリが組み立てると **`http://` になります**。★**相対パスで済ませる**★か、
どうしても絶対URLが必要なら `X-Forwarded-Proto` を見てください。

---

## 段階4 — `redact.ts` に cookie 名を追加（★既存テストが2件落ちます★）

`lib/logger/redact.ts` のコメントが申し送っています。

> cookie 名そのもの（`dwatch_session` 等）は未決なので入れない。
> **認証実装時に決まった名前をここへ追加すること。**

★**`dwatch_session` を禁止キーに追加してください。**★ そして ★**同じコミットで
`lib/logger/__tests__/redact.test.ts` を2箇所直すこと**★。直さないと必ず落ちます。

| 落ちるテスト | 直し方 |
|---|---|
| 認証情報8キーの**配列を順序込みで完全一致**させているテスト | 期待値の配列に `dwatch_session` を追加（**並び順も合わせる**） |
| `expect(FORBIDDEN_LOG_FIELDS).toHaveLength(21)` | ★**`22` に更新**★ |

★**テストを「落ちるから消す」のは禁止です。**★ このテストは「マスキング対象を勝手に減らさせない」
ための固定なので、**期待値を正しく増やす**のが直し方です。

---

## 段階5 — 認証イベントのログと ★段数の実測★

### 5-1. 認証イベントの追加

`lib/logger/events.ts` は**イベント型の allowlist**になっています。認証のイベントが無いので追加します。

- **ログイン成功 / ログイン失敗 / IP拒否**の3つ
- ★**トークンの値・cookie の値・入力値を絶対にログへ入れないこと**★（段階4 でマスキング対象に入れますが、**そもそも渡さない**のが正しい）
- **接続元IPをログに出すかは判断が必要**です。運用上は追跡に有用ですが、
  `redact.ts` の個人情報13キーの方針と衝突しないかを確認し、
  ★**判断した内容と理由を段階6 の記録に書いてください**★

### 5-2. ★`X-Forwarded-For` の段数を実測で確定する（新規）★

★**プロキシは1段（D-079 項1）で確定していますが、実測で裏を取ってから段階6 に記録します。**★
設定値の思い込みが1つ残るだけで IP制限は無意味化するので、**必ず一度は生値を見てください**。

**手順**

1. ★**`X-Forwarded-For` の生値を1回だけログに出し、`,` 区切りの要素数を数える**★
2. ★**必ず `lib/logger` の既存の仕組みを使うこと**★
   - `docs/logging-design.md` を先に読む（§4 マスキング規約 / §8 のステップ一覧）
   - `lib/logger/events.ts` は ★**イベント型の allowlist**★ なので、**型を足さないと渡せません**。
     一時確認用のイベント型を足して `logEvent()` から出す（5-1 で足すイベントに相乗りしてもよい）
   - ★**`console.log` を残さないこと**★。`eslint.config.mjs` は `no-console` を全体に適用し、
     `lib/logger/**` だけを除外しています（`npm run lint` が落ちます）
3. ★**確認が済んだら、そのログは消す（既定）か debug レベルに落とすこと**★
   - ★**注意: 現状 `lib/logger` に debug レベルはありません**★ — `lib/logger/index.ts:20` の
     `LogLevel` は `"info" | "warn" | "error"` です（`resolveLevel()` は開発時に pino の
     しきい値を `debug` にしますが、`logEvent()` から debug は出せません）
   - したがって ★**既定は「消す」**★。debug で残したいなら `LogLevel` の拡張が必要で、
     それは**この指示書の範囲外**です。★**どちらにしたかを段階6 に記録すること**★
4. ★**IPアドレスは個人情報になりうるので、書く前に `lib/logger/redact.ts` のマスキング方針を確認すること**★
   - `FORBIDDEN_PII_FIELDS`（13件）と `FORBIDDEN_CREDENTIAL_FIELDS`（8件）に
     ★**`ip` / `client_ip` / `x_forwarded_for` は入っていません**★ ＝ ★**redact は止めてくれません。
     出す/出さないは実装者の判断です**★
   - 5-1 の「接続元IPをログに出すか」の判断と ★**同じ結論に揃えること**★（片方だけ出すと方針が崩れる）
   - 一時ログである以上、★**確認したらすぐ落とす**★のが最も安全です

**判定表**

| 実測した要素数 | 末尾の値 | 解釈 | `TRUSTED_PROXY_HOPS` |
|---|---|---|---|
| ★**1**★ | 社内IP（実際の接続元） | ★**想定どおり**★ — クライアントが XFF を送っておらず、nginx が1個足した状態 | ★**1**★ |
| ★**2以上**★ | 社内IP（実際の接続元） | 想定どおり — クライアントが自称 XFF を送ってきており、nginx がその後ろに足した | ★**1**★ |
| ★**2以上**★ | ★**社内IPでない値（`10.x` / `172.16-31.x` / `192.168.x` などプライベートIP）**★ | ★**2段構成を疑う**★ — 末尾が LB の内部IPになっている可能性 | ★**2**★（変更は設定値だけ。コード変更不要） |

- ★**要素数だけでは「nginx 経由の正常系」と「直接アクセス」を区別できません**★（段階1 の期待値表の注を参照）。
  ★**見るのは要素数ではなく「末尾の値が社内IPかどうか」です**★
- ★**実測結果（要素数・末尾の値の種別）と、決めた `TRUSTED_PROXY_HOPS` の値を段階6 の記録に残すこと**★
  （生のIPアドレスは `docs/decisions.md` に書かないこと。「社内IPだった」等の記述で足ります）

---

## 段階5-B — 環境変数のセットアップ手順と検証スクリプト（★後から挿入した段階★）

★**この段階は 2026-09-10 のユーザー判断を受けて後から挿入したものです。段階番号は繰り下げていません**★
（★指示書K の段階7-B と同じやり方★）。**実行順は 段階5 → 段階5-B → 段階6 です。**

### この段階の位置づけ

1-1-c で `ALLOW_DIRECT_ACCESS` を環境変数に移した見返りとして、ユーザーが求めた条件がこれです。

> その代わりサーバーに載せ替えた際にすぐに環境変数を設定できるようにバッチか手順など作成してください

★**「設定を忘れたら閉じる」だけでは不十分で、「設定すべきものが分かる」状態を用意する**★のが目的です。
★**サーバは Linux（さくらのクラウド VM。`CLAUDE.md` §3）なので、Windows の `.bat` ではなく
シェルスクリプトか手順書＋npm スクリプトが適切です**★。本指示書は ★**npm スクリプト＋手順書**★ を採ります
（`.sh` を足すと Windows のローカル開発から実行できず、`package.json` の既存の形からも外れるため）。

### ★前提の実測（2026-09-10 時点。着手時に再確認すること）★

| 項目 | 実測 |
|---|---|
| ★デプロイ手順の文書は存在しない★ | `docs/` に `deploy` 系のファイルは無い（`architecture.md` / `decisions.md` / `fixtures.md` / `getting-started.md` / `handoff.md` / `logging-design.md` / `setup-checklist-daigo.md` / `spec.md` / `handoff/`） |
| ★コードが実際に読んでいる環境変数★ | ★**`DATABASE_URL` / `LOG_LEVEL` / `NODE_ENV` / `USE_FIXTURES` の4つだけ**★（`lib/` `app/` `scripts/` `prisma7.config.ts` を grep した実測。`lib/generated/prisma/` は生成物なので除く）<br>`lib/db/prisma.ts:28` `DATABASE_URL` ／ `lib/db/use-fixtures.ts:17` `USE_FIXTURES` ／ `lib/logger/index.ts:23` `LOG_LEVEL` ／ `lib/logger/index.ts:28` `NODE_ENV` ／ `scripts/seed-local-db.ts:54` `DATABASE_URL` |
| ★`.env.example` にあるキー★ | `USE_FIXTURES`(:2) / `DATABASE_URL`(:6) / `INSPECTION_DB_URL`(:9) / `AUTH_TOKEN`(:12) / `LOG_LEVEL`(:15) |
| ★`.env.local` にあるキー★ | 上から `LOG_LEVEL` を除いた4つ |
| ★`AUTH_TOKEN` と `INSPECTION_DB_URL` はコードから読まれていない★ | ★**`AUTH_TOKEN` は本指示書の段階1-2 で使い始めます**★（`INSPECTION_DB_URL` は点検記録機能の未実装分。本指示書では触らない） |
| ★`.env.example:12` は `AUTH_TOKEN=changeme`★ | ★**`.env.local` をこれから作った人は `changeme` のままになります**★（検査1 が拾う対象そのもの） |
| `package.json` の `scripts`(:5-13) | `dev` / `build` / `start` / `lint` / `test` / `test:watch` / `seed:local` |
| `seed:local` の形(`package.json:12`) | `tsx scripts/seed-local-db.ts`（`tsx` は devDependencies `package.json:31`） |

### ★★踏むと痛い地雷（先に読むこと）★★

| # | 内容 |
|---|---|
| ★1★ | ★**`.gitignore:34` が `.env*` を無視し、`:35` の `!.env.example` だけが例外です。したがって `.env.production.example` を作っても Git に入りません**★（`git add docs/... .env.production.example` が `The following paths are ignored` で失敗する）。★**`.gitignore` に `!.env.production.example` を追加すること**★。`.env.example` の直後に並べて書くと意図が読み取れます |
| ★2★ | ★**`.env.production.example` は「例」であって秘密ではありません。実値を書かないこと**★（`CLAUDE.md` §9.3「認証情報を絶対にコミットしない」）。★DB のホスト名・ユーザー名・パスワードを書かない★ |
| ★3★ | ★**検証スクリプトを `tsx` で単体実行すると `.env.local` は自動で読まれません**★（Next.js が読むのはアプリ実行時だけ）。★`scripts/seed-local-db.ts:23-25` が `dotenv.config({ path: ".env.local" })` → `dotenv.config()` の順で読んでいる前例に倣うこと★ |
| ★4★ | ★**`vitest.config.ts` の `include` は `["lib/**/*.test.ts", "app/**/*.test.ts"]` です**★。★**`scripts/` 配下に置いたテストは実行されません**★ → ★**純粋関数は `lib/` 配下に置くこと**★（下記） |

### (1) `.env.production.example`（新規）

★**本番で設定すべき環境変数のテンプレート**★です。★**各行にコメントで「何のための値か」「未設定だとどうなるか」を書くこと**★。

- ★**`ALLOW_DIRECT_ACCESS` は値を書かないこと**★。★**コメントアウトした上で「本番では設定しない」と明記する**★
  （★行があると、つい設定してしまいます。かといって完全に書かないと「知らずに設定してしまう」ので、
  「設定するな」という指示として残すのが正解です★）
- ★`AUTH_TOKEN` は `changeme` のようなダミーを置き、「★必ず変更する★」と明記★
- ★`USE_FIXTURES` は本番では `false` を明記★。★**未設定だと `true` になり（`lib/db/use-fixtures.ts:17` の
  `!== "false"`）、実DBに繋がらないままサンプルデータを表示します**★ — ★これも例外が出ない事故です★

書き方の例（★内容は着手時の実測に合わせること★）:

```sh
# 本番で設定する環境変数のテンプレート（docs/deploy.md を先に読むこと）
# ★このファイルに実値を書かないこと。コピーして .env.local を作り、そちらに書く★

# 実DBに接続する。★本番では必ず false★（未設定だと true になり、サンプルデータのまま動く）
USE_FIXTURES=false

# TeamWorks構築のMySQL（★参照用アカウント★）。未設定だと起動しても画面が全部エラーになる
DATABASE_URL="mysql://USER:PASSWORD@HOST:3306/DATABASE"

# 簡易トークン認証の共有トークン（指示書I 段階1-2）。
# ★必ず変更すること★。未設定・空なら誰もログインできない（=全機能が使えない）
AUTH_TOKEN=changeme

# 動作ログの出力レベル。未設定なら NODE_ENV から判定（本番は info）
LOG_LEVEL=info

# ★★ALLOW_DIRECT_ACCESS は本番では設定しないこと★★
# nginx を通らない直接アクセスを素通りさせる開発専用スイッチ。
# 未設定なら false（拒否）。ローカル開発の .env.local にだけ書く。
# ALLOW_DIRECT_ACCESS=true
```

★**あわせて `.env.example`（＝開発用）にも `ALLOW_DIRECT_ACCESS=true` を追記すること**★。
★**開発者は `docs/getting-started.md:96` の `cp .env.example .env.local` で環境を作ります**★。
★**ここに書いておかないと、新しく入った人はローカルで全ページ 403 になり、原因に辿り着けません**★
（★本番用の `.env.production.example` とは向きが逆になりますが、それが意図です。
両方のファイルにその旨をコメントで書くこと★）。

### ★★(2) 環境変数の検証スクリプト（新規。★これが本命★）★★

★**置き場所と実行方法は実装者が決めてよい**★。推奨は次の形です（★`seed:local` が `tsx` で動いている前例に倣う★）。

| 置き場所 | 中身 |
|---|---|
| ★`lib/config/env-check.ts`★ | ★**純粋関数**★。`Record<string, string \| undefined>` を受け取り、検査結果の配列を返す。★`process.env` を直接読まないこと★ |
| ★`lib/config/__tests__/env-check.test.ts`★ | 上のテスト（★`vitest.config.ts` の `include` に入る場所★。地雷4 参照） |
| `scripts/check-env.ts` | ★**薄い実行部だけ**★。dotenv で読み込み → 純粋関数へ渡す → 整形して出力 → ★終了コードを返す★ |
| `package.json` の `scripts` | `"check:env": "tsx scripts/check-env.ts"` を追加（★`seed:local` の隣★） |

#### ★検証する内容（危険な値を検出して止める）★

| # | 検査 | 結果 |
|---|---|---|
| ★1★ | ★`AUTH_TOKEN` が未設定・空・`changeme` のまま★ | ★**エラー**★（`.env.example:12` の初期値がまさに `changeme`。★これを本番に持ち込むと共有トークンが公開情報になります★） |
| ★2★ | ★`ALLOW_DIRECT_ACCESS=true` が設定されている★ | ★**本番判定なら エラー / それ以外なら 警告**★（開発機では正常な設定なので落としてはいけない） |
| ★3★ | ★`USE_FIXTURES` が未設定または `false` 以外★ | ★**本番判定なら 警告**★（実DBに繋がらずサンプルデータのまま動く） |
| ★4★ | ★`DATABASE_URL` が未設定★ | ★**エラー**★ |
| ★5★ | ★`DATABASE_URL` のホストが `localhost` / `127.0.0.1`★ | ★**本番判定なら 警告**★ |
| 6 | `LOG_LEVEL` が想定外の値 | ★**警告**★（pino のレベル名 `trace` / `debug` / `info` / `warn` / `error` / `fatal` 以外なら警告）<br>★**注意: `lib/logger/index.ts:20` の `LogLevel` は `"info" \| "warn" \| "error"` の3つだけですが、`resolveLevel()`(`:22-25`) は開発時に `debug` を返します。検査で `debug` を弾かないこと**★ |

#### ★★設計上の要点★★

★**要点1 — 「本番判定」を何で行うかを決めること**★

- ★**`NODE_ENV === "production"` が素直です**★（`lib/logger/index.ts:27-29` の `isProduction()` が同じ判定をしている前例）
- ★**これは指示書I の「`NODE_ENV` ではなく設定値で明示的に切れ」と矛盾しません。**★
  ★**あの指示は `ALLOW_DIRECT_ACCESS`（＝ リクエストを通す／弾くという実際の挙動）の話**★です。
  ★**検証スクリプトの分岐は「警告の文言をどちらに寄せるか」でしかなく、挙動を変えません**★。
  ★**この区別を実装のコメントにも書くこと**★（後任が「`NODE_ENV` を使っている、規約違反だ」と誤読するのを防ぐため）
- 判定そのものも ★**純粋関数の引数として渡すこと**★（`isProduction: boolean`）。テストで両方を回せる形にする

★**要点2 — エラーメッセージに値そのものを出さないこと**★

- ★**`DATABASE_URL` にはパスワードが入ります**★。★**出力はターミナルに残り、CI のログにも残ります**★
- ★**`lib/db/local-guard.ts` が同じ配慮をしている前例です**★ —
  `lib/db/local-guard.ts:7-8` のコメント（★「呼び出し側はエラーメッセージに DATABASE_URL の値を出さないこと」
  「CLAUDE.md §9.3。パスワードがログに残るため、この関数自身も受け取った url を返さない」★）と、
  `lib/db/local-guard.ts:26-28` の実際のメッセージ（★末尾が「値はこのエラーには出しません」★）を
  読んでから書くこと
- ★**「`DATABASE_URL` が未設定です」「`DATABASE_URL` が localhost を指しています」のように、
  何が問題かだけを言うこと**★。★**`AUTH_TOKEN` の値も同様に出さない**★
  （検査1 は「`changeme` のままです」と**キーワード名だけ**言えば足ります）

★**要点3 — 終了コードを返すこと**★

- ★**エラーが1件でもあれば非0（`process.exitCode = 1`）**★。警告だけなら 0
- ★**デプロイ手順で `npm run build` の前に挟めるようにするため**★です（下の (3) の手順3）
- 警告とエラーは**出力でも区別が付くようにする**こと（例: `ERROR:` / `WARN:` の接頭辞）

★**要点4 — 純粋関数に切り出してテストを書くこと**★

- ★**`lib/db/local-guard.ts` と同じ形**★（`isLocalDwatchDatabaseUrl()` が純粋関数、
  `assertLocalDwatchDatabaseUrl()` が薄い包み）。★**DB 無し・環境変数無しでテストできます**★
- ★**期待値表を先に作ること**★（`CLAUDE.md` §6-2）。最低限、検査1〜6 それぞれについて
  ★**「引っかかる入力」「引っかからない入力」「本番判定 true/false で結果が変わること」**★ を固定する
- ★**「エラーメッセージに値が含まれていないこと」もテストで固定すること**★
  （例: パスワード入りの `DATABASE_URL` を渡し、★出力にそのパスワード文字列が含まれないこと★を assert する）。
  ★これは要点2 が後から壊されるのを防ぐ唯一の手段です★

### (3) デプロイ時の手順書（新規）

★**置き場所は `docs/deploy.md` を推奨**★（★`docs/` に既存の同種文書が無いことを確認済み★。
開発側の手順は `docs/getting-started.md` にあるので、★**本番側はそれと別文書にする**★。
`docs/getting-started.md:96` の `cp .env.example .env.local` が開発側の対応する記述です）。

**書く内容:**

1. ★**環境変数の一覧と、それぞれ本番で何を入れるか（表）**★ — 上の実測表を元にする。
   ★**「コードが読んでいるのは4つだけ ＋ 本指示書で `AUTH_TOKEN` が5つ目になる」ことを明記**★
2. ★★**セットアップの手順を番号付きで**★★:
   1. `.env.production.example` をコピーして `.env.local` を作る
   2. 各値を埋める（★**`AUTH_TOKEN` は必ず変更する**★）
   3. ★**`npm run check:env` を実行して通ることを確認する**★（★エラーが出たら先に進まない★）
   4. `npm run build`
   5. `npm run start`
3. ★★**`ALLOW_DIRECT_ACCESS` は本番で設定しない、と太字で書くこと**★★。
   ★**設定すると nginx を通らない直接アクセスが素通りします**★
4. ★**nginx 側の前提**★:
   - ★**`X-Forwarded-For` に「追記」すること**★（D-077 項5。`$proxy_add_x_forwarded_for` 相当）。
     ★上書きにすると `TRUSTED_PROXY_HOPS` の前提が崩れます★
   - ★**アプリのポートに nginx 以外が到達できないようにすること**★。
     ★**これはネットワーク側の設定であり、アプリでは防げません**★ —
     指示書I の段階1 期待値表の注（★「直接アクセスを本当に塞ぐのは★ネットワーク側（アプリのポートに
     nginx 以外が到達できないこと）★です。`ALLOW_DIRECT_ACCESS` はあくまで『XFF が取れないとき』の保険であって、
     偽装 XFF は防げません」★）と、段階6 の申し送り（★「アプリのポートが nginx からのみ到達可能かは未確認のまま」★）
     を引くこと
5. ★**許可IPリスト（`ALLOWED_IPS`）はコードにあるので、変更にはデプロイが必要**★ と明記する
   （★環境変数ではないので、`.env.local` を直しても変わりません★。1-1-d の理由も1行で添える）
6. ★**トラブル時の切り分け — 403 が出たら何を疑うか**★:

| 症状 | 疑うところ |
|---|---|
| ★**全員が 403**★ | ①`ALLOWED_IPS`（`lib/config/allowed-ips.ts`）に接続元が入っていない ②`TRUSTED_PROXY_HOPS` の段数違い（★2段構成なら末尾は LB の内部IPになる。段階5-2 の判定表を参照★） ③`ALLOW_ALL_WHEN_EMPTY=false` かつ allowlist が空 |
| ★**特定の人だけ 403**★ | `ALLOWED_IPS` の**表記ゆれ**（★IPv6 短縮形 / IPv4射影 `::ffff:` / ポート付き は正規化しないので、書き方が違うだけで拒否されます。段階1-1-b★） |
| ★**ローカルで 403**★ | `.env.local` に `ALLOW_DIRECT_ACCESS=true` が無い（★これが正しい既定の挙動です★） |
| ★**ログインできるが誰でも入れてしまう**★ | ★**`ALLOW_DIRECT_ACCESS=true` が本番に残っている**★（★`npm run check:env` の検査2 が検出します★）／ `ALLOWED_IPS` にプライベートIPを書いてしまっている |

### 段階5-B の完了条件

1. ★**`npm run check:env`（相当）が動き、危険な値でエラーになること**★
   - ★**`AUTH_TOKEN=changeme` のまま実行してエラー（終了コード非0）になることを実際に確認する**★
   - ★**`ALLOW_DIRECT_ACCESS=true` で警告（開発機）／エラー（本番判定）になることを確認する**★
2. ★**検証の純粋関数にテストがあること**★（★`lib/` 配下に置くこと。`scripts/` では vitest が拾いません★）
   - ★**エラーメッセージに `DATABASE_URL` の値が含まれないことのテストを含む**★
3. ★**`.gitignore` に `!.env.production.example` を追加し、`git status` にこのファイルが現れること**★
   （★追加しないと Git に入りません。地雷1★）
4. `docs/deploy.md` が存在し、★**手順1〜5 をなぞれば本番が立ち上がる状態になっていること**★
5. ★**型チェック・lint・テストが通ること**★（`npx tsc --noEmit` エラー0 / `npm test` 全パス / `npm run lint` 0）
6. ★**`.env.production.example` に実値（実在のホスト名・ユーザー名・パスワード・トークン）が入っていないこと**★

---

## 段階6 — 文書の更新と記録

### `docs/decisions.md`（★着手直前に最終番号を確認★）

- 段階0 の `securitySchemes` の変更内容
- ★**`ALLOW_ALL_WHEN_EMPTY` の既定を `true` にしたこと。本番では `false` にする必要があること**★
- ★**`ALLOW_DIRECT_ACCESS` の既定を `false` にしたこと。ローカル開発でのみ `true` にすること**★
  （★`ALLOW_ALL_WHEN_EMPTY` で代用してはいけない理由も残す★）
- ★★**`ALLOW_DIRECT_ACCESS` を TypeScript 定数ではなく環境変数にした理由**★★（2026-09-10 のユーザー判断。段階1-1-c）
  - ★**TypeScript 定数だと、開発のため `true` にした行をそのままコミットする事故が起こりえた。
    `.gitignore:34` の `.env*` により、環境変数なら構造的にコミットできない**★
  - ★**そうなった場合「nginx を通らない直接アクセスが素通り」するが、例外もテスト失敗も出ない**★（静かに壊れる型の事故）
  - ★★**`=== "true"` にした理由**★★ ＝ ★**未設定・空文字・タイポ・`1`・`TRUE` がすべて `false`（拒否）になり、
    本番で設定し忘れても安全側（fail-closed）に倒れるから**★
  - ★★**既存の `USE_FIXTURES`（`lib/db/use-fixtures.ts:17`）は `!== "false"`（未設定なら `true`）だが、
    あれは「開発しやすさ優先」の設計であり、セキュリティに関わる本設定では意図的に逆にしたこと**★★。
    ★**「既存に合わせて統一しよう」として戻されるのを防ぐため、必ず記録に残すこと**★
- ★**`ALLOWED_IPS` / `TRUSTED_PROXY_HOPS` / `ALLOW_ALL_WHEN_EMPTY` は環境変数に移さず
  TypeScript 定数のままにしたこと**★（段階1-1-d）。★理由 ＝ この3つは本番で意味のある値なのでコードで管理し
  レビューと履歴を残す。`ALLOW_DIRECT_ACCESS` だけが「本番では常に false、true にするのは開発中だけ」という
  性質だから分けた★
- ★**段階5-B で作ったもの3点**★: `.env.production.example` / ★`npm run check:env`（検証スクリプト）★ / `docs/deploy.md`
  - ★**`.gitignore` に `!.env.production.example` を追加したこと**★（★`.env*` に飲まれて Git に入らないため。
    `.gitignore:34-35`★）
  - ★**検証スクリプトの「本番判定」に `NODE_ENV === "production"` を使ったこと**★と、
    ★**それが「`NODE_ENV` ではなく設定値で切れ」という本指示書の方針と矛盾しない理由**★
    （★あの方針は `ALLOW_DIRECT_ACCESS` ＝ リクエストの通す／弾くの話。検証スクリプトの分岐は
    警告文言の寄せ方でしかなく、アプリの挙動を変えない★）
  - ★**検証スクリプトのエラーメッセージに値そのものを出さない方針にしたこと**★
    （★`DATABASE_URL` にパスワードが入るため。`lib/db/local-guard.ts:7-8` と同じ配慮★）
  - ★**コードが読んでいる環境変数は `DATABASE_URL` / `LOG_LEVEL` / `NODE_ENV` / `USE_FIXTURES` の4つで、
    本指示書で `AUTH_TOKEN` が5つ目になったこと**★
- ★**`X-Forwarded-For` は nginx が「追記」する（D-077 項5、2026-09-08 のユーザー回答）。
  したがって末尾側を信じる**★
- ★**LB と nginx は同一 ＝ プロキシは1段（D-079 項1、2026-09-08 のユーザー回答「★1★は同一です」）。
  よって `TRUSTED_PROXY_HOPS = 1`（末尾を取る）で確定したこと**★
- ★**段階5-2 の実測結果**★（`X-Forwarded-For` の要素数と末尾の値の種別。★生のIPは書かない★）と、
  ★**最終的に決めた `TRUSTED_PROXY_HOPS` の値**★
- ★**`TRUSTED_PROXY_HOPS` を1段確定後も設定値のまま残した理由**★ ＝ 後から2段だと発覚しても
  ★**fail-closed（全員403）で即座に気づき、値を `2` にするだけで直る（コード変更不要）**★
- ★**段階5-2 の一時ログを「消した」か「debug に落とした」か**★（`LogLevel` に debug が無いため
  既定は削除。拡張した場合はその旨も）
- ★**アプリのポートが nginx からのみ到達可能かは未確認のまま**★（着手はブロックしないが、
  偽装 XFF を本当に塞ぐのはネットワーク側なので申し送る）
- ★**`ALLOWED_IPS` は文字列の完全一致のみで CIDR / レンジ / ワイルドカード非対応。
  P1 は単一IPの列挙のみと割り切ったこと。拠点ネットワーク全体を許可したくなったら
  `lib/config/allowed-ips.ts` と `isAllowedIp()` を作り直すことになる**★
- ★**IPアドレスの表記（IPv6短縮形 / IPv4射影 / ポート付き）を正規化しないと決めたこと。
  `ALLOWED_IPS` には nginx が実際に付ける表記をそのまま書く**★
- 段階5 で「接続元IPをログに出すか」をどう判断したか（**理由も**）
- **申し送り2件**: ①Server Actions を使い始めたら各関数内で認証・認可を検証する必要がある
  ②LB 配下では `serverActions.allowedOrigins` の設定が必要になる可能性
- 実測値（`npm test` のファイル数・テスト数 / `npx tsc --noEmit` / `npm run lint`）

### 既に古くなっている記述を直すこと

★**行番号は着手時にズレている可能性があります。必ず該当箇所を実際に開いて確認すること。**★

| 文書 | 内容 |
|---|---|
| `docs/architecture.md:39` | `proxy.ts` の状態 ★**「未着手（認証方式の決定待ち）」のまま残っています**★ → **実装済みに** |
| `docs/architecture.md:49-52` | `securitySchemes` 未対応の注記（★**`bearerAuth` / Laravel Sanctum は D-001 / D-003 で Next.js に変更済みの残骸**★）と、「HTTPS の終端場所が回答待ちで `proxy.ts` も未着手だから保留」という理由を**まとめて削除**（段階0 で解決） |
| `docs/architecture.md:188-191` | 未確定表（`## 8. 未確定` は 184 行目）から「リバースプロキシの有無」「HTTPS の終端場所」（★**D-072 項1 と D-077 項5 で解決済み**★）「ログイン画面の追加可否」「cookie の失効方式」を**解決済みとして外す** |
| `docs/architecture.md:71,85,127` | `app/login/` / `app/api/auth/` / `lib/auth/` の「予定」を**実装済みに** |
| `docs/logging-design.md:438` | 「フェーズ2 は `proxy.ts` は認証との共存方針（§9 の申し送り）を確認してから着手」→ ★**解除。共存の形を実際に書いたので、その内容に更新**★ |
| `docs/spec.md:141` | 「API共通実装（認証、エラー形式、ページネーション）」のチェックボックス |
| ★`docs/getting-started.md:96,100`★ | ★**`cp .env.example .env.local` の直後に、ローカルでは `ALLOW_DIRECT_ACCESS=true` が必要だと1行足す**★（★これが無いと新しく入った人はローカルで全ページ 403 になり、原因に辿り着けません。段階5-B (1)★） |
| ★`docs/deploy.md`（新規）★ | ★**段階5-B (3) で作成**★。開発側の `getting-started.md` と対になる本番側の手順書 |
| `docs/handoff.md` | **末尾に追記を1つ追加**。冒頭の「最新の追記」ポインタも更新 |
| `.claude/commands/d-watch.md` | 「認証が未着手で URL を知っていれば全画面が開く」という記述を**消す**。テスト数と決定番号を実測値に更新 |

### ★「LB の `X-Forwarded-For` の挙動を確認」という外部待ちの記述を消すこと★

★**D-077 項5（追記か上書きか）と D-079 項1（何段か）でどちらも 2026-09-08 に解決済み**★なので、
指示書I の実行時に**残っている全箇所から消してください**。
★**行番号は着手時にズレている可能性があります。必ず該当箇所を実際に開いて確認すること**★
（下表は 2026-09-08 時点の実測値）。

| 文書 | 該当行 |
|---|---|
| `.claude/commands/d-watch.md:166` | 未実行指示書の表の「外部待ち」列。★**`X-Forwarded-For` は解決済みと書きつつ「LB と nginx は同一か2段か が新たな未確認事項」と書いてある**★ → ★**D-079 項1 で解決したので、この未確認事項の記述も消して「外部待ちなし」にする**★ |
| `docs/handoff.md:1165, 1167`〜`1170` | ★**「着手前にユーザーへ確認が必要な点（1件）」の見出しと本文**★（「追記か上書きか」の確認事項と、先頭を取ると素通りする旨の注記）。★**素通りの注意自体は残す価値があるので、「確認待ち」の部分だけ落とす**★ |
| `docs/handoff.md:1214, 1297, 1427, 1515` | 各追記の「次にやること」にある ★**「着手前にLBの`X-Forwarded-For`の挙動を確認」**★ |
| `docs/handoff.md:1604` | 「D-077 項5 で解決した」の記述に ★**段数も D-079 項1 で解決したことを追記**★ |

★**`docs/handoff.md:1157` の「`request.ip` は Next 15 で削除済み。`X-Forwarded-For` を読む以外に手段が無い」
は事実なので消さないこと。**★

---

## 完了条件

1. `npx tsc --noEmit` エラー0 / `npm test` 全パス / `npm run lint` 0
2. ★**`middleware.ts` が存在しないこと**★（`proxy.ts` であること）
3. `npm run dev` の出力に ★**`The "middleware" file convention is deprecated`** の警告が出ないこと★
4. **未認証で `/dashboard` を開くと `/login` へ飛ぶこと**（実際にブラウザで確認）
5. **未認証で `/api/dashboard` を叩くと 401 で、本文が `{ error: { code, message } }` であること**
6. **正しいトークンでログインすると全画面が開き、`dwatch_session` cookie が
   `httpOnly` / `Secure` / `SameSite=Lax` で、★`Expires` / `Max-Age` を持たないこと★**
7. ★**`X-Forwarded-For: <許可IP>` を自分で付けたリクエストが素通りしないこと**★
   （許可リストを設定した状態で、**先頭に許可IPを書いた偽装ヘッダ**を送って 403 になることを確認）
   ★**追記が確定した（D-077 項5）ので、これが最も重要な検証です。絶対に省略しないこと。**★
   - ★**（1段確定なので通常は不要）将来2段構成（`TRUSTED_PROXY_HOPS = 2`）になった場合は、
     末尾から2番目を偽装しても弾けることも確認する**★
     （例: `X-Forwarded-For: <許可IP>, <許可IP>` を送る。nginx の追記で末尾に実接続元が足されるため、
     末尾から2番目には**自称の値がそのまま入り得ます**）
   - ★**`ALLOW_DIRECT_ACCESS` を未設定（＝ `false`）にした状態で `X-Forwarded-For` を付けずに
     直接叩くと 403 になること**★（allowlist が**空のまま**でも 403 であること ＝ 期待値表の行9）
   - ★**`.env.local` から `ALLOW_DIRECT_ACCESS` の行を消して（またはコメントアウトして）確認すること**★。
     ★**`ALLOW_DIRECT_ACCESS=false` と書いて確認するだけでは不十分です**★ —
     ★本番で起きるのは「未設定」であって「`false` と書いてある」ではないため★
     （★`=== "true"` なので `ALLOW_DIRECT_ACCESS=1` や `=TRUE` でも 403 になることを1回見ておくとなお良い★）
8. **静的ファイル（CSS / JS / 画像）が未認証でも壊れずに読めること**
   （`matcher` の除外が効いているかの確認。ログイン画面が崩れていたらここが原因）
9. **ログに `dwatch_session` の値やトークンが出ていないこと**（実際にログを見て確認）
10. `docs/` と `.claude/commands/d-watch.md` の更新
11. ★**段階5-2 の実測を行い、`TRUSTED_PROXY_HOPS` の値を確定して段階6 に記録したこと**★
    - ★**`X-Forwarded-For` の生値を1回だけ `lib/logger` 経由で出し、要素数と末尾の値の種別を確認した**★
    - ★**確認用のログを消した（または debug に落とした）こと。`console.log` が1つも残っていないこと**★
      （`npm run lint` の `no-console` で機械的にも確認できる）
12. ★**段階5-B の3点が揃っていること**★
    - ★**`.env.production.example` があり、`.gitignore` に `!.env.production.example` を追加したので
      `git status` に現れること**★（★追加を忘れると Git に入りません★）
    - ★**`npm run check:env` が動き、`AUTH_TOKEN=changeme` のままなら終了コード非0 で落ちること**★
    - ★**`ALLOW_DIRECT_ACCESS=true` を検証スクリプトが検出すること**★（本番判定でエラー／開発機で警告）
    - ★**検証の純粋関数にテストがあり、エラーメッセージに `DATABASE_URL` の値が出ないことを固定していること**★
    - ★**`docs/deploy.md` があり、手順1〜5 と「`ALLOW_DIRECT_ACCESS` を本番で設定しない」の明記があること**★
    - ★**`.env.production.example` に実値（実在のホスト名・ユーザー名・パスワード・トークン）が無いこと**★
