/**
* 環境変数の検証(指示書I 段階5-B)。★純粋関数。`process.env` を直接読まない★
*
* `scripts/check-env.ts` が dotenv で読み込んだ値を渡す。
* `lib/db/local-guard.ts` と同じ形(純粋関数+薄い包み)にしてあり、
* ★DB無し・環境変数無しでテストできる★。
*
* ★★要点: エラーメッセージに値そのものを出さないこと★★
* `DATABASE_URL` にはパスワードが入る。出力はターミナルに残り、CI のログにも残る。
* `lib/db/local-guard.ts` が同じ配慮をしている(末尾が「値はこのエラーには出しません」)。
* ★「何が問題か」だけを言い、値は書かない★。`AUTH_TOKEN` も同様
* (検査1 は「`changeme` のままです」とキーワード名だけ言えば足りる)。
*
* ★「本番判定」に `NODE_ENV === "production"` を使っていることについて★
* 本指示書には「開発時だけ通す条件は `NODE_ENV` ではなく設定値で明示的に切れ」とあるが、
* ★矛盾しない★。あの指示は `ALLOW_DIRECT_ACCESS`(= リクエストを通す/弾くという
* **アプリの実際の挙動**)の話である。
* ★この検証スクリプトの分岐は「警告の文言をどちらに寄せるか」でしかなく、
* アプリの挙動を一切変えない★。
* 後任が「`NODE_ENV` を使っている、規約違反だ」と誤読しないようここに明記する。
* 判定そのものも引数(`isProduction: boolean`)で受け取り、テストで両方を回せる形にしてある。
*/
export type EnvCheckSeverity = "error" | "warn";
export interface EnvCheckFinding {
severity: EnvCheckSeverity;
/** 対象の環境変数名 */
key: string;
/** ★値を含めないこと★。何が問題かだけを書く */
message: string;
}
/** pino が受け付けるレベル名。`lib/logger` の LogLevel(3つ)より広い点に注意 */
const PINO_LEVELS = ["trace", "debug", "info", "warn", "error", "fatal"];
/**
* 危険な値を検出する。エラーが1件でもあれば呼び出し側は非0で終了する。
*
* @param env `process.env` 相当(dotenv 読み込み後)
* @param isProduction 本番判定。★警告の寄せ方だけを変え、挙動は変えない★
*/
export function checkEnv(
env: Record<string, string | undefined>,
isProduction: boolean
): EnvCheckFinding[] {
const findings: EnvCheckFinding[] = [];
// 検査1: AUTH_TOKEN が未設定・空・changeme のまま
// (.env.example の初期値がまさに changeme。本番に持ち込むと共有トークンが公開情報になる)
const authToken = env.AUTH_TOKEN;
if (!authToken) {
findings.push({
severity: "error",
key: "AUTH_TOKEN",
message:
"未設定または空です。未設定だと誰もログインできません(=全機能が使えません)。",
});
} else if (authToken === "changeme") {
findings.push({
severity: "error",
key: "AUTH_TOKEN",
message:
".env.example の初期値 changeme のままです。必ず変更してください。",
});
}
// 検査2: ALLOW_DIRECT_ACCESS=true が設定されている
// (開発機では正常な設定なので落としてはいけない)
if (env.ALLOW_DIRECT_ACCESS === "true") {
findings.push({
severity: isProduction ? "error" : "warn",
key: "ALLOW_DIRECT_ACCESS",
message: isProduction
? "本番で true になっています。nginx を通らない直接アクセスが素通りします。この行を削除してください。"
: "true です。開発機では正しい設定ですが、★本番へ持ち込まないこと★。",
});
}
// 検査3: USE_FIXTURES が未設定または false 以外
// (lib/db/use-fixtures.ts は `!== "false"` なので、未設定だと true になる)
if (isProduction && env.USE_FIXTURES !== "false") {
findings.push({
severity: "warn",
key: "USE_FIXTURES",
message:
"false ではありません。未設定・その他の値はすべて true 扱いになり、実DBに繋がらずサンプルデータのまま動きます。",
});
}
// 検査4: DATABASE_URL が未設定
const databaseUrl = env.DATABASE_URL;
if (!databaseUrl) {
findings.push({
severity: "error",
key: "DATABASE_URL",
message:
"未設定です。起動しても画面が全部エラーになります(値はこの出力には出しません)。",
});
} else if (isProduction && isLocalHostUrl(databaseUrl)) {
// 検査5: ホストが localhost / 127.0.0.1
findings.push({
severity: "warn",
key: "DATABASE_URL",
message:
"ホストが localhost / 127.0.0.1 を指しています。本番のDBを指しているか確認してください(値はこの出力には出しません)。",
});
}
// 検査6: LOG_LEVEL が想定外の値
// ★debug を弾かないこと★ — lib/logger の LogLevel は info/warn/error の3つだが、
// resolveLevel() は開発時に pino のしきい値として debug を返す
const logLevel = env.LOG_LEVEL;
if (logLevel && !PINO_LEVELS.includes(logLevel)) {
findings.push({
severity: "warn",
key: "LOG_LEVEL",
message: `pino のレベル名(${PINO_LEVELS.join(" / ")})ではありません。`,
});
}
// 検査7: 書き込み用アカウント(DATABASE_WRITE_USER / DATABASE_WRITE_PASSWORD。指示書AT §3-2)
// ★値も、ユーザー名も出さない★
const writeCredentials = databaseWriteCredentials(env);
if (writeCredentials === "none") {
findings.push({
severity: "warn",
key: "DATABASE_WRITE_USER",
message:
"DATABASE_WRITE_USER / DATABASE_WRITE_PASSWORD が未設定です。書き込みは参照用アカウントで行います。",
});
} else if (writeCredentials === "partial") {
findings.push({
severity: "error",
key: "DATABASE_WRITE_USER",
message:
"DATABASE_WRITE_USER と DATABASE_WRITE_PASSWORD の片方だけが設定されています。両方設定するか、両方消してください(片方だけだと書き込みが失敗します)。",
});
} else if (!env.DATABASE_HOST || !env.DATABASE_NAME) {
// 書き込み用の接続は部品変数で組み立てる(URL 形式は設けない。`%40` が壊れる問題を持ち込まないため)
findings.push({
severity: "error",
key: "DATABASE_WRITE_USER",
message:
"書き込み用アカウントを使うときは DATABASE_HOST と DATABASE_NAME も必要です(接続先は参照用と同じDB)。",
});
}
return findings;
}
/**
* 書き込み用アカウントの環境変数(`DATABASE_WRITE_USER` / `DATABASE_WRITE_PASSWORD`)の状態(指示書AT §3-2)。
*
* - `none` … 2つとも未設定(空文字も未設定とみなす)→ ★参照用の接続で書く★(いまはこれで動く)
* - `partial` … 片方だけ → ★書き込みは例外★(黙って参照用に倒さない)
* - `both` … 2つとも設定 → 書き込み用の接続を別に作る(接続先は `DATABASE_HOST` / `PORT` / `NAME`)
*
* ★`lib/db/prisma.ts` の `getWritePrisma()` と `checkEnv()` の両方がこの1本で判定する★(基準を揃える)。
* ★値は返さない★。
*/
export function databaseWriteCredentials(
env: Record<string, string | undefined>
): "none" | "partial" | "both" {
const hasUser = Boolean(env.DATABASE_WRITE_USER);
const hasPassword = Boolean(env.DATABASE_WRITE_PASSWORD);
if (hasUser && hasPassword) return "both";
if (hasUser || hasPassword) return "partial";
return "none";
}
/** `DATABASE_URL` のホストがローカルか。★url の値は返さない★ */
function isLocalHostUrl(url: string): boolean {
try {
const parsed = new URL(url);
return parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1";
} catch {
// パースできない = ローカル判定はできない。検査4 とは別問題なのでここでは黙る
return false;
}
}
/** エラーが1件でもあるか(呼び出し側の終了コード判定用) */
export function hasError(findings: readonly EnvCheckFinding[]): boolean {
return findings.some((f) => f.severity === "error");
}
/** 出力用に整形する。★ここでも値を出さない★ */
export function formatFinding(finding: EnvCheckFinding): string {
const prefix = finding.severity === "error" ? "ERROR" : "WARN ";
return `${prefix}: ${finding.key} — ${finding.message}`;
}