Newer
Older
client_dwatch / lib / api / path-params.ts
/**
 * パスパラメータ(`/clean-logs/{id}/...`、`/ds-devices/{serial_number}/...`)の検証。
 *
 * ★★`serial_number` は Prisma の `where` に入る★★(指示書AH 段階2 —
 *   `prismaSource.dsDeviceExists()` が `where: { serial_number }` で照会する)。
 *   ★値はクエリビルダ経由で束縛される★(生SQLはアプリコードに1件も無い。`CLAUDE.md` §3)が、
 *   ★入口で長さを締めておくのは変わらず有効★(`controls.serial_number` は varchar(100))。
 *   ★★当たり外れを決めるのはここではない★★ — DBの照合順序は大小文字を区別しない(ci)ため、
 *   最終判定は `dsDeviceExists()` 側の JS の `===` が行う(D-4)。
 *   保存先パスも `lib/settings/ds-device-notes.ts` で固定、マップは `Object.create(null)`。
 *   (`id` 側は `logs.find((l) => l.id === logId)` の等値比較と Prisma の `where`。
 *   指示書AE 段階2-2 / D-123)
 *
 * ★`lib/api/` に置く理由★
 *   パスパラメータの形式とエラー形式(`CLAUDE.md` §9.3)は全APIルート共通の関心事で、
 *   `lib/clean-logs/` や `lib/ds-devices/` という個別ドメインの持ち物ではない。
 *   `lib/api/query-result.ts`(`fail()`)と同じ考え方。
 *
 * ★純粋関数にすること★(`CLAUDE.md` §9.4)— DBアクセスやログ出力を混ぜない。
 */
import { fail, type QueryParseResult } from "@/lib/api/query-result";
import {
  PATH_ERROR_ID_MAX_LENGTH,
  PATH_ID_MAX,
  PATH_SERIAL_NUMBER_MAX_LENGTH,
} from "@/lib/config";

/**
 * 数値ID用のパスパラメータを検証する。
 *
 * ★`Number.parseInt()` に判定を任せないこと★
 *   `parseInt` は**前方一致**なので、次が黙って通ってしまう:
 *   `"-5"` → `-5` / `"12abc"` → `12` / `"1.9"` → `1` / `"1e9"` → `1`。
 *   どれも「送った文字列とは違う値」で検索が走るので、404 になるべき要求が
 *   別のレコードに当たりうる。
 *
 * ★`^\d+$` の全体一致で判定する★ — `lib/reports/query.ts` の `toIntStrict()`(`^-?\d+$`)が
 *   既にある前例で、それに倣っている。ID は 1 以上なので符号を許さない分だけ狭い。
 *   なお JavaScript の `\d` は ASCII の `[0-9]` だけなので、全角数字(`"1"`)は通らない。
 *   `$` も(`m` フラグが無いので)末尾改行を許さない。
 *
 * ★トリムしない★ — `toIntStrict()` は `.trim()` してから判定するが、あれは利用者が
 *   手で組み立てるクエリ文字列用。パスセグメントの前後に空白が付く正当な経路は無く、
 *   緩める理由が無い。
 *
 * @returns 成功なら `{ ok: true, query: <正の整数> }`、失敗なら 422 の `QueryParseFailure`
 */
export function parsePathId(value: string): QueryParseResult<number> {
  if (!/^\d+$/.test(value)) {
    return invalidId();
  }
  const n = Number(value);
  // `^\d+$` を通っていても、桁数次第で `Number()` の結果が元の値とずれる
  // (安全整数を超えると丸められる)。丸められた値で検索させない。
  if (!Number.isSafeInteger(n) || n > PATH_ID_MAX || n < 1) {
    return invalidId();
  }
  return { ok: true, query: n };
}

/**
 * D-S制御盤のシリアル番号のパスパラメータを検証する。
 *
 * ★長さ上限だけをかけ、文字種は絞らない★ — 理由は
 * `lib/config/index.ts` の `PATH_SERIAL_NUMBER_MAX_LENGTH` のコメントに書いた
 * (実データの形式が未確定。`CLAUDE.md` §7「仕様が不明な箇所を推測で実装しない」)。
 * 空文字も弾く(Next.js のルーティング上そもそも来ないが、
 * 「空で実在チェックを通す」経路を残さない)。
 */
export function parsePathSerialNumber(value: string): QueryParseResult<string> {
  if (value.length === 0 || value.length > PATH_SERIAL_NUMBER_MAX_LENGTH) {
    // ★入力値をエコーしない★(`ds-devices/[serial_number]/note/route.ts` の
    //   `DEVICE_NOTE_MAX_LENGTH` を details に入れる前例に倣い、上限値だけを返す)
    return fail(422, "validation_failed", "serial_numberの形式が不正です", {
      serial_number: [`${PATH_SERIAL_NUMBER_MAX_LENGTH}文字以内で指定してください`],
    });
  }
  return { ok: true, query: value };
}

/**
 * アラートの `error_id`(`robot_error_reports.error_id` = `varchar(50)`)のパスパラメータを検証する
 * (`PATCH /prechecks/{id}/status`。指示書AT 段階1)。
 *
 * ★`parsePathSerialNumber()` と同じ作法★ — ★空文字と上限超だけを 422★ にし、文字種は絞らない
 * (`error_id` の形式は TeamWorks 側が決める。`PATH_ERROR_ID_MAX_LENGTH` のコメント)。
 * ★`Number()` を通さない★(`varchar` なので。D-105 項2)。
 */
export function parsePathErrorId(value: string): QueryParseResult<string> {
  if (value.length === 0 || value.length > PATH_ERROR_ID_MAX_LENGTH) {
    // ★入力値をエコーしない★(上限値だけを返す)
    return fail(422, "validation_failed", "idの形式が不正です", {
      id: [`${PATH_ERROR_ID_MAX_LENGTH}文字以内で指定してください`],
    });
  }
  return { ok: true, query: value };
}

/**
 * ★エラー文言に入力値をエコーしないこと★
 *   受け取った文字列をそのまま返すと、ログやブラウザの表示に
 *   攻撃者が選んだ文字列が載る経路になる(`app/api/auth/login/route.ts:46-51` が
 *   ログイン失敗時に入力値を出さないのと同じ考え方)。
 *   返すのは「何を期待しているか」だけにする。
 */
function invalidId(): QueryParseResult<number> {
  return fail(422, "validation_failed", "idの形式が不正です", {
    id: ["1以上の整数で指定してください"],
  });
}