Newer
Older
client_dwatch / docs / handoff / 指示書I_認証とproxy.md

指示書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 が独立ファイルになっている前例に倣ってください。

/**
 * 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" にしないこと★★

// ✅ 正しい
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 の実物:

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"★
(★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/

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

★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 を見てください。


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/ は生成物なので除く)
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に繋がらないままサンプルデータを表示します★ — ★これも例外が出ない事故です★

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

# 本番で設定する環境変数のテンプレート(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 以外なら警告)
★**注意: 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 に実値(実在のホスト名・ユーザー名・パスワード・トークン)が無いこと★