# 構成

> Worker 1 つと D1 1 つで動く部品の一覧と、問い合わせ 1 件の処理の流れです。

このページでは、goodnight-web を構成する部品と、問い合わせ 1 件が届いてから保存され、その後の処理が終わるまでの流れがわかります。Webサイト、問い合わせ API、管理画面は、Static Assets 付きの Worker 1 つと D1 データベース 1 つで動きます。

設計は次の制約で決まっています。

- Worker 1 つと D1 データベース 1 つ。Queues、Cron Triggers、KV は使いません。
- 必須の外部サービスは Cloudflare と GitHub だけです。
- フェイルクローズ。設定やシークレットが足りないときは、それを使う機能を止めます。


## 部品

| 部品                      | 役割                                                                                                                                                                                                                          |
| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Worker                    | Webサイトごとに 1 つ。Static Assets で静的なページを配信し、API と管理画面を動かします。                                                                                                                                      |
| Static Assets             | Astro のビルド結果（`/`、`/blog/`、`/contact/` など）。Worker のコードを動かさずに、Cloudflare が配信します。                                                                                                                 |
| Hono アプリ               | Worker の中で動く Web フレームワーク。`@goodnight-web/server` の `createGoodnightWorker(options)` で作ります。`run_worker_first` により、`/api/*`、`/admin`、`/admin/*` だけが届きます。                                      |
| D1                        | SQLite のデータベース 1 つ。問い合わせ、設定、Better Auth のテーブルを保存します。マイグレーションは npm パッケージに入っています。                                                                                           |
| Turnstile                 | Cloudflare のボット対策。問い合わせフォームで使い、Worker がトークンをサーバー側で検証します。                                                                                                                                |
| Rate Limiting binding     | Workers の binding。問い合わせの送信、セットアップ、Passkey でのログインの回数を `ip_hash`（IP アドレスのハッシュ）ごとに制限します。無料プランで使えます。                                                                   |
| Better Auth と Passkey    | 管理者のログイン。Passkey だけを使います。メールとパスワードでのログインは無効です。                                                                                                                                          |
| 管理画面                  | React、TanStack Router・Form・Table で作り、Vite でビルドしたアプリ。ビルド済みの状態で `@goodnight-web/server` に入っていて、Worker が配信します。                                                                           |
| Slack Webhook（任意）     | 新しい問い合わせを短く知らせます。個人情報は含めません。                                                                                                                                                                      |
| Cloudflare Access（任意） | `/admin`、`/admin/*`、`/api/admin/*`、`/api/setup`、`/api/auth/*` の前に置く、もう 1 段のログイン。推奨ですが必須ではありません。`ACCESS_AUD` と `ACCESS_TEAM_DOMAIN` を設定すると、Worker も Access のトークンを確認します。 |
| Cloudflare WAF（任意）    | ご自身で設定するルールで、ドメインへのリクエストを Worker に届く前に制限します。WAF がなくても goodnight-web は同じように動きます。                                                                                           |

## ルート

| パス                             | 呼び出し元         | 内容                                                                                                                                                                                              |
| -------------------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `/`、`/blog/*`、`/contact/` など | 訪問者             | 静的なページ。Worker のコードは動きません。                                                                                                                                                       |
| `GET /api/contact/form`          | 問い合わせページ   | 現在のフォームの項目と、フォームが使えるかどうかを返します。公開で、読み取り専用です。                                                                                                            |
| `POST /api/contact`              | 問い合わせフォーム | 送信内容を確認し、回数を制限して、保存します。                                                                                                                                                    |
| `/api/setup`                     | 最初の管理者       | 最初の管理者を作ります。最初の Passkey を登録すると閉じます。                                                                                                                                     |
| `/api/auth/*`                    | 管理者             | Better Auth。Passkey の登録とログインです。                                                                                                                                                       |
| `/api/admin/*`                   | 管理画面           | 問い合わせの一覧・詳細・削除、未読/既読とスパムの疑いの切り替え、CSV の書き出し、フォームの編集とバージョン。問い合わせフォームに足りない設定と Slack 通知の有無を返すステータス API もあります。 |
| `/admin`、`/admin/*`             | 管理者             | ビルド済みの管理画面（React アプリ）。データを取るたびにセッションが必要です。                                                                                                                    |

管理画面の問い合わせ一覧は 1 ページ 50 件で、受信日時の順に並びます。一覧の API は未読の件数とスパムの疑いの件数も返し、メールアドレスで検索できます。

## 問い合わせ 1 件の流れ

`POST /api/contact` は、次の順に処理します。

1. **Origin とサイズ。** サイト自身のオリジンからのリクエストか、`Content-Type` が想定どおりか、32 KB 以下かを確認します。満たさなければ、本文を読む前に拒否します。
2. **honeypot。** フォームには、人には見えない欄 `gnw_hp_x` があります。Worker は、この欄が埋まっているかを記録して先へ進みます。埋まっていても応答は変わりません。手順 5 でスパムの疑いの印が付くだけです。
3. **Turnstile。** Worker がトークンを Turnstile の siteverify API に送り、`success`、`hostname`、`action` を確認します。失敗したら 400 を返し、何も保存しません。
4. **回数制限。** Rate Limiting binding が、`ip_hash` ごとにリクエストを数えます。上限を超えたら 429 を返します。
5. **入力の検証とスパムスコア。**
   - zod でメールアドレスと同意を確認します。
   - フォームの項目は、D1 にある現在のフォームの定義から作った zod のスキーマで確認します。ブラウザから送られた定義は使いません。定義にない項目や、リクエスト本文の想定外のキーは拒否します。
   - 訪問者が古いバージョンのフォームを開いていたら、409 `form_outdated` を返します。
   - 簡単なルールでスパムスコアを足します。スコアが 3 以上か、honeypot が埋まっていれば、スパムの疑いの印を付けます。印が付いても、状態 `unread`（未読）で保存します。
6. **保存してから応答。** 直近 1 時間に保存された問い合わせが全体で 500 件以上のときは、何も保存せずに 503 `contact_busy` を返します。そうでなければ D1 に行を INSERT し、成功してから訪問者に 200 を返します。これより後の処理で、応答が変わることはありません。
7. **応答の後（`ctx.waitUntil` の中）。**
   - スパムの疑いがなく、Slack Webhook が設定されていれば、新しい問い合わせの通知として、サイト名、ページのパス、受信日時、管理画面の URL を送り、`notified_at` を記録します。
   - 前回の削除から 1 時間以上たっていれば、保持期間（既定は 180 日）を過ぎた問い合わせと、期限切れの Passkey のチャレンジとセッションを削除します。

問い合わせ API はフェイルクローズです。次のどれかに当てはまると、API は 503 を返し、フォームには「お問い合わせフォームは準備中です」と表示します。

- `TURNSTILE_SECRET` か `TURNSTILE_SITE_KEY` が設定されていない。
- `APP_SECRET` がないか、32 文字より短いか、明らかにランダムでなく、`IP_HASH_SECRET` も設定されていない。設定した `IP_HASH_SECRET` が短すぎる場合や、明らかにランダムでない場合も止まります。
- Rate Limiting binding がない。

設定は [Turnstile を設定する](https://goodnight-web.dev/ja/docs/setup/turnstile.md)を参照してください。

## コードでは

Webサイトの Worker は 3 行です。既定の言語を、`@goodnight-web/server` のファクトリー関数に渡すだけです。

```ts title="templates/starter/src/worker.ts"
import { createGoodnightWorker } from '@goodnight-web/server';
import { siteConfig } from '../site.config';

export default createGoodnightWorker({ locale: siteConfig.defaultLocale });
```

`wrangler.jsonc` では、API と管理画面のパスだけを Worker に送ります。ほかのパスは、Worker のコードを動かさずに Static Assets が返します。

```jsonc title="templates/starter/wrangler.jsonc"
"assets": {
  "directory": "./dist",
  "binding": "ASSETS",
  "not_found_handling": "404-page",
  // The contact API and the admin screen run in the Worker. Every other
  // path is a page from ./dist.
  "run_worker_first": ["/api/*", "/admin", "/admin/*"],
},
```

ファクトリーは Hono アプリを 1 つ作ります。最初にセキュリティヘッダー、HTTPS、Cloudflare Access のミドルウェアが動き、続いて機能ごとのルートが動きます。どのルートにも一致しないリクエストは、`ASSETS` binding に渡ります。

```ts title="packages/server/src/app.ts"
app.use('*', securityHeaders);
// The admin UI and /api/*: HTTPS only (except on localhost). Then the
// admin UI, admin API, setup, and Better Auth: Cloudflare Access when
// ACCESS_AUD and ACCESS_TEAM_DOMAIN are set.
app.use('*', requireHttps);
app.use('*', requireAccess);
app.route('/api/contact', contactRoutes);
app.route('/api/setup', setupRoutes);
app.route('/api/auth', authRoutes);
app.route('/api/admin', adminRoutes);
app.all('/api/*', (c) => apiError(c, 404, 'not_found'));
app.on(['GET', 'HEAD'], ['/admin', '/admin/*'], (c) =>
  serveAdminFile(c.req.raw),
);
```

問い合わせ、セットアップ、認証、管理 API、管理画面のコードは、`packages/server/src/features/` の下で、機能ごとのフォルダーに分かれています。

## シークレットと binding

| 名前                   | 種類                   | 用途                                                                                                                                                |
| ---------------------- | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| `DB`                   | D1 binding             | 保存するすべてのデータ                                                                                                                              |
| `ASSETS`               | Static Assets binding  | Astro でビルドしたページ                                                                                                                            |
| `CONTACT_RATE_LIMITER` | Rate Limiting binding  | `ip_hash` ごとの回数制限（問い合わせ、セットアップ、Passkey でのログイン）                                                                          |
| `TURNSTILE_SITE_KEY`   | 変数                   | 公開してよい Turnstile のサイトキー。`wrangler.jsonc` に書きます。ないと問い合わせ API は止まります。                                               |
| `APP_SECRET`           | シークレット           | 最初の管理者を作るときのセットアップトークンで、セッションの鍵と IP アドレスのハッシュの鍵の元にもなります。32 文字以上。セットアップ後も残します。 |
| `ADMIN_EMAILS`         | シークレットまたは変数 | 管理者になれるメールアドレス                                                                                                                        |
| `TURNSTILE_SECRET`     | シークレット           | Turnstile のシークレットキー。siteverify で使います。ないと問い合わせ API は止まります。                                                            |
| `SLACK_WEBHOOK_URL`    | シークレット（任意）   | 新しい問い合わせの通知                                                                                                                              |
| `ACCESS_AUD`           | 変数（任意）           | Cloudflare Access アプリケーションの AUD タグ。`ACCESS_TEAM_DOMAIN` と一緒に設定します。                                                            |
| `ACCESS_TEAM_DOMAIN`   | 変数（任意）           | Cloudflare Access のチームドメイン。たとえば `your-team.cloudflareaccess.com`。                                                                     |
| `SETUP_TOKEN`          | シークレット（任意）   | 個別のセットアップトークン。設定すると、セットアップではこの値だけを受け付けます。セットアップ後に削除します。                                      |
| `BETTER_AUTH_SECRET`   | シークレット（任意）   | 個別の Better Auth の署名用の鍵。`APP_SECRET` から作る鍵の代わりに使います。                                                                        |
| `IP_HASH_SECRET`       | シークレット（任意）   | `HMAC-SHA256(ip, key)` の個別の鍵。`APP_SECRET` から作る鍵の代わりに使います。                                                                      |

`BETTER_AUTH_SECRET` か `IP_HASH_SECRET` が設定されていないときは、Worker が Web Crypto の [HKDF](https://goodnight-web.dev/ja/docs/glossary.md#hkdf) を使い、用途ごとに異なる決まったラベルで、その鍵を `APP_SECRET` から作ります。32 文字より短いシークレットや、明らかにランダムでないシークレット（同じ文字の繰り返しなど）はないものとして扱い、それを使う機能を止めます。設定した値が短すぎる場合も、`APP_SECRET` から作った鍵には置き換えません。シークレットは Worker の中だけで使います。ブラウザに返すことも、D1 に書くこともありません。

`/admin`、`/admin/*` と、問い合わせ API を含む `/api/` の下のすべてのパスは、`https://` でだけ動きます（`localhost` を除く）。[セキュリティモデル](https://goodnight-web.dev/ja/docs/security.md#https)を参照してください。

## スタイルとフォント [#styles-and-fonts]

Web フォントは、Webサイト、管理画面、このドキュメントサイトのどこでも読み込みません。文字は、訪問者の端末にあるフォントで表示されます。たとえば macOS と iOS ではヒラギノ、Windows ではメイリオや游ゴシックです。

3 つとも、システムフォントの並びを CSS のトークンとして 1 か所で決めています。フォントのファイルも配信サービスも使わないので、Content Security Policy にフォント用のホストはありません。

starter は、独自のデザイントークン（CSS 変数）を持ちます。テーマの 3 色は `site.config.ts` の `theme.primary`、`theme.background`、`theme.foreground` から取り、CSS 変数として `<html>` に設定します。

ほかの色合いは、この 3 色を混ぜて作ります。3 つの値を変えれば、サイト全体の色が変わります。

```css title="templates/starter/src/styles/global.css"
:root {
  --site-primary: #2f6b4e;
  --site-background: #fbfaf5;
  --site-foreground: #253229;
}

@theme inline {
  /* Fonts: the visitor's system fonts. The site loads no web fonts. */
  --font-sans:
    system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Hiragino Sans',
    'Hiragino Kaku Gothic ProN', 'Noto Sans JP', 'Yu Gothic UI', Meiryo,
    sans-serif;

  --color-background: var(--site-background);
  --color-foreground: var(--site-foreground);
```

管理画面は、ライトとダークのモードを含む独自のトークンを `@goodnight-web/server` の中に持っています。サイトの色を変えても、管理画面の色は変わりません。

## 関連ページ

- [しくみの詳細](https://goodnight-web.dev/ja/docs/how-it-works.md): 設計の判断を 1 つずつ説明しています。
- [配布と更新のしくみ](https://goodnight-web.dev/ja/docs/distribution.md): どのファイルをご自身で持つかを説明しています。
