# ログイン不要の生成AIチャットを『請求書破産』から守る — エッジ多層防御と、一貫性モデルで選ぶデータストア

> ログイン不要の生成AIチャットは、攻撃者にとって『あなたの財布で叩けるLLM API』です。Vercel WAF・Proxy（旧middleware）・Turnstileの4層防御を実コードで設計し、レート制限に結果整合性、決済に強い一貫性を選ぶ理由を、Redis・CockroachDB・DynamoDBのアーキテクチャから解説します。

- Published: 2026-08-14
- Author: 友田 陽大
- Tags: Next.js, Vercel, AI, セキュリティ, アーキテクチャ設計, コスト最適化
- URL: https://tomodahinata.com/es/blog/anonymous-ai-chat-cost-dos-defense-edge-rate-limiting-turnstile-guide
- Category: Generative AI, LLMs & RAG
- Pillar guide: https://tomodahinata.com/es/blog/vercel-ai-sdk-production-llm-apps-streaming-tools-rag

## Key points

- ログイン不要のLLMチャットの脅威は可用性ではなく請求額。攻撃者はサーバーを落とす必要がなく、単価の高い推論エンドポイントを叩き続けるだけでよい（OWASP LLM10:2025 Unbounded Consumption / API4:2023）
- 2026年の前提は3つ変わった。(1) Vercel KV は提供終了し Marketplace の Upstash Redis へ、(2) Next.js 16 で middleware.ts は非推奨・proxy.ts へ改名され既定ランタイムは Node.js、(3) 面のDDoS防御は自前コードではなく Vercel WAF のレート制限（関数を起動せず落とす）の仕事
- 『IP＋セッション』を1本の文字列キーに連結する設計は穴になる。攻撃者がセッションIDを回すだけでバケツが無限に増えるため、粗いIP系リミッタと細かいセッション系リミッタを独立に持ち、ANDで通す
- レート制限は結果整合性で正しい。多少すり抜けても損失は数リクエスト分の推論コストであり、同期レプリケーションのレイテンシを毎リクエストに払う価値がない。逆に課金・残高は強い一貫性が要る（CockroachDB は Raft の過半数合意＋HLC、DynamoDB は既定が結果整合性で ConsistentRead は2倍のコスト）
- Turnstile は『最終防壁』ではなく多層防御の1枚。トークンは300秒・1回限りで、サーバー側 siteverify 検証を欠くと意味がない。検証は必ず推論を呼ぶ前に行い、失敗はフェイルクローズ、KVS障害はフェイルオープンと非対称に設計する

---

最初に結論を述べます。**ログイン不要の生成AIチャットにおける最大のリスクは「サービスが落ちること」ではなく「請求書が青天井になること」です。** 攻撃者はあなたのサーバーを落とす必要がありません。ログインの壁がない推論エンドポイントを、スクリプトで叩き続けるだけでいい。1リクエストが数円でも、毎秒100発を一晩続ければ朝には数十万円です。可用性は無傷のまま、財務だけが壊れます。

そして2026年8月現在、この問題を扱う日本語記事の多くが**3つの前提を間違えています**。

1. **Vercel KV はもう存在しません。** 製品として提供終了し、既存ストアは2024年12月に Upstash Redis へ自動移行されました。今から作るなら Marketplace 経由の Upstash Redis です。
2. **`middleware.ts` は Next.js 16 で非推奨になりました。** `proxy.ts` へ改名され、既定ランタイムは Edge ではなく **Node.js** です。しかも公式は「最後の手段として使うことを推奨」と明記しています。
3. **「エッジでDDoSを面で防ぐ」の主語はあなたのコードではありません。** ボリューム型を自前の proxy で受けると、落としたリクエストの分だけ proxy の実行料金を払うことになります。面の防御は Vercel WAF のレート制限（＝関数を起動せずに落とす層）の仕事です。

本記事は、この3点を踏まえた**2026年版の正しい部品表**で、ログイン不要チャットの多層防御を設計します。前半は防御アーキテクチャ（WAF / Proxy / Route Handler / プロバイダ予算の4層）とその実装、後半は「なぜレート制限は結果整合性でよく、課金は強い一貫性が要るのか」を Redis・CockroachDB・DynamoDB のアーキテクチャから解きます。

なお、レート制限そのもの（固定ウィンドウ vs スライディングウィンドウ、アトミック性、`x-forwarded-for` の罠）の基礎は[Next.jsで『正しく効く』レート制限](/blog/nextjs-serverless-rate-limiting-vercel-guide)で扱っています。本記事はその上で、**匿名・高単価・LLM** という条件が加わったときに何が変わるかに集中します。

---

## 1. 脅威モデル：これは「DoS」ではなく「コストのDoS」

**TL;DR: 従量課金のクラウドとトークン課金のLLMが組み合わさると、攻撃の目的が「落とす」から「使わせる」に変わります。可用性の指標（エラー率・レイテンシ）は正常なまま被害が進むため、監視の設計も変わります。**

OWASP は生成AIアプリ向けの Top 10 で、この脅威を **LLM10:2025 Unbounded Consumption（無制限の消費）** として独立項目に格上げしました。従来の API セキュリティでは **API4:2023 Unrestricted Resource Consumption** に相当します。両者に共通する指摘は明快です——リクエストの処理には帯域・CPU・メモリだけでなく、**サードパーティAPIの従量課金という金銭的コスト**がかかる。その消費に上限がなければ、可用性とコストの両方が攻撃面になる。

ログイン不要のチャットに固有の事情を並べると、危険度がはっきりします。

| 条件 | 通常のAPI | ログイン不要のLLMチャット |
|---|---|---|
| 1リクエストの原価 | 0.001円未満（DB読み取り） | 数円〜数十円（入力＋出力トークン） |
| 攻撃に必要な準備 | 認証情報の窃取・量産 | なし（URLを知っているだけ） |
| 応答時間 | 数十ms | 数秒〜数十秒（＝関数の実行時間も課金） |
| 攻撃者の利得 | データ窃取 | 無料のLLM代理利用（モデル蒸留・転売） |
| 検知のしやすさ | エラー率が上がる | **何も壊れない**（請求書だけが増える） |

最後の行が本質です。攻撃者にとって「あなたのチャット」は**無料で使える LLM プロキシ**であり、彼らはむしろサービスを健全に保ちたい。だから CPU 使用率もエラー率もレイテンシも正常なまま、月末に請求書が届きます。**ダッシュボードの緑は、この攻撃に対して何の保証にもなりません。**

もう一つ、匿名チャット特有の派生リスクがあります。攻撃者が高品質なモデルの出力を大量収集して自前モデルの学習に使う**モデル蒸留（model distillation）**です。これは1回あたりの単価より「総トークン量」で殴られるため、リクエスト数ベースの制限だけでは取りこぼします。後述するトークンバケットの `rate` オプションで**重み付き課金**を実装する理由がここにあります。

私自身、[無人キオスクでの生成AI音声接客システム](/case-studies/ai-voice-chatbot)を設計・構築した際、最初に設計したのは RAG でもプロンプトでもなく「来店客が誰であれ、1端末・1セッションあたり何回までモデルを呼ぶか」という消費の境界でした。無人・ログイン不要の生成AIを本番に出すというのは、**技術的にはモデルを繋ぐ話ではなく、消費の上限をどこに置くかを決める話**です。

---

## 2. 2026年の正しい部品表 — 変わった3つの前提

**TL;DR: Vercel KV は廃止、`middleware.ts` は `proxy.ts` に改名・Node.js既定、面のDDoSはWAFの領域。この3点を踏まえないと、動かないコードか、無駄に高いコードになります。**

### 2.1 Vercel KV は提供終了 → Marketplace の Upstash Redis

多くの記事がいまだに `@vercel/kv` と `kv.get()` を書いていますが、**Vercel KV は製品として提供されていません**。既存の KV ストアは2024年12月に Upstash Redis へ自動移行され、現在の導線は Vercel Marketplace から Upstash for Redis を導入する形です。統合を入れると、認証情報がプロジェクトの環境変数（`UPSTASH_REDIS_REST_URL` / `UPSTASH_REDIS_REST_TOKEN`）に自動注入されます。

コード側の対応は単純です。`@vercel/kv` を捨て、`@upstash/redis` と `@upstash/ratelimit` を直接使ってください。歴史的経緯として `@vercel/kv` は元々 Upstash のラッパーだったので、移行は事実上「ラッパーを剥がす」作業です。

### 2.2 `middleware.ts` は非推奨 → `proxy.ts`（既定は Node.js ランタイム）

Next.js 16 で `middleware` のファイル規約は **`proxy` に改名され、`middleware.ts` は非推奨（将来削除予定）** になりました。関数名も `middleware` から `proxy` に変わります。公式のコドモッドが用意されています。

```bash
npx @next/codemod@canary middleware-to-proxy .
```

同時に、実行モデルも変わりました。バージョン履歴には `v16.0.0: Middleware is deprecated and renamed to Proxy. Proxy defaults to the Node.js runtime` と明記され、**`runtime` の設定オプションは Proxy では利用不可（設定するとエラー）** です。「エッジで動かすために `runtime: 'edge'` を書く」という古い作法は、Proxy では文字通り例外になります。

さらに重要なのが、Next.js 公式が Proxy の位置づけについて書いていることです。

> we are moving away from Middleware ... this feature is recommended to be used as a last resort
> （Middleware から離れつつある。この機能は最後の手段として使うことを推奨する）

そして設計上の注意として、こうも書かれています。

> you should not attempt relying on shared modules or globals
> （共有モジュールやグローバル変数に依存しようとしてはならない）

これは後述する `ephemeralCache`（モジュールスコープの `Map`）の扱いに直結します。**あれは正しさの機構ではなく、あくまで最適化**である、という理解が必須です。

なお、Vercel 側の製品名は依然 **Routing Middleware** で、これは Fluid Compute の上に構築され、**キャッシュより前に、グローバルに実行される**という性質は変わっていません。「Next.js のファイル規約が `proxy.ts` に変わった／Vercel のプラットフォーム機能は Routing Middleware のまま」と整理してください。

### 2.3 面のDDoS防御は「あなたのコード」ではなく WAF の仕事

ここが設計上いちばん誤解されている点です。**proxy でレート制限して 429 を返しても、その proxy の実行自体は課金対象**です。Routing Middleware は Fluid Compute の課金モデルで、使ったコンピュート資源に応じて課金されます。つまり秒間1万発のフラッドを自前コードで受けると、**1万回分の実行料を払いながら防いでいる**ことになります。

Vercel WAF のレート制限は、**リクエストが関数に到達する前**に落とせます。仕様を正確に押さえておきましょう。

| 項目 | Hobby | Pro | Enterprise |
|---|---|---|---|
| カウントキー | IP、JA4 ダイジェスト | IP、JA4 ダイジェスト | ＋ User Agent、任意ヘッダ |
| アルゴリズム | 固定ウィンドウ | 固定ウィンドウ | ＋ トークンバケット |
| ウィンドウ | 10秒〜10分 | 10秒〜10分 | 10秒〜1時間 |
| ルール数 | 1／プロジェクト | 40／プロジェクト | 1000／プロジェクト |
| アクション | Log / Deny / Challenge / 既定の429 | 同左 | 同左 |

そして公式が明記している重要な制限——**レート制限のカウンタはリージョンごとに保持される**ため、複数リージョンにまたがるトラフィックは、設定した上限を合計で超えうる。**WAF は粗い網であり、精密な上限ではありません。** だからこそアプリ層の制限と併用します。

以上を統合すると、防御は「1枚の壁」ではなく**役割の違う4層**になります。

| 層 | 何を止めるか | 実装 | 誤爆時のコスト | 課金 |
|---|---|---|---|---|
| L1 プラットフォーム | ボリューム型フラッド、既知の悪性IP | Vercel 自動DDoS緩和 ＋ WAF レート制限 | 高（正規ユーザーごと落ちる） | 関数を起動しない |
| L2 Proxy（旧middleware） | 明らかな濫用、粗いIP単位の上限、本文を読む前の早期棄却 | `proxy.ts` ＋ `@upstash/ratelimit` | 中 | Proxy の実行分 |
| L3 Route Handler | セッション単位の精密な上限、ボット判定、入力検証 | `app/api/chat/route.ts` ＋ Turnstile | 低（丁寧なUIで再試行可能） | 関数の実行分 |
| L4 プロバイダ | 全体の上限（最後の砦） | AI Gateway / プロバイダ側の予算・使用量上限 | — | — |

**L4 を必ず入れてください。** L1〜L3 はすべて「あなたのコードが正しく動いていれば」効く防御です。デプロイ事故で proxy の matcher が外れた、環境変数が消えた、Redis が落ちてフェイルオープンした——そのときに請求を止められるのは、プロバイダ側のハード上限だけです。**コードの外に、コードでは越えられない天井を置く。** これは冗長ではなく、独立した故障ドメインを作るという設計です。

---

## 3. エッジで捌く意味と、捌けないもの

**TL;DR: エッジ（キャッシュ前・グローバル実行）の価値は「攻撃をユーザーに近い場所で終わらせ、オリジンとDBに触れさせない」ことです。ただし低遅延の恩恵は、参照するデータストアが遠いと消し飛びます。**

中央集権型のアーキテクチャでは、東京の攻撃者からのリクエストも、バージニアの API サーバーまで到達してから拒否されます。往復のレイテンシ、帯域、そして拒否判定を下す計算資源のすべてを、**攻撃者が指定した場所ではなくあなたが指定した1点**で負担することになる。単一の集中点は、そのままボトルネックであり単一障害点です。

エッジ展開は、この構造を反転させます。Routing Middleware は世界中のPOPで**キャッシュより前に**実行されるため、拒否判定は攻撃者の隣で終わり、オリジンにも DB にも到達しません。防御が「点」から「面」になる、というのはこの意味においてです。

ただし、ここに**エッジ最大の落とし穴**があります。Vercel 公式が明記しているとおりです。

> If your Routing Middleware depends on a database far away from one of our supported regions, the overall latency of API requests could be slower than expected
> （Routing Middleware が遠方のデータベースに依存していると、API リクエスト全体のレイテンシは期待より遅くなりうる）

シンガポールのPOPで判定しても、参照する Redis が us-east-1 にあれば、**判定のたびに太平洋を往復**します。エッジに置いたのに遅くなる、という典型的な失敗です。対策は3つ。

1. **グローバルに読める（あるいは複製された）ストアを選ぶ** — Upstash の読み取りレプリカ、Edge Config など。
2. **判定に必要なデータをリクエスト自身に載せる** — 署名済みCookie（後述のセッションID）は、ネットワークを1往復も使わずに検証できます。
3. **`ephemeralCache` でホットな拒否をローカルに閉じる** — すでに上限超過と判明した識別子は、Redis に問い合わせずローカルの `Map` で即座に落とせます。

3番目について、Upstash のドキュメントは `ephemeralCache` を「関数がホットな間データをキャッシュし、コールドのときだけ Redis から取得する」機構と説明し、これが効いたときのレスポンスは `reason: "cacheBlock"` になると定めています。ただし前述のとおり Next.js は Proxy でグローバル依存を避けよと言っています。**両者は矛盾しません。** `ephemeralCache` が消えても正しさは壊れず（Redis に問い合わせ直すだけ）、残っていればコストが下がる——という**片方向の最適化**だからです。この非対称性を理解せずに「ローカルキャッシュがあるから大丈夫」と考えると壊れます。

---

## 4. なぜエッジのKVSは「HTTPベース」でなければならないか

**TL;DR: Redis のネイティブプロトコルは TCP 接続ベースで、接続を張りっぱなしにする前提です。使い捨ての関数が何千個も同時に立ち上がるサーバーレス／エッジでは、この前提が接続枯渇として牙を剥きます。Upstash が REST API を提供している理由がこれです。**

従来型のサーバーは、起動時にコネクションプールを1つ作り、プロセスが生きている限り再利用します。100台のサーバーが各10接続を持てば1000接続。管理可能です。

サーバーレスは違います。関数インスタンスは使い捨てで、同時に何百・何千と立ち上がり、それぞれが独立したプロセスです。プールの共有相手がいない。しかもエッジランタイム（Cloudflare Workers、WebAssembly、Fastly Compute@Edge など）では、そもそも**生の TCP ソケットが使えません**。Upstash のドキュメントはこの制約を端的にこう書いています——Redis プロトコルは接続ベース（connection based）だが、REST API はリクエストベース（request based）である、と。

| 観点 | TCP接続型 Redis | HTTPベース KVS（Upstash REST） |
|---|---|---|
| 接続モデル | 長時間保持・プール前提 | ステートレス（リクエスト単位） |
| エッジランタイム | 動かない（TCP不可） | 動く（`fetch` のみ） |
| 同時実行1万 | 接続枯渇・`ERR max number of clients` | 問題なし（HTTPの多重化） |
| コールドスタート | ハンドシェイク＋認証のRTT | HTTPリクエスト1往復 |
| 認証 | AUTH コマンド | Bearer トークン |
| 適性 | 常駐サーバー、高頻度パイプライン | サーバーレス、エッジ、Function |

`@upstash/redis` は内部的に `fetch` を使うため、**Node.js / Edge / Workers のどれでも同じコードが動きます**。これは「便利」以上の意味を持ちます。レート制限のロジックを proxy と Route Handler の両方で共有でき、ランタイムごとに実装を分岐させる必要がない——DRY が守れるということです。

---

## 5. レート制限の設計 — 複合キーの罠と、2本のリミッタ

**TL;DR: 「IP＋セッションID」を1本の文字列キーに連結する設計は穴になります。攻撃者はセッションIDを回すだけでバケツを無限に増やせるからです。粗いIP系リミッタと細かいセッション系リミッタを独立に持ち、両方を通過した場合のみ許可してください。**

### 5.1 なぜ `ip:sessionId` の1本キーが破綻するのか

素朴な発想はこうです。「IPだけだと同じオフィスの全員が巻き込まれる。セッションIDだけだとCookieを消されたら終わり。じゃあ両方を組み合わせよう」。そして `ratelimit.limit(ip + ":" + sessionId)` と書く。

これは**セキュリティ的にほぼ無意味**です。レート制限のキーとは「バケツの識別子」であり、キーが変われば新しいバケツが作られます。攻撃者は Cookie を送らない（あるいは毎回ランダムなIDを送る）だけで、**リクエストごとに新品のバケツ**を手に入れます。IP を含めたことは何の制約にもなりません。

正しい設計は、**独立した2本のリミッタを AND で通す**ことです。

| リミッタ | 識別子 | 上限（例） | 何を守るか | 誤爆リスク |
|---|---|---|---|---|
| 粗い網 | IPv4 は完全なアドレス、IPv6 は `/64` プレフィックス | 60回／分 | 単一発信元からの物量攻撃 | 中（NAT・大学・企業） |
| 細かい網 | 署名済みCookieのセッションID | 10回／分（バースト20） | 正常な1利用者の体感を守る | 低（自分のセッションだけ） |

粗い網は「攻撃者が Cookie を捨てても効く」ため防御の背骨、細かい網は「同じIPの他人を巻き込まない」ための公平性の装置です。役割が違うので、上限も別々に設定します。

**IPv6 を `/64` で丸める**のは実務上の必須事項です。IPv6 では末端ユーザーにすら `/64`（18,446,744,073,709,551,616 アドレス）が割り当てられるのが普通で、完全アドレスでキーを作ると攻撃者はアドレスを1つずつずらすだけで制限を回避できます。これは IPv4 の常識をそのまま持ち込むと確実に踏む地雷です。

### 5.2 クライアントIPの取得 — Vercel では信頼できる

一般論として `x-forwarded-for` はクライアントが自由に詐称できるため、信頼できるプロキシ経由でのみ読むべきヘッダです。ただし Vercel 上では話が違います。公式ドキュメントが明記しています。

> we currently overwrite the X-Forwarded-For header and do not forward external IPs. This restriction is in place to prevent IP spoofing.
> （X-Forwarded-For ヘッダを上書きし、外部IPは転送しない。IPスプーフィング防止のための制限である）

つまり Vercel にデプロイしている限り、`x-forwarded-for`（および同値の `x-real-ip` / `x-vercel-forwarded-for`）は**プラットフォームが保証する値**です。自前で「左からn番目を取る」といったパース処理を書く必要はありません。ただし Vercel の前段に自前プロキシを挟む構成では `x-forwarded-for` が上書きされうるため、その場合は `x-vercel-forwarded-for` を読みます。

### 5.3 なぜトークンバケットか

`@upstash/ratelimit` は3つのアルゴリズムを提供しています。LLMチャットに適するのはどれか。

| アルゴリズム | 挙動 | バースト耐性 | コスト | チャット適性 |
|---|---|---|---|---|
| `fixedWindow(limit, window)` | ウィンドウ単位でカウント | 境界で2倍通る | 最安 | △（境界問題） |
| `slidingWindow(limit, window)` | 直前ウィンドウで加重平均 | 滑らか | 中 | ○ |
| `tokenBucket(refillRate, interval, maxTokens)` | 一定速度で補充、残量があれば通す | **明示的に設計可能** | 高（計算量） | ◎ |

チャットの利用パターンは「しばらく黙って読む→立て続けに3〜4回質問する」という**バースト性**を本質的に持ちます。固定ウィンドウで「毎分10回」に絞ると、正常な連投が弾かれてUXが壊れる。トークンバケットなら **`maxTokens` でバーストの許容量、`refillRate` で長期的な平均レート**を独立に設計できます。

```ts
// 60秒で10トークン補充、バケツの最大は20
// → 平均10回/分だが、休んだ直後は20回まで連打できる
Ratelimit.tokenBucket(10, "60 s", 20)
```

引数の順序に注意してください。`tokenBucket(refillRate, interval, maxTokens)` です（`limit` が先に来る `fixedWindow` / `slidingWindow` と並びが違います）。また Upstash のドキュメントは、トークンバケットは計算量の面で高価であり、**`MultiRegionRatelimit` では未サポート**であると明記しています。グローバル分散が必要ならスライディングウィンドウに切り替える判断が要ります。

さらに LLM 特有の要件として、`limit()` の `rate` オプションによる**重み付き消費**があります。

```ts
// 長いプロンプトや高性能モデルの指定は、より多くのトークンを消費させる
await limiter.limit(identifier, { rate: estimateCost(input, model) });
```

「リクエスト数」ではなく「原価」でバケツを削る。これが、モデル蒸留のような**総トークン量で殴ってくる攻撃**への対抗策です。

---

## 6. 実装 — 型安全でフェイルセーフな4ファイル

以降のコードは TypeScript strict 前提で、`any` と型キャスト（`as`）を使用していません。外部入力（環境変数・リクエストボディ・外部APIレスポンス）はすべて境界で Zod により検証・ナローイングします。

### 6.1 基盤モジュール — 環境変数の検証と構造化ログ

**設計意図:** `process.env.FOO!` は型システムに対する嘘です。存在しない環境変数は `undefined` として実行時まで生き延び、本番で初めて `Cannot read properties of undefined` になります。起動時に一度だけ検証し、以降は完全に型付けされた値だけを流通させます（フェイルファスト）。

```ts
// lib/env.ts
import "server-only";
import { z } from "zod";

/**
 * サーバー専用の環境変数。モジュール読み込み時に一度だけ検証し、
 * 不正ならプロセスを起動させない（フェイルファスト）。
 */
const serverEnvSchema = z.object({
  // Vercel Marketplace の Upstash 統合が自動注入する
  UPSTASH_REDIS_REST_URL: z.url(),
  UPSTASH_REDIS_REST_TOKEN: z.string().min(1),

  // Cloudflare Turnstile のシークレット（クライアントへ渡してはならない）
  TURNSTILE_SECRET_KEY: z.string().min(1),

  // 匿名セッションIDの署名鍵。32バイト以上のランダム値を推奨
  SESSION_SECRET: z.string().min(32),

  // 推論プロバイダ。Vercel AI Gateway 経由なら "provider/model" 形式
  CHAT_MODEL: z.string().min(1).default("anthropic/claude-sonnet-4.5"),

  // 環境の識別（構造化ログのフィールドに使う）
  VERCEL_ENV: z.enum(["production", "preview", "development"]).default("development"),
});

const parsed = serverEnvSchema.safeParse(process.env);

if (!parsed.success) {
  // 値そのものは絶対に出力しない。欠落しているキー名だけを報告する。
  const invalidKeys = [...new Set(parsed.error.issues.map((issue) => issue.path.join(".")))];
  throw new Error(`[env] Invalid server environment variables: ${invalidKeys.join(", ")}`);
}

export const serverEnv = parsed.data;
```

```ts
// lib/env.client.ts
import { z } from "zod";

/**
 * クライアントへ露出してよい公開値のみ。
 * NEXT_PUBLIC_* は静的置換されるため、プロパティアクセスを直書きする必要がある。
 */
const clientEnvSchema = z.object({
  NEXT_PUBLIC_TURNSTILE_SITE_KEY: z.string().min(1),
});

export const clientEnv = clientEnvSchema.parse({
  NEXT_PUBLIC_TURNSTILE_SITE_KEY: process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY,
});
```

**なぜファイルを2つに分けるのか:** 1つにまとめると、クライアントコンポーネントが公開キー目当てにインポートした瞬間、スキーマに含まれるシークレットのキー名がクライアントバンドルの静的解析対象になります。`import "server-only"` は、サーバー専用モジュールがクライアント側に混入したときビルドを失敗させる保険です。

そしてログ。`console.error("失敗しました")` は本番では何の役にも立ちません。**将来 Sentry や Log Drain へ流すことを前提に、最初から構造化**しておきます。出力先を1箇所に閉じ込めておけば、移行はこのファイルの中だけで済みます（SRP）。

```ts
// lib/observability.ts
type LogLevel = "info" | "warn" | "error";

/**
 * 構造化ログの1レコード。値は文字列・数値・真偽値・null に限定する。
 * オブジェクトを許すと、うっかりリクエストボディごと出力する事故が起きる。
 */
interface LogFields {
  readonly level: LogLevel;
  /** ドット区切りのイベント名（例: "rate_limit.blocked"）。集計のキーになる */
  readonly event: string;
  readonly [key: string]: string | number | boolean | null | undefined;
}

/**
 * PII とシークレットを載せないことは呼び出し側の責務。
 * ここでは「何が起きたか」の識別子と、非PIIの計測値だけを流す。
 */
export function logEvent(fields: LogFields): void {
  const line = JSON.stringify({ ...fields, timestamp: new Date().toISOString() });

  // 将来 Sentry を入れる際は、この分岐の中だけを差し替えればよい。
  if (fields.level === "error") {
    console.error(line);
    return;
  }
  if (fields.level === "warn") {
    console.warn(line);
    return;
  }
  console.info(line);
}
```

### 6.2 `lib/rate-limit.ts` — 2本のリミッタと観測可能な結果

**設計意図:** レート制限の「ポリシー」と「実行」を分離します（SRP）。ポリシーは定数として一箇所に集約し、マジックナンバーを排除。実行側は失敗の扱い（フェイルオープン）を1箇所に閉じ込め、呼び出し側に例外処理を強要しません。

```ts
// lib/rate-limit.ts
import "server-only";
import { Ratelimit, type Duration } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
import { serverEnv } from "./env";
import { logEvent } from "./observability";

const redis = new Redis({
  url: serverEnv.UPSTASH_REDIS_REST_URL,
  token: serverEnv.UPSTASH_REDIS_REST_TOKEN,
});

/**
 * ホットなインスタンス内で「拒否済み」の識別子を記憶し、Redis への往復を省く。
 * 注意: これは最適化であって正しさの機構ではない。Next.js の Proxy は
 * グローバル状態への依存を明示的に禁じており、消えても動作は変わらない
 * （Redis に問い合わせ直すだけ）。片方向の最適化として扱うこと。
 */
const ephemeralCache = new Map<string, number>();

interface TokenBucketPolicy {
  /** interval ごとに補充されるトークン数（＝長期的な平均レート） */
  readonly refillRate: number;
  /** 補充の間隔 */
  readonly interval: Duration;
  /** バケツの最大容量（＝許容するバーストの大きさ） */
  readonly maxTokens: number;
  /** Redis キーの名前空間。層ごとに必ず分ける */
  readonly prefix: string;
}

/** 上限値のSSoT。運用中の調整はこのオブジェクトだけを触る。 */
const POLICIES: Readonly<Record<"network" | "session", TokenBucketPolicy>> = {
  // 粗い網: 発信元ネットワーク単位。Cookie を捨てても効く防御の背骨。
  network: { refillRate: 60, interval: "60 s", maxTokens: 90, prefix: "rl:net" },
  // 細かい網: セッション単位。1利用者の体感を守る公平性の装置。
  session: { refillRate: 10, interval: "60 s", maxTokens: 20, prefix: "rl:sess" },
};

function createLimiter(policy: TokenBucketPolicy): Ratelimit {
  return new Ratelimit({
    redis,
    limiter: Ratelimit.tokenBucket(policy.refillRate, policy.interval, policy.maxTokens),
    prefix: policy.prefix,
    ephemeralCache,
    // Redis が応答しない場合、この時間を超えたリクエストは通す（フェイルオープン）。
    // 可用性 > 厳密性。レート制限の障害でサービスを止めない。
    timeout: 1_000,
    // 本番のみ分析を有効化。Redis コマンドを追加消費するため開発では切る。
    analytics: serverEnv.VERCEL_ENV === "production",
  });
}

const limiters: Readonly<Record<keyof typeof POLICIES, Ratelimit>> = {
  network: createLimiter(POLICIES.network),
  session: createLimiter(POLICIES.session),
};

export interface RateLimitVerdict {
  readonly allowed: boolean;
  /** 拒否した層。監視とレスポンスヘッダに使う */
  readonly limitedBy: keyof typeof POLICIES | null;
  /** 再試行可能になるまでの秒数（Retry-After 用） */
  readonly retryAfterSeconds: number;
  /** 完了を待つべき非同期処理（analytics の書き込みなど） */
  readonly pending: readonly Promise<unknown>[];
}

const ALLOWED: RateLimitVerdict = {
  allowed: true,
  limitedBy: null,
  retryAfterSeconds: 0,
  pending: [],
};

export interface RateLimitInput {
  /** 粗い網の識別子（IPv4 完全アドレス / IPv6 は /64 プレフィックス） */
  readonly networkId: string;
  /** 細かい網の識別子（署名済みCookie のセッションID） */
  readonly sessionId: string;
  /** 1リクエストで消費するトークン数。入力長やモデルで重み付けする */
  readonly cost: number;
}

/**
 * 2本のリミッタを AND で評価する。粗い網を先に引くのは、
 * 攻撃トラフィックを最小のコストで棄却するため（フェイルファスト）。
 */
export async function checkRateLimit(input: RateLimitInput): Promise<RateLimitVerdict> {
  const checks: readonly [keyof typeof POLICIES, string][] = [
    ["network", input.networkId],
    ["session", input.sessionId],
  ];

  const pending: Promise<unknown>[] = [];

  for (const [layer, identifier] of checks) {
    try {
      const result = await limiters[layer].limit(identifier, { rate: input.cost });
      pending.push(result.pending);

      if (!result.success) {
        return {
          allowed: false,
          limitedBy: layer,
          retryAfterSeconds: Math.max(1, Math.ceil((result.reset - Date.now()) / 1_000)),
          pending,
        };
      }
    } catch (error) {
      // フェイルオープン: レート制限基盤の障害を、サービス全体の障害にしない。
      // ただし沈黙させず、必ず異常として記録する（L4 のプロバイダ上限が最後の砦）。
      logEvent({
        level: "error",
        event: "rate_limit.unavailable",
        layer,
        message: error instanceof Error ? error.message : "unknown error",
      });
      return ALLOWED;
    }
  }

  return { ...ALLOWED, pending };
}
```

**フェイルオープンの是非について。** レート制限が壊れたときリクエストを通す（オープン）か止める（クローズ）かは、思想ではなく計算で決めます。ここでは通しています。理由は「Redis 障害でチャット全体が停止する損失」＞「その数分間に濫用される推論コスト」だから。ただしこれは **L4 のプロバイダ側予算上限が存在すること**を前提にした判断です。天井がないならフェイルクローズにすべきです。**フェイルオープンは、別の場所に天井がある場合にのみ許される選択**です。

### 6.3 `lib/session.ts` — 改ざん不能な匿名セッション

**設計意図:** ログイン不要でも「同一利用者らしさ」の識別子は必要です。ただし平文のランダムIDでは、攻撃者が値を変えるだけでバケツを増やせます。HMAC 署名を付けて**サーバーが発行したIDであることを検証可能**にし、偽造されたIDはセッション層の制限を通さず即座に弾きます。

```ts
// lib/session.ts
import "server-only";
import { serverEnv } from "./env";

const SESSION_COOKIE = "aichat_sid";
const SESSION_MAX_AGE_SECONDS = 60 * 60 * 24; // 24時間

/** Web Crypto のみを使用（Node.js / Edge の両ランタイムで同一コードが動く）。 */
async function hmac(payload: string): Promise<string> {
  const key = await crypto.subtle.importKey(
    "raw",
    new TextEncoder().encode(serverEnv.SESSION_SECRET),
    { name: "HMAC", hash: "SHA-256" },
    false,
    ["sign"],
  );
  const signature = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(payload));
  return Array.from(new Uint8Array(signature))
    .map((byte) => byte.toString(16).padStart(2, "0"))
    .join("");
}

/** タイミング攻撃を避ける定数時間比較。 */
function timingSafeEqual(a: string, b: string): boolean {
  if (a.length !== b.length) return false;
  let diff = 0;
  for (let i = 0; i < a.length; i += 1) {
    diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
  }
  return diff === 0;
}

export async function issueSessionId(): Promise<string> {
  const id = crypto.randomUUID();
  return `${id}.${await hmac(id)}`;
}

/** 署名が正しいときだけ ID を返す。偽造・欠落は null。 */
export async function verifySessionId(value: string | undefined): Promise<string | null> {
  if (!value) return null;
  const separatorIndex = value.lastIndexOf(".");
  if (separatorIndex <= 0) return null;

  const id = value.slice(0, separatorIndex);
  const signature = value.slice(separatorIndex + 1);
  const expected = await hmac(id);

  return timingSafeEqual(signature, expected) ? id : null;
}

interface SessionCookieSpec {
  readonly name: string;
  readonly options: {
    readonly httpOnly: true;
    readonly secure: true;
    /** JSはCookieを読まない（httpOnly）。CSRF面を狭めるため lax 固定 */
    readonly sameSite: "lax";
    readonly path: "/";
    readonly maxAge: number;
  };
}

export const sessionCookie: SessionCookieSpec = {
  name: SESSION_COOKIE,
  options: {
    httpOnly: true,
    secure: true,
    sameSite: "lax",
    path: "/",
    maxAge: SESSION_MAX_AGE_SECONDS,
  },
};
```

### 6.4 `proxy.ts` — 早期棄却と、セッションの発行

**設計意図:** ユーザーの要望どおり「入口でのレート制限」を担いますが、**Next.js 16 の正しいファイル規約（`proxy.ts` / `export function proxy`）** に従います。ここでの役割は2つに絞ります——(1) 粗い網での早期棄却、(2) 匿名セッションの発行。本文のパースや推論に関わる判断は Route Handler の責務です（SRP）。

```ts
// proxy.ts （Next.js 16。旧 middleware.ts。関数名も proxy に変わる）
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { checkRateLimit } from "@/lib/rate-limit";
import { issueSessionId, sessionCookie, verifySessionId } from "@/lib/session";
import { clientIp, networkKey } from "@/lib/client-identifier";
import { logEvent } from "@/lib/observability";

/**
 * 保護対象を明示する。matcher を広げるほど、静的アセットにまで
 * 課金対象の実行が乗る。ここでは推論エンドポイントとチャット画面のみ。
 */
export const config = {
  matcher: ["/api/chat", "/chat"],
};

/** 1リクエストの基準コスト。実際の重み付けは Route Handler で再評価する。 */
const BASE_COST = 1;

export async function proxy(request: NextRequest): Promise<NextResponse> {
  const networkId = networkKey(clientIp(request.headers));

  // 署名検証に通らない Cookie は「無い」ものとして扱い、新規発行する。
  const existingSessionId = await verifySessionId(
    request.cookies.get(sessionCookie.name)?.value,
  );
  const sessionId = existingSessionId ?? (await issueSessionId());

  // 新規発行のセッションは定義上どのバケツも消費していないため、
  // セッション層は必ず通過する。Cookie を捨て続ける攻撃者を捕まえるのは
  // 常にネットワーク層の役目である——2本を独立させる理由がこれ。
  const verdict = await checkRateLimit({
    networkId,
    // 署名込みの文字列ではなく、生のID部分をキーにする
    sessionId: existingSessionId ?? sessionId.split(".")[0],
    cost: BASE_COST,
  });

  if (!verdict.allowed) {
    logEvent({
      level: "warn",
      event: "rate_limit.blocked",
      layer: verdict.limitedBy,
      path: request.nextUrl.pathname,
    });

    return NextResponse.json(
      {
        error: "rate_limited",
        message: "リクエストが多すぎます。少し時間をおいて再度お試しください。",
        retryAfterSeconds: verdict.retryAfterSeconds,
      },
      {
        status: 429,
        headers: { "Retry-After": String(verdict.retryAfterSeconds) },
      },
    );
  }

  const response = NextResponse.next();

  if (!existingSessionId) {
    response.cookies.set({
      name: sessionCookie.name,
      value: sessionId,
      ...sessionCookie.options,
    });
  }

  return response;
}
```

```ts
// lib/client-identifier.ts
const IPV6_PREFIX_SEGMENTS = 4; // /64 = 16bit × 4

/**
 * クライアントの実IP。Vercel は x-forwarded-for を上書きし、外部からの
 * 詐称値を転送しないため、このヘッダはプラットフォームが保証する値として
 * 扱える。自前プロキシを前段に置く構成では x-vercel-forwarded-for を優先。
 *
 * 戻り値は「そのままAPIに渡してよい実アドレス」。取得できなければ null。
 */
export function clientIp(headers: Headers): string | null {
  const raw =
    headers.get("x-vercel-forwarded-for") ??
    headers.get("x-forwarded-for") ??
    headers.get("x-real-ip");

  if (!raw) return null;

  const address = raw.split(",")[0].trim();
  return address.length > 0 ? address : null;
}

/**
 * レート制限のキー用に IP を丸める。実IPとは用途が違うので関数を分ける
 * （丸めた値は不完全なアドレスであり、外部APIに渡してはならない）。
 *
 * IPv6 は /64 で丸める。完全アドレスで数えると、末端ユーザーですら
 * 2^64 個のアドレスを持つため、制限を無限に回避されてしまう。
 */
export function networkKey(ip: string | null): string {
  if (!ip) return "unknown";
  if (!ip.includes(":")) return ip;
  return ip.split(":").slice(0, IPV6_PREFIX_SEGMENTS).join(":");
}
```

`middleware.ts` のまま運用しているプロジェクトは、`export function middleware(...)` に置き換えれば同じコードが動きます（Next.js 16 では非推奨・将来削除）。移行はコドモッド1行で済みます。

### 6.5 `lib/turnstile.ts` — 外部APIレスポンスを信用しない

**設計意図:** `siteverify` の戻り値は外部入力です。`response.json()` の結果を型注釈だけで信じるのは、実質的な `any` の再導入にほかなりません。Zod で検証し、`success` が `true` であることに加えて **`action` と `hostname` の一致まで確認**します。これを怠ると、攻撃者が別サイトに設置した同一サイトキーのウィジェットで取得したトークンを流用できます。

```ts
// lib/turnstile.ts
import "server-only";
import { z } from "zod";
import { serverEnv } from "./env";
import { logEvent } from "./observability";

const SITEVERIFY_ENDPOINT = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
const VERIFY_TIMEOUT_MS = 3_000;

/** 公式が定める siteverify のレスポンス形。未知のキーは無視する。 */
const siteverifyResponseSchema = z.object({
  success: z.boolean(),
  "error-codes": z.array(z.string()).default([]),
  challenge_ts: z.string().optional(),
  hostname: z.string().optional(),
  action: z.string().optional(),
  cdata: z.string().optional(),
});

export type TurnstileFailureReason =
  | "missing-token"
  | "invalid-token"
  | "expired-or-duplicate"
  | "action-mismatch"
  | "verification-unavailable";

export type TurnstileResult =
  | { readonly ok: true }
  | { readonly ok: false; readonly reason: TurnstileFailureReason };

interface VerifyParams {
  readonly token: string | undefined;
  /** クライアントの実IP。丸めた値ではなく完全なアドレスを渡すこと */
  readonly remoteIp: string | null;
  /** ウィジェット側で指定した action。値の一致を必ず確認する */
  readonly expectedAction: string;
  /** 自サイトのホスト名。他所で取得したトークンの流用を防ぐ */
  readonly expectedHostname: string;
  /** 再試行しても二重消費にならないための冪等キー */
  readonly idempotencyKey: string;
}

export async function verifyTurnstile(params: VerifyParams): Promise<TurnstileResult> {
  if (!params.token) return { ok: false, reason: "missing-token" };

  const body = new FormData();
  body.append("secret", serverEnv.TURNSTILE_SECRET_KEY);
  body.append("response", params.token);
  body.append("idempotency_key", params.idempotencyKey);
  // remoteip は任意項目。不正な値を送ると bad-request で弾かれるため、
  // 実アドレスが取れたときだけ付ける。
  if (params.remoteIp !== null) {
    body.append("remoteip", params.remoteIp);
  }

  let payload: unknown;
  try {
    const response = await fetch(SITEVERIFY_ENDPOINT, {
      method: "POST",
      body,
      signal: AbortSignal.timeout(VERIFY_TIMEOUT_MS),
    });
    payload = await response.json();
  } catch (error) {
    // Cloudflare 側の障害・タイムアウト。ボット検証は「安全側 = 拒否」に倒す。
    // レート制限（可用性優先でオープン）とは逆の判断であることに注意。
    logEvent({
      level: "error",
      event: "turnstile.unavailable",
      message: error instanceof Error ? error.message : "unknown error",
    });
    return { ok: false, reason: "verification-unavailable" };
  }

  const parsed = siteverifyResponseSchema.safeParse(payload);
  if (!parsed.success) {
    logEvent({ level: "error", event: "turnstile.malformed_response" });
    return { ok: false, reason: "verification-unavailable" };
  }

  const result = parsed.data;

  if (!result.success) {
    // timeout-or-duplicate は「300秒超過」か「トークンの再利用」を意味する。
    // ユーザーには再試行を促し、攻撃としては扱わない。
    const isReuse = result["error-codes"].includes("timeout-or-duplicate");
    logEvent({
      level: "warn",
      event: "turnstile.rejected",
      codes: result["error-codes"].join(","),
    });
    return { ok: false, reason: isReuse ? "expired-or-duplicate" : "invalid-token" };
  }

  // success: true だけでは不十分。どのウィジェットで・どのドメインで
  // 取得されたトークンなのかを照合して初めて意味を持つ。
  if (result.action !== params.expectedAction || result.hostname !== params.expectedHostname) {
    logEvent({
      level: "warn",
      event: "turnstile.context_mismatch",
      action: result.action ?? "none",
      hostname: result.hostname ?? "none",
    });
    return { ok: false, reason: "action-mismatch" };
  }

  return { ok: true };
}
```

Turnstile の仕様で必ず押さえるべき点は3つです。**トークンは発行から300秒（5分）で失効し、検証は1回限り**。再利用は `timeout-or-duplicate` として拒否されます。したがってクライアント側には「トークンが期限切れになったら自動で取り直す」実装が必須です（次節の `expired-callback`）。そして `idempotency_key` は、ネットワーク障害でレスポンスを取り逃した際に**同じトークンで安全に再検証**するための仕組みです。

### 6.6 `app/api/chat/route.ts` — 推論を呼ぶ前に、すべて終わらせる

**設計意図:** LLM の呼び出しは、このシステムで最も高価な操作です。したがって**そこへ到達する前にすべての棄却理由を潰し切る**（フェイルファスト）。検証の順序は「安いものから順に」——本文サイズ → スキーマ → Turnstile（外部API1往復）→ セッション単位のレート制限 → 推論、と原価の昇順に並べます。

```ts
// app/api/chat/route.ts
import { convertToModelMessages, createUIMessageStreamResponse, streamText, toUIMessageStream } from "ai";
import { z } from "zod";
import { after } from "next/server";
import { serverEnv } from "@/lib/env";
import { verifyTurnstile } from "@/lib/turnstile";
import { checkRateLimit } from "@/lib/rate-limit";
import { verifySessionId, sessionCookie } from "@/lib/session";
import { clientIp, networkKey } from "@/lib/client-identifier";
import { logEvent } from "@/lib/observability";

export const maxDuration = 30;

const TURNSTILE_ACTION = "chat-message";
const MAX_BODY_BYTES = 16 * 1024;
const MAX_MESSAGES = 40;

/** 受け付ける本文の形。UIメッセージの必要最小限だけを許可する。 */
const requestSchema = z.object({
  turnstileToken: z.string().min(1).max(2048),
  messages: z
    .array(
      z.object({
        id: z.string().min(1).max(128),
        role: z.enum(["user", "assistant", "system"]),
        parts: z
          .array(z.object({ type: z.literal("text"), text: z.string().min(1).max(4_000) }))
          .min(1)
          .max(8),
      }),
    )
    .min(1)
    .max(MAX_MESSAGES),
});

/** 入力の総文字数からトークンバケットの消費量を見積もる（重み付き課金）。 */
function estimateCost(text: string): number {
  return Math.max(1, Math.ceil(text.length / 500));
}

function errorResponse(status: number, code: string, message: string, retryAfter?: number): Response {
  return Response.json(
    { error: code, message },
    {
      status,
      headers: retryAfter === undefined ? undefined : { "Retry-After": String(retryAfter) },
    },
  );
}

export async function POST(request: Request): Promise<Response> {
  // --- 検証1: 本文サイズ（最も安いチェックを最初に） ---
  // これは善意のクライアント向けの早期棄却にすぎない（ヘッダは詐称できる）。
  // 実効的な上限は次のスキーマ検証（文字数上限・配列長上限）が担保する。
  const contentLength = Number(request.headers.get("content-length") ?? "0");
  if (contentLength > MAX_BODY_BYTES) {
    return errorResponse(413, "payload_too_large", "メッセージが長すぎます。");
  }

  // --- 検証2: スキーマ（推論を呼ぶ前に構造を確定させる） ---
  const parsed = requestSchema.safeParse(await request.json().catch(() => null));
  if (!parsed.success) {
    return errorResponse(400, "invalid_request", "リクエストの形式が正しくありません。");
  }
  const { turnstileToken, messages } = parsed.data;

  // --- 検証3: セッションの真正性（偽造Cookieはここで落ちる） ---
  const cookieHeader = request.headers.get("cookie") ?? "";
  const rawSession = cookieHeader
    .split(";")
    .map((part) => part.trim())
    .find((part) => part.startsWith(`${sessionCookie.name}=`))
    ?.slice(sessionCookie.name.length + 1);

  const sessionId = await verifySessionId(rawSession);
  if (!sessionId) {
    return errorResponse(401, "invalid_session", "セッションが無効です。ページを再読み込みしてください。");
  }

  const ip = clientIp(request.headers);
  const networkId = networkKey(ip);

  // --- 検証4: Turnstile（外部API 1往復。ここまでは自前で完結している） ---
  const turnstile = await verifyTurnstile({
    token: turnstileToken,
    remoteIp: ip,
    expectedAction: TURNSTILE_ACTION,
    expectedHostname: new URL(request.url).hostname,
    idempotencyKey: `${sessionId}:${messages[messages.length - 1].id}`,
  });

  if (!turnstile.ok) {
    const status = turnstile.reason === "verification-unavailable" ? 503 : 403;
    return errorResponse(status, turnstile.reason, "認証に失敗しました。もう一度お試しください。");
  }

  // --- 検証5: 重み付きレート制限（推論の直前・最後の関門） ---
  const inputText = messages
    .flatMap((message) => message.parts.map((part) => part.text))
    .join("");
  const cost = estimateCost(inputText);

  const verdict = await checkRateLimit({ networkId, sessionId, cost });

  if (!verdict.allowed) {
    return errorResponse(
      429,
      "rate_limited",
      "利用が集中しています。少し時間をおいて再度お試しください。",
      verdict.retryAfterSeconds,
    );
  }

  // analytics などの後処理はレスポンスを止めずに完了させる
  after(() => Promise.allSettled(verdict.pending));

  // --- ここまで通過して初めて、課金される推論を呼ぶ ---
  logEvent({ level: "info", event: "chat.accepted", cost });

  const result = streamText({
    model: serverEnv.CHAT_MODEL,
    messages: await convertToModelMessages(messages),
    onError: ({ error }) => {
      logEvent({
        level: "error",
        event: "chat.inference_failed",
        message: error instanceof Error ? error.message : "unknown error",
      });
    },
  });

  return createUIMessageStreamResponse({
    stream: toUIMessageStream({ stream: result.stream }),
  });
}
```

**ストリーミングに `runtime = "edge"` は不要です。** これは非常に多く見られる誤解ですが、`ReadableStream` も SSE も AI のトークンストリーミングも、既定の Node.js ランタイム（Fluid Compute）で何の設定もなく動きます。Edge を選ぶと Node.js API が使えなくなり、実行時間の上限も厳しくなるため、**むしろ不利**です。

### 6.7 `components/chat-input.tsx` — a11y と再試行を織り込んだUI

**設計意図:** Turnstile は「見えない」からこそ、失敗したときにユーザーが何が起きたか分かりません。トークンの期限切れ（300秒）・検証エラー・レート制限（429）を**区別して伝え、区別して回復させる**のがこのコンポーネントの責務です。

```tsx
// components/chat-input.tsx
"use client";

import { useCallback, useEffect, useId, useRef, useState } from "react";
import Script from "next/script";
import { clientEnv } from "@/lib/env.client";

const TURNSTILE_SCRIPT = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
const TURNSTILE_ACTION = "chat-message";

/** 明示レンダリング用の最小限の型。`any` を避けるため自前で宣言する。 */
interface TurnstileRenderOptions {
  sitekey: string;
  action: string;
  appearance: "always" | "execute" | "interaction-only";
  callback: (token: string) => void;
  "error-callback": () => void;
  "expired-callback": () => void;
}

interface TurnstileApi {
  render: (container: HTMLElement, options: TurnstileRenderOptions) => string;
  reset: (widgetId: string) => void;
  remove: (widgetId: string) => void;
}

declare global {
  interface Window {
    turnstile?: TurnstileApi;
  }
}

type Status = "loading-challenge" | "ready" | "sending" | "error";

interface ChatInputProps {
  readonly onSend: (input: { text: string; turnstileToken: string }) => Promise<void>;
  readonly disabled?: boolean;
}

export function ChatInput({ onSend, disabled = false }: ChatInputProps) {
  const [status, setStatus] = useState<Status>("loading-challenge");
  const [errorMessage, setErrorMessage] = useState<string | null>(null);
  const [value, setValue] = useState("");

  const tokenRef = useRef<string | null>(null);
  const widgetIdRef = useRef<string | null>(null);
  const containerRef = useRef<HTMLDivElement | null>(null);

  const inputId = useId();
  const errorId = useId();

  const renderWidget = useCallback(() => {
    const container = containerRef.current;
    const turnstile = window.turnstile;
    if (!container || !turnstile || widgetIdRef.current !== null) return;

    widgetIdRef.current = turnstile.render(container, {
      sitekey: clientEnv.NEXT_PUBLIC_TURNSTILE_SITE_KEY,
      action: TURNSTILE_ACTION,
      // 対話が必要なときだけウィジェットを表示する（通常は不可視）
      appearance: "interaction-only",
      callback: (token) => {
        tokenRef.current = token;
        setStatus("ready");
        setErrorMessage(null);
      },
      "error-callback": () => {
        tokenRef.current = null;
        setStatus("error");
        setErrorMessage("セキュリティ確認に失敗しました。ページを再読み込みしてください。");
      },
      // トークンは300秒で失効する。黙って壊れるのを防ぐため必ず取り直す。
      "expired-callback": () => {
        tokenRef.current = null;
        setStatus("loading-challenge");
        if (widgetIdRef.current !== null) window.turnstile?.reset(widgetIdRef.current);
      },
    });
  }, []);

  useEffect(() => {
    return () => {
      const widgetId = widgetIdRef.current;
      if (widgetId !== null) window.turnstile?.remove(widgetId);
    };
  }, []);

  const isBusy = status === "sending" || status === "loading-challenge";
  const canSubmit = status === "ready" && value.trim().length > 0 && !disabled;

  const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    const token = tokenRef.current;
    if (!canSubmit || !token) return;

    setStatus("sending");
    setErrorMessage(null);

    try {
      await onSend({ text: value.trim(), turnstileToken: token });
      setValue("");
    } catch (error) {
      setStatus("error");
      setErrorMessage(
        error instanceof Error ? error.message : "送信に失敗しました。時間をおいてお試しください。",
      );
    } finally {
      // トークンは1回限り。成否にかかわらず必ず次の分を取り直す。
      tokenRef.current = null;
      if (widgetIdRef.current !== null) {
        window.turnstile?.reset(widgetIdRef.current);
        setStatus("loading-challenge");
      }
    }
  };

  return (
    <form onSubmit={handleSubmit} className="flex flex-col gap-2">
      <Script src={TURNSTILE_SCRIPT} strategy="afterInteractive" onReady={renderWidget} />

      <label htmlFor={inputId} className="text-sm font-medium">
        メッセージ
      </label>

      <textarea
        id={inputId}
        value={value}
        onChange={(event) => setValue(event.target.value)}
        onKeyDown={(event) => {
          // Enter で送信 / Shift+Enter で改行（IME変換中は送信しない）
          if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
            event.preventDefault();
            event.currentTarget.form?.requestSubmit();
          }
        }}
        rows={3}
        maxLength={4000}
        disabled={disabled || status === "sending"}
        aria-describedby={errorMessage ? errorId : undefined}
        aria-invalid={status === "error"}
        className="w-full resize-none rounded-md border border-neutral-300 p-3 disabled:opacity-60"
        placeholder="質問を入力してください"
      />

      {/* 不可視チャレンジのマウント先。対話が必要なときだけ内容が描画される */}
      <div ref={containerRef} />

      {/* エラーは支援技術にも即座に伝える */}
      <p id={errorId} role="alert" aria-live="assertive" className="min-h-5 text-sm text-red-600">
        {errorMessage}
      </p>

      <button
        type="submit"
        disabled={!canSubmit}
        aria-busy={isBusy}
        className="self-end rounded-md bg-neutral-900 px-4 py-2 text-white disabled:opacity-50"
      >
        {status === "sending" ? "送信中…" : status === "loading-challenge" ? "確認中…" : "送信"}
      </button>
    </form>
  );
}
```

a11y の要点は4つです。`<label>` と入力の明示的な関連付け、`aria-invalid` と `aria-describedby` によるエラーの結び付け、`role="alert"` + `aria-live="assertive"` による**画面を見ていない利用者へのエラー通知**、そして `aria-busy` による処理中状態の伝達。加えて `isComposing` の判定は、日本語入力で変換確定の Enter が誤送信になる事故を防ぎます——**日本語UIでは事実上必須**です。

---

## 7. データ・グラビティ — なぜレート制限は「ズレてよい」のか

**TL;DR: 一貫性は強ければ強いほど良い、ではありません。強い一貫性は「合意」というネットワーク往復を必ず伴い、その遅延を毎リクエスト払うことになります。判断基準は『すり抜け1回の損失額』です。**

### 7.1 CAP の現実的な読み方

CAP 定理は「一貫性（C）・可用性（A）・分断耐性（P）のうち2つしか選べない」と要約されますが、実務での意味はもっと単純です。**ネットワーク分断は必ず起きる（P は選択肢ではない）。だから実質的な選択は「分断中に、古いデータを返してでも応答するか（AP）／エラーを返してでも正しさを守るか（CP）」の二択**です。

この選択を「アプリ全体」で1回決めるのが最大の誤りです。正しくは**データの種類ごとに**決めます。

| データ | 1回間違えたときの損失 | 正しい選択 | 具体例 |
|---|---|---|---|
| レート制限カウンタ | 推論1回分（数円） | **AP（結果整合性）** | Upstash Redis |
| チャット履歴・ログ | 表示が数百ms古い | **AP** | Redis / DynamoDB |
| 課金・残高・クレジット | 実損（二重課金・マイナス残高） | **CP（強い一貫性）** | CockroachDB / RDBMS |
| 認証・権限 | セキュリティ侵害 | **CP** | 同上 |

「レート制限を CockroachDB で強い一貫性を持って管理する」は技術的には可能ですが、**すべてのチャットリクエストに Raft の合意ラウンド（数十ms〜）を上乗せする**ことを意味します。守る対象が数円の推論コストであることを考えれば、明確な過剰設計です。逆に「クレジット残高を Redis で管理する」は、レプリケーション遅延の窓で残高がマイナスになりうる——**こちらは過小設計**です。

### 7.2 3つのストアを、アーキテクチャの原理で比べる

「速い・遅い」ではなく、**なぜそうなるのか**で比較します。

#### Redis（Upstash）— インメモリ・直列実行

データが RAM 上にあり、ディスクI/Oが判断経路に入りません。そしてコマンドを1つずつ順に実行するモデルであるため、`INCR` や Lua スクリプトが**ロックなしで原子的**に成立します。レート制限のカウンタ更新（読む→足す→書く）が並行リクエストで壊れないのは、この性質のおかげです。

代償はメモリコストと耐久性です。RAM は SSD より桁違いに高価なので、TTL と `maxmemory` の追い出しポリシー（`allkeys-lru` / `volatile-ttl` など）の設計が必須になります。レート制限用途では TTL が自然に効くため、`volatile-ttl` 系との相性が良好です。**カウンタが最悪失われても、次のウィンドウで作り直せばよい**——この「失っても壊れない」性質こそが、レート制限を Redis に置ける理由です。

#### CockroachDB — Raft ＋ HLC による分散SQL

データは**レンジ**という単位に分割され、それぞれが複数の**レプリカ**として分散配置されます。各レンジのレプリカ群は Raft グループを構成し、**書き込みは過半数（クォーラム）の合意を得て初めてコミット**されます。既定のレプリケーション係数は3で、1ノードの障害に耐えます（5なら2ノード）。

読み取りは**リースホルダー**という単一のレプリカが担当し、Leader Leases により Raft リーダーとリースホルダーを一致させることで、強い一貫性を持つ読み取りに余分な合意ラウンドを不要にしています。書き込み側では **Parallel Commits** により、コミットのレイテンシを**2ラウンドの合意から1ラウンドへ**削減しています。

分散システムで最も厄介な「どちらのイベントが先か」の問題には、**HLC（Hybrid Logical Clocks）** で対処します。物理時刻（常にローカルの壁時計に近い）と論理カウンタ（同一物理時刻のイベントを区別する）を組み合わせたタイムスタンプです。Google Spanner が原子時計とGPSによる TrueTime に依存するのに対し、CockroachDB は**限界が定まった半同期のクロック**に依拠します。既定の最大クロックオフセットは **500ms**（マルチリージョン構成では 250ms が推奨）で、**あるノードが半数以上のノードと最大オフセットの80%を超えてずれていると検知した場合、そのノードは即座に自らクラッシュします**。一貫性を守るために可用性を犠牲にする、というCPの立場が実装として現れた挙動です。

#### DynamoDB — ディスクベース・多AZレプリケーション

SSD 上にデータを保持し、複数のアベイラビリティゾーンにレプリケーションします。ここで最も重要な仕様は「**結果整合性が既定である**」ことです。

- 既定の読み取りは結果整合性。直前の書き込みが反映されていない可能性がある。
- `ConsistentRead: true` を指定すると強い一貫性の読み取りになるが、**コストは結果整合性読み取りの2倍**。
- 強い一貫性読み取りは**テーブルと LSI でのみ利用可能**。GSI とストリームからの読み取りは常に結果整合性。
- グローバルテーブルは既定が **MREC**（マルチリージョン結果整合性、通常1秒以内で伝播）。同期レプリケーションを行う **MRSC**（マルチリージョン強一貫性）も選択できる。

つまり DynamoDB は「一貫性の強さを**読み取りごとに、コストと引き換えに**買える」設計です。GSI に強い一貫性がないという制約は、設計時に必ず効いてきます。

### 7.3 選定表

| 観点 | Upstash Redis | CockroachDB | DynamoDB |
|---|---|---|---|
| データ配置 | インメモリ | ディスク（分散） | SSD（多AZ） |
| 整合性モデル | 単一リージョンは実質アトミック／レプリカは結果整合性 | 直列化可能（Serializable） | 既定は結果整合性、読み取り単位で強一貫性を選択可 |
| 合意機構 | なし（単一プライマリ） | Raft（過半数クォーラム） | 内部レプリケーション |
| クロック依存 | なし | HLC（既定 max-offset 500ms） | なし |
| 主なレイテンシ要因 | ネットワークRTTのみ | 合意ラウンド | ストレージ＋レプリケーション |
| エッジからの接続 | HTTP（最適） | 要ドライバ（TCP） | 要SDK／HTTP API |
| クエリ | KV・データ構造 | SQL・JOIN・トランザクション | KV・限定的クエリ |
| このシステムでの役割 | **レート制限、セッション、キャッシュ** | **課金、クレジット残高、監査** | **会話ログ、大量の追記データ** |

**組み合わせて使うのが正解です。** 「1つのDBですべてを賄う」は運用が単純になる反面、必ずどこかで過剰か過小になります。データ・グラビティ（データは重く、動かしにくい）を前提にすれば、**最初にデータの性質を分類し、それぞれに適したストアを割り当てる**ほうが、後からの移行コストを避けられます。

---

## 8. 本番投入前チェックリスト

- [ ] **L4 の天井があるか。** プロバイダ側（AI Gateway 等）に予算・使用量の上限を設定した。コードが全滅しても請求は止まる。
- [ ] `@vercel/kv` を使っていない。Marketplace の Upstash 統合で環境変数が注入されている。
- [ ] `middleware.ts` ではなく `proxy.ts`（Next.js 16）。`export function proxy` になっている。
- [ ] Proxy の `matcher` が保護対象だけに絞られている（静的アセットに実行が乗っていない）。
- [ ] レート制限のキーが「IP＋セッション」の**連結ではなく**、独立した2本の AND になっている。
- [ ] IPv6 を `/64` で丸めている。
- [ ] Turnstile を**サーバー側で** `siteverify` 検証している。`success` に加えて `action` と `hostname` を照合している。
- [ ] Turnstile トークンの `expired-callback`（300秒）を実装し、送信の成否にかかわらず `reset()` している。
- [ ] レート制限は**フェイルオープン**、ボット検証は**フェイルクローズ**。この非対称が意図的だと説明できる。
- [ ] 429 に `Retry-After` を付けている。UI が待ち時間を表示する。
- [ ] 拒否・障害の構造化ログが出ている（PIIとシークレットを含まない）。
- [ ] Vercel WAF のレート制限ルールを、まず **Log アクション**で数日観測してから Deny に切り替えた。
- [ ] 「請求額」そのものにアラートを設定した。エラー率とレイテンシは、この攻撃では上がらない。

最後の項目を強調しておきます。**この攻撃の唯一確実な検知手段は、金額の監視です。** リクエスト数・トークン消費量・推定コストを時系列で可視化し、平常時の3倍などの閾値でアラートを飛ばしてください。

---

## 9. まとめ

ログイン不要の生成AIチャットを守るとは、**「誰が使えるか」を制御することではなく、「どれだけ使えるか」の天井を、独立した複数の場所に置くこと**です。

1. **脅威は請求書**。可用性の指標は正常なまま被害が進む。監視すべきは金額。
2. **2026年の部品表を更新する**。Vercel KV は廃止、`middleware.ts` は `proxy.ts`（Node.js 既定）、面の防御は WAF。
3. **4層に分ける**。WAF（関数を起動しない）→ Proxy（粗い網）→ Route Handler（精密な網＋ボット検証）→ プロバイダ予算（最後の砦）。
4. **複合キーを1本に連結しない**。粗い網と細かい網を独立に持ち、AND で通す。
5. **一貫性はデータごとに選ぶ**。レート制限は結果整合性で正しく、課金は強い一貫性が要る。判断基準は「すり抜け1回の損失額」。
6. **失敗の方向を非対称に設計する**。可用性のための機構（レート制限）はオープンに、安全のための機構（ボット検証）はクローズに倒す。

そして最も重要なのは、**これらすべてが「あなたのコードが正しく動く限り」という条件付きの防御**だと自覚することです。デプロイ事故、環境変数の消失、依存サービスの障害——どれも起きます。だからコードの外側、プロバイダ側の予算上限という**コードでは越えられない天井**を必ず置いてください。それが、朝起きたときに請求書ではなくアラートを受け取るための、最後の設計判断です。
