# 指示書AE — セキュリティ強化と仕上げ（群1 「上位10件に戻す」をリンクに / 群2 セキュリティ / 群3 小さな積み残し）

| 項目 | 内容 |
|---|---|
| 実行環境 | ★**Cursor 内の Claude が実装します**★（`.claude/commands/d-watch.md` の役割分担表を参照） |
| 実行状態 | ★★**完了（群1〜群3 の全段階。2026-09-13）**★★ — ★決定番号 **D-121〜D-127**★。★詳細は文書末尾の「実行状態」表★。<br>★★残した積み残しが2つあります★★: ★段階3-1 ①（同一エリア同日2回清掃の二重計上。★仕様判断が要るため見送り★）／ ⑤（`docs/handoff/analysis/code-analysis-pure.md` の古い転記。★別エージェント作業中のため見送り★）★。<br>★完了時の実測: `tsc` = **0** / `lint` = **0** / `npm test` = **50ファイル / 1200テスト 全パス**（着手前 1192 → 段階3-1 ② で8件増）★ |
| 位置づけ | ★ユーザー指示（2026-09-13）★ ①「★『上位10件に戻す』を押せるとわかるように他と同じようにリンク化★」／②「★他にSQLインジェクションなどのセキュリティ面も強化★」に対応する1本です。③ として監督の照合で挙がった小さな積み残しを付けてあります |
| 実行順 | ★★**群1 → 群2 → 群3**★★（★群どうしは独立なので入れ替えても壊れませんが、この順が一番短く区切れます★） |
| 対象 | ★本書に全部書いてあります★（他の指示書を参照しません） |
| ★着手前確認★ | ★★**なし**★★ — ★①②はユーザー指示（2026-09-13）で確定、③は監督判断★。★★ユーザーの回答を待たずに群1 から実行してよい★★。<br>★ただし「やらない」という判断をしてよい段階が2つあります★（★段階2-4 のCSV★ / ★段階2-7 のセキュリティヘッダ★）。★どちらも「やらない」で構いませんが、その場合は理由を `docs/decisions.md` に書くこと★ |
| 取得する決定番号 | ★★**着手直前に `grep -nE "^### D-(0\|1)[0-9][0-9]" docs/decisions.md \| tail -3` で最終番号を実測すること**★★。★★`^### D-0` だけでは3桁台（D-100 以降）を拾えない★★。★2026-09-07 に `D-068` の二重採番が実際に起きています★。★★本書には見込み番号を書きません★★。★群ごとに1つ取るのが自然です（群1 はモックから意図的に外れる記録、群2 は禁止事項の機械化、群3 は積み残し）★ |
| 段階数 | ★群1★ = 段階1-0〜1-1（2段階。コミット1つ）／ ★群2★ = 段階2-0〜2-8（9段階。コミット最大6つ）／ ★群3★ = 段階3-0〜3-2（3段階。コミット最大2つ） |
| 着手前の基準 | ★★**着手直前に自分で `npm test` / `npx tsc --noEmit` / `npm run lint` を実行し、その実測値を基準にすること**★★。★作成時点の実測（2026-09-13。★★鵜呑みにしない★★）: `npm test` = **48ファイル / 1137テスト（全パス）** / `tsc` = **0** / `lint` = **0** / `db.cleanLogs()` = **538件** / `db.prechecks()` = **58件**★ |
| 作成 | 2026-09-13（作成時点の決定事項最終番号 **D-120**（`docs/decisions.md:7762`）/ HEAD = `041a809` / ブランチ = `develop`。★着手時に実測すること★） |

> 実行後はこの表と、文書末尾の「実行状態」表を更新してください。

> ★**この指示書を読んでいるあなたへ**★ — `.claude/commands/d-watch.md` の「実装は Cursor 側」は
> **2つの環境の役割分担**です。**Cursor の中で動いている Claude は実装担当**です。
> ★調査や改訂ではなく実装を行ってください。★
> ★★**途中で止めてよい指示書です**★★ — ★各群の終わりに「★ここで止めてもよい★」と明記してあります★。

---

## ★★冒頭に読むべきこと — 本書は何で、何ではないか★★

### ★★本書は「脆弱性を直す指示書」ではありません★★

★★**作成時点の実測では、アプリコードに実際に危険な箇所は見つかっていません**★★。

| 調べたこと | 実測結果（2026-09-13） |
|---|---|
| 生SQL（`$queryRaw` / `$executeRaw` / `*Unsafe` / `Prisma.raw`） | ★**アプリコードに1件も無い**★（`lib/generated/**` = Prisma の生成物にある型宣言のみ） |
| `orderBy` のカラム名 | ★**固定リテラル1箇所だけ**★（`lib/db/index.ts:673` の `orderBy: { reported_at: "desc" }`）。★`sort` / `order` というクエリパラメータはリポジトリ全体に存在しない★ |
| `take` / `skip` にユーザー入力 | ★**渡している箇所は無い**★。Prisma への `take` は `lib/db/index.ts:674` の `take: 1` のみ |
| エラーレスポンスへの内部情報 | ★**`err.message` / スタックトレース / 接続文字列 / テーブル名を本文に載せている箇所は1つも無い**★。全 catch が固定文言＋`details: null` |
| DBへの書き込み | ★**アプリコードに1件も無い**★（`scripts/seed-local-db.ts` はローカル検証用シードで、アプリの書き込みではない） |
| リポジトリ内の認証情報 | ★**実在の値は無い**★（`pw` / `s3cret` / `root@localhost` / `example.com` 等のダミーのみ） |

★★だから本書の群2 は「穴を塞ぐ」作業ではなく、**「いま安全であることを、人の注意力に頼らず機械で固定する」作業**です★★。

> ★★**無理に脆弱性を作り出さないこと**★★（ユーザー指示 2026-09-13）。
> ★「直すところが無い」は失敗ではありません★。★段階2-0 の棚卸しで「無い」と確認できたら、
> それを**そのまま記録して次へ進んでください**★。
> ★★「何か直さないと成果に見えない」と考えて、動いているコードを触らないこと★★。

### ★★ただし、危険だと判断した箇所が1つだけあります★★

★★**アプリコードではなく、DB接続の経路です**★★。→ ★詳細は段階2-8（申し送り）★。

- ★テスト用DBが**公開IPに平文（TLS無効）で開いている**★（★監督実測★:
  `have_ssl=DISABLED` / `require_secure_transport=OFF` / `参照用アカウント@%`）
- ★`npm audit` が `mariadb` コネクタ（`@prisma/adapter-mariadb` の依存）に
  ★**「`ssl: true` を指定してもMitMに平文パスワードが漏れる」**★ と
  ★**「機密情報の平文送信」**★ を報告している。★★`No fix available`（上げても直らない）★★
- ★`lib/db/prisma.ts:38` は `new PrismaMariaDb(databaseUrl)` で、★TLSの指定が無い★

★この2つが重なると、DB接続の資格情報が経路上で保護されていない状態になります★。
★★これは DFA のアプリコードでは直せません（TeamWorks とインフラ側の話）★★。
★段階2-8 で「申し送り」として文書に落とし、コードは触らないこと★。

### ★本書で新しく決めたこと★

| # | 決めたこと | どこ |
|---|---|---|
| 1 | ★`collapseBtn` は `color` だけを `var(--pr)` に変える★（★クラスを消したり、別モジュールから `composes` したりしない★） | 段階1-1 |
| 2 | ★生SQL禁止は **ESLint ルール**で機械化する★（★grep を手順書に書くだけにしない★） | 段階2-1 |
| 3 | ★`settings/advice` の PATCH に長さ上限を入れる★（★`lib/config/` の定数として外出し★） | 段階2-3 |
| 4 | ★トークンの定数時間比較は**すでに実装済み**なので何もしない★ | 段階2-5 |

---

## ★★実行順とその理由★★

### ★指示書AC とは独立。★ただし同時に走らせないこと★★

- ★本書は `docs/handoff/指示書AC_絞り込みをSQLに降ろす.md` と★**前後どちらでもよい**★
- ★★ただし**同時に走らせないこと**★★ — ★指示書AC は `lib/db/index.ts` と `lib/*/query.ts` を
  大きく書き換えます。本書の段階2-1（ESLint ルール）と段階2-2（パスパラメータ）が
  同じ時間帯に同じ木を触ると、どちらの検証結果も信用できなくなります★
- ★どちらを先にやってもよいが、★**終わってからもう一方を始めること**★

### 群の中の順序

1. ★**群1 が先**★ — ★1行の CSS 変更で、他のどの群とも重なりません★。★一番短く区切れるので最初に通す★
2. ★**群2 が中**★ — ★段階2-0（棚卸し）を必ず先にやること★。★棚卸し前に「直し」を始めると、
   直した後の姿を「元からそうだった」と誤記録します★
3. ★**群3 が最後**★ — ★群3-1（`docs/handoff.md` の先頭）は、★**本書の成果を追記した後**★に
   直すのが自然です（直後にまた古くなるため）★

---

## ★★コミットと検証の規律（★全群に効きます★）★★

1. ★★群ごとに区切ってコミットすること★★ — ★群をまたいだ1コミットを作らない★
2. ★★群の切れ目で `npx tsc --noEmit` / `npm run lint` / `npm test` の3つが通ること★★
   （★群の中の各段階でも通すこと。段階割りはそうなるように組んであります★）
3. ★★`git add -A` 禁止★★（`CLAUDE.md` §7。★過去に別作業の途中ファイルを巻き込む事故あり★）—
   ★★必ずファイルパスを明示して `git add` すること★★
4. ★★`docs/decisions.md` をコミットするときは `git diff docs/decisions.md` を先に見ること★★ —
   ★未コミットの追記が同居していると巻き込みます★（★指示書W で実際に起きた★）
5. ★★`docs/handoff/` 配下の他ファイルに触らないこと★★ —
   ★★別のエージェントが `docs/handoff/指示書AC_絞り込みをSQLに降ろす.md` を作成中です★★。
   ★本書が触ってよいのは `docs/handoff.md`（群3-1）と本書自身だけ★
6. ★★途中で止めてよい★★ — ★各群の終わりに「ここで止めてもよい」と書いてあります★。
   ★止めるときは、どの群のどの段階まで終わったかを作業メモに残すこと★

---

## ★禁止事項（★本書の全群・全段階に効きます★）★

1. ❌ ★★`git add -A` の使用★★ — ★ファイルパスを明示する★（`CLAUDE.md` §7）
2. ❌ ★★メインDBのスキーマ変更・マイグレーション作成★★（TeamWorks の領域。D-056）
3. ❌ ★★`robot_error_reports` / `control_error_histories` の
   `status` / `comment` / `responded_at` / `resolved_at` ★**以外**★への DB 書き込み★★（D-042）。
   ★★本書のどの群も、アプリコードから DB に書き込みません★★
4. ❌ ★★閾値・コード値のハードコード★★ — ★`lib/config/` から読む★（`CLAUDE.md` §7）。
   ★段階2-3 の長さ上限を数値で直書きしないこと★
5. ❌ ★★`lib/fixtures/` の既存の**値**を変更すること★★ — ★本書はどの群も fixtures の値を変えません★
6. ❌ ★★モックに無い配色を発明すること★★ — ★群1 で使うのは `var(--pr)` だけ。
   これは★同じフッタの隣のボタンが既に使っている色★です（モック `d-watch-mock.jsx:818`）★
7. ❌ ★★`npm audit fix --force` を使うこと★★ — ★`prisma@6` へのダウングレードを含む破壊的変更が走ります★
8. ❌ ★★依存パッケージの major を勝手に上げること★★ — ★段階2-6 は**記録するだけ**★
9. ❌ ★★セキュリティの調査で、実在の攻撃手順を書き残すこと★★ —
   ★★「どこが守られているか」「何を検証したか」だけを書くこと★★。
   ★再現手順・ペイロード・具体的な突破方法を `docs/` にも commit message にも残さないこと★
10. ❌ ★★次のファイルに触ること★★:
    `README.md` / `docs/code-map.md` / `docs/handoff/管理画面_設計検討_20260910.md` /
    ★`.claude/worktrees/**`★ / ★`docs/handoff/` 配下の本書以外のファイル（`docs/handoff.md` は除く）★
11. ❌ ★★一時スクリプトをリポジトリに残すこと★★ —
    ★一時ファイルは作業用ディレクトリに置き、終わったら消すこと★。
    ★★成功時にも必ず `process.exit(0)` を書くこと★★（★Prisma の接続が残ってハングした事故あり★）
12. ❌ ★★DBへの書き込み・スキーマ変更を伴う調査★★ — ★★調査は `SELECT` のみ★★
13. ❌ ★★仕様が不明な箇所を推測で実装すること★★（`CLAUDE.md` §7）— ★止めて確認を求める★

---

# ★★群1 — 「上位10件に戻す」をリンクに見せる★★

## やること

★施設別パフォーマンス表のフッタにある「上位10件に戻す」が、★**押せるように見えない**★★。
★同じフッタの「全N施設を表示 →」はリンク色（`var(--pr)`）なのに、
「上位10件に戻す」だけ地味な文字色（`var(--tx-m)`）で、ただのラベルに見えます★。
★★これを隣のボタンと同じ見た目に揃えます★★。

## ★★これはモックから意図的に外れる変更です（★記録に残すこと★）★★

★`CLAUDE.md` §5 は「モック（`d-watch-mock.jsx`）が画面レイアウト・文言・遷移の正」と定めています★。
★★そして現状の実装は、モックに**忠実**です★★:

```
d-watch-mock.jsx:818  ... style={{ fontSize: 12, color: c.pr,  ... }}>全{RANK.length}施設を表示 →
d-watch-mock.jsx:823  ... style={{ fontSize: 12, color: c.txM, ... }}>上位10件に戻す
```

★★つまり「実装ミス」ではありません★★。★モックがそう描いていたので、そのとおり移植してあります★。

★★今回はユーザー指示（2026-09-13）でモックより利用者の判断を優先します★★。
★指示書AA（D-120）が「モックに無いUIを例外として新規に作った」のと同じ扱いです★。
★★`docs/decisions.md` に「モックから意図的に外れた」と理由つきで記録すること★★ —
★記録しないと、次にモックと実装を突き合わせた人が「バグだ」と言って元に戻します★。

---

## 段階1-0 — 実測（★読むだけ・コミットなし★）

★★行番号を信じないこと★★。★次を自分で実測して控えること★:

```bash
grep -n "expandBtn\|collapseBtn" components/features/reports/FacilityPerformanceTable.tsx
grep -n "^\.expandBtn\|^\.collapseBtn" components/features/reports/FacilityPerformanceTable.module.css
grep -n "^\.linkBtn\|^\.linkBtnMuted" components/features/reports/ReportsScreen.module.css
grep -n "上位10件に戻す" lib/i18n.ts
```

★作成時点の実測（2026-09-13。★鵜呑みにしない★）★:

| 場所 | 内容 |
|---|---|
| `components/features/reports/FacilityPerformanceTable.tsx:251` | 「全N施設を表示 →」= `styles.expandBtn` |
| `components/features/reports/FacilityPerformanceTable.tsx:260` | 「上位10件に戻す」= `styles.collapseBtn` |
| `components/features/reports/FacilityPerformanceTable.module.css:107` | `.expandBtn` → `color: var(--pr)` |
| `components/features/reports/FacilityPerformanceTable.module.css:115` | `.collapseBtn` → `color: var(--tx-m)` |
| `components/features/reports/ReportsScreen.module.css:210` | `.linkBtn` → ★`.expandBtn` と**完全に同一の4宣言**★ |
| `components/features/reports/ReportsScreen.module.css:218` | `.linkBtnMuted` → ★`.collapseBtn` と完全に同一。★どこからも使われていない（デッドCSS）★★ |
| `lib/i18n.ts:88` | `上位10件に戻す: "Back to top 10"` ★既に登録済み★ |

★★`.expandBtn` と `.collapseBtn` の差は `color` の1宣言だけです★★:

```css
.expandBtn  { font-size: 12px; color: var(--pr);   background: none; border: none; cursor: pointer; }
.collapseBtn{ font-size: 12px; color: var(--tx-m); background: none; border: none; cursor: pointer; }
```

★`cursor: pointer` は**両方に既にあります**★。★足りないのは色だけです★。

---

## 段階1-1 — ★`collapseBtn` の色を揃える★（1コミット）

### 1-1-1. ★★やること — 変更は CSS の1行だけ★★

`components/features/reports/FacilityPerformanceTable.module.css` の `.collapseBtn`:

```css
.collapseBtn {
  font-size: 12px;
  color: var(--pr);        /* ← ここだけ。var(--tx-m) から変える */
  background: none;
  border: none;
  cursor: pointer;
}
```

★コメントを添えること★（★次の人がモックと突き合わせて戻さないように★）:

```css
/* ★モックは c.txM（地味な色）だが、押せると分からないためリンク色に揃えた★
   （ユーザー指示 2026-09-13 / D-xxx）。★隣の .expandBtn と同じ見た目にする★ */
```

★`D-xxx` は着手時に実測した番号に置き換えること★。

### 1-1-2. ★★やらないこと（★ここを外すと差分が膨らみます★）★★

| ❌ やらない | なぜ |
|---|---|
| ★`.collapseBtn` を消して TSX で `styles.expandBtn` を使い回す★ | ★クラス名は「どちらのボタンか」を表しています★。消すと `module.css:100` のコメント（`/* フッタ（全N施設を表示 → / 上位10件に戻す）... */`）と食い違う |
| ★`composes: linkBtn from "./ReportsScreen.module.css"` を書く★ | ★表コンポーネントが画面コンポーネントの CSS に依存します★。★責務の境界（`FacilityPerformanceTable.tsx:22-29` のコメント）を壊す★ |
| ★`ReportsScreen.module.css` / `ReportsScreen.tsx` を触る★ | ★ユーザー指示で明示的に禁止★（「`ReportsScreen.tsx` の他の部分に差分を出さないこと」） |
| ★`text-decoration: underline` を足す★ | ★隣の `.expandBtn` に下線はありません★。★「既存の作法に合わせる」＝下線なし・色だけ★ |
| ★文言を変える★ | ★`t("上位10件に戻す")` のまま★。★`lib/i18n.ts:88` に対訳も既にある★ |
| ★`:hover` を新設する★ | ★`.expandBtn` に `:hover` はありません★。★片方だけ足すと非対称になります★ |
| ★`.linkBtnMuted`（デッドCSS）を消す★ | ★本書の範囲外★。★`ReportsScreen.module.css` に差分を出さない★ |

### 1-1-3. ★★検証★★

1. ★`npx tsc --noEmit` / `npm run lint` / `npm test` が通ること★
   （★CSS だけの変更なので、テスト件数は**48ファイル / 1137件から1件も増減しない**はずです★。
   ★増減したら手が滑っています★）
2. ★★画面で確認すること★★ — ★`npm run dev` → レポート画面（`/reports`）→
   施設別パフォーマンス表の「全N施設を表示 →」を押す → フッタが「上位10件に戻す」に変わる★
   - ★★**ライトとダークの両方**で見ること★★
     （`--pr` は `app/globals.css:7`（ダーク `#3b82f6`）と `:27`（ライト `#2563eb`）の
     ★両方に定義があります★ので、新しい配色を発明せずに両テーマで成立します）
   - ★「全N施設を表示 →」と「上位10件に戻す」が★**同じ色・同じサイズ**★に見えること★
3. ★★EN 表示でも確認すること★★（`Back to top 10` になる。★器を壊さない★）

> ★表を10件以下に絞ると「全N施設を表示 →」自体が出ません★
> （`FacilityPerformanceTable.tsx:163` の `showExpand = !showAll && totalCount > items.length`）。
> ★確認するときは施設が11件以上出る期間・絞り込みを選ぶこと★。

### この段階の完了条件

- [ ] `.collapseBtn` の `color` が `var(--pr)` になっている（★変更は CSS の1宣言のみ★）
- [ ] ★`git diff --stat` が `FacilityPerformanceTable.module.css` の1ファイルだけ★
- [ ] `tsc` 0 / `lint` 0 / `npm test` が着手前と同じ件数で全パス
- [ ] ★ライト・ダーク両方で隣のボタンと同じ見た目★
- [ ] `docs/decisions.md` に ★「モックから意図的に外れた」と理由つき★ で記録
- [ ] コミット（★ファイルパスを明示★）

## ★群1 の終わり★

★★ここで止めてもよい★★ — ★ユーザー指示①はこれで完結しています★。

---

# ★★群2 — セキュリティ強化★★

## ★★この群の考え方★★

★★「人が気をつける」で終わらせないこと★★（ユーザー指示 2026-09-13）。
★禁止事項を文書に書き足すだけなら、次の実装者は読まずに書きます★。
★★`npm run lint` で落ちる形にすること★★ — ★CI（`.github/workflows/ci.yml:29`）が
`npm run lint` を走らせているので、ESLint ルールにすれば**自動的に PR で検知されます**★。

---

## 段階2-0 — ★★棚卸し（★読むだけ・コミットなし★）★★

★★自分で実測すること★★。★下の表は作成時点（2026-09-13）の実測値で、★**鵜呑みにしないこと**★★。

```bash
# 生SQL（★lib/generated は Prisma の生成物なので除外して数える★）
grep -rn 'queryRaw\|executeRaw\|Unsafe\|Prisma\.raw' --include=*.ts --include=*.tsx app lib components scripts \
  | grep -v '^lib/generated/'

# orderBy と sort/order パラメータ
grep -rn 'orderBy' --include=*.ts app lib | grep -v '^lib/generated/'
grep -rn '"sort"\|"order"' --include=*.ts --include=*.tsx app lib components

# API ルートの一覧と searchParams の扱い
find app/api -name route.ts | sort
grep -rln 'searchParams' app/api

# リポジトリ内の認証情報（★ヒットは全部ダミーのはず★）
git grep -nIE "mysql://[^U]|password[[:space:]]*[:=][[:space:]]*[\"'][^\"']{4,}" -- ':!node_modules' ':!docs'
```

### ★作成時点の実測（2026-09-13）★

| 調査項目 | 結果 |
|---|---|
| 生SQL | ★**0件**★（`lib/generated/prisma/internal/*.ts` の型宣言のみ。★あれは Prisma のメソッド定義で、呼び出しではない★） |
| `orderBy` | ★**1箇所のみ**★ `lib/db/index.ts:673` `orderBy: { reported_at: "desc" }`（★固定リテラル★） |
| `sort` / `order` クエリパラメータ | ★**リポジトリ全体に存在しない**★。並べ替えは全部 JS 側の固定コンパレータ（`sortDsAlerts` 等） |
| `where` にユーザー入力 | ★**無い**★。`where` は `deleted_at: null` 等の固定条件のみ。絞り込みは全部 JS 側（`filterXxx`） |
| Prisma への `take` / `skip` | ★**ユーザー入力からは渡していない**★。`take: 1`（`lib/db/index.ts:674`）のみ |
| `per_page` / `limit` のクランプ | ★7つの `parseXxxQuery()` すべてが `Math.min(PAGINATION.maxPerPage, Math.max(1, toInt(...)))`★。★`maxPerPage = 200`（`lib/config/index.ts:177-193`）★ |
| エラー本文への内部情報 | ★**0件**★。全 catch が固定文言＋`details: null`。例外は `logApiError()` でログにだけ出る |
| DB書き込み | ★**アプリコードに0件**★ |
| リポジトリ内の認証情報 | ★**実在の値0件**★（`pw` / `s3cret` / `abc123` / `root@localhost` / `db.example.internal` 等のダミーのみ） |

### ★★すでに守られていること（★触らないこと★）★★

| 守り | 実装 | 状態 |
|---|---|---|
| トークンの定数時間比較 | `lib/auth/token.ts:26-44`（`node:crypto` の `timingSafeEqual`。★長さ違いも同じ時間で false★） | ★**実装済み。何もしない**★ |
| `X-Forwarded-For` の★末尾★を見る | `lib/auth/client-ip.ts:37-51`（`parts[parts.length - hops]`。★先頭は使わない★。D-077 項5） | ★**実装済み**★ |
| 足りない段数を先頭で埋めない | `lib/auth/client-ip.ts:46`（`parts.length < hops` なら `null`） | ★**実装済み**★ |
| `ALLOW_DIRECT_ACCESS` の fail-closed | `lib/config/allowed-ips.ts:56`（`=== "true"`。★`!== "false"` にしない★。D-101） | ★**実装済み**★ |
| cookie の `httpOnly` / `secure` / `sameSite` | `lib/auth/cookie.ts:29-34` | ★**実装済み**★ |
| ログの秘匿化 | `lib/logger/redact.ts`（PII 13種＋認証情報9種。★キー名で判定、大文字小文字無視★） | ★**実装済み**★ |
| ログイン失敗時に入力値を出さない | `app/api/auth/login/route.ts:46-51` | ★**実装済み**★ |
| シードが本番DBに書かないガード | `lib/db/local-guard.ts:23-31`（★`dwatch_local` 以外なら例外★） | ★**実装済み**★ |
| エラーに `DATABASE_URL` の値を出さない | `lib/config/env-check.ts` / `lib/db/local-guard.ts`（★「値はこのエラーには出しません」★） | ★**実装済み**★ |

★★この表の行を「念のため」書き直さないこと★★ — ★どれも理由つきで確定しています（D-072 / D-077 / D-079 / D-101）★。

### この段階の完了条件

- [ ] 上の実測コマンドを全部自分で走らせ、★結果を控えた★
- [ ] ★作成時点の表と食い違う項目があれば、**自分の実測を正**として本書を直した★
- [ ] ★コミットしない★

---

## 段階2-1 — ★★生SQL禁止を ESLint で機械化する★★（1コミット）

### 2-1-1. なぜ ESLint か

★`CLAUDE.md` §3 は「なるべく MySQL 互換の SQL を使う」「生SQL を書くときは特に注意する」と
書いていますが、★**禁止とは書いていません**★★。
★本番は MariaDB 10.11 / ローカル検証は MySQL 8.4 なので、★生SQL はそもそも
「ローカルで通っても本番で通らない」リスクがあります★。
★★加えて `*Unsafe` 系と文字列連結は SQL インジェクションの入口そのものです★★。

★grep を検証手順に書くだけでは、次の実装者が読まなければ素通りします★。
★★`npm run lint` が CI（`.github/workflows/ci.yml:29`）で走っているので、ESLint ルールにすれば
PR で自動的に落ちます★★。

### 2-1-2. ★★やること★★

`eslint.config.mjs` に ★`no-restricted-syntax`★ のブロックを1つ追加する。

```js
  // ★生SQL禁止（指示書AE 段階2-1 / D-xxx）★
  //   SQLインジェクションの入口を構造的に塞ぐ。Prisma のクエリビルダ経由に統一する。
  //   ★lib/generated/** は先頭の ignores で除外済み★ —
  //   あそこには Prisma が生成した $queryRaw の「型宣言」があり、呼び出しではない。
  //   ★どうしても生SQLが必要になったら、このルールを消すのではなく
  //     タグ付きテンプレート（`prisma.$queryRaw`...`）だけを個別に eslint-disable し、
  //     理由を docs/decisions.md に書くこと。★文字列連結と *Unsafe は例外を作らない★
  {
    files: ["app/**", "lib/**", "components/**", "scripts/**", "proxy.ts"],
    rules: {
      "no-restricted-syntax": [
        "error",
        {
          selector:
            "MemberExpression[property.name=/^\\$(query|execute)Raw(Unsafe)?$/]",
          message:
            "生SQLは使わないこと（CLAUDE.md §3 / 指示書AE 段階2-1）。Prisma のクエリビルダを使う。",
        },
        {
          selector: "MemberExpression[object.name='Prisma'][property.name='raw']",
          message:
            "Prisma.raw は使わないこと（指示書AE 段階2-1）。Prisma のクエリビルダを使う。",
        },
      ],
    },
  },
```

★`D-xxx` は着手時に実測した番号に置き換えること★。

### 2-1-3. ★★ルールが本当に効くことを確かめる（★必須★）★★

★★書いただけで満足しないこと★★。★セレクタのタイポは「常に何も検知しない」形で失敗します★。

1. ★一時的に★ 任意の `lib/` 配下のファイルへ次の1行を足す:
   ```ts
   const _tmp = () => prisma.$queryRawUnsafe("SELECT 1");
   ```
2. `npm run lint` を走らせ、★**その行でエラーになること**★を目で確認する
3. ★`$queryRaw`（Unsafe なし）と `Prisma.raw` でも同様に確認する★
4. ★★足した行を消す★★。★`git diff` が `eslint.config.mjs` だけになっていることを確認する★
5. `npm run lint` が ★0★ に戻ることを確認する

### 2-1-4. ★併せて文書に明文化すること★

★`CLAUDE.md` は本書では触りません★（★本書の担当外★）。★代わりに `docs/decisions.md` の
決定エントリに、次の3つを禁止として書くこと★:

- ❌ `$queryRawUnsafe` / `$executeRawUnsafe`（★例外なし★）
- ❌ SQL 文字列の連結（★ユーザー入力の有無にかかわらず★）
- ❌ `orderBy` のカラム名をユーザー入力から組み立てること（★許可リストのみ★）
- △ `$queryRaw` / `$executeRaw` の★タグ付きテンプレート★は、★どうしても必要な場合に限り★
  個別 `eslint-disable` ＋理由の記録で許す。★★その箇所を TeamWorks に知らせること★★
  （`CLAUDE.md` §3 の「MariaDB 特有の DML を使いたい場合は TeamWorks に知らせる」）

### この段階の完了条件

- [ ] `eslint.config.mjs` にルールが入っている
- [ ] ★一時コードで**実際に落ちることを確認した**★（★確認したら一時コードを消した★）
- [ ] `npm run lint` が 0
- [ ] `npx tsc --noEmit` 0 / `npm test` が着手前と同じ件数で全パス
- [ ] コミット（★`eslint.config.mjs` のみ★）

---

## 段階2-2 — ★★パスパラメータの検証を厳しくする★★（1コミット）

### 2-2-1. ★現状（★自分で実測すること★）★

| ルート | 現状 |
|---|---|
| `app/api/clean-logs/[id]/advice/route.ts:33-37` | `Number.parseInt(id, 10)` → `Number.isNaN` のみ 422 |
| `app/api/clean-logs/[id]/areas/route.ts:47-51` | ★同一実装★ |
| `app/api/ds-devices/[serial_number]/note/route.ts:48` | ★形式・長さとも未検証★ |

★★`Number.parseInt` は前方一致なので、次が黙って通ります★★:

| 入力 | `parseInt` の結果 |
|---|---|
| `-5` | `-5`（★負数★） |
| `12abc` | `12`（★ゴミが付いていても通る★） |
| `1.9` | `1`（★非整数が切り捨てられる★） |
| `1e9` | `1`（★指数表記が意図と違う値になる★） |

### 2-2-2. ★★いま危険ではない理由（★記録すること★）★★

★★この値は DB クエリにもファイルパスにも渡りません★★。
★用途は `logs.find((l) => l.id === logId)` の等値比較だけで、一致しなければ 404 です★
（`clean-logs/[id]/advice/route.ts:48-51` / `[id]/areas/route.ts:70`）。
★`serial_number` も `devices.some(...)` の実在チェックを通らなければ 404 で、
保存先のパスは `lib/settings/ds-device-notes.ts:53-55` で固定、
マップは `Object.create(null)` です★。

★★だからこれは「穴を塞ぐ」ではなく「入口を厳しくして、将来 DB に降ろしたときに崩れないようにする」作業です★★。
★★指示書AC が絞り込みを SQL に降ろすので、そのとき `id` が Prisma の `where` に入ります★★。
★その前に入口を締めておくのが本段階の意味です★。

### 2-2-3. ★★やること★★

★共通の小さな純粋関数を1つ作り、3ルートから呼ぶ★。★置き場所は `lib/api/` が自然です★
（既に `lib/api/query-result.ts` の `fail()` がある）。

- ★`^\d+$` の**完全一致**で判定する★（★`parseInt` の前方一致に頼らない★）。
  ★`lib/reports/query.ts:88-92` の `toIntStrict`（`^-?\d+$`）が★**既にある前例**★なので、
  それに倣うこと。★新しい流儀を発明しない★
- ★上限を設ける★ — ★`Number.isSafeInteger` を超える値を弾く★。
  ★上限値は `lib/config/` の定数として外出しすること★（`CLAUDE.md` §7。★直書き禁止★）
- ★負数・`0` を弾く★（★ID は 1 以上★）
- ★外れたら 422 `validation_failed`★（★既存ルートと同じ形★）。
  ★★エラー文言に入力値をエコーしないこと★★
- ★`serial_number` は★長さ上限★と★文字種★を決めて検証する★。
  ★★実データの形式が不明なら、**長さ上限だけ**にして文字種は絞らないこと★★
  （`CLAUDE.md` §7「仕様が不明な箇所を推測で実装しない」）。
  ★長さ上限は `lib/config/` の定数に★

### 2-2-4. ★★テストを書くこと★★

★`lib/` 配下の該当する `__tests__/` に純粋関数のテストを追加する★。★期待値表を先に作ること★:

| 入力 | 期待 |
|---|---|
| `"1"` | 通る（`1`） |
| `"538"` | 通る（`538`） |
| `"0"` | ★422★ |
| `"-5"` | ★422★ |
| `"1.9"` | ★422★ |
| `"12abc"` | ★422★ |
| `"1e9"` | ★422★ |
| `""` | ★422★ |
| `"9007199254740993"`（安全整数超） | ★422★ |
| 全角数字 `"１"` | ★422★ |

### この段階の完了条件

- [ ] 純粋関数＋テストがある（★上の期待値表を全部含む★）
- [ ] 3ルートが同じ関数を通している
- [ ] ★上限値が `lib/config/` の定数★（★直書きしていない★）
- [ ] ★エラー文言に入力値をエコーしていない★
- [ ] `tsc` 0 / `lint` 0 / `npm test` 全パス（★テストは増える★）
- [ ] コミット（★ファイルパスを明示★）

---

## 段階2-3 — ★★`settings/advice` の PATCH に長さ上限を入れる★★（1コミット）

### 2-3-1. ★★これが群2 で一番実のある直しです★★

★`app/api/settings/advice/route.ts:76-81` の検証は★**型チェックだけ**★です★:

| フィールド | 現状の検証 | 足りないもの |
|---|---|---|
| `rule`（ruleId） | ★`DEFAULT_ADVICE_RULES[ruleId]` の存在チェック★ | ★★十分（許可リスト方式）★★ |
| `title` | `typeof === "string"` | ★**長さ上限なし**★ |
| `text` | `typeof === "string"` | ★**長さ上限なし**★ |
| `actions` | `Array.isArray` ＋要素の `typeof === "string"` | ★**配列長の上限なし・要素長の上限なし**★ |

★★これは**すぐ隣の実装と非対称**です★★:

- `app/api/ds-devices/[serial_number]/note/route.ts:69-76` は
  ★`DEVICE_NOTE_MAX_LENGTH`（= 200、`lib/config/index.ts:236`）で上限をかけています★
- ★その理由は `lib/config/index.ts:230-235` に書いてあります★:
  > 上限が無いと1件の備考でストアのファイル全体が肥大し、
  > ★**全件読み込み→書き戻しという保存方式ごと遅くなる**★ため。
- ★★`settings/advice` は**まったく同じ保存方式**です★★（`lib/settings/advice.ts:126-139` が
  JSON ストアを全件読み→書き戻し）。★同じ理由が同じだけ当てはまるのに、上限だけ無い★

★さらに、ここに入れた値は `GET /settings/advice` と
`/clean-logs/[id]/advice`（`app/api/clean-logs/[id]/advice/route.ts:85-96`）で
★そのまま画面に返ります★★。

### 2-3-2. ★★やること★★

★`lib/config/index.ts` に定数を足し、`DEVICE_NOTE_MAX_LENGTH` と同じ書き方で理由を書く★。

★★値は次を推奨します（★実行者が変えてよい。変えたら理由を書くこと★）★★ —
★`lib/config/advice-rules.ts` の `DEFAULT_ADVICE_RULES` の実物から逆算した数字です★:

| 定数 | 推奨値 | 根拠（★着手時に実物を見て確かめること★） |
|---|---|---|
| `ADVICE_TITLE_MAX_LENGTH` | `100` | ★既定値は「出発失敗（R1）」等で15文字前後★。余裕を見て100 |
| `ADVICE_TEXT_MAX_LENGTH` | `400` | ★既定値は60文字前後★。R2 の「現場の通路状況＋…」が最長 |
| `ADVICE_ACTION_MAX_LENGTH` | `100` | ★既定値は15文字前後★ |
| `ADVICE_ACTIONS_MAX_ITEMS` | `10` | ★既定値は3件★ |

★超過したら 422 `validation_failed`★。★`details` には**上限値だけ**を入れ、入力値をエコーしないこと★
（★`ds-devices/[serial_number]/note/route.ts:74` が `DEVICE_NOTE_MAX_LENGTH` を `details` に
入れている前例に倣う★）。

### 2-3-3. ★★やらないこと★★

- ❌ ★`actions` の**中身**を許可リストで縛ること★ — ★自由入力のメモです★
- ❌ ★既定値（`DEFAULT_ADVICE_RULES`）を書き換えること★
- ❌ ★上限を数値で直書きすること★（★`lib/config/` へ★）
- ❌ ★`ds-device-notes` 側の `DEVICE_NOTE_MAX_LENGTH`（= 200）を変えること★

### 2-3-4. テスト

★既存の `settings/advice` のテストに境界値を足す★:

| 入力 | 期待 |
|---|---|
| `title` = 上限ちょうど | 通る |
| `title` = 上限+1 | ★422★ |
| `text` = 上限+1 | ★422★ |
| `actions` = 上限件数ちょうど | 通る |
| `actions` = 上限件数+1 | ★422★ |
| `actions` の1要素が要素長上限+1 | ★422★ |

### この段階の完了条件

- [ ] 4定数が `lib/config/index.ts` にあり、★`DEVICE_NOTE_MAX_LENGTH` と同じ密度で理由が書いてある★
- [ ] `app/api/settings/advice/route.ts` が4定数すべてで検証している
- [ ] ★`details` に入力値をエコーしていない★
- [ ] 境界値テストが全部ある
- [ ] `tsc` 0 / `lint` 0 / `npm test` 全パス
- [ ] コミット

---

## 段階2-4 — ★★CSVの仕上げ（★やらない判断をしてよい★）★★（1コミット。任意）

### 2-4-1. ★見つかったこと★

`app/api/clean-logs/export/route.ts:18-23` の `csvCell()`:

```ts
function csvCell(value: string): string {
  if (/[",\n\r]/.test(value)) {
    return `"${value.replaceAll('"', '""')}"`;
  }
  return value;
}
```

★`"` `,` 改行は正しくエスケープしています★。
★★ただし、先頭が `=` `+` `-` `@` の値は表計算ソフトが**数式として解釈します**★★
（いわゆる CSV インジェクション）。
★出力される値の出所は DB 由来の施設名・機種名・シリアルで、
これらを書いているのは★**TeamWorks のデータ収集処理**★です（`CLAUDE.md` §2）。
★★つまり DFA 側でその内容を保証できません★★。

### 2-4-2. ★★判断が要ります★★

★★これは「やらない」で構いません★★。理由:

- ★P1 は**DFA社内のみ**の利用★（`CLAUDE.md` §1）で、CSV を開くのは運用担当だけ
- ★対策（先頭に `'` を付ける等）は★**出力される CSV の中身が変わる**★ため、
  ★受け取る側の手順に影響します★

★★どちらを選んでもよいので、選んだ理由を `docs/decisions.md` に書くこと★★。

- ★やる場合★: `csvCell()` の中だけで完結させる。★★`'` を前置する方式にし、
  条件（先頭が `=` `+` `-` `@` のとき）と理由をコメントに書く★★。
  ★テストを必ず足すこと★（★`=SUM(1)` → `'=SUM(1)`、`正常な施設名` → そのまま★）。
  ★既存の `"`/`,`/改行のエスケープを壊さないこと★
- ★やらない場合★: ★`csvCell()` の上に「★検討したうえで P1 では対策しない★」とコメントを1つ残すこと★。
  ★次に見た人が同じ調査を最初からやり直さずに済みます★

### 2-4-3. ★併せて見つかったこと（★こちらは直さない★）★

★`app/api/clean-logs/export/route.ts:79-81` が、`parseCleanLogQuery` を通した後に
★**生の `searchParams` をもう一度読んで**★ `Content-Disposition` のファイル名に入れています★:

```ts
const from = searchParams.get("date_from") ?? "";
const to = searchParams.get("date_to") ?? "";
const range = from && to ? `_${from}_${to}` : "";
```

★★いまは安全です★★ — ★`parseCleanLogQuery` が先に `^\d{4}-\d{2}-\d{2}$`
（`lib/clean-logs/query.ts:28`）で弾いているので、ヘッダに制御文字は入りません★。
★★ただし「検証した場所」と「使う場所」が別の関数に分かれた暗黙の依存です★★。

★★直さなくてよいですが、コメントを1行足すこと★★:

```ts
// ★生値を使ってよいのは、上の parseCleanLogQuery が ^\d{4}-\d{2}-\d{2}$ で
//   既に弾いているから★。★この検証を緩めるならここも直すこと★
```

★`parsed.query` 側に正規化済みの値があるなら、そちらを使うほうが素直です★。
★★どちらにするかは実行者判断★★。

### この段階の完了条件

- [ ] ★やる / やらない を決めて、理由を `docs/decisions.md` に書いた★
- [ ] （やった場合）テストがある / （やらない場合）コメントが残っている
- [ ] ★`Content-Disposition` の暗黙依存にコメントを足した★
- [ ] `tsc` 0 / `lint` 0 / `npm test` 全パス

---

## 段階2-5 — ★★本番前チェックリストを `docs/deploy.md` に集約する★★（1コミット）

### 2-5-1. ★★コードは変えません★★

★段階2-0 の表のとおり、認証・IP制限まわりは**すべて実装済み**です★。
★足りないのは「本番へ出す直前に何を確かめるか」が1箇所にまとまっていないことです★。

★`docs/deploy.md` には既に材料があります★（★着手時に実測すること★）:

| 節 | 内容 |
|---|---|
| `docs/deploy.md:57` | ★`ALLOW_DIRECT_ACCESS` は本番で設定しないこと★ |
| `docs/deploy.md:74` | ★`X-Forwarded-For` に「追記」すること（D-077 項5）★ |
| `docs/deploy.md:88` | ★アプリのポートに nginx 以外が到達できないようにすること★ |
| `docs/deploy.md:125` | ★許可IPリストの変更にはデプロイが必要★ |

### 2-5-2. ★★やること — 「セキュリティ確認」の節を1つ足す★★

★チェックボックス形式で、★**それぞれに「どう確かめるか」のコマンドか観点**★を添えること★。

| # | 確認項目 | 確かめ方 |
|---|---|---|
| 1 | ★`AUTH_TOKEN` が `changeme` のままでない★ | ★`npm run check:env`★（`lib/config/env-check.ts:56-70` が★エラーで落とします★） |
| 2 | ★`ALLOW_DIRECT_ACCESS` が本番で未設定★ | ★`npm run check:env`★（検査2。★本番判定でエラー★） |
| 3 | ★`USE_FIXTURES=false`★ | ★`npm run check:env`★（検査3。★未設定だと `true` 扱いで、実DBに繋がらないまま動きます★） |
| 4 | ★`ALLOW_ALL_WHEN_EMPTY = false` かつ `ALLOWED_IPS` が埋まっている★ | ★`lib/config/allowed-ips.ts` を目視★。★★環境変数ではないのでデプロイが必要★★ |
| 5 | ★`TRUSTED_PROXY_HOPS` が実構成と合っている★ | ★`docs/deploy.md:170` の判定表★。★2段構成になったら 2 にする★ |
| 6 | ★nginx が XFF を「追記」している（上書きでない）★ | ★`docs/deploy.md:74` の手順★ |
| 7 | ★リポジトリに認証情報が入っていない★ | ★段階2-0 の `git grep` コマンド★ |
| 8 | ★`npm audit` の結果を確認した★ | ★段階2-6★ |

### 2-5-3. ★★「実装済みだから確認不要」にしないこと★★

★項目4 と 5 は★**コードに書いてあるので `.env.local` を直しても変わりません**★
（`docs/deploy.md:125-139` / `.env.production.example` の末尾）。
★★デプロイ担当がここを見落とすと、`ALLOW_ALL_WHEN_EMPTY = true` のまま本番へ出ます★★。
★チェックリストに入れる意味はここにあります★。

### 2-5-4. ★過去の事故を記録に残すこと★

★`docs/deploy.md` の同じ節に、★**過去に実在のDBユーザー名がコミットされた**★ことを書くこと★:

- ★`c832891`（`fix: テストから実在のDBユーザー名を除去する`）で除去済み★
- ★★ただし**Git の履歴には残っています**★★
- ★だから「いま `git grep` して出ない」＝「漏れていない」ではありません★
- ★★本番の資格情報は、履歴に入った値と**別のものを発行すること**★★（→ 段階2-8 の申し送り）

### この段階の完了条件

- [ ] `docs/deploy.md` にセキュリティ確認の節がある（★8項目＋確かめ方★）
- [ ] ★`c832891` の事故と「履歴には残る」ことが書いてある★
- [ ] ★★実在の攻撃手順を書いていない★★
- [ ] コミット（★`docs/deploy.md` のみ★）

---

## 段階2-6 — ★★`npm audit` を検証手順に入れ、結果をそのまま記録する★★（1コミット）

### 2-6-1. ★★直すかどうかは別判断です★★

★★この段階でやるのは「走らせて、出た結果をそのまま書き残す」ことだけです★★。

- ❌ ★`npm audit fix --force` 禁止★（★`prisma@6.19.3` へのダウングレードが走ります★）
- ❌ ★major を勝手に上げること禁止★
- ★`npm audit fix`（`--force` なし）も、★この段階では実行しないこと★★ —
  ★`package-lock.json` が動くと、段階2-1〜2-3 の差分と混ざります★

### 2-6-2. ★作成時点の実測（2026-09-13）★

```
8 vulnerabilities (1 moderate, 7 high)
```

| パッケージ | 経路 | 内容 | 修正 |
|---|---|---|---|
| ★`mariadb` 3.4.0-3.4.5★ | ★`@prisma/adapter-mariadb`（**直接依存**）★ | ★`ssl: true` でも MitM に平文パスワードが漏れる★ / ★機密情報の平文送信★ / ★特定クライアント文字セット（big5 / gbk / sjis / cp932 / gb18030）下での SQL インジェクション★ | ★★`No fix available`★★ |
| `mysql2` ≤3.23.0 | `prisma` 経由 | 認証プラグインのダウングレードで平文資格情報が漏れる / 圧縮プロトコルの解凍爆弾 | `--force` のみ（★使わない★） |
| `deepmerge-ts` <8.0.0 | `@prisma/config` → `prisma` | 再帰オブジェクトでスタック枯渇 | `--force` のみ（★使わない★） |
| `js-yaml` 4.0.0-4.3.1 | — | `maxTotalMergeKeys` が CPU 使用を制限しない | `npm audit fix` で可 |
| `sharp` <0.35.4 | — | libheif 由来 | `npm audit fix` で可 |

### 2-6-3. ★★`mariadb` の SQL インジェクション勧告について（★確かめること★）★★

★★これは「アプリコードに生SQLが無い」では防げない層の話です★★ —
★コネクタが**パラメータをエスケープする処理**の問題だからです★。

★★ただし、影響を受けるのは特定のクライアント文字セット（big5 / gbk / sjis / cp932 / gb18030）の場合だけです★★。

★作成時点の実測では `lib/db/prisma.ts:38` は `new PrismaMariaDb(databaseUrl)` で、
`DATABASE_URL`（`.env.example` / `.env.production.example`）にも文字セット指定はありません★。
★★つまり既定（utf8mb4）で、**該当しない**と読めます★★。

★★着手時に自分で確かめること★★:

```bash
grep -n "charset\|collation" .env.example .env.production.example lib/db/prisma.ts
```

★★`DATABASE_URL` に `charset=` / `collation=` が入っていないこと★★を確認し、
★その事実を記録すること★。
★★もし該当する文字セットが指定されていたら、そこで止めてユーザーに報告すること★★
（★勝手に変えない★）。

### 2-6-4. ★★やること★★

1. ★`npm audit` を走らせ、★出力をそのまま★ `docs/deploy.md` のセキュリティ節（段階2-5 で作った節）に
   ★日付つきで貼る★
2. ★上の「該当しない」確認の結果を1行で書く★
3. ★★`mariadb` の「`No fix available`」を段階2-8 の申し送りに繋げること★★
4. ★`package-lock.json` を変更しないこと★（★`git diff --stat` で確認★）

### この段階の完了条件

- [ ] `npm audit` の結果が日付つきで記録されている
- [ ] ★文字セットの確認結果が書いてある★
- [ ] ★`package-lock.json` に差分が無い★
- [ ] ★`npm audit fix` を実行していない★
- [ ] コミット（★`docs/deploy.md` のみ★）

---

## 段階2-7 — ★★セキュリティヘッダ（★やらない判断をしてよい★）★★（1コミット。任意）

### 2-7-1. ★現状★

`next.config.ts` は `serverExternalPackages: ["pino"]` だけで、★`headers()` の設定はありません★。
★つまり `X-Content-Type-Options` / `Referrer-Policy` / `X-Frame-Options` / CSP のいずれも出ていません★。

### 2-7-2. ★★判断が要ります★★

★★「やらない」で構いません★★。理由:

- ★P1 は**DFA社内のみ**・IP制限つき★。★外部からそもそも到達しません★
- ★★ヘッダは nginx 側で付けるほうが素直です★★（★アプリを再デプロイせずに直せる★）。
  ★`docs/deploy.md:72` に既に「nginx 側の前提」の節があります★
- ★★CSP を入れると画面が壊れる可能性があります★★（Next.js のインラインスクリプト）。
  ★壊れたら**無理に通さず戻すこと**★

★★推奨: アプリには入れず、`docs/deploy.md` の「nginx 側の前提」に**申し送りとして書く**★★。

- ★やる場合★: ★CSP は入れないこと★。★`X-Content-Type-Options: nosniff` /
  `Referrer-Policy: same-origin` / `X-Frame-Options: DENY` の3つだけにする★。
  ★入れたら `npm run dev` で全画面を開いて壊れていないことを確認すること★
- ★やらない場合★: ★`docs/deploy.md` の nginx 節に「★アプリ側では付けていない。nginx で付けること★」と書く★

### この段階の完了条件

- [ ] ★やる / やらない を決めて、理由を `docs/decisions.md` に書いた★
- [ ] （やった場合）★全画面が壊れていないことを目視確認した★
- [ ] `tsc` 0 / `lint` 0 / `npm test` 全パス

---

## 段階2-8 — ★★申し送り（TeamWorks / インフラ）★★（1コミット）

### 2-8-1. ★★これはコードでは直せません★★

★★DFA のアプリコードに手を入れても解決しない問題です★★。
★★文書に落として、TeamWorks とインフラ側に渡すこと★★。

### 2-8-2. ★書くこと★

★`docs/handoff.md` に追記する★（★★`docs/handoff/` 配下の他の新規ファイルを作らないこと★★ —
★別エージェントが指示書AC を作成中です★）。

| # | 事実 | 依頼したいこと |
|---|---|---|
| 1 | ★テスト用DBが**公開IPに平文で開いている**★（★監督実測★: `have_ssl=DISABLED` / `require_secure_transport=OFF` / `参照用アカウント@%`） | ★接続元の制限（`参照用アカウント@%` を★固定IP★に）★ / ★TLS の有効化★ / ★パスワードの再発行★ |
| 2 | ★`npm audit` が `mariadb` コネクタに「`ssl: true` でも MitM に平文パスワードが漏れる」を報告。★`No fix available`★★ | ★★TLS を有効にしても、このコネクタでは資格情報が保護されない可能性がある★★。★経路そのものを閉じる（固定IP / 内部ネットワーク）ことを優先してほしい★ |
| 3 | ★過去に実在のDBユーザー名がコミットされ `c832891` で除去。★履歴には残っている★★ | ★★本番の資格情報は、履歴に入った値と**別のものを発行する**こと★★ |
| 4 | ★本番はさくらインターネットのデータベースアプライアンス★（`CLAUDE.md` §3 / D-100） | ★★初めから閉じた構成になる見込みだが、**明示の確認は未取得**★★（D-100）。★確認したい★ |

### 2-8-3. ★★いつまでに、を書くこと★★

★★「ステージングに実データが入る前に決めること」★★ — ★これが期限です★。
★実データが入った後では、平文経路を通った資格情報を前提に運用が始まってしまいます★。

### 2-8-4. ★★書かないこと★★

- ❌ ★★実在のホスト名・IP・ユーザー名・パスワードを書くこと★★
  （★`CLAUDE.md` §9.3。★★`docs/` はリポジトリに入ります★★）
- ❌ ★★実在の攻撃手順・再現コマンドを書くこと★★（★本書の禁止事項9★）
- ★「公開IPに平文で開いている」という**事実の記述**までにとどめること★

### この段階の完了条件

- [ ] `docs/handoff.md` に申し送りが追記されている（★4項目＋期限★）
- [ ] ★★実在の資格情報・ホスト名・攻撃手順を書いていない★★
- [ ] コミット

## ★群2 の終わり★

★★ここで止めてもよい★★ — ★ユーザー指示②はこれで完結しています★。

---

# ★★群3 — 小さな積み残し★★

## 段階3-0 — ★★`docs/handoff.md` の先頭を直す（★実害あり。必ずやること★）★★（1コミット）

### 3-0-1. ★★何が起きているか★★

`docs/handoff.md:6`:

```
- ★**最新の追記: 2026-09-11（追記36）**★ — **下の「追記36」が最新です。**
```

★★実際の最終追記は「追記40」です★★（`docs/handoff.md:3363` `## 2026-09-11（追記40）`）。

★★この文書は、自分自身で警告を書いています★★:

```
docs/handoff.md:3  **この文書は日付ごとの追記が下へ積み上がる形式です。必ず最後まで読んでください。**
docs/handoff.md:4  先頭だけ読むと最初の追記時点の状態で止まります。
```

★★警告しておきながら、その先頭行が4件ぶん古い★★。
★次に読む人は「追記36 が最新」と信じて止まり、★**追記37〜40 の成果（指示書AB の4群 = D-117〜D-120）を
まるごと見落とします**★★。

### 3-0-2. ★★やること★★

1. ★`docs/handoff.md:6` を★**実測した最終番号**★に直す★
   （★★着手時にもう一度数えること★★。★群2-8 で自分が追記していれば、その番号が最新です★）
   ```bash
   grep -n "^## .*（追記[0-9]*）" docs/handoff.md | tail -3
   ```
2. ★要約文も、★**その最新の追記の内容**★に合わせて書き直すこと★
   （★番号だけ直して中身が追記36 の説明のままだと、かえって紛らわしくなります★）
3. ★★`docs/handoff.md:817` にも古い記述があります★★:
   ```
   **最初に読むのは ... 追記7（この節）まで。**
   ```
   ★これも stale です★。★直すか、せめて「★この節は古い★」と1行足すこと★

### 3-0-3. ★★再発を防ぐ★★

★★同じことが5回起きています（追記36 → 40 で4件放置）★★。
★`docs/handoff.md` の先頭に、★**「追記したら必ずこの行も直すこと」**★と1行書き足すこと★。

### この段階の完了条件

- [ ] ★先頭の「最新の追記」が実測値と一致している★
- [ ] ★要約文が最新の追記の内容になっている★
- [ ] ★`:817` の古い記述に手当てをした★
- [ ] ★「追記したらこの行も直す」の注意書きがある★
- [ ] コミット（★`docs/handoff.md` のみ★）

---

## 段階3-1 — ★軽微な積み残し（★実行者判断で拾ってよい★）★（1コミット。任意）

★★どれも**いま実害はありません**★★。★時間があれば拾う、無ければ飛ばしてよい★。
★★飛ばす場合は、その旨を作業メモに残すこと★★。

### ① ★同じエリアが同じ日に2回清掃されると二重計上される★

- ★場所★: `lib/clean-logs/areas.ts:110` `buildAreaCompletions()`
- ★現状★: `rows.map(...)` のみで `section_id` の一意化なし。
  ★分母・分子の積み上げ（`:125-131`）も全行を無条件に加算★:
  ```ts
  for (const r of rows) {
    cleanArea += r.clean_area;
    taskArea  += r.task_area;
  }
  ```
- ★前段の絞り込み `selectAreaRowsForLog()`（`:55-62`）も `facility_id` と `date` の一致だけ★
- ★★現データでは0件★★（★着手時に確かめること★）。
  ★ただし「同一ロボットの同日2回清掃」は実在前提として扱われています★（追記33）
- ★★拾う場合の注意★★: ★どう集約するかは**仕様判断**です★
  （★最後の1回を採るのか / 面積を合算するのか / 両方出すのか★）。
  ★★決められないなら**止めて確認を求めること**★★（`CLAUDE.md` §7）。
  ★★推測で実装しないこと★★

### ② ★`buildPageItems()` の `page` に非整数・NaN のガードが無い★

- ★場所★: `lib/pagination/page-items.ts:93-98`
- ★現状★: `totalPages` には `Number.isFinite` ガードがあるが、
  ★`page` は `clamp()` を通るだけ★。★`Math.min(max, Math.max(min, NaN))` は `NaN` を素通りさせます★
- ★★いまは到達不能★★ — ★呼び出し元は `components/ui/Pagination.tsx:67` の1箇所だけで、
  6画面すべて `useState(1)` の整数 state。★URL クエリから `page` を読む経路は存在しません★★
- ★★ただしユーザー指摘のとおり「ページ番号をURLに持たせた瞬間に穴が開く」★★
- ★拾う場合★: `totalPages` と同じ形で `Number.isFinite(page)` ＋ `Math.trunc()` を足し、
  ★テストに `NaN` / `2.5` / `Infinity` を追加する★

### ③ ★501 / `not_implemented` の残骸★

- ★テスト名★: `lib/api/__tests__/client.test.ts:98`
  `test("501 の定型メッセージも serverMessage で取れる（エリア別完了率）", ...)`
  ★同種がもう1本: `lib/logger/__tests__/api.test.ts:81, 94`★
- ★型★: `lib/types/index.ts:28` の `ErrorCode` に `| "not_implemented"`
- ★★返している実装は0件★★（エリア別完了率は D-119 で本実装済み。
  `app/api/clean-logs/[id]/areas/route.ts:13` にその旨のコメントあり）
- ★★`d-watch-api.yaml` には `not_implemented` も `501` も出てきません★★
  （★実測済み。`error.code` は `type: string` のまま enum 無し★）。
  ★★つまり型から外しても API 契約と乖離しません★★。むしろ `CLAUDE.md` §9.2
  （「型は YAML のスキーマ名と揃える」）に近づきます
- ★拾う場合★: ★テスト名を「汎用の 5xx」等に直す★（★テスト自体は消さないこと。
  `serverMessage` の取り出しは今も有効な検証です★）＋ ★`ErrorCode` から `"not_implemented"` を外す★
- ★★併せて古い記述が残っている文書★★（★直すかは任意★）:
  `docs/handoff/PR説明_develop_to_main_骨子.md:212` /
  `docs/handoff/analysis/code-analysis-impure.md:3195`
  ★★ただし `docs/handoff/` 配下は別エージェントが作業中です。★触るなら `git status` を先に見ること★★

### ④ ★`scripts/seed-local-db.ts` のコメントの件数がずれている★

- ★場所★: `scripts/seed-local-db.ts:506`
  ```
   *   ★分布は `lib/fixtures/prechecks.ts` の8件に倣う（未解消3 / 対応中2 / 解消3）★。
  ```
- ★実測★: `lib/fixtures/prechecks.ts:38` の配列は ★**9件**★
  （`id` = `"1","2","3","9","4","5","6","7","8"`）。★分布は 未解消**4** / 対応中2 / 解消3★
- ★原因★: 後から足した `id: "9"`（`prechecks.ts:94`、境界値検証用）が反映されていない
- ★★直下の `ERROR_RESOLUTION_ROTATION`（`:508-517`）自体は8要素で自己整合しています★★。
  ★★ずれているのは**説明文のほう**なので、コメントだけ直すこと★★。
  ★★配列を触らないこと★★（★禁止事項5★）
- ★ついでに見つかった別のずれ（★任意★）★: `scripts/seed-local-db.ts:268`
  `/** 完了行の何件ごとに 2/3/4 を当てるか（240行なら各5件ずつになる） */` —
  `ABNORMAL_CLEAN_ERROR_EVERY = 17`（`:269`）なので 240行なら該当14行、
  ★3値ローテーションで 5/5/4★。★「各5件ずつ」にならない★。
  ★★ただし「完了行=240」という前提自体が未検証です。直す前に実測すること★★

### ⑤ ★`code-analysis-pure.md` の古い転記★

- ★場所★: `docs/handoff/analysis/code-analysis-pure.md` の ★**4箇所**★
  （★監督の照合では「3箇所」とされていましたが、実測は4箇所です★）:

| 行 | 内容 |
|---|---|
| 2977 | `算出基準はTeamWorks確認事項の回答待ちのため、暫定で稼働率ベース。`（コード引用ブロック） |
| 3071 | 同文（「63〜67行目のコメント（引用）」の再掲） |
| 3075 | `**★「TeamWorks確認事項の回答待ち」= 仕様が未確定である★**。` |
| 3195 | `加えて 65行目「算出基準はTeamWorks確認事項の回答待ち…」` |

- ★★現行コードでは既に否定済み★★: `lib/judge/kpi.ts:70`
  `これは DFA 側の UI 判断であって、TeamWorks 待ちではない`、
  `kpi.ts:72` `（2026-09-11 に全文検索で確認。ヒット0）。以前ここに「回答待ち」と書かれていたのは誤りで、`
  ★`docs/handoff.md:3143`（追記37）にも同旨★
- ★★注意★★: ★`docs/handoff/analysis/` 配下です★。
  ★★別エージェントが `docs/handoff/` で作業中なので、触る前に `git status` を見ること★★。
  ★競合しそうなら**この項目は飛ばすこと**★
- ★拾う場合★: ★元の引用（2977 / 3071）は「当時のコード」の記録なので**消さない**★。
  ★★「★この記述は D-118 / 追記37 で否定済み★」という注記を添える形にすること★★
  （★解析文書は「その時点の観測記録」なので、書き換えると記録として壊れます★）

### この段階の完了条件

- [ ] ★拾った項目 / 飛ばした項目を作業メモに書いた★
- [ ] ★①を拾った場合、仕様判断を推測で埋めていない★
- [ ] `tsc` 0 / `lint` 0 / `npm test` 全パス
- [ ] コミット

---

## 段階3-2 — ★記録★（1コミット）

1. ★`docs/decisions.md` に決定エントリを書く★（★着手時に実測した番号★）
   - ★群1: モックから意図的に外れた理由★
   - ★群2: 生SQL禁止を ESLint で機械化したこと / 長さ上限を入れたこと /
     ★**棚卸しの結果「アプリコードに危険な箇所は無かった」という事実**★
   - ★段階2-4 / 2-7 で「やらない」を選んだ場合、その理由★
2. ★`docs/handoff.md` に追記する★（★段階3-0 の先頭行も一緒に直すこと★）
3. ★本書冒頭の表と末尾の「実行状態」表を更新する★

### この段階の完了条件

- [ ] `docs/decisions.md` に記録（★`git diff docs/decisions.md` を先に見た★）
- [ ] `docs/handoff.md` に追記（★先頭の「最新の追記」も直っている★）
- [ ] 本書の表2つを更新
- [ ] コミット

## ★群3 の終わり★

★★ここで止めてもよい★★ — ★本書の全群が完了です★。

---

# 実行状態

★★全段階完了（2026-09-13）★★。★ブランチ `develop`。**`git push` はしていません**★。

| 群 | 段階 | 状態 | 決定番号 | コミット |
|---|---|---|---|---|
| 群1 | 1-0 実測 | ★完了★（読むだけ） | — | — |
| 群1 | 1-1 collapseBtn の色 | ★完了★ | ★D-121★ | `25172dc` |
| 群2 | 2-0 棚卸し | ★完了★（★**危険な箇所は0件**★。読むだけ） | — | — |
| 群2 | 2-1 生SQL禁止を ESLint 化 | ★完了★ | ★D-122★ | `f90ed25` |
| 群2 | 2-2 パスパラメータ検証 | ★完了★ | ★D-123★ | `76f31e6` |
| 群2 | 2-3 advice の長さ上限 | ★完了★ | ★D-124★ | `25ede81` |
| 群2 | 2-4 CSV（任意） | ★完了（★「やる」を選択★）★ | ★D-125★ | `5d9cc7b` |
| 群2 | 2-5 本番前チェックリスト | ★完了★（`docs/deploy.md` 7節） | — | `e75d319` |
| 群2 | 2-6 npm audit の記録 | ★完了★ | — | `6fc2255` |
| 群2 | 2-7 セキュリティヘッダ（任意） | ★完了（★「アプリに入れない」を選択★）★ | ★D-126★ | `03f7fce` |
| 群2 | 2-8 申し送り | ★完了★（`docs/handoff.md` 追記41） | — | `a592053` |
| 群3 | 3-0 handoff.md の先頭 | ★完了★（追記36 → ★追記41★ に修正。★再発防止の注意書きも追加★） | — | `4c5beeb` |
| 群3 | 3-1 軽微な積み残し（任意） | ★完了（★②③④を拾い、★①⑤は見送り★）★ | ★D-127★ | `b20790e` |
| 群3 | 3-2 記録 | ★完了★（D-127 ＋ `docs/handoff.md` 追記42 ＋ 本表） | ★D-127★ | （3-2 のコミット） |

### ★段階3-1 の内訳（★拾った / 見送った★）★

| # | 内容 | 判断 | 理由 |
|---|---|---|---|
| ① | ★同一エリアが同日2回清掃されると二重計上★ | ★★見送り★★ | ★どう集約するか（最後の1回 / 合算 / 両方）が**仕様判断**★。★`CLAUDE.md` §7 のとおり推測で埋めず、確認を求める★ |
| ② | ★`buildPageItems()` の `page` に NaN ガードが無い★ | ★拾った★ | ★`normalizePage()` に寄せ、`pageRange()` にも同じガードを適用（同じ `clamp()` を通っていて同じ穴があった）★。★テスト8件追加（1192 → 1200）★ |
| ③ | ★501 / `not_implemented` の残骸★ | ★拾った（★コードのみ★）★ | ★`ErrorCode` から外し、501 前提のテスト2本は**消さずに** 503 / 404 へ置き換えた★。★`docs/handoff/` 配下の文書は別エージェント作業中のため触っていない★ |
| ④ | ★シードのコメントの件数のずれ★ | ★拾った★ | ★実測 9件（未解消4 / 対応中2 / 解消3）に修正。★コメントのみ★。ローテーション表と fixtures は触っていない★ |
| ⑤ | ★`code-analysis-pure.md` の古い転記★ | ★★見送り★★ | ★`docs/handoff/analysis/` は★**別エージェントが作業中**★。指示書の「競合しそうなら飛ばす」に従った★ |

### ★完了時の検証（実測 2026-09-13）★

| 項目 | 結果 |
|---|---|
| `npx tsc --noEmit` | ★0★ |
| `npm run lint` | ★0★ |
| `npm test` | ★**50ファイル / 1200テスト 全パス**★（着手前 1192 → 段階3-1 ② で★8件増★） |

★★触っていないファイル★★: `README.md` / `docs/code-map.md` /
`docs/handoff/管理画面_設計検討_20260910.md`（★いずれも他作業の未コミット変更。着手時に `git status` で確認★）。
