/**
* 制御盤のログの API の検証(指示書AY 段階3-3・段階7-2。§11-4 M7・M8)。
* 期待値の正は段階3-3 の V1〜V10・X1〜X6 と段階7-2 の W1〜W11・指示書BJ の R45〜R48(`__tests__/validate.test.ts`)。
*
* - ★ブラウザ向け(クエリ・本体・パス)の誤りは 422 `validation_failed`★(D17)。
* ただし `page` / `per_page` は 422 にせず丸める・空文字のパラメータは省略と同じ(既存の一覧 API と同じ作法。M7)。
* - ★制御盤から来た JSON(result / prepared / complete / fail)の形の誤りは、すべて 400 `result_invalid`★(D25 ④)。
* ★本体は生の文字列で受け取る★ — JSON でない・オブジェクトでない(`null`・配列・数値・文字列)も同じ 400(R43・R44)。
*
* ★純粋関数★(`CLAUDE.md` §9.4)。★入力値をエラー文言にエコーしない★(`lib/api/path-params.ts` と同じ考え方)。
*/
import { isJsonObject } from "@/lib/api/json-body";
import { fail, type QueryParseResult } from "@/lib/api/query-result";
import { parseDate } from "@/lib/clean-logs/query";
import { PAGINATION } from "@/lib/config";
import {
AGENT_POLL_WAIT_SEC,
DS_LOG_DEFAULT_PER_PAGE,
EXPORT_FILE_IDS_MAX_BYTES,
EXPORT_MAX_FILES,
LIST_RESULT_MAX_BYTES,
LIST_RESULT_MAX_ITEMS,
PATH_COMMAND_ID_MAX_LENGTH,
PATH_EXPORT_ID_MAX_LENGTH,
} from "@/lib/config/ds-agent";
import type { DeviceLogQuery } from "@/lib/ds-logs/query";
import type {
DsAgentComplete,
DsAgentFail,
DsAgentListResult,
DsAgentPrepared,
DsLogFile,
DsLogSort,
DsLogType,
} from "@/lib/types";
const SORTS: readonly DsLogSort[] = ["date_desc", "type_date_desc"];
/**
* `file_id` の長さの上限(一覧・エクスポート共通)。
* 制御盤の常駐プログラムが受け取れる上限 `FILE_ID_MAX_LEN`(`agent.py:64`。`decode_file_id()` が使う)とそろえる。指示書BK K6
*/
export const FILE_ID_MAX_LENGTH = 8192;
/** 制御盤の失敗の報告の `code` / `message` の長さ(段階2 の `DsAgentFail`。制御盤側も同じ値) */
const AGENT_CODE_MAX_LENGTH = 64;
const AGENT_MESSAGE_MAX_LENGTH = 500;
const SHA256_HEX = /^[0-9a-f]{64}$/;
/** `YYYY-MM-DDTHH:MM:SS` + ★オフセット必須★(`Z` か `±HH:MM`)。小数秒は受けない */
const ISO_WITH_OFFSET = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(Z|[+-]\d{2}:\d{2})$/;
/** 0 を許す整数。★先頭の 0・符号・小数・空白を通さない・9桁まで★(X4〜X6) */
const SEQ = /^(0|[1-9][0-9]{0,8})$/;
const INVALID_JSON_MESSAGE = "JSONの形式が不正です";
// ── 共通 ──
/** 空白だけの値は省略と同じ(BH の作法。V4) */
function presentParam(searchParams: URLSearchParams, name: string): string | null {
const value = searchParams.get(name);
if (value === null || value.trim() === "") return null;
return value;
}
function toInt(value: string | null, fallback: number): number {
if (!value) return fallback;
const n = Number.parseInt(value, 10);
return Number.isNaN(n) ? fallback : n;
}
/**
* オフセット付きの ISO 8601 を瞬間(ms)にする。形が違う・存在しない日付なら `null`。
* ★`Date.parse("2026-02-30T00:00:00+09:00")` は NaN にならず 3/2 になる★ので、日付の部分(先頭10文字)を
* `parseDate()` に通して確かめる(4-24)。★`toJstIso()` で戻して全体を比べない★(`Z` など正しい入力まで弾く)。
*/
function parseInstant(value: string): number | null {
if (!ISO_WITH_OFFSET.test(value)) return null;
if (parseDate(value.slice(0, 10)) === null) return null;
const hh = Number(value.slice(11, 13));
const mm = Number(value.slice(14, 16));
const ss = Number(value.slice(17, 19));
if (hh > 23 || mm > 59 || ss > 59) return null;
const ms = Date.parse(value);
return Number.isNaN(ms) ? null : ms;
}
function isNonNegativeInteger(value: unknown): value is number {
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
}
function isStringOfLength(value: unknown, min: number, max: number): value is string {
return typeof value === "string" && value.length >= min && value.length <= max;
}
function parseJsonObject(text: string): Record<string, unknown> | null {
let body: unknown;
try {
body = JSON.parse(text);
} catch {
return null;
}
return isJsonObject(body) ? body : null;
}
function resultInvalid(message: string) {
return fail(400, "result_invalid", message);
}
// ── ブラウザ向け(段階3-3)──
/**
* `GET /ds-devices/{serial_number}/logs` のクエリ(V1〜V6)。
* `page` / `per_page` は `lib/ds-devices/query.ts` と同じく丸める(`per_page` の既定は `DS_LOG_DEFAULT_PER_PAGE`)。
*/
export function parseDeviceLogQuery(searchParams: URLSearchParams): QueryParseResult<DeviceLogQuery> {
let types: string[] | null = null;
const typesRaw = presentParam(searchParams, "types");
if (typesRaw !== null) {
types = typesRaw.split(",");
// ★全体が空は省略と同じ(V4)だが、並びの中の空の要素は打ち間違いなので通さない(V4b)★
if (types.some((t) => t === "")) {
return fail(422, "validation_failed", "typesの形式が不正です", {
types: ["カンマ区切りの種類のキーを指定してください(空の要素は不可)"],
});
}
}
const fromRaw = presentParam(searchParams, "from");
const from = fromRaw === null ? null : parseInstant(fromRaw);
if (fromRaw !== null && from === null) {
return fail(422, "validation_failed", "fromの形式が不正です", {
from: ["オフセット付きの ISO 8601(例 2026-10-19T11:00:00+09:00)で、存在する日時を指定してください"],
});
}
const toRaw = presentParam(searchParams, "to");
const to = toRaw === null ? null : parseInstant(toRaw);
if (toRaw !== null && to === null) {
return fail(422, "validation_failed", "toの形式が不正です", {
to: ["オフセット付きの ISO 8601(例 2026-10-19T12:00:00+09:00)で、存在する日時を指定してください"],
});
}
if (from !== null && to !== null && from > to) {
return fail(422, "validation_failed", "fromはto以前にしてください", {
from: ["to 以前の日時を指定してください"],
});
}
const sortRaw = presentParam(searchParams, "sort") ?? "date_desc";
if (!SORTS.includes(sortRaw as DsLogSort)) {
return fail(422, "validation_failed", "sortの値が不正です", {
sort: [`${SORTS.join(" / ")} のいずれかを指定してください`],
});
}
const page = Math.max(1, toInt(searchParams.get("page"), 1));
const perPage = Math.min(
PAGINATION.maxPerPage,
Math.max(1, toInt(searchParams.get("per_page"), DS_LOG_DEFAULT_PER_PAGE))
);
return { ok: true, query: { types, from, to, sort: sortRaw as DsLogSort, page, perPage } };
}
/** エクスポートで選んだ ID の合計が大きすぎるときの message(指示書BK K7。画面の文言と共有) */
export const EXPORT_FILE_IDS_TOO_LARGE_MESSAGE = "選んだファイルが多すぎます。数を減らしてください";
/**
* `POST /ds-devices/{serial_number}/logs/exports` の本体(V7〜V9)。生の文字列で受け取る。
* JSON でない・オブジェクトでない本体は 422「JSONの形式が不正です」(note と同じ文。M7 (d))。
* ★ID の合計の上限(K7)★: `file_ids` を JSON にした UTF-8 バイト数が `EXPORT_FILE_IDS_MAX_BYTES` を超えたら 422。
*/
export function parseExportRequest(text: string): QueryParseResult<{ fileIds: string[] }> {
const body = parseJsonObject(text);
if (body === null) return fail(422, "validation_failed", INVALID_JSON_MESSAGE);
const fileIds = body.file_ids;
const invalid = () =>
fail(422, "validation_failed", "file_idsの形式が不正です", {
file_ids: [
`1〜${EXPORT_MAX_FILES}件の重複しない文字列(各1〜${FILE_ID_MAX_LENGTH}文字)の配列で指定してください`,
],
});
if (!Array.isArray(fileIds) || fileIds.length < 1 || fileIds.length > EXPORT_MAX_FILES) return invalid();
if (!fileIds.every((id) => isStringOfLength(id, 1, FILE_ID_MAX_LENGTH))) return invalid();
if (new Set(fileIds).size !== fileIds.length) return invalid();
// ★件数・各 ID・重複のあとで合計を見る★(先に見ると V7 の既存行の文言と食い違う)
if (new TextEncoder().encode(JSON.stringify(fileIds)).length > EXPORT_FILE_IDS_MAX_BYTES) {
return fail(422, "validation_failed", EXPORT_FILE_IDS_TOO_LARGE_MESSAGE, {
file_ids: [
`ファイル ID の合計が ${EXPORT_FILE_IDS_MAX_BYTES} バイト(JSON・UTF-8)以内になるように選んでください`,
],
});
}
return { ok: true, query: { fileIds: fileIds as string[] } };
}
/**
* 制御盤向け `GET /ds-agent/commands` の `wait`(V10・表5 R8)。省略は `AGENT_POLL_WAIT_SEC`。
* ★範囲外・数でない値は丸めずに 422★(制御盤のプログラムの誤りを早く見つけるため)。
*/
export function parseAgentPollWait(value: string | null): QueryParseResult<number> {
if (value === null) return { ok: true, query: AGENT_POLL_WAIT_SEC };
if (/^[1-9][0-9]*$/.test(value)) {
const n = Number(value);
if (n <= AGENT_POLL_WAIT_SEC) return { ok: true, query: n };
}
return fail(422, "validation_failed", "waitの値が不正です", {
wait: [`1〜${AGENT_POLL_WAIT_SEC}の整数で指定してください`],
});
}
// ── パスパラメータ(X1〜X6。M8)──
/**
* `export_id`(X1〜X3)。★`parsePathReportId()` と同じ作法★ — 空と上限超だけ 422・文字種は絞らない・`Number()` を通さない。
*/
export function parsePathExportId(value: string): QueryParseResult<string> {
if (value.length === 0 || value.length > PATH_EXPORT_ID_MAX_LENGTH) {
return fail(422, "validation_failed", "export_idの形式が不正です", {
export_id: [`${PATH_EXPORT_ID_MAX_LENGTH}文字以内で指定してください`],
});
}
return { ok: true, query: value };
}
/** `command_id`(X1〜X3)。`parsePathExportId()` と同じ作法 */
export function parsePathCommandId(value: string): QueryParseResult<string> {
if (value.length === 0 || value.length > PATH_COMMAND_ID_MAX_LENGTH) {
return fail(422, "validation_failed", "command_idの形式が不正です", {
command_id: [`${PATH_COMMAND_ID_MAX_LENGTH}文字以内で指定してください`],
});
}
return { ok: true, query: value };
}
/**
* チャンクの `seq`(X4〜X6)。★0 を許す★ので `parsePathId()`(1以上)は使わない。
* 9桁まで(4MB × 10億 は 100MB の上限より十分大きい)。
*/
export function parsePathSeq(value: string): QueryParseResult<number> {
if (!SEQ.test(value)) {
return fail(422, "validation_failed", "seqの形式が不正です", {
seq: ["0以上の整数(9桁まで・先頭の0なし)で指定してください"],
});
}
return { ok: true, query: Number(value) };
}
// ── 制御盤から来た本体(段階7-2。形の誤りはすべて 400 result_invalid)──
/** 一覧の `types[].key`: 1文字以上・`,` を含まない・前後に空白が無い(指示書BJ J5) */
function isLogTypeKey(value: unknown): value is string {
return typeof value === "string" && value.length >= 1 && !value.includes(",") && value.trim() === value;
}
function isLogType(value: unknown): value is DsLogType {
return isJsonObject(value) && isLogTypeKey(value.key) && typeof value.label === "string";
}
function isLogFile(value: unknown, typeKeys: ReadonlySet<string>): value is DsLogFile {
if (!isJsonObject(value)) return false;
return (
typeof value.file_id === "string" &&
isStringOfLength(value.file_id, 1, FILE_ID_MAX_LENGTH) &&
typeof value.name === "string" &&
typeof value.type === "string" &&
typeKeys.has(value.type) &&
isNonNegativeInteger(value.size_bytes) &&
typeof value.modified_at === "string" &&
parseInstant(value.modified_at) !== null
);
}
/**
* 一覧の結果(W1〜W11)。★1件でも形が違えば結果全体を不正とする★(黙って落とさない)。
* ★本体の大きさは読んだ文字列の UTF-8 のバイト数で判定する★(`Content-Length` が無くても。W8)。
* `{ error: { code, message } }` は失敗の報告として受理する(W9。中継側で failCommand と同じ扱い)。
* `name` は `{root の name}/{相対パス}` で、★`/` を理由に弾かない・中身を解析しない★(W11)。
*/
export function parseAgentListResult(text: string): QueryParseResult<DsAgentListResult> {
if (new TextEncoder().encode(text).length > LIST_RESULT_MAX_BYTES) {
return resultInvalid("一覧の結果が大きすぎます");
}
const body = parseJsonObject(text);
if (body === null) return resultInvalid(INVALID_JSON_MESSAGE);
if ("error" in body) {
const error = body.error;
if (
isJsonObject(error) &&
isStringOfLength(error.code, 1, AGENT_CODE_MAX_LENGTH) &&
isStringOfLength(error.message, 1, AGENT_MESSAGE_MAX_LENGTH)
) {
return { ok: true, query: { error: { code: error.code, message: error.message } } };
}
return resultInvalid("失敗の報告の形式が不正です");
}
const { types, items, truncated } = body;
if (!Array.isArray(types) || !types.every(isLogType)) return resultInvalid("typesの形式が不正です");
const typeKeys = new Set(types.map((t) => t.key));
if (typeKeys.size !== types.length) return resultInvalid("typesのkeyが重複しています");
if (typeof truncated !== "boolean") return resultInvalid("truncatedの形式が不正です");
if (!Array.isArray(items) || items.length > LIST_RESULT_MAX_ITEMS) return resultInvalid("itemsの形式が不正です");
if (!items.every((item) => isLogFile(item, typeKeys))) return resultInvalid("itemsの形式が不正です");
if (new Set(items.map((item) => item.file_id)).size !== items.length) {
return resultInvalid("itemsのfile_idが重複しています");
}
return {
ok: true,
query: {
types: types.map((t) => ({ key: t.key, label: t.label })),
items: items.map((item) => ({
file_id: item.file_id,
name: item.name,
type: item.type,
size_bytes: item.size_bytes,
modified_at: item.modified_at,
})),
truncated,
},
};
}
/** prepared の本体(`total_bytes` は1以上の整数・`sha256` は小文字の16進64桁・`file_count` は1以上の整数) */
export function parseAgentPrepared(text: string): QueryParseResult<DsAgentPrepared> {
const body = parseJsonObject(text);
if (body === null) return resultInvalid(INVALID_JSON_MESSAGE);
const { total_bytes, sha256, file_count } = body;
if (
!isNonNegativeInteger(total_bytes) ||
total_bytes < 1 ||
typeof sha256 !== "string" ||
!SHA256_HEX.test(sha256) ||
!isNonNegativeInteger(file_count) ||
file_count < 1
) {
return resultInvalid("preparedの形式が不正です");
}
return { ok: true, query: { total_bytes, sha256, file_count } };
}
/** complete の本体(`total_bytes` は1以上の整数・`sha256` は小文字の16進64桁)。値の照合は中継側(D15) */
export function parseAgentComplete(text: string): QueryParseResult<DsAgentComplete> {
const body = parseJsonObject(text);
if (body === null) return resultInvalid(INVALID_JSON_MESSAGE);
const { total_bytes, sha256 } = body;
if (
!isNonNegativeInteger(total_bytes) ||
total_bytes < 1 ||
typeof sha256 !== "string" ||
!SHA256_HEX.test(sha256)
) {
return resultInvalid("completeの形式が不正です");
}
return { ok: true, query: { total_bytes, sha256 } };
}
/** fail の本体(`code` は1〜64文字・`message` は1〜500文字) */
export function parseAgentFail(text: string): QueryParseResult<DsAgentFail> {
const body = parseJsonObject(text);
if (body === null) return resultInvalid(INVALID_JSON_MESSAGE);
const { code, message } = body;
if (!isStringOfLength(code, 1, AGENT_CODE_MAX_LENGTH) || !isStringOfLength(message, 1, AGENT_MESSAGE_MAX_LENGTH)) {
return resultInvalid("failの形式が不正です");
}
return { ok: true, query: { code, message } };
}