日付: 2026-10-19 / 担当: Cursor(または監督が指名する実装担当)/ 基点: ★指示書AX の実装の完了後の develop の先端★(着手時に git log --oneline -1 で記入: _______) 前提: ★D-179★(docs/decisions.md:12934 以降。★本書の作成と同時に監督がコミットする★)/ ★指示書AX の実装コミット★(担当ファイルが重なる。下記)/ ★監督判断 第2ラウンド(2026-10-19。M1〜M11。§11 に反映済み。ブリーフより優先)★ / 設計書 docs/handoff/設計_制御盤ログの取り出し_20261019.md(★作成済み・本書と突き合わせ済み(2026-10-19)。制御盤の常駐プログラムの仕様はそちら★)/ D-077(XFF の末尾)/ D-101(IP 制限・ALLOW_DIRECT_ACCESS)/ D-123(シリアルの文字種を絞らない)/ D-4(当たり外れは JS の ===)/ D-054(summary と meta.total)/ D-143(DFA が作るのはビューアー。サーバーの構築保守は TeamWorks)/ D-154(セキュリティはリリース直前)/ CLAUDE.md §3・§6・§7・§9.3・§9.5・§9.6
★ユーザー決定(§2-1 の U1〜U5)と監督の設計判断(§2-2 の D1〜D25)は確定仕様。変えないこと★(CLAUDE.md §0-1)。 ★期待値表(§3)は正。変えないこと★(CLAUDE.md §0-2)。★§11 Q1〜Q16 はすべて監督判断(第2ラウンドと追加)で確定し、本文に反映済み★(★Q14・Q15 は本書の案 P1・P2 をそのまま採用。§2-3★)。
★担当ファイルが指示書AX と重なる★: components/features/ds-logs/(AX 段階7 が DsLogScreen.module.css:83 のコメントを直す)・d-watch-api.yaml・lib/i18n.ts・lib/types/index.ts・lib/fixtures/index.ts。 ★AX の実装コミットが develop に入ってから着手する★。
★行番号は本書の作成時(2026-10-19)の作業ツリーで確かめたもの。develop の先端は AX のコミットで動いている(作成中にも動いた)★。 ★着手時に develop の先端で、本書に書いた行番号をすべて確かめ直すこと★(段階0-4)。ずれていたら関数名・文で探し直す。
★docs/decisions.md に足すのは「D-179 の末尾への『実装の記録』」だけ★(§13 の下書き)。★新しい D 番号は取らない★(★D-180 は指示書AX が予約済み★)。
| # | やること | 段階 |
|---|---|---|
| 1 | ★API 契約を先に書く★: ブラウザ向け ★6本★(一覧・エクスポートの準備・ダウンロード・トークンの状態/発行/失効)と制御盤向け ★6本★(指示の取得・一覧の結果・★準備完了(prepared)★・チャンク・完了・失敗)を d-watch-api.yaml に |
段階2 |
| 2 | 設定値(lib/config/ds-agent.ts)と純粋関数(lib/ds-logs/query.ts・presets.ts・file-name.ts・validate.ts) |
段階3 |
| 3 | ★制御盤トークン★(lib/ds-agent/tokens.ts)。★ハッシュだけを保存★。★発行・失効は D-Watch の API と画面から★(スクリプトは作らない。D16) |
段階4・7・9 |
| 4 | ★認証★: /api/ds-agent/ 配下は ★Bearer だけ★、それ以外は ★cookie だけ★(lib/proxy/authenticate.ts・proxy.ts) |
段階5 |
| 5 | ★中継(relay)★: メモリ内の待ち行列・ロングポーリング・一覧の待ち合わせ・★エクスポートの「準備 → 送信」の2段★・ストリーム(lib/ds-agent/relay.ts)。★時刻とタイマーを注入できる形★ |
段階6 |
| 6 | ルート12本(app/api/ds-devices/[serial_number]/logs/**・…/agent-token・app/api/ds-agent/**) |
段階7 |
| 7 | fixtures の偽制御盤(lib/fixtures/ds-device-logs.ts・lib/ds-agent/fixture-agent.ts)。★relay の同じ経路を通す★ |
段階8 |
| 8 | 画面: D-Sec ログの表の各行に「ログ」ボタン → ★モーダル★(種類・期間・並べ替え・一覧・選択・エクスポート・★制御盤トークン★) | 段階9 |
| 9 | ログのイベント(lib/logger/events.ts) |
段階10 |
| 10 | 画面確認(fixtures)と、★偽制御盤スクリプトによる非 fixtures の通し確認★ | 段階11 |
★変えないもの★: D-Sec ログの一覧(GET /ds-devices・dsDevices())と備考(PATCH /ds-devices/{serial_number}/note)の挙動/ 既存の cookie 認証・IP 制限の判定(authenticate() の今の分岐)/lib/settings/json-store.ts の中身/prisma//メインDB(★本機能は DB に書かない。読むのは 404 の判定の dsDeviceExists() だけ★)。
cat で見ている。★Pi Connect にはファイル転送・ポート転送・コマンド実行の公開 API が無い★(D-179 追記 項1。出典は D-179)。→ Pi Connect だけでは要望を満たせない。★Pi Connect は常駐プログラムの導入・更新に使う★。node_modules)):
package.json:18)。middleware は proxy に改名(proxy.ts:1-21)。★proxy は Node ランタイム固定★。node_modules/next/dist/docs/01-app/03-api-reference/05-config/01-next-config-js/proxyClientMaxBodySize.md:7-9・:71-76。 本体を最後まで受け取ってからハンドラが動く — node_modules/next/dist/server/next-server.js:1197-1217(cloneBodyStream() → finalize())・node_modules/next/dist/server/body-streams.js:52-118(bytesRead > bodySizeLimit で console.warn と push(null))。 → ★proxy を通るパスで 100MB を1本の POST で流すことはできない★。proxy.ts の matcher は静的ファイル以外の全パス(proxy.ts:112-114。/api を含む)。★「matcher で除外しない。関数の中で分岐する」方針★(proxy.ts:106-107)。lib/proxy/authenticate.ts(★判定するだけ・同期・deps 引数でテスト可能★。:59-94)。順序は ★IP 制限(403)→ 認証(401 / redirect)★。公開パスは /login と /api/auth/login だけ(:25)。AUTH_TOKEN)を cookie dwatch_session に入れる方式。比較は verifyToken()(lib/auth/token.ts:22-41。timingSafeEqual)。lib/config/allowed-ips.ts(ALLOWED_IPS 空 :24-26・ALLOW_ALL_WHEN_EMPTY = true :35・TRUSTED_PROXY_HOPS = 1 :63)。★このファイルは proxy のバンドルに judge 系を引き込まないため lib/config/index.ts から独立させてある★(:4-8)。npm run build → npm run start(docs/deploy.md §2)。Docker の可能性あり(同 §1)。★PM2 / cluster の設定は無い → 1プロセス★。前段に nginx(XFF を追記。D-077。★既定の proxy_read_timeout は60秒★)。nginx の設定は TeamWorks 管理でリポジトリに無い。createJsonStore()(lib/settings/json-store.ts:39。.tmp → rename、壊れたら .bak。★read() は非同期★ :45)。前例 lib/settings/ds-device-notes.ts(data/settings/…。/data は .gitignore:50)。logEvent(level, payload)(lib/logger/index.ts:112)。イベントは lib/logger/events.ts の型付き union(:456-471)に足す。★キー名に token / authorization / facility_name / note 等を使うと本番で消される★(lib/logger/redact.ts:12-46)。components/features/ds-logs/DsLogScreen.tsx(表の <colgroup> :437-443・見出し :445-453・日時の整形 formatDateTime() :41)。★モーダルの共通部品は無い★。前例は components/features/detail-log/AreaModal.tsx:65(role="dialog" aria-modal="true"・自前の overlay)。ページ送り components/ui/Pagination.tsx(total / page / perPage / onPageChange。★per_page の選択は持たない★)。日付は <input type="date"> + openDatePicker()(components/ui/date-picker.ts:11)。/ds-devices(d-watch-api.yaml:1376)・/ds-devices/{serial_number}/note(:1413。path の maxLength: 100・文字種は絞らない)。パスの検証は parsePathSerialNumber()(lib/api/path-params.ts:70-79。★422 validation_failed★)。実在は db.dsDeviceExists()(lib/db/index.ts:370。fixtures :680・Prisma :1944)。vitest.config.ts。★include は lib/** と app/** だけ★ — components/ は走らない)。ルートのテストは Prisma 経路と fixtures 経路の2ファイルに分ける流儀(app/api/ds-devices/[serial_number]/note/__tests__/route.test.ts / route.fixtures.test.ts。ストアは fs.mkdtemp + __setDeviceNotesStoreForTesting())。| # | 決定 | 帰結 |
|---|---|---|
| U1 | ★「アップロードしたくない」=サーバーに保存しなければ、通過はよい★ | ★D-Watch はチャンクをメモリで受けて、待っているブラウザの応答へ流すだけ★。ディスク・DB・ストアに書かない(D3) |
| U2 | ★制御盤のソフトは DFA が作っている。言語は Python★ | 常駐プログラムは本書の範囲外(§10)。設計書が正 |
| U3 | ★ログを取り出すのは DFA の社内だけ★(顧客・現場には見せない) | 既存の cookie 認証・IP 制限の内側に置く。facilities.contract による出し分けはしない |
| U4 | ★A案(自前の中継)をすぐ進める★(D を先に作る案は採らない) | 本書 |
| U5 | ★ログのフォルダ・ファイル名の規則は後日★ | 制御盤の設定ファイルで与える。★D-Watch は「種類(key / label)」を制御盤から受け取るだけ★で、種類の値を持たない・直書きしない |
| # | 決定 | 理由 |
|---|---|---|
| D1 | ★ファイルは制御盤が zip にし、CHUNK_MAX_BYTES(4MB)以下のチャンクに分けて送る。チャンクごとに SHA-256(X-Chunk-Sha256)を付け、D-Watch は長さとハッシュを検証する。最後に complete で合計の長さと全体の SHA-256 を照合する★ |
★proxy の 10MB バッファ(超過分は切り捨て・クライアントにはエラーにならない)を避け、切り捨てを検知する★(§1) |
| D2 | ★接続はすべて制御盤から開く。ロングポーリング★(GET /api/ds-agent/commands?wait=25) |
店舗のネットワークに入口を作らない。★Next.js のルートハンドラは WebSocket のサーバーになれない★ |
| D3 | ★サーバーのディスクに書かない★(一時ファイル・os.tmpdir() も禁止)。チャンクはメモリで受けてブラウザの応答へ流す |
U1 |
| D4 | ★relay はモジュールのシングルトン。globalThis に置く(開発時の HMR で二重にしない)★。★1プロセス前提★(next start を複数立てる・cluster にすると壊れる。再起動で進行中のエクスポートは失われる) |
前例 lib/db/prisma.ts:10-22・:76-84(globalThis.__dwatchPrisma) |
| D5 | ★/api/ds-agent/ 配下は cookie では通さない。Authorization: Bearer を lib/ds-agent/tokens.ts で検証する★。★それ以外のパスで Bearer を出されても無視する(cookie が要る)★。★ルートでも requireAgent(request) で再検証して serial を得る★(★ヘッダで serial を渡さない★) |
D-179 追記 項4-4・項5 |
| D6 | ★IP 制限は今のまま全パスにかける(agent パスも)★。★リリース時に許可リストを入れると制御盤が弾かれる★ → 店舗の IP を載せるか、agent パスだけ IP 制限を外すかは ★リリース時に決める(D-154)★。★本書では決めない★ | D-179 追記 項4-5 |
| D7 | ★制御盤トークンは制御盤ごと。32バイトの乱数を base64url(43文字)★。保存は DFA 独自ストア data/settings/ds-agent-tokens.json(createJsonStore)に { [serial_number]: { token_sha256, issued_at } }。★生の値は保存しない・ログに出さない★。★メインDBには書かない★ |
D-179 追記 項4-4 |
| D8 | ★一覧は制御盤からもらい、絞り込み(種類・期間)・並べ替え・ページ分けは D-Watch 側の純粋関数で行う★(lib/ds-logs/query.ts) |
期待値表でテストできる場所に置く |
| D9 | ★設定値は lib/config/ds-agent.ts(新規・独立ファイル)★。値は 段階3-1 の表。★ハードコード禁止★(CLAUDE.md §7) |
lib/config/index.ts に足すと proxy のバンドルに judge 系を引き込む(allowed-ips.ts:4-8 と同じ理由) |
| D10 | ★fixtures の偽制御盤は relay の同じ経路(待ち行列 → 結果 → 準備完了 → チャンク → 完了)を通す★。fixtures の制御盤のうち1台(★DS-001★)は未接続にして 409 を出す |
CLAUDE.md §9.5 |
| D11 | ★画面は D-Sec ログの表の各行の「ログ」ボタン → モーダル★(AreaModal.tsx の作りに倣う)。★モックに無い画面なので、文言・配置は本書 段階9 を正とする★ |
ユーザー要望(PUDU の画面) |
| D12 | ★ログに出すのはファイル数とバイト数だけ★。★ファイルの中身・ファイル名の一覧全体・トークンは出さない★ | 段階10 |
| D13 | ★却下★: 1本の POST で 100MB を流しそのパスだけ matcher から外す(proxy.ts:106-107 に反する)/experimental.proxyClientMaxBodySize を上げる(全量をメモリに溜めてからハンドラが動く)/別プロセスの中継(nginx の振り分けを TeamWorks に頼む・認証が二重)/WebSocket(ルートハンドラはなれない) |
D-179 追記 項4-2 |
| D14 | ★エクスポートは「準備 → 送信」の2段★(M1)。 ① POST …/logs/exports は 404 / 409 / 429 / 422 を判定 → 制御盤に kind: "export"(params { file_ids, max_bytes, chunk_bytes })→ ★制御盤が zip を作り終えて POST /api/ds-agent/commands/{command_id}/prepared { total_bytes, sha256, file_count } を送るまで待つ(EXPORT_PREPARE_TIMEOUT_SEC = 50)★ → ★201★ { export_id, download_url, total_bytes, file_count, expires_in_sec }。★準備中の失敗(too_large / file_not_allowed / file_not_found / io_error 等)はこの POST の 502 agent_error(details.agent_code)★、50秒で来なければ 504 agent_timeout。② GET …/logs/exports/{export_id}(★1回だけ★)は制御盤に kind: "send"(params { export_command_id })→ 最初のチャンクを EXPORT_FIRST_CHUNK_TIMEOUT_SEC(30)待つ(来なければ 504 JSON)→ ★200 で流す。Content-Length: total_bytes を付ける★。流し始めた後の失敗は controller.error() |
★準備の失敗を画面の JSON で返せる★。★長い待ちを nginx の60秒より短くする★。ブラウザに進捗が出る |
| D15 | ★上限の判定は zip のバイト数に一本化★(M1)。D-Watch は ★prepared の total_bytes ≤ EXPORT_MAX_BYTES★ を確かめ(超えたら 502 agent_error・agent_code too_large)、★流れたバイト数が total_bytes と一致し、SHA-256 が prepared の sha256 と一致すること★を complete で確かめる |
制御盤は元ファイルの合計で早く断り、zip の大きさでも断る(設計書) |
| D16 | ★トークンの発行・失効は D-Watch の API と画面から★(M8): POST /api/ds-devices/{serial_number}/agent-token → 201 { token, issued_at }(★生の値はこの応答で1回だけ。既にあれば作り直し、古いものは無効★)/DELETE …/agent-token → 204(★未発行でも 204。§2-3 P2★)/★発行日時の読み取りは GET …/agent-token → 200 { issued_at }(§2-3 P1)★。★既存の cookie 認証★。★scripts/ds-agent-token.ts は作らない★ |
★本番のサーバーは TeamWorks が構築保守する(D-143)ので、サーバー上でスクリプトを動かす前提を置かない★ |
| D17 | ★検証エラーは 422 validation_failed★(既存の流儀。M2。ブリーフの 400 validation_error は誤り)。★制御盤向けの形の誤りは 400 result_invalid / chunk_invalid★ |
§11 Q1・Q10 |
| D18 | ★一覧の上限と truncated★(M3): 制御盤は ★更新日時の新しい順に並べてから★ 2万件または JSON 4MB のどちらかに達したら打ち切り truncated: true。LIST_RESULT_MAX_BYTES = 4MB。★ブラウザ向けの一覧の応答に truncated を足し、画面は「件数が多いため、新しいものから一部だけを表示しています」★ |
§11 Q3 |
| D19 | ★認証の通った制御盤の ★すべての★ リクエスト(commands・result・prepared・chunks・complete・fail)で last_seen_at を更新する★(M4) |
エクスポートの最中(ポーリングが途切れても)未接続にしない |
| D20 | ★EXPORT_IDLE_TIMEOUT_SEC = 45★(M5)。★チャンクの POST に 204 を返すまでの待ち(流量制御)も最大45秒。超えたら 410 export_cancelled にしてエクスポートを失敗にする★ |
nginx の60秒より短く。制御盤のチャンクの POST のタイムアウトは60秒(設計書) |
| D21 | ★ダウンロードは <a href download> を作って click()★(M1)。★location.href は使わない★。★GET が JSON のエラーになるのは制御盤の切断などまれな場合だけで、そのときは JSON がファイルとして保存されうる(既知の制約)★ |
準備の失敗は POST で返る(D14) |
| D22 | ★proxy を async にし、agent パスの判定を authenticateAgent()(新規・async)に分ける★。authenticate() は agent パスなら cookie があっても unauthenticated。★トークンのストアは mtime を見てメモリにキャッシュしてよい★ |
§11 Q8 |
| D23 | ★本書の暫定の採用(M10)★: 期間ボタンの to は省略(Q4)/未知の種類キーは0件(Q5)/zip のファイル名のシリアルは [A-Za-z0-9._-] 以外を _・filename* も付ける(Q6)/順番違いのチャンクはエクスポートも失敗(Q9)/待ち行列が満杯なら 429 agent_busy・410 command_expired・400 result_invalid(Q10)/偽制御盤スクリプト scripts/ds-agent-fake.ts をコミット(Q11)/同じ制御盤の2本目のポーリングは古い方を 204(Q12)/期間の既定は直近24時間・種類や期間を変えたら選択を消す(Q13)/404 の判定は db.dsDeviceExists()(DB は読むだけ) |
§11 |
| D24 | ★zip の中のパスは {root の name}/{root からの相対パス}(制御盤側。M6)★。D-Watch は zip の中身を見ない。★偽制御盤スクリプトと fixtures の zip は root の名前つきで作る★ |
本物と同じ形で画面確認する |
| D25 | ★追加の監督判断(2026-10-19)★: ① ★chunks / complete / 送信中の fail の URL の command_id は ★export の指示の id★★。 send の指示は自分の command_id を持ち、params.export_command_id で export を指す(★send の id には何も送られてこない★)② ★ send が来たのに制御盤に zip が無い → 制御盤は fail { code: "export_not_found" }★。D-Watch は最初のチャンクの前なら GET を 502 agent_error、流し始めた後ならストリームを error③ ★list は export と並行して処理される(制御盤側)★。★D-Watch はエクスポートの進行中でも list の指示を待ち行列に入れてよい★(エクスポートの進行中を理由に list を断らない) ④ ★制御盤から来た JSON(result / prepared / complete / fail)の形の誤りは、すべて 400 result_invalid★。チャンクは 400 chunk_invalid⑤ ★prepared から send・complete・fail・失効までを「進行中」に数える★(1台1本の上限=429 export_busy の判定に入る。準備中も数える)。★prepared の後に GET が EXPORT_CLAIM_TIMEOUT_SEC(60秒)来なければ D-Watch はエクスポートを失効させ、進行中から外す★(制御盤は120秒で zip を消す) |
§11 Q16。設計書も同じ内容で直す |
| # | 決定 | §11 | |
|---|---|---|---|
| P1 | ★トークンの状態を読む GET /api/ds-devices/{serial_number}/agent-token を足す★(`{ issued_at: string \ |
null }`。★トークン・ハッシュは返さない★) | Q14 |
| P2 | ★未発行の DELETE …/agent-token も 204★(冪等) |
Q15 |
★表1〜表6 はブリーフ §5 の柱と第2ラウンドの監督判断から本書で具体化したもの★(★表1・表2・表4・表6 と表3 の関数の行は第1ラウンド、表5 と表3 の API の行は第2ラウンドで作り直した★)。 ★表3 KA4・KA7 は本書の案(P1・P2)が §11 Q14・Q15 で採用されて確定した行★。
lib/ds-logs/query.ts の queryDeviceLogs()。入力の8件)types(制御盤から来た順)= [{ key: "app", label: "アプリ" }, { key: "comm", label: "通信" }, { key: "sys", label: "システム" }](★ダミー。本物の値はユーザーが後日教える★)
| file_id | name | type | size_bytes | modified_at(制御盤から来た値) | 備考 |
|---|---|---|---|---|---|
f1 |
app_20261019_1100.log |
app | 1000 | 2026-10-19T11:00:00+09:00 |
f3 と同時刻 |
f2 |
app_20261019_0900.log |
app | 2000 | 2026-10-19T09:00:00+09:00 |
|
f3 |
comm_20261019_1100.log |
comm | 3000 | 2026-10-19T11:00:00+09:00 |
f1 と同時刻 |
f4 |
comm_20261018_1200.log |
comm | 4000 | 2026-10-18T12:00:00+09:00 |
★ちょうど 24時間前★ |
f5 |
sys_20261019_1130.log |
sys | 5000 | 2026-10-19T11:30:00+09:00 |
|
f6 |
sys_20261018_1159.log |
sys | 6000 | 2026-10-18T11:59:59+09:00 |
24時間前の1秒前 |
f7 |
app_20261019_0200.log |
app | 7000 | ★2026-10-18T17:00:00Z★ |
★= 10/19 02:00 JST。オフセット違い★ |
f8 |
app_20261019_1200.log |
app | 8000 | 2026-10-19T12:00:00+09:00 |
★ちょうど now★ |
| # | 入力 | 期待(file_id の並び) | total | 根拠 |
|---|---|---|---|---|
| Q1 | 指定なし(sort=date_desc) |
f8, f5, f1, f3, f2, f7, f4, f6 | 8 | 新しい順・★同時刻は name 昇順★(app_… < comm_…) |
| Q2 | types=app |
f8, f1, f2, f7 | 4 | |
| Q3 | types=app,sys |
f8, f5, f1, f2, f7, f6 | 6 | |
| Q4 | types=xyz |
(空) | 0 | D23(200・0件) |
| Q5 | types=app,xyz |
f8, f1, f2, f7 | 4 | D23 |
| Q6 | from=2026-10-19T11:00:00+09:00 |
f8, f5, f1, f3 | 4 | ★ちょうど from は入る★ |
| Q7 | to=2026-10-19T11:00:00+09:00 |
f2, f7, f4, f6 | 4 | ★ちょうど to は入らない★ |
| Q8 | from=2026-10-18T12:00:00+09:00&to=2026-10-19T12:00:00+09:00 |
f5, f1, f3, f2, f7, f4 | 6 | f4 は入る・f8 は入らない・f6 は前 |
| Q9 | from=2026-10-19T02:00:00+09:00&to=2026-10-19T02:00:01+09:00 |
f7 | 1 | ★比較は瞬間(Date.parse)。文字列で比べない★ |
| Q10 | from=2026-10-19T02:30:00Z |
f8, f5 | 2 | from 側の Z も瞬間で |
| Q11 | sort=type_date_desc |
f8, f1, f2, f7, f3, f4, f5, f6 | 8 | ★types 配列の順 → 新しい順 → name 昇順★ |
| Q12 | sort=type_date_desc&types=comm,app |
f8, f1, f2, f7, f3, f4 | 6 | ★クエリの順ではなく制御盤の types の順★ |
| Q13 | from=2026-10-18T12:00:00+09:00(表2 の「直近24時間」) |
f8, f5, f1, f3, f2, f7, f4 | 7 | f6 は入らない |
| Q14 | Q1 を perPage=3, page=1 |
f8, f5, f1 | 8 | ★純粋関数には任意の perPage を渡せる★(ルートの 50/100/200 は検証側) |
| Q15 | Q1 を perPage=3, page=3 |
f4, f6 | 8 | 最終ページの端数 |
| Q16 | Q1 を perPage=3, page=4 |
(空) | ★8★ | ★範囲外のページは空配列、total は正しい★ |
| Q17 | 出力の modified_at |
f7 は ★2026-10-19T02:00:00+09:00★、他は入力どおり |
— | ★出力は toJstIso()(lib/format/date.ts:37)で JST に揃える★(CLAUDE.md §9.3) |
lib/ds-logs/presets.ts。now = 2026-10-19T12:00:00+09:00)| # | 入力 | from | to | 根拠 |
|---|---|---|---|---|
| T1 | 1h(直近1時間) |
2026-10-19T11:00:00+09:00 |
★なし★ | D23(制御盤の時計が進んでいても最新を外さない) |
| T2 | 3h(直近3時間) |
2026-10-19T09:00:00+09:00 |
なし | D23 |
| T3 | 24h(直近24時間) |
2026-10-18T12:00:00+09:00 |
なし | D23 |
| T4 | all(全て) |
なし | なし | |
| T5 | 日付の範囲 2026-10-18〜2026-10-19 |
2026-10-18T00:00:00+09:00 |
★2026-10-20T00:00:00+09:00★ |
★終了日を含む★ |
| T6 | 2026-10-31〜2026-10-31 |
2026-10-31T00:00:00+09:00 |
2026-11-01T00:00:00+09:00 |
月またぎ |
| T7 | 開始だけ 2026-10-19 |
2026-10-19T00:00:00+09:00 |
なし | |
| T8 | 終了だけ 2026-10-18 |
なし | 2026-10-19T00:00:00+09:00 |
|
| T9 | 2026-10-19〜2026-10-18(逆) |
★null(不正)★ |
— | 画面が「開始日は終了日以前にしてください」を出し、取得しない |
| T10 | now を UTC で渡す(2026-10-19T03:00:00Z)・1h |
2026-10-19T11:00:00+09:00 |
なし | ★実行環境のタイムゾーンに依存しない★ |
lib/ds-agent/tokens.ts、API は app/api/ds-devices/[serial_number]/agent-token/route.ts。ストアは mkdtemp)| # | 入力 | 期待 | 根拠 |
|---|---|---|---|
| K1 | issueAgentToken("DS-002") |
戻り値の token が /^[A-Za-z0-9_-]{43}$/・issued_at が +09:00 の ISO。ストアに DS-002: { token_sha256: 64桁の16進, issued_at }。★ストアのファイルの中身に生のトークンの文字列が無い★ |
D7 |
| K2 | verifyAgentAuthorization("Bearer " + K1 の値) |
"DS-002" |
|
| K3 | "Bearer " + 形は正しいが違う43文字 |
null |
|
| K4 | null / "" |
null |
|
| K5 | K1 の値だけ(Bearer なし) |
null |
|
| K6 | "bearer " + 値(小文字)/"Bearer " + 値(空白2つ)/末尾に空白 |
null(★厳密に Bearer +空白1つ★) |
D22 |
| K7 | ストアが空(未登録) | null |
|
| K8 | revokeAgentToken("DS-002") の後に K2 |
null |
|
| K9 | DS-002 を再発行 |
★古い値は null、新しい値は "DS-002"★ |
D16 |
| K10 | DS-002 と DS-003 を発行 |
それぞれの値がそれぞれのシリアルを返す | |
| K11 | ストアの1件の token_sha256 が壊れている(64桁の16進でない) |
★その1件だけ無視し例外にしない★。他の制御盤は通る | |
| K12 | 検証した後に ★別のストアのインスタンスから★ 失効させる(ファイルの mtime が変わる) | 次の検証は null(★キャッシュは mtime で無効になる★) |
D22 |
| KA1 | POST /api/ds-devices/DS-002/agent-token(cookie) |
★201★ { token, issued_at }。token は K1 の形。★ストアには SHA-256 だけ★ |
D16 |
| KA2 | もう一度 POST | 201・新しい値。★1回目の値で verifyAgentAuthorization が null★ |
D16 |
| KA3 | DELETE /api/ds-devices/DS-002/agent-token |
★204★。以後 K2 は null |
D16 |
| KA4 | 未発行の DELETE |
★204★(冪等) | ★P2(Q15 採用)★ |
| KA5 | POST /api/ds-devices/DS-999/agent-token(制御盤に無い) |
★404 not_found★。ストアは変わらない |
D23 |
| KA6 | パスのシリアルが101文字 | ★422 validation_failed★ |
D17 |
| KA7 | GET /api/ds-devices/DS-002/agent-token |
発行後 { issued_at: "…+09:00" }/未発行 { issued_at: null }。★応答に token / token_sha256 が無い★ |
★P1(Q14 採用)★ |
| KA8 | KA1〜KA3 のログ | ds_agent.token_issued / ds_agent.token_revoked が ★serial_number だけ★ で1行ずつ。★値に K1 の文字列が無い★ |
段階10 |
lib/proxy/authenticate.ts。deps で IP・AUTH_TOKEN・トークン検証を差し替える)| # | パス | IP | cookie | Authorization | 期待 |
|---|---|---|---|---|---|
| A1 | /api/ds-agent/commands |
許可 | なし | 正しい Bearer | ★allow★ |
| A2 | 同上 | 許可 | ★正しい cookie★ | なし | ★unauthenticated(401)★ |
| A3 | 同上 | 許可 | なし | 違う Bearer | unauthenticated |
| A4 | 同上 | 許可 | 正しい cookie | 違う Bearer | unauthenticated |
| A5 | /api/ds-devices |
許可 | なし | ★正しい Bearer★ | ★unauthenticated(Bearer を無視)★ |
| A6 | /api/ds-devices |
許可 | 正しい cookie | 正しい Bearer | allow(Bearer は無視) |
| A7 | /api/ds-agent/commands |
★拒否★ | なし | 正しい Bearer | ★deny_ip(403)★。★トークン検証を呼ばない★(スパイで0回) |
| A8 | /api/ds-agents(似た名前) |
許可 | 正しい cookie | なし | allow(★agent パスではない★。前方一致の誤り) |
| A9 | /ds-logs(画面) |
許可 | なし | 正しい Bearer | unauthenticated(redirect) |
| A10 | /api/ds-agent / /api/ds-agent/ |
許可 | なし | 正しい Bearer | allow(agent パス。ルートが無いので後段で 404) |
| A11 | /api/ds-devices/DS-002/agent-token |
許可 | なし | ★正しい Bearer★ | ★unauthenticated★(★制御盤のトークンでトークンを作り直せない★) |
lib/ds-agent/relay.ts。★時刻とタイマーを注入★。t は秒。制限値はテストで小さく差し替えてよい)★エクスポートの状態★: 準備中(POST が待っている)→ 準備完了(201 を返した。受け取り待ち)→ 送信待ち(GET。send を入れた)→ 流れ中 → 完了 / 失敗 / 取り消し / 失効。★準備中〜流れ中を「進行中」と数える★。
| # | 操作 | 期待(relay の結果 → ルートの HTTP) | 根拠 |
|---|---|---|---|
| R1 | t=0 制御盤が poll(DS-002, wait=25)。t=1 ブラウザが requestList(DS-002) |
poll が ★即座に★ { command_id, kind: "list", params: {} }(→ 200)。t=2 submitListResult → ブラウザに一覧(→ 200) |
|
| R2 | R1 で結果が来ない | ★t=16(依頼から15秒)で timeout(→ 504 agent_timeout)★。t=15.999 ではまだ待っている |
|
| R3 | R2 の後 t=17 に submitListResult |
gone(→ 410 command_expired) |
D23 |
| R4 | poll が t=0 に始まり t=25 に指示なしで返る(→ 204)。以後リクエストなし | ★t=85 は接続中(ちょうど60秒)★、★t=85.001 は未接続(→ 409 agent_offline)★。last_seen_at は t=25 |
|
| R5 | 一度もリクエストしていない | 未接続。last_seen_at: null・agent_version: null |
|
| R6 | poll 中(待っている間) | 接続中(last_seen_at が60秒より前でも) |
|
| R7 | ★poll が t=25 に終わり、以後ポーリングは無いが t=80 にチャンクの POST が来た★ | ★last_seen_at は t=80★。t=140 は接続中、t=140.001 は未接続。★result・prepared・chunks・complete・fail のどれでも同じ★ |
★D19(M4)★ |
| R8 | poll の wait が 0 / 26 / abc |
★422 validation_failed★ |
D17 |
| R9 | 同じ制御盤の2本目の poll(1本目が待っている) | ★1本目が即座に null(204)★、2本目が待つ |
D23 |
| R10 | ★準備★: t=0 createExport(DS-002, [f1, f2]) |
制御盤の poll に { kind: "export", params: { file_ids: [f1, f2], max_bytes: EXPORT_MAX_BYTES, chunk_bytes: CHUNK_MAX_BYTES } }。t=10 prepared { total_bytes: 9437184, sha256, file_count: 2 } → ★createExport が prepared(→ 201 { export_id, download_url, total_bytes: 9437184, file_count: 2, expires_in_sec: 60 })★ |
D14 |
| R11 | 準備で prepared が来ない | ★t=50 はまだ待っている、t=50.001 で timeout(→ 504 agent_timeout)★。その後の prepared は gone(→ 410) |
D14 |
| R12 | 準備中に制御盤が fail { code: "too_large" }(file_not_allowed / file_not_found / io_error も同じ) |
★createExport が agent_error(→ 502。details.agent_code = ["too_large"])★ |
D14 |
| R13 | prepared の total_bytes が ★ちょうど EXPORT_MAX_BYTES★ / ★EXPORT_MAX_BYTES + 1★ |
201 / ★502 agent_error(agent_code too_large)★。後者はエクスポートを失敗にする(prepared の応答は 204 のまま。後の send は来ない) |
D15 |
| R14 | prepared(201)から ★t=+60 に claim★ / ★t=+60.001 に claim★ | 受理 / not_found(→ 404 export_not_found。失効) |
|
| R15 | ★同じ export_id で2回目の claim★ | not_found(→ 404) |
|
| R16 | パスのシリアルが違う claim(DS-003 の URL で DS-002 の export_id) | not_found(→ 404) |
|
| R17 | ★送信★: claim → 制御盤の poll に { kind: "send", params: { export_command_id } } → seq 0(4MB)→ seq 1(4MB)→ seq 2(1MB)→ complete { total_bytes: 9437184, sha256 } |
claim が seq 0 の到着で ★ストリーム(→ 200・Content-Length: 9437184)★。読んだバイト列が seq 0〜2 と一致。ストリームが閉じる |
D14・★D25 ①(チャンク・complete は export の指示の command_id へ。send の command_id へ送ると 404)★ |
| R18 | claim から ★t=+30 に seq 0★ / ★来ないまま t=+30.001★ | 受理 / ★timeout(→ 504 agent_timeout。まだ JSON で返せる)★。その後のチャンクは 410 |
D14 |
| R19 | send の後・最初のチャンクの前に制御盤が fail(★export_not_found = 制御盤に zip が無い★ を含む) |
claim が agent_error(→ 502。agent_code は制御盤の code) |
D14・★D25 ②★ |
| R20 | ★prepared の後・send の前★にチャンク |
★409 chunk_out_of_order★ |
|
| R21 | seq 0 の後に ★seq 2★ | 409 chunk_out_of_order。★ストリームを error で閉じる★ |
D23 |
| R22 | ★ハッシュ不一致★(流し始めた後) | 400 chunk_invalid。ストリームを error |
D1 |
| R23 | ★最初のチャンクのハッシュ不一致★(まだ応答していない) | チャンクは 400。★claim は agent_error(→ 502。agent_code chunk_invalid)★ |
D1 |
| R24 | チャンクの長さ ★ちょうど CHUNK_MAX_BYTES★ / ★CHUNK_MAX_BYTES + 1★ / ★0バイト★ |
受理 / 400 / 400 | D1 |
| R25 | ★受け取った合計が prepared の total_bytes を超えた★ |
そのチャンクは 400 chunk_invalid。ストリームを error |
★D15★ |
| R26 | complete の total_bytes が prepared と違う/★受け取った合計が total_bytes に足りない★ |
400。ストリームを error | D15 |
| R27 | ★complete の sha256 が prepared の sha256 と違う★/★受け取ったバイト列の SHA-256 が prepared の sha256 と違う★ |
400。ストリームを error | ★D15★ |
| R28 | 流し始めた後に制御盤が fail(export_not_found 等。★URL は export の指示の command_id★) |
ストリームを error。fail は 204 |
★D25 ①②★ |
| R29 | 流し始めた後にブラウザが切断(cancel / request.signal の abort) |
★次のチャンクは cancelled(→ 410 export_cancelled)★ |
|
| R30 | ★流量制御★: seq 0 を受けたがブラウザが読んでいない | ★seq 0 の pushChunk は保留(204 を返さない)★。reader.read() で解決する。★relay が持つチャンクは常に2つ以下★ |
|
| R31 | ★R30 の保留が ★t=+45★ / ★t=+45.001★ まで続く | まだ保留 / ★410 export_cancelled・エクスポートは失敗(ストリームを error)★ |
★D20(M5)★ |
| R32 | 流し始めた後、★次のチャンクが来ないまま t=+45 / t=+45.001★ | 流れたまま / ★ストリームを error★。その後のチャンクは 410 | ★D20★ |
| R33 | 同じ制御盤で進行中(準備中・★prepared の後(受け取り待ち)★・送信待ち・流れ中のどれか)に2本目の createExport |
busy(→ 429 export_busy)。1本目が完了・失敗・失効した後は受理 |
★D25 ⑤★ |
| R34 | 3台で進行中に4台目の createExport |
busy(→ 429) |
|
| R35 | DS-003 のトークンで DS-002 の command_id に result・prepared・チャンクを送る | not_found(→ 404)。★403 にしない★ |
|
| R36 | ★list の command_id に prepared★ を送る |
not_found(→ 404) |
|
| R37 | 待ち行列に PENDING_COMMANDS_PER_DEVICE(5)件あるときの requestList / createExport |
busy(→ 429 agent_busy) |
D23 |
| R38 | 一覧の結果に truncated: true |
そのままブラウザへ | ★D18★ |
| R39 | 状態の変化 | 未接続 → 接続中で ds_agent.connected を1回、接続中 → 未接続で ds_agent.disconnected を1回。★リクエストのたびには出さない★ |
|
| R40 | ★t=0 prepared(201)。GET が来ない★ → t=60 / ★t=60.001★ | 進行中のまま(t=60 に createExport すると 429) / ★失効★: 進行中から外れ、★同じ制御盤の createExport が受理される★。claim は 404 export_not_found。その後に制御盤が送る fail / チャンクは gone(→ 410) |
★D25 ⑤★(制御盤は120秒で zip を消す) |
| R41 | ★エクスポートの流れ中(または準備中)に requestList★ |
★待ち行列に入れ、制御盤の poll に list が渡る★(断らない)。一覧の結果が返る |
★D25 ③★ |
| R42 | ★send の指示の command_id★ にチャンク・complete を送る |
not_found(→ 404) |
★D25 ①★ |
| R43 | result / prepared / complete / fail の本体の ★形の誤り★(JSON でない・必須の項目が無い・型が違う) | ★400 result_invalid(4つとも同じ)★。状態は変えない |
★D25 ④★ |
lib/ds-logs/file-name.ts の exportFileName(serial, now))| # | 入力 | 期待 |
|---|---|---|
| N1 | DS-002・2026-10-19T12:34:56+09:00 |
DS-002_logs_20261019-123456.zip |
| N2 | 同じ瞬間を 2026-10-19T03:34:56Z で |
同上(★JST★) |
| N3 | 2026-10-19T15:00:00Z |
…_20261020-000000.zip(日付が変わる) |
| N4 | シリアル DS 002/"x" |
DS_002__x__logs_….zip(D23) |
★テストの書き方の注意★:
+09:00 付きの ISO 文字列で書く★。new Date(2026, 9, 19) のような実行環境のタイムゾーンに依存する書き方をしない(表2 T10)createRelay({ now, setTimer, clearTimer, limits }) に偽の時計を渡し、advance(秒) で進める(vi.useFakeTimers() でもよいが、now と setTimer を両方とも同じ偽の時計から取ること)limits.chunkMaxBytes を小さく(例 16 バイト)差し替えて★ 同じ規則を確かめてよい。★ただし R13・R24 の境界(ちょうど・+1)と R11・R14・R18・R31・R32 の秒の境界は、差し替えた値で必ず書く★CHUNK_MAX_BYTES + 1・EXPORT_PREPARE_TIMEOUT_SEC 等)。★R21〜R23・R27 の「わざと違う値」だけは直書きし、コメントで理由を書く★deps.verifyAgentToken のスタブで作る(ストアに触らない)。★A7 はスタブが呼ばれていないこと(toHaveBeenCalledTimes(0))を見る★__setAgentTokenStoreForTesting()(新規。ds-device-notes.ts の __setDeviceNotesStoreForTesting() と同じ形)で mkdtemp のストアに差し替える。★data/settings/ の実物に触らない★。KA1〜KA8 はルートのテスト(段階7-4)| # | 罠 | どう避けるか |
|---|---|---|
| 4-1 | ★proxy は本体を 10MB までメモリにバッファし、超えた分を切り捨てる。クライアントにはエラーにならず、サーバーに警告が1行出るだけ★(§1 の出所) | ★チャンクは CHUNK_MAX_BYTES(4MB)以下★。★長さとハッシュを必ず検証する★。★experimental.proxyClientMaxBodySize を上げて解決しようとしない★。★agent パスを matcher から外さない★(proxy.ts:106-107) |
| 4-2 | ★relay がモジュールのシングルトンであること★ | ★globalThis.__dwatchDsAgentRelay に置く★(lib/db/prisma.ts と同じ形。declare global)。★ルートごとにモジュールが別に読み込まれても同じ relay を見る★(推測。段階11-3 の通し確認で、依頼と結果が別ルートで噛み合うことを確かめる)。★1プロセス前提を壊す変更(cluster・PM2 の複数インスタンス・next start の複数起動)をしない★ |
| 4-3 | ★全チャンクを配列に溜める★ | ★流量制御★: new ReadableStream({ pull, cancel }, { highWaterMark: 1 })。pushChunk は enqueue した後、★desiredSize > 0 になる(pull が呼ばれる)まで 204 を返さない★(★最大 45秒。超えたら 410。D20★) |
| 4-4 | ★応答を流し始めた後にステータスを変えようとする★ | ★最初のチャンクが届くまで Response を返さない★(それまでの失敗は JSON の 504 / 502)。★流し始めた後の失敗は controller.error()★。ログに残す |
| 4-5 | ★ダウンロードの応答ヘッダの付け忘れ★ | ★Content-Type: application/zip・Content-Disposition: attachment; filename="…"; filename*=UTF-8''…・Cache-Control: no-store・X-Accel-Buffering: no・Content-Length: total_bytes★ を必ず付ける(nginx に応答をバッファさせない=一時ファイルに書かせない)。ルートのテストで5つとも見る |
| 4-6 | ★サーバーのディスクに書く★(一時ファイル・os.tmpdir()・fs.mkdtemp・createWriteStream) |
★禁止★(D3)。lib/ds-agent/relay.ts・lib/ds-logs/**・app/api/ds-agent/**・app/api/ds-devices/[serial_number]/logs/** は ★node:fs / node:os を import しない★(段階7-4 で確かめる)。★トークンのストア(tokens.ts)だけは書いてよい★(発行・失効のときだけ) |
| 4-7 | ★認証の取り違え★ | ★agent パスは cookie で通さない。通常パスは Bearer で通さない★(表4)。★ヘッダで serial を受け取らない★。ルートは requireAgent() で再検証し、★トークンから serial を得る★。★command_id が別の制御盤のものなら 404★(表5 R35)。★トークンの発行・失効は cookie のパス★(表4 A11) |
| 4-8 | ★トークン・中身をログやストアに残す★ | ★生のトークンは保存しない(SHA-256 だけ)★。★出すのは発行の POST の応答の1回だけ★(画面の state にも閉じたら残さない・localStorage に入れない)。★ファイルの中身・ファイル名の一覧全体をログに出さない★。イベントのキー名に token / authorization / note / detail を使わない(lib/logger/redact.ts が本番で消す) |
| 4-9 | ★ブラウザ・制御盤の切断に気づかない★ | ★ReadableStream の cancel() と request.signal の abort の両方★でエクスポートを取り消す(次のチャンクは 410)。★ポーリングと準備の POST の request.signal も見る★(ポーリングが切れたら待ちを外す。準備の POST が切れたらエクスポートを取り消す)。★実際の切断で効くかは段階11 で確かめる★ |
| 4-10 | ★長い待ちが nginx の60秒を超える★ | ★準備の POST は最大 50秒(EXPORT_PREPARE_TIMEOUT_SEC)・チャンクの保留とチャンクの間隔は最大 45秒・ダウンロードの最初のチャンクは最大 30秒・ポーリングは最大 25秒・一覧は最大 15秒★。★設定値を上げて 60秒以上にしない★(D14・D20) |
| 4-11 | ★Content-Length と実際のバイト数が食い違う★ |
Content-Length は ★prepared の total_bytes★。★超えるチャンクは受けない(R25)★。足りないまま終わるのは失敗のときだけ(ブラウザ側はダウンロード失敗になる。それでよい) |
| 4-12 | ★「実測」と書くときに出所を書かない★ | CLAUDE.md §6-6。★「当方の fixtures 由来」「当方の偽制御盤スクリプト由来」「構造(コード・node_modules)」★を書き分ける |
| 4-13 | ★JP/EN・ダーク/ライト★ | 辞書のキーは ★日本語の原文★(lib/i18n.ts)。★既にあるキーは足さない★(重複は lint で落ちる。閉じる・キャンセル・読み込み中…・種類・日付 は既にある)。★色は CSS 変数★。★components/ はテスト対象外なので画面は目視で確かめる★ |
| 4-14 | ★t() に生値を通す★ |
ファイル名・種類のラベル(制御盤から来る)・シリアル・施設名・★トークン★は ★t() に通さない★ |
| 4-15 | ★シリアルの照合を緩める★ | ★relay の鍵・トークンのシリアル・パスのシリアルは JS の ===★(D-4)。大小文字を揃えない・trim しない |
| 4-16 | ★設定値の直書き★ | ★lib/config/ds-agent.ts から読む★。★このファイルは node: の import を持たない★(画面からも import する) |
| 4-17 | ★modified_at を文字列で比べる・並べる★ |
★Date.parse() の瞬間で比べる★(表1 Q9・Q10)。★オフセットの無い値は制御盤の結果ごと不正★(段階7-2) |
| 4-18 | ★制御盤の結果を検証せずに返す★ | ★制御盤の応答は外から来たデータ★。形・長さ・型を検証し、違えば 502 agent_error(段階7-2)。★ファイル名は React の通常の描画だけ★(dangerouslySetInnerHTML を使わない) |
| 4-19 | ★git add -A・eslint-disable の追加★ |
禁止(CLAUDE.md §7)。★他のセッションの未コミットの変更を含めない★ |
develop で作業する★(.claude/worktrees/ の中ではない。ワークツリーは main 基点で古い)。git pull で最新にし、★指示書AX の実装コミットが入っていること★を git log で確かめる。入っていなければ着手しない。基点のコミットを冒頭の欄に書く。git status を控える。★自分が作っていない変更・未追跡ファイルは他のセッションの作業★。触らない・コミットに含めない。docs/decisions.md に D-179 がコミット済みか★を確かめる。未コミットなら ★§13 の追記は D-179 のコミットを待つ★(git add -p で自分の塊だけを選ぶのでもよい)。d-watch-api.yaml・lib/types/index.ts・lib/i18n.ts・lib/fixtures/index.ts・DsLogScreen.tsx / .module.css — AX が触る)。ずれは作業報告に書く。npx prisma generate → npx tsc --noEmit -p . → npm test が通ることを確かめ、★テストの件数を記録する★。通らなければ着手前に報告。CLAUDE.md 末尾): node_modules/next/dist/docs/01-app/03-api-reference/03-file-conventions/proxy.md(async にできること :31)・…/05-config/01-next-config-js/proxyClientMaxBodySize.md・…/01-getting-started/15-route-handlers.md(★GET はキャッシュされない★。app/api/settings/display-limits/route.ts:27-35)・…/03-file-conventions/route.md(ストリーミング・request.signal)。★この時点ではテストが落ちる(関数・型が無い)のが正しい★。★コミットはテストだけで1つ(「テストだけ。この時点では落ちる」と明記)★。 各ファイルの冒頭コメントに対応する表を写し、「指示書AY §3 表N。ブリーフ §5 と監督判断 第2ラウンドから本書で具体化。★P1・P2 の行は §11 Q14・Q15 の採用で確定★」と書く。
lib/ds-logs/__tests__/query.test.ts(新規): 表1 Q1〜Q17(it.each)。★Q14〜Q16 は純粋関数を直接呼ぶ★lib/ds-logs/__tests__/presets.test.ts(新規): 表2 T1〜T10lib/ds-logs/__tests__/file-name.test.ts(新規): 表6 N1〜N4lib/ds-agent/__tests__/tokens.test.ts(新規): 表3 K1〜K12(mkdtemp。afterEach で消す)lib/proxy/__tests__/authenticate.test.ts(★足す★): 表4 A1〜A11。★既存のテストは無修正で通ること★lib/ds-agent/__tests__/relay.test.ts(新規): 表5 R1〜R43lib/ds-logs/__tests__/validate.test.ts(新規): 段階3-3 の V1〜V7 と、段階7-2 の W1〜W9(表3 KA1〜KA8 のルートのテストは、ルートの形が段階7 で決まるので段階7 で書く)
d-watch-api.yaml。★実装より先。CLAUDE.md §6-1★)components.securitySchemes(:1734 付近)に ★agentBearer★(type: http・scheme: bearer。description: 制御盤ごとのトークン。/ds-agent/ 配下だけ。cookie では通らない。D-179)ds-agent(制御盤向け)/ds-devices/{serial_number}/note の後ろ・/ds-alerts の前に置く)。★検証エラーはすべて ValidationError(422 validation_failed。D17)★:| パス | メソッド | security | 要点 | |
|---|---|---|---|---|
/ds-devices/{serial_number}/logs |
GET | cookieAuth(既定) | クエリ types(カンマ区切り。省略で全種類)・from / to(ISO 8601 ★オフセット必須★。from ≤ modified_at < to)・sort(date_desc 既定 / type_date_desc)・page(1〜)・per_page(★50 / 100 / 200。既定 200★)。200 は DsLogListResponse(★truncated を含む。D18★)。409 agent_offline(details.last_seen_at に最終接続。無ければ空配列)・504 agent_timeout・502 agent_error(details.agent_code)・429 agent_busy・404 not_found・422 |
|
/ds-devices/{serial_number}/logs/exports |
POST | cookieAuth | 本体 { file_ids: string[] }(1〜EXPORT_MAX_FILES 件・重複なし・各 1〜1024 文字)。★201★ { export_id, download_url, total_bytes, file_count, expires_in_sec }。★最大 50秒待つ(制御盤が zip を作る)★。502 agent_error(too_large / file_not_allowed / file_not_found / io_error 等)・504・409・429 export_busy / agent_busy・404・422 |
|
/ds-devices/{serial_number}/logs/exports/{export_id} |
GET | cookieAuth | ★1回だけ使える★。200 は application/zip(format: binary)と ★5つのヘッダ(4-5)★。504 agent_timeout(30秒)・502 agent_error・404 export_not_found(無い・失効・使用済み・★パスのシリアルと違う★)。description に ★流し始めた後の失敗はダウンロードの失敗になる(ステータスは200のまま)★・★JSON のエラーはまれ(制御盤の切断など)で、ブラウザによってはファイルとして保存される(D21)★ |
|
/ds-devices/{serial_number}/agent-token |
GET | cookieAuth | ★P1(Q14 採用)★。200 `{ issued_at: string \ | null }`。★トークン・ハッシュは返さない★。404・422 |
| 同上 | POST | cookieAuth | 本体なし。★201★ { token, issued_at }。description: ★生の値はこの応答で1回だけ・既にあれば作り直して古いものは無効・保存は SHA-256 だけ(D16)★。404・422 |
|
| 同上 | DELETE | cookieAuth | ★204★(★未発行でも 204(冪等)。P2(Q15 採用)★)。404・422 | |
/ds-agent/commands |
GET | ★agentBearer★ | wait(1〜25。既定 25)・ヘッダ X-Agent-Version。200 DsAgentCommand(★kind は list / export / send★)/ 204(指示なし)/ 401 / 422 |
|
/ds-agent/commands/{command_id}/result |
POST | agentBearer | 本体 DsAgentListResult(★4MB 以下★)。204 / 400 result_invalid / 404 / 410 command_expired |
|
/ds-agent/commands/{command_id}/prepared |
POST | agentBearer | ★本体 { total_bytes, sha256, file_count }★(export の指示だけ)。204 / 400 result_invalid / 404 / 410 command_expired |
|
/ds-agent/commands/{command_id}/chunks/{seq} |
POST | agentBearer | ★export の指示の command_id(D25)★。application/octet-stream・ヘッダ X-Chunk-Sha256(小文字の16進64桁)。204(★ブラウザ側に空きができてから。最大45秒★)/ 400 chunk_invalid / 409 chunk_out_of_order / 410 export_cancelled / 404 |
|
/ds-agent/commands/{command_id}/complete |
POST | agentBearer | ★export の指示の command_id(D25)★。{ total_bytes, sha256 } → 204 / 400 result_invalid(形の誤り・値の不一致)/ 410 export_cancelled / 404 |
|
/ds-agent/commands/{command_id}/fail |
POST | agentBearer | { code, message }(code は 1〜64 文字・message は 1〜500 文字。★送信中の失敗は export の指示の command_id。制御盤に zip が無いときは export_not_found(D25)★)→ 204 / 400 result_invalid / 404 / 410 export_cancelled |
DsLogType { key, label }・DsLogFile { file_id, name, type, size_bytes, modified_at }・DsAgentStatus { connected, last_seen_at, agent_version }・ DsLogListResponse { agent, types, items, truncated, meta }・DsLogExportRequest・DsLogExportResponse { export_id, download_url, total_bytes, file_count, expires_in_sec }・ DsAgentTokenStatus { issued_at }・DsAgentTokenIssued { token, issued_at }・ DsAgentCommand { command_id, kind: [list, export, send], params }・DsAgentListResult({ types, items, truncated } または { error: { code, message } })・DsAgentPrepared・DsAgentComplete・DsAgentFaillib/types/index.tsCLAUDE.md §9.2。DsDevice(:1024 付近)の近く)ErrorCode(:31-45)に足す★: agent_offline / agent_timeout / agent_error / agent_busy / export_busy / export_not_found / chunk_invalid / chunk_out_of_order / export_cancelled / command_expired / result_invalid。★validation_error は足さない(D17)★コミット(YAML と型で1つ)。
lib/config/ds-agent.ts(新規。★node: を import しない・lib/config/index.ts を import しない★)| 名前 | 値 | 意味 |
|---|---|---|
AGENT_POLL_WAIT_SEC |
25 | ポーリングの待ちの上限(wait の最大・既定) |
AGENT_OFFLINE_AFTER_SEC |
60 | 最後のリクエストからこれを ★超えたら★ 未接続(D19) |
LIST_TIMEOUT_SEC |
15 | 一覧の結果を待つ上限 |
EXPORT_PREPARE_TIMEOUT_SEC |
★50★ | ★制御盤が zip を作り終えるまで(POST が待つ上限。nginx の60秒より短く)★ |
EXPORT_CLAIM_TIMEOUT_SEC |
60 | 201 から ダウンロード URL を開くまでの上限(expires_in_sec) |
EXPORT_FIRST_CHUNK_TIMEOUT_SEC |
30 | 開いてから最初のチャンクまで |
EXPORT_IDLE_TIMEOUT_SEC |
★45★ | ★流し始めた後のチャンクの間隔の上限、かつチャンクの POST を保留する上限(D20)★ |
CHUNK_MAX_BYTES |
4 * 1024 * 1024 |
1チャンクの上限(proxy の 10MB より十分小さく) |
EXPORT_MAX_BYTES |
100 * 1024 * 1024 |
★1回の zip の上限(仮。D-179 追記 項5)★ |
EXPORT_MAX_FILES |
500 | 1回に選べるファイル数 |
EXPORT_CONCURRENCY_PER_DEVICE |
1 | 制御盤ごとの同時エクスポート |
EXPORT_CONCURRENCY_TOTAL |
3 | 全体の同時エクスポート |
LIST_RESULT_MAX_BYTES |
★4 * 1024 * 1024★ |
一覧の結果の本体の上限(D18) |
LIST_RESULT_MAX_ITEMS |
20000 | 一覧の件数の上限(制御盤はここで打ち切り truncated) |
PENDING_COMMANDS_PER_DEVICE |
5 | 制御盤ごとの待ち行列の上限 |
DS_LOG_PER_PAGE_OPTIONS |
[50, 100, 200] |
画面のページあたり件数(★PAGINATION.maxPerPage(lib/config/index.ts:438)と同じ 200 が上限★) |
DS_LOG_DEFAULT_PER_PAGE |
200 | 既定 |
各定数の docstring に ★「何の上限か・なぜその値か・出所(D-179・監督判断 第2ラウンド M1〜M5)」★ を書く。★リクエストを待たせる5つ(LIST_TIMEOUT_SEC 15・AGENT_POLL_WAIT_SEC 25・EXPORT_FIRST_CHUNK_TIMEOUT_SEC 30・EXPORT_IDLE_TIMEOUT_SEC 45・EXPORT_PREPARE_TIMEOUT_SEC 50)には「nginx の proxy_read_timeout(既定60秒)より短くすること」★ と書く。
lib/ds-logs/query.ts(新規。純粋関数)export interface DeviceLogQuery {
types: readonly string[] | null; // null = 全種類
from: number | null; // 瞬間(ms)。from ≤ t
to: number | null; // t < to
sort: "date_desc" | "type_date_desc";
page: number; // 1〜
perPage: number;
}
/** 表1。★入力の配列を書き換えない★。並べ替えの同値は name の `<` / `>`(`localeCompare` を使わない) */
export function queryDeviceLogs(
types: readonly DsLogType[], items: readonly DsLogFile[], query: DeviceLogQuery
): { items: DsLogFile[]; total: number }
modified_at は Date.parse() で比べる★。出力の modified_at は ★toJstIso(new Date(ms))★(表1 Q17。秒まで)type_date_desc の種類の順は ★types 配列の添字★。★types に無い種類の項目は来ない★(段階7-2 の検証で弾く)lib/ds-logs/validate.ts。新規。lib/api/query-result.ts の QueryParseResult を返す。★エラーは 422 validation_failed★)| # | 入力 | 期待 |
|---|---|---|
| V1 | パラメータなし | { types: null, from: null, to: null, sort: "date_desc", page: 1, perPage: 200 } |
| V2 | from=2026-10-19(日付だけ)/from=2026-10-19T11:00:00(★オフセットなし★)/from=abc |
422 |
| V3 | from > to |
422。★from = to は通る(0件)★ |
| V4 | types=(空)/types=app,,sys(空の要素) |
422 |
| V5 | sort=name |
422 |
| V6 | per_page=30 / page=0 / page=1.5 |
422 |
| V7 | POST の本体 { file_ids: [] } / 501件 / 重複あり / 要素が文字列でない / 1025文字 |
422 |
コミット。
lib/ds-agent/tokens.ts)/** 32バイトの乱数を base64url。ストアには SHA-256 の16進だけを書く。★生の値は戻り値で1回だけ★ */
export async function issueAgentToken(serialNumber: string): Promise<{ token: string; issued_at: string }>
export async function revokeAgentToken(serialNumber: string): Promise<boolean> // 無ければ false
export async function agentTokenIssuedAt(serialNumber: string): Promise<string | null> // P1(Q14 採用)
/** `Authorization` ヘッダの生値 → serial または null。★全件と timingSafeEqual で比べ、途中で抜けない★ */
export async function verifyAgentAuthorization(header: string | null): Promise<string | null>
export function __setAgentTokenStoreForTesting(store: JsonStore<AgentTokens> | null): void
path.join(process.cwd(), "data", "settings", "ds-agent-tokens.json")(ds-device-notes.ts:53-55 と同じ理由)。★再デプロイで消えないこと(Docker なら volume)は監督が docs/deploy.md に書く(§12-2)★lib/logger を import しない★(proxy から呼ぶため。json-store.ts:15-17 と同じ考え方)。壊れたストアの警告は onWarn で settings.store_corrupt を出す(呼び出し側。前例 ds-device-notes.ts:65-73)/^Bearer ([A-Za-z0-9_-]{43})$/★(表3 K5・K6)。形が違えば ★ストアを読まずに null★token_sha256 を timingSafeEqual★。★見つかっても残りを比べ終えてから返す★fs.stat の mtimeMs が変わったら読み直す★(表3 K12)。★発行・失効の後は自分のキャッシュを捨てる★issued_at は toJstIso(new Date())(+09:00)dsDeviceExists() で見る。表3 KA5)コミット(表3 K1〜K12 が通ること)。
lib/proxy/authenticate.ts と proxy.ts。D22)authenticate.ts に ★isAgentPath(pathname)★(pathname === "/api/ds-agent" || pathname.startsWith("/api/ds-agent/")。表4 A8・A10)authenticate()(同期)★: IP 判定の後、★agent パスなら cookie を見ずに unauthenticated★(表4 A2 の保険。proxy からは呼ばれない)。★それ以外の分岐は変えない★authenticateAgent(pathname, forwardedFor, authorization, deps)(新規・async)★:
authenticate() と同じ関数を共有。複製しない)★ → 拒否なら deny_ip(★トークン検証を呼ばない★。表4 A7)deps.verifyAgentToken ?? verifyAgentAuthorization → null なら unauthenticated、そうでなければ allowproxy.ts: ★export async function proxy★。isAgentPath(pathname) なら await authenticateAgent(…, request.headers.get("authorization"))、そうでなければ今の authenticate()。 ★401 の応答は今の API の 401 と同じ形★。agent パスの 401 では ★ds_agent.auth_failed(warn)を出す★(reason: "missing" | "invalid"・route。★ヘッダの値は出さない★)。 ★冒頭コメントの「実行順」と matcher のコメントは変えず、agent パスの分岐を1行足す★lib/ds-agent/require-agent.ts(新規)★: requireAgent(request): Promise<{ ok: true; serial: string } | { ok: false; response: Response }>。★制御盤向けのルートの先頭で必ず呼ぶ★(D5)。★通ったら relay の touch(serial) で last_seen_at を更新する(D19。表5 R7)★★既存の lib/proxy/__tests__/authenticate.test.ts が無修正で通ること★。コミット。
lib/ds-agent/relay.ts)export interface RelayDeps {
now: () => number; // ms
setTimer: (fn: () => void, ms: number) => unknown; // 既定は setTimeout(★unref() する★)
clearTimer: (handle: unknown) => void;
limits: RelayLimits; // 既定は lib/config/ds-agent.ts
onEvent?: (e: RelayEvent) => void; // ログの受け口(段階10)。relay は logger を import しない
}
export function createRelay(deps?: Partial<RelayDeps>): Relay
export function getRelay(): Relay // globalThis.__dwatchDsAgentRelay(無ければ作る)
export function __setRelayForTesting(relay: Relay | null): void
| 側 | メソッド | 戻り値 | ||||
|---|---|---|---|---|---|---|
| 制御盤 | touch(serial) |
void(★認証の通ったすべてのリクエストで呼ぶ。D19★) |
||||
| 制御盤 | poll(serial, agentVersion, waitSec, signal) |
`Promise<AgentCommand \ | null>` | |||
| 制御盤 | submitListResult(serial, commandId, result) |
`"ok" \ | "not_found" \ | "gone"` | ||
| 制御盤 | submitPrepared(serial, commandId, { total_bytes, sha256, file_count }) |
`"ok" \ | "not_found" \ | "gone"(★total_bytesが上限を超えても"ok"`。エクスポート側を失敗にする。R13★) |
||
| 制御盤 | failCommand(serial, commandId, code, message) |
`"ok" \ | "not_found" \ | "gone"` | ||
| 制御盤 | pushChunk(serial, exportCommandId, seq, bytes, sha256Hex) |
`Promise<"ok" \ | "invalid" \ | "out_of_order" \ | "cancelled" \ | "not_found">` |
| 制御盤 | completeExport(serial, exportCommandId, totalBytes, sha256Hex) |
`"ok" \ | "invalid" \ | "cancelled" \ | "not_found"` | |
| ブラウザ | agentStatus(serial) |
{ connected, last_seen_at, agent_version } |
||||
| ブラウザ | requestList(serial) |
`Promise<{ kind: "ok"; result } \ | { kind: "offline" \ | "timeout" \ | "busy" } \ | { kind: "agent_error"; code }>` |
| ブラウザ | createExport(serial, fileIds, signal) |
`Promise<{ kind: "prepared"; exportId; totalBytes; fileCount; expiresInSec } \ | { kind: "offline" \ | "busy" \ | "timeout" } \ | { kind: "agent_error"; code }>` |
| ブラウザ | claimExport(serial, exportId, signal) |
`Promise<{ kind: "stream"; body: ReadableStream; totalBytes } \ | { kind: "not_found" \ | "timeout" } \ | { kind: "agent_error"; code }>` |
command_id / export_id)は crypto.randomUUID()★(推測できない値。パスに出る)createExport で export の指示を入れ ★prepared を最大 50秒待つ★ → prepared の total_bytes を ★EXPORT_MAX_BYTES と比べる(D15)★ → 201 → claimExport で ★send の指示({ export_command_id })を入れる★ → 最初のチャンクを最大30秒 → 流す。★チャンク・complete・送信中の fail は export の指示の command_id に来る。send の指示は自分の id を持ち、params.export_command_id で export を指す(D25 ①)★。★prepared から送信・完了・失敗・失効までを進行中に数え、prepared から60秒 GET が無ければ失効させて進行中から外す(D25 ⑤)★。★list はエクスポートの進行中でも待ち行列に入れる(D25 ③)★complete で確かめること(D15)★: 受け取った合計 = prepared の total_bytes = complete の total_bytes、かつ 受け取ったバイト列の SHA-256 = prepared の sha256 = complete の sha256last_seen_at は touch()(すべての agent リクエスト)と poll の開始・終了で更新★。poll が待っている間は接続中(表5 R4〜R7)。★状態が変わったときだけ onEvent★(表5 R39。未接続への変化はタイマーで見つける)node:crypto の createHash を逐次★(全体のハッシュのためにチャンクを溜めない)not_found/失効の直後は gone(410)を返せるよう、★短い間(例 EXPORT_IDLE_TIMEOUT_SEC)だけ墓標を残す★unref()★(テストとプロセスの終了を止めない)コミット(段階1 の表5 が通ること)。
app/api/ds-devices/[serial_number]/)| ファイル | 流れ |
|---|---|
logs/route.ts(GET) |
parsePathSerialNumber() → クエリの検証(3-3)→ ★db.dsDeviceExists()(404)★ → requestList() → 結果の検証(7-2)→ queryDeviceLogs() → 200(agent・types・items・★truncated★・meta) |
logs/exports/route.ts(POST) |
同じ順で検証・404 → ★createExport(serial, fileIds, request.signal)(最大50秒)★ → ★201★(download_url は /api/ds-devices/${encodeURIComponent(serial)}/logs/exports/${exportId}) |
logs/exports/[export_id]/route.ts(GET) |
claimExport(serial, exportId, request.signal) → stream なら new Response(body, { headers })(★4-5 の5つ★。ファイル名は exportFileName(serial, now))→ それ以外は JSON のエラー |
agent-token/route.ts(GET / POST / DELETE) |
parsePathSerialNumber()(422)→ db.dsDeviceExists()(404)→ agentTokenIssuedAt / issueAgentToken / revokeAgentToken → 200 / ★201★ / ★204★。★POST の応答に Cache-Control: no-store★。ログ ds_agent.token_issued / ds_agent.token_revoked(段階10) |
withRequestId(request, …) で包む★(既存のルートと同じ)。500 は logApiError()ensureFixtureAgent()(段階8)★。★それ以外の分岐をルートに書かない★(CLAUDE.md §9.5)lib/ds-logs/validate.ts)★1件でも形が違えば、結果全体を不正とする★(黙って落とさない)→ ブラウザには 502 agent_error(details.agent_code = ["result_invalid"])、制御盤には 400 result_invalid。
| # | 結果 | 期待 |
|---|---|---|
| W1 | 正しい(types 3・items 8・truncated: false) |
受理 |
| W2 | types の key が重複 |
不正 |
| W3 | items[].type が types に無い |
不正 |
| W4 | modified_at に ★オフセットが無い★(Python の naive な isoformat()) |
不正 |
| W5 | size_bytes が負・小数・文字列 |
不正 |
| W6 | file_id が重複 |
不正 |
| W7 | items が LIST_RESULT_MAX_ITEMS + 1 件 |
不正(制御盤はそこで打ち切る約束。D18) |
| W8 | 本体が LIST_RESULT_MAX_BYTES(4MB)を超える |
不正(★Content-Length が無くても読んだ長さで判定★) |
| W9 | { error: { code, message } } |
failCommand と同じ扱い(ブラウザに 502・agent_code は制御盤の code) |
prepared の本体(total_bytes は0以上の整数・sha256 は小文字の16進64桁・file_count は1以上の整数)・complete の本体・fail の本体も同じファイルで検証し、★形が違えばすべて 400 result_invalid(D25 ④)★。
app/api/ds-agent/)| ファイル | 要点 |
|---|---|
commands/route.ts(GET) |
★requireAgent()★ → wait の検証(422)→ X-Agent-Version は /^[0-9A-Za-z.+-]{1,32}$/ に合えば採り、合わなければ null(拒否しない)→ poll(serial, version, wait, request.signal) → 200 / 204 |
commands/[command_id]/result/route.ts |
requireAgent() → 本体(request.text() の長さで W8)→ 検証 → submitListResult → 204 / 400 / 404 / 410 |
commands/[command_id]/prepared/route.ts |
requireAgent() → 本体の検証 → submitPrepared → 204 / 400 / 404 / 410 |
commands/[command_id]/chunks/[seq]/route.ts |
requireAgent() → seq は0以上の整数 → X-Chunk-Sha256 は /^[0-9a-f]{64}$/ → new Uint8Array(await request.arrayBuffer()) → pushChunk → 204 / 400 / 409 / 410 / 404 |
commands/[command_id]/complete/route.ts |
requireAgent() → { total_bytes, sha256 } の検証(★形の誤りは 400 result_invalid★)→ completeExport |
commands/[command_id]/fail/route.ts |
requireAgent() → { code, message } の検証(★形の誤りは 400 result_invalid★)→ failCommand |
★agent のルートは DB を読まない★(USE_FIXTURES に関係なく同じ動き)。
| ファイル | 内容 |
|---|---|
app/api/ds-devices/[serial_number]/logs/__tests__/route.fixtures.test.ts |
fixtures で: DS-002 の一覧が200(agent.connected true・types 3つ・truncated false)・絞り込みが効く/★DS-001 は 409★/DS-999 は 404/★POST → 201(total_bytes が fixtures の zip の長さ)→ GET で zip のバイト列が fixtures の zip と一致・ヘッダ5つ★/★2回目の GET は 404★ |
app/api/ds-devices/[serial_number]/logs/__tests__/route.test.ts |
★非 fixtures★(vi.mock("@/lib/db/use-fixtures", () => ({ USE_FIXTURES: false }))・db.dsDeviceExists を vi.spyOn)。relay は __setRelayForTesting(createRelay(偽の時計))。表5 の HTTP の対応(409 / 504 / 502 / 429 / 404 / 422)と、表1 の絞り込みがルートを通って効くこと |
app/api/ds-devices/[serial_number]/agent-token/__tests__/route.test.ts(新規) |
★表3 KA1〜KA8★(ストアは mkdtemp・db.dsDeviceExists を vi.spyOn・ログは logEvent をスパイ) |
app/api/ds-agent/__tests__/routes.test.ts |
トークンストアは mkdtemp。★Bearer なし・違う → 401(requireAgent() の再検証)★・R7(どのルートでも last_seen_at が進む)・R35・R36・チャンクの 400 / 409 / 410・W1〜W9・prepared の検証 |
app/api/ds-agent/__tests__/end-to-end.test.ts |
★ルートのハンドラを直接呼んで一連★: ブラウザ GET logs ↔ 制御盤 GET commands → POST result / ★ブラウザ POST exports ↔ 制御盤 GET commands(export)→ POST prepared → 201 → ブラウザ GET exports ↔ 制御盤 GET commands(send)→ chunks 0..2 → complete★。★読んだ zip のバイト列と SHA-256 が送ったものと一致・Content-Length が一致★ |
| ★ディスクに書かないこと★(同じ end-to-end のファイル) | (a) ★vi.spyOn(fs, "writeFileSync")・vi.spyOn(fs.promises, "writeFile")・vi.spyOn(fs.promises, "appendFile")・vi.spyOn(fs, "createWriteStream")・vi.spyOn(fs.promises, "mkdtemp")・vi.spyOn(os, "tmpdir")★ を一連の間に仕掛け、★0回★であること(import fs from "node:fs" / import os from "node:os" の既定の import に仕掛ける)。★トークンの発行(ストアの書き込み)はスパイを仕掛ける前に済ませる★ (b) ★ソースの静的な検査★: lib/ds-agent/relay.ts・lib/ds-logs/*.ts・app/api/ds-agent/**/route.ts・app/api/ds-devices/[serial_number]/logs/**/route.ts を読み、from "node:fs" / from "fs" / from "node:os" / tmpdir / mkdtemp の文字列が無いこと |
★ルート本体は静的 import★(it() の中で await import() しない。note/__tests__/route.test.ts の冒頭コメント)。コミット。
lib/fixtures/ds-device-logs.ts(新規):
fixtureDeviceLogTypes: 種類3つ(★ダミーの名前★。例 app / comm / sys、ラベル「アプリ」「通信」「システム」)fixtureDeviceLogItems(now: Date): ★数百件(例 300)★を now から決まった間隔で作る。★now ちょうど・1時間前ちょうど・3時間前ちょうど・24時間前ちょうど・その1秒前★ の項目を必ず含める(画面の目視用。境界の正しさはテストが持つ)。大きさは 1KB〜50MB(★選択の合計が 100MB を超える組み合わせが作れること★)FIXTURE_EXPORT_ZIP_BASE64: ★中身がダミーの文字だけの小さな正しい zip★。★中のパスは root の名前つき(例 fixture-logs/app_20261019_1100.log。D24)★(フォルダ fixture-logs を PowerShell の Compress-Archive で包み base64 にする。★実在の店名・ログを入れない★)FIXTURE_AGENT_OFFLINE_SERIALS = ["DS-001"](D10)lib/fixtures/index.ts に export * from "./ds-device-logs";lib/ds-agent/fixture-agent.ts(新規): ensureFixtureAgent(relay, serial):
FIXTURE_AGENT_OFFLINE_SERIALS と fixtures に無いシリアルは何もしない★(→ 409 / 404 が本物と同じ経路で出る)relay.poll(serial, "fixture", AGENT_POLL_WAIT_SEC, …) を1本だけ待たせておき★、指示が来たら ★本物と同じメソッド★ で返す: list → submitListResult/★export → submitPrepared(fixtures の zip の長さ・SHA-256・選んだ件数)★/★send → 小さく(例 1KB)分けて pushChunk を複数回 → completeExport★(流量制御の経路を通す)globalThis の Set で二重起動を防ぐ)docs/fixtures.md は監督が直す★(ブリーフ §7)。足すべきことは §12-2コミット。
★見た目は AreaModal.tsx / .module.css から借りる。新しい共通部品は作らない★(components/ui/ に Modal を作らない)。
components/features/ds-logs/DsLogScreen.tsx<colgroup> に .colLogs を足す・幅は固定 ★例 80px★、DsLogScreen.module.css の min-width も同じだけ広げる(:82 のコメントの規則))。各行に ログ ボタン → モーダルを開く(serial_number と facility_name を渡す)components/features/ds-logs/DeviceLogsModal.tsx(新規)と .module.css| 部分 | 中身(★文言はこのとおり。JP/EN の辞書に足す★) |
|---|---|
| 見出し | 「制御盤のログ」+シリアル・施設名。右に ★接続状態★: 「接続中」(ok の色)/「未接続」(ng の色)+「最終接続 {日時}」(無ければ「最終接続 -」)。閉じる |
| 種類 | 制御盤から来た types のチェックボックス(★ラベルは生値。t() に通さない★)。★既定は全部★。★1つも選ばれていなければ取得せず「種類を1つ以上選んでください」★ |
| 期間 | ボタン「直近1時間」「直近3時間」「直近24時間」「全て」(presets.ts)+ <input type="date"> 2つ(openDatePicker。表2 T5〜T9)。★既定は「直近24時間」★(D23) |
| 並べ替え | 「日付」(date_desc)「種類+日付」(type_date_desc) |
| 一覧 | チェックボックス・ファイル名・種類(ラベル)・大きさ(KB / MB)・更新日時。見出しに「このページをすべて選択」。下に Pagination + ページあたり件数のセレクト(DS_LOG_PER_PAGE_OPTIONS)。★truncated のときは一覧の上に「件数が多いため、新しいものから一部だけを表示しています」(D18)★ |
| 選択中 | 「選択: {n}件・合計 {size}」。★合計が EXPORT_MAX_BYTES を超えたら警告「1回に取り出せるのは {max} までです」+エクスポートを押せなくする★(★元ファイルの合計での目安。最終の判定は制御盤の zip の大きさ(D15)★)。★EXPORT_MAX_FILES を超えても同じ★(「1回に選べるのは {n} 件までです」) |
| エクスポート | 押す → ★「制御盤で準備中…」(最大50秒。ボタンを押せなくする)★ → POST が 201 → download_url を ★<a href download> を作って click()(D21)★ → 「ダウンロードを開始しました({size})」 |
| エラー | 409「制御盤が接続されていません(最終接続: {日時})」/504「制御盤から応答がありません。しばらくしてから再度お試しください」/429 export_busy「この制御盤は別のエクスポートを処理中です」/429 agent_busy「制御盤が混み合っています。しばらくしてから再度お試しください」/502「制御盤でエラーが発生しました({code})」(★too_large は「選んだファイルが大きすぎます(1回 {max} まで)」★) |
| ★制御盤トークン★(下部。D16) | 「制御盤トークン」+「発行日時 {日時}」/「未発行」(GET …/agent-token。P1(Q14 採用))。ボタン「発行(作り直し)」「失効」。発行: ★確認「トークンを発行します。発行済みのトークンは使えなくなります。よろしいですか?」★ → POST → ★トークンを読み取り専用の欄に1回だけ表示・「コピー」ボタン・注意「この値は二度と表示されません。制御盤の設定ファイルに入れてください」★。★モーダルを閉じたら state から消す★。 失効: ★確認「トークンを失効させます。この制御盤はログを送れなくなります。よろしいですか?」★ → DELETE。 ★この部分は制御盤が未接続(409)でも使える★(初めて設定するときは未接続のため) |
file_id → size_bytes の Map で持つ。★ページ送り・並べ替え・ページあたり件数では保持★、★種類・期間を変えたら消す★t() に通す)。★一覧の取得は毎回制御盤に問い合わせる★(最大15秒)ので、★前の応答が後から返って上書きしないよう、要求ごとの番号で古い応答を捨てる★role="dialog" aria-modal="true"・Esc で閉じる・背景のスクロールを止める★(AreaModal.tsx の作りを確かめて揃える)。★準備中に閉じたら POST を AbortController で取り消す★(サーバー側はエクスポートを取り消す。4-9)formatDateTime()(DsLogScreen.tsx:41)と同じ形lib/i18n.ts★足す前に grep する★(閉じる・キャンセル・読み込み中…・種類・日付 は既にある)。EN は ★暫定★。 例: 「ログ」"Logs"/「制御盤のログ」"Controller logs"/「接続中」"Connected"/「未接続」"Not connected"/「直近1時間」"Last 1 hour"/「直近24時間」"Last 24 hours"/「種類+日付」"Type + date"/「このページをすべて選択」"Select all on this page"/「エクスポート」"Export"/「制御盤で準備中…」"Preparing on the controller…"/「制御盤トークン」"Controller token"/「発行(作り直し)」"Issue (regenerate)"/「失効」"Revoke"/「コピー」"Copy"/ほか 9-2 の文言すべて。
コミット(画面は vitest で走らないので段階11 で見る)。
lib/logger/events.ts)| event | level | フィールド(★これ以外を載せない★) | どこで |
|---|---|---|---|
ds_agent.connected / ds_agent.disconnected |
info | serial_number・agent_version |
relay の onEvent(状態が変わったときだけ。表5 R39) |
ds_agent.auth_failed |
warn | route・reason(missing / invalid) |
proxy(段階5)。★ヘッダの値・トークンを出さない★ |
★ds_agent.token_issued / ds_agent.token_revoked★ |
info | ★serial_number だけ★ |
agent-token のルート(表3 KA8。D16) |
ds_logs.list |
info | serial_number・item_count・truncated・duration_ms・result(ok / offline / timeout / busy / agent_error) |
GET logs |
ds_logs.export_prepared |
info | serial_number・file_count・total_bytes・duration_ms |
POST exports の 201 |
ds_logs.export_started |
info | serial_number・file_count |
claim で流し始めたとき |
ds_logs.export_completed |
info | serial_number・file_count・total_bytes・duration_ms |
complete |
ds_logs.export_failed |
warn | serial_number・stage(prepare / send)・reason・bytes_sent |
失敗・取り消し・失効・タイムアウト |
LogEvent の union(:456-471)に足す。★reason は固定の文字列の union★(制御盤から来た message を載せない。制御盤の code は agent_code として固定の長さで切って載せてよい)file_name / file_ids / token / authorization を載せない★。テストで「イベントの値に file_id・ファイル名・トークンが入っていない」ことを確かめるコミット。
★アプリ内ブラウザ(mcp__Claude_Browser__*)を使う。Edge(ヘッドレス・CDP を含む)は使わない★(ユーザー指示 2026-10-16)。 ★ユーザーの :3000(next dev。ステージング向け)は止めない★。★next dev ではなく next build → next start で確かめる★(1プロセス・本番と同じ形。4-2)。
npx tsc --noEmit -p . 0件/npm run lint 0件/npm test 全件通過(★段階0 の件数から増えている★)npm run build の出力で、足したルートがすべて ƒ(Dynamic)★であることgrep -rn "node:fs\|from \"fs\"\|node:os\|tmpdir\|mkdtemp\|createWriteStream" lib/ds-agent/relay.ts lib/ds-logs app/api/ds-agent "app/api/ds-devices/[serial_number]/logs" grep -rn "proxyClientMaxBodySize" next.config.ts grep -rn "console.log" lib/ds-agent lib/ds-logs app/api/ds-agent grep -rn "validation_error" lib app
scripts/ds-agent-token.ts が無いこと★(D16):3100)USE_FIXTURES=true npm run build(PowerShell: $env:USE_FIXTURES='true'; npm run build)AUTH_TOKEN をその場で生成★し、★環境変数で渡して★ npx next start -p 3100。★トークンを文書・コミット・報告に書かない★preview_start の url で http://localhost:3100 を開き、ログインするDS-002 で開く → 接続中・種類3つ・既定の期間(直近24時間)で一覧が出るfixture-logs/…★DS-001 で開くと「制御盤が接続されていません(最終接続: -)」★。★それでも制御盤トークンの部分は使える★Esc で閉じるresize_window 1440×900・JP/EN)。終わったら desktop に戻すdwatch_local)npm run seed:local(★ローカル以外を拒否する★)。DS-002 が controls にあることUSE_FIXTURES=false で ★ホストが localhost・DB 名が dwatch_local であることを確かめてから★ npm run build → next start -p 3100。★ALLOW_DIRECT_ACCESS=true を環境変数で★(XFF が無いため)DS-002)の「制御盤トークン」で発行★し、★その値を環境変数 DS_AGENT_TOKEN で偽制御盤に渡す★(★画面・ファイル・報告に残さない★)scripts/ds-agent-fake.ts(D23。コミットする)★を tsx で動かす:
--server http://localhost:3100・トークンは環境変数list には ★固定の一覧(種類2つ・10件程度)★ → export には ★--zip <パス> のファイルの長さ・SHA-256・件数で prepared★ → send には ★そのファイルを 4MB ごとに送り complete★(★file_ids は見ない。中身の正しさは制御盤(Python)の仕様★)fake-root)を Compress-Archive で包む(中のパスが fake-root/…)--zip のファイルと一致★(Get-FileHash / certutil -hashfile)・★ブラウザに進捗(大きさ)が出る(Content-Length)★。サーバーのログに ds_logs.export_prepared と ds_logs.export_completed(total_bytes が一致)--fail-prepare too_large で、画面に「選んだファイルが大きすぎます」(POST の 502)ds_logs.export_failed(stage: send)ds_agent.disconnected が1回X-Accel-Buffering: no・Cache-Control: no-store・Content-Length があり、★Content-Encoding が付いていない★こと(read_network_requests)★結果の報告には出所を書く★(「当方の fixtures 由来」「当方の偽制御盤スクリプト由来」「当方のシード由来」。CLAUDE.md §6-6)。
| ファイル | 内容 | 段階 |
|---|---|---|
lib/ds-logs/__tests__/query.test.ts(新規) |
表1 | 1 |
lib/ds-logs/__tests__/presets.test.ts(新規) |
表2 | 1 |
lib/ds-logs/__tests__/file-name.test.ts(新規) |
表6 | 1 |
lib/ds-logs/__tests__/validate.test.ts(新規) |
V1〜V7・W1〜W9・prepared の本体 | 1 |
lib/ds-agent/__tests__/tokens.test.ts(新規) |
表3 K1〜K12 | 1 |
lib/proxy/__tests__/authenticate.test.ts(足す) |
表4。★既存は無修正で通る★ | 1 |
lib/ds-agent/__tests__/relay.test.ts(新規) |
表5(★準備 → 送信の2段★) | 1 |
app/api/ds-devices/[serial_number]/logs/__tests__/route.fixtures.test.ts(新規) |
fixtures の経路 | 7 |
app/api/ds-devices/[serial_number]/logs/__tests__/route.test.ts(新規) |
非 fixtures の経路 | 7 |
★app/api/ds-devices/[serial_number]/agent-token/__tests__/route.test.ts(新規)★ |
★表3 KA1〜KA8★ | 7 |
app/api/ds-agent/__tests__/routes.test.ts(新規) |
制御盤向けの6本 | 7 |
app/api/ds-agent/__tests__/end-to-end.test.ts(新規) |
一連・★ディスクに書かない★ | 7 |
lib/logger/__tests__/…(既存の流儀に合わせる) |
足したイベントに禁止のキーが無い | 10 |
| ★無修正で通ること★ | app/api/ds-devices/**(既存の2本)・lib/settings/__tests__/*・lib/proxy/__tests__/request-id.test.ts・lib/auth/__tests__/* |
— |
d-watch-api.yaml・lib/types/index.ts(足すだけ)・lib/config/ds-agent.ts(新規)・lib/ds-logs/**(新規)・lib/ds-agent/**(新規)・ lib/proxy/authenticate.ts・proxy.ts(段階5 の範囲だけ)・scripts/ds-agent-fake.ts(新規。開発用)・ app/api/ds-devices/[serial_number]/logs/**(新規)・★app/api/ds-devices/[serial_number]/agent-token/**(新規)★・app/api/ds-agent/**(新規)・lib/fixtures/ds-device-logs.ts(新規)・lib/fixtures/index.ts(1行)・ components/features/ds-logs/**・lib/i18n.ts(足すだけ)・lib/logger/events.ts(足すだけ)・ docs/decisions.md(★D-179 の末尾に「実装の記録」を足すだけ★)・§8 のテスト
CLAUDE.md★(監督が直す)・★docs/decisions.md の D-179 の本文とそれ以前★・docs/spec.md・docs/handoff.md・docs/code-map.md・★docs/fixtures.md★・docs/deploy.md(★監督。§12-2★)docs/handoff/設計_制御盤ログの取り出し_20261019.md★ と docs/handoff/ の他の文書(他の担当の作業)scripts/ds-agent-token.ts を作ること★(D16)・package.json(★script を足さない★)prisma/schema.prisma / lib/generated/prisma/**★・★メインDBへの書き込み★(★本機能は DB に書かない★。4カラムの UPDATE も使わない)・生SQLlib/settings/json-store.ts・lib/settings/ds-device-notes.ts・lib/auth/**(使うだけ)・lib/config/allowed-ips.ts(★IP 制限の扱いはリリース時。D6★)・lib/config/index.ts(★ds-agent の値を足さない。D9★)next.config.ts(★proxyClientMaxBodySize を足さない。4-1★)・proxy.ts の matcherd-watch-mock.jsx(モックに無い画面。書き換えない)・components/ui/**(共通部品を作らない)GET /ds-devices・PATCH /ds-devices/{serial_number}/note の挙動・lib/fixtures/ds-devices.tsAUTH_TOKEN・発行した制御盤トークン・DB の接続情報)を文書・コード・テスト・コミット・作業報告に書くこと★git add -A★・eslint-disable の追加{root の name}/…。D24)・指示の値と設定ファイルの値の小さいほうを採ること・別スレッドでの処理・配布の方法も制御盤側★。本書の偽制御盤スクリプトは ★D-Watch 側の確認用★ で、その代わりにはならない/api/ds-agent/ の本体 5MB と一時ファイルに書かないこと・X-Accel-Buffering を効かせること・proxy_read_timeout を60秒より短くしないこと)。監督が照会するfacilities.contract による出し分け★Q1〜Q13 は監督判断(第2ラウンド。M1〜M11)で確定し、本文に反映済み。Q14〜Q16 は追加の監督判断で確定(Q14・Q15 は本書の案 P1・P2 をそのまま採用)。未確定は無い★。 ★判断が変わったら、「直す場所」を本文と同時に直すこと★(本文と末尾の決定が食い違ったまま渡さない)。
| # | 論点 | 回答 | 反映した場所 |
|---|---|---|---|
| Q1 | ブラウザ向けの検証エラーのステータスとコード(ブリーフは 400 validation_error。既存は 422 validation_failed) |
★422 validation_failed★(M2。ブリーフの 400 は誤り)。制御盤向けの形の誤りは 400 result_invalid / chunk_invalid |
§2-2 D17 / 表3 KA6 / 表5 R8 / 段階2 / 3-3 / 7 / 11-1 |
| Q2 | エクスポートの指示を制御盤に渡す時点 | ★「準備 → 送信」の2段★(M1)。POST で export を渡し prepared を最大50秒待って 201、GET で send を渡す。★暫定(GET の時点で渡す)は置き換え★ |
§0 / §2-2 D14・D15 / 表5 R10〜R34 / 4-10・4-11 / 段階2・3-1・6・7・8・9・11 |
| Q3 | truncated を画面に伝えるか |
★伝える★(M3)。制御盤は新しい順に並べてから 2万件 / 4MB で打ち切る。LIST_RESULT_MAX_BYTES は 4MB |
§2-2 D18 / 表5 R38 / 段階2・3-1・7-2・9-2・10 |
| Q4 | 期間ボタンの to |
★省略(暫定どおり)★ | D23 / 表2 T1〜T3 |
| Q5 | types の未知のキー |
★0件(暫定どおり)★ | D23 / 表1 Q4・Q5 |
| Q6 | zip のファイル名 | ★[A-Za-z0-9._-] 以外を _・filename* も(暫定どおり)★ |
D23 / 表6 N4 / 4-5 |
| Q7 | ダウンロードの開き方 | ★<a href download> の click()(暫定どおり。M1)★。GET の JSON のエラーはまれで、ファイルとして保存されうる(既知の制約) |
D21 / 段階2・9-2 |
| Q8 | proxy の非同期化 | ★async にし authenticateAgent() を分ける(暫定どおり)★。★トークンのストアは mtime を見てメモリにキャッシュしてよい★ |
D22 / 表3 K12 / 表4 / 段階4・5 |
| Q9 | 順番違いのチャンク | ★エクスポートも失敗(暫定どおり)★ | D23 / 表5 R21 |
| Q10 | ブリーフに名前の無いエラー | ★429 agent_busy・410 command_expired・400 result_invalid(暫定どおり。M2)★ |
D17・D23 / 段階2 / 表5 R3・R37 |
| Q11 | 偽制御盤スクリプトをコミットするか | ★scripts/ds-agent-fake.ts としてコミット(暫定どおり)★。★zip は root の名前つき(M6)★ |
D23・D24 / 段階11-3 / §9 |
| Q12 | 同じ制御盤からの2本目のポーリング | ★古い方を 204(暫定どおり)★ | D23 / 表5 R9 |
| Q13 | 画面の既定と選択の保持 | ★期間の既定は直近24時間・種類/期間を変えたら選択を消す(暫定どおり)★ | D23 / 段階9-2 |
| — | (M4)未接続の判定 | ★認証の通った制御盤のすべてのリクエストで last_seen_at を更新★ |
D19 / 表5 R7 / 段階5-5・6 |
| — | (M5)タイムアウト | ★EXPORT_IDLE_TIMEOUT_SEC = 45。流量制御の保留も最大45秒で 410★ |
D20 / 表5 R31・R32 / 段階3-1 |
| Q16 | チャンク・complete を送る command_id |
★export の指示の id★(追加の監督判断 2026-10-19)。send は自分の id を持ち params.export_command_id で指す。あわせて: send で zip が無ければ fail export_not_found/list はエクスポートと並行(待ち行列に入れてよい)/制御盤の JSON の形の誤りはすべて 400 result_invalid/prepared から失効までを進行中に数え、60秒で失効 |
§2-2 D25 / 表5 R17・R19・R28・R33・R40〜R43 / 段階2 / 6-2 / 7-2・7-3 |
| — | (M8)トークンの発行 | ★D-Watch の API(POST / DELETE …/agent-token)と画面から。スクリプトは作らない★。ログ ds_agent.token_issued / token_revoked |
D16 / 表3 KA1〜KA8 / 表4 A11 / 段階2・4・7・9・10・11 / §9 |
| # | 論点 | 具体例 | 回答 | 反映した場所 | |
|---|---|---|---|---|---|
| Q14 | ★トークンの発行日時を画面に出すための読み取り★ | M8 の API は POST と DELETE だけ。画面は「発行日時」を出す(M8)ので読む手段が要る。一覧の GET(…/logs)に載せると、未接続(409)のときに出せない — ★初めて設定するときは必ず未接続★ |
★採用(P1)★: GET …/agent-token → 200 `{ issued_at: string \ |
null }`(★トークンの値・ハッシュは返さない★)。★ブラウザ向け API は6本★ | 段階2 / 表3 KA7 / 段階4・7・9-2 |
| Q15 | ★未発行の DELETE★ | 二重に押した・別の人が先に失効させた | ★採用(P2)★: ★204(冪等)★ | 表3 KA4 / 段階2・7 |
modified_at は必ずオフセット付き★(naive な isoformat() は結果ごと不正になる。W4)X-Chunk-Sha256 は小文字の16進64桁★。seq は0から連番。409 / 410 が返ったら送信をやめるtotal_bytes(0以上の整数)・sha256(小文字の16進64桁)・file_count(1以上)。★send が来たら同じ zip を送る(作り直さない)★ — complete の sha256 は prepared と同じでなければ 400items[].type は必ず types のどれか★・file_id は重複させない(W2〜W8)CLAUDE.md §3 の「リリース直前のセキュリティ強化」の対象に ★制御盤トークンの失敗の回数制限★・★agent パスの IP 制限の扱い(D6)★・★トークンの発行・失効を誰ができるか★ を足すdocs/fixtures.md に fixtures の制御盤のログ(種類3つ・約300件・境界の項目・未接続 DS-001・ダミーの zip(fixture-logs/…))を足す(ブリーフ §7)docs/deploy.md に ★1プロセス前提★(PM2 / cluster にしない・再起動で進行中のエクスポートは失われる)と ★data/settings/ds-agent-tokens.json が再デプロイで消えないこと(Docker なら volume。M8)★docs/spec.md / docs/code-map.md に機能とファイルの対応- ★実装の記録(20XX-XX-XX。指示書AY)★:
1. ★コミット★: (段階ごとのハッシュを列挙)
2. ★API★: ブラウザ向け6本(`GET /ds-devices/{serial_number}/logs`・`POST …/logs/exports`(準備。最大50秒・201)・`GET …/logs/exports/{export_id}`(送信。`Content-Length` 付き)・`GET / POST / DELETE …/agent-token`)と制御盤向け6本(`/ds-agent/commands`・`…/result`・`…/prepared`・`…/chunks/{seq}`・`…/complete`・`…/fail`。kind は list / export / send)。`d-watch-api.yaml` が正
3. ★部品★: `lib/ds-agent/relay.ts`(メモリ内の中継。`globalThis`。1プロセス前提)・`lib/ds-agent/tokens.ts`(`data/settings/ds-agent-tokens.json` に SHA-256 だけ。mtime でキャッシュ)・`lib/ds-logs/{query,presets,file-name,validate}.ts`(純粋関数)・`lib/config/ds-agent.ts`(設定値。待ちは 15 / 25 / 30 / 45 / 50秒で nginx の60秒より短い)・`scripts/ds-agent-fake.ts`(開発用の偽制御盤)
4. ★認証★: proxy を async にし、`/api/ds-agent/` 配下は `authenticateAgent()`(IP → Bearer)。cookie では通らない。ルートでも `requireAgent()` で再検証し、すべての agent リクエストで `last_seen_at` を更新。トークンの発行・失効は cookie のパス(API と画面)
5. ★監督判断 第2ラウンド(M1〜M11)を反映★。★§11 Q14・Q15 は本書の案(P1 `GET …/agent-token`・P2 未発行の DELETE も 204)を監督判断で採用★
6. ★確認★: テスト件数(前 → 後)/fixtures の画面確認(当方の fixtures 由来)/非 fixtures の通し確認(当方の偽制御盤スクリプト由来: zip の大きさ・チャンク数・SHA-256 の一致・準備の失敗・切断で 410・未接続)
7. ★本書と違う判断をしたところ★: (あれば)
段階ごとに分ける(段階1 / 段階2 / 段階3 / 段階4 / 段階5 / 段階6 / 段階7 / 段階8 / 段階9 / 段階10 / D-179 の追記)。★git add はファイルパスを明示する★(git add -A 禁止)。 メッセージには ★何を変えたか★ と ★なぜ(D-179。制御盤のログを中継で取り出す・サーバーに保存しない)★ を書く。★段階1 は「テストだけ(この時点では落ちる)」と明記★。 ★docs/decisions.md は自分の塊だけをコミットする★(他のセッションの未コミットの差分を巻き込まない)。★PR の作成とマージは daichi がレビューしてから★(D-154)。
validation_failed(D17)。§11 Q14・Q15 の採用(P1 GET …/agent-token・P2 未発行の DELETE も 204)を実装したtsc 0件・lint 0件・npm run build 成功(足したルートが ƒ)npm test 全件通過: 前 件 → 後 件scripts/ds-agent-token.ts が無いnext.config.ts を変えていない★。/api/ds-agent/ は cookie で通らず、他のパスは Bearer で通らない(表4)total_bytes の上限・complete の合計と SHA-256 の照合、GET に Content-Length(表5)ds_agent.token_issued / token_revoked に serial_number だけ)DS-001 で 409 の表示(トークンの部分は使える)★/トークンの発行・作り直し・失効/JP/EN・ダーク/ライトdocs/decisions.md の D-179 の末尾に「実装の記録」を足した(D-179 の本文・CLAUDE.md には触っていない)CLAUDE.md §6-6★)/本書と違う判断をしたところ/行番号のずれ/§12 の申し送り