eCourt-note/docs/PLAN-STAGED.md

166 lines
48 KiB
Markdown
Raw Normal View History

feat(n11): add base64 document staging from e-Court เพิ่มทางเข้าเอกสารสำหรับ e-Court ที่ไม่มี URL ให้ดึง — POST /api/note/files/stage (x-api-key DOCVER_API_KEY) ส่งเนื้อไฟล์ PDF/รูปเป็น base64 ระบบเก็บใน RAM staging (TTL sliding 8 ชม. + LRU + cap 100MB/ไฟล์ 256MB รวม — ห้ามเขียน disk/DB/MinIO แม้แต่ไบต์เดียวตามโจทย์) ส่วน annotation/bookmark เก็บถาวรตามระบบเดิม: - lib/staged-docs.ts: Map บน globalThis + sentinel "staged:<uuid>.<ext>" ใน pdf_url (ext จาก magic bytes — ไม่ต้อง migration) + hasStagedDoc แยกจาก getStagedDoc - lib/ecourt-fid.ts: external_id เก็บ HMAC-SHA256(pepper, fid) แทน fid ดิบ (env FID_HASH_PEPPER fail-closed) + แปลง row เก่าด้วย npm run migrate:ecourt-fid - route stage: อ่าน body แบบ stream นับไบต์ + idle 60 วิ/total 10 นาที = 408 + concurrency default 1 = 429 + base64 strict + magic bytes กำหนด ext เอง - registerStagedFile: ลำดับกัน race 23505 — stage ใต้ id ก่อน insert โดนชนแล้ว re-stage ใต้ id ผู้ชนะ + try/finally คืน RAM ผู้แพ้ทุกทางออก - ทางอ่าน 4 จุด: /api/note/pdf + export route (410 gate ไทย), resolvePublicPdfPath guard PdfPathError, PDFViewer โหลดผ่าน route + objectURL (gate ครบทุกชนิดไฟล์) - session route (D5): ลิงก์ fid-only ตัดสิน staging ตั้งแต่แลก token — มี bytes ได้ fileId / หมดอายุ fileError "พ้นช่วงจัดเตรียม" / token ออกทุกกรณี - Swagger UI: endpoint ใหม่ + คู่มือ retry (408/429/5xx ยิงซ้ำได้ stage ซ้ำปลอดภัย) - MockStageForm บน /note/dev ทดสอบ 3 ขั้นในหน้าเดียว (ต้องถือ API key จริง) Verify ครบตาม docs/PLAN-STAGED.md: tsc สะอาด + vitest 59 เคสใหม่ผ่าน + E2E browser จริง (textLayer ไทยเรียงถูก / export ฝัง annotation / restart ได้ fileError ตั้งแต่ bootstrap / RAM 100MB: peak WS +580MB commit จริง +92MB) — ผ่าน code review รอบ 7 แก้ครบทุก finding Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-11-05 12:53:56 +07:00
# แผน N11 — เอกสาร base64 จาก e-Court ผ่าน RAM staging (ไม่เก็บไฟล์)
> สร้าง 2026-11-04 ตามข้อตกลงกับผู้ใช้ · แก้รอบ 2 2026-11-04 ตาม code review (กัน race insert, body แบบ stream + concurrency, ผู้อ่าน `pdf_url` ครบชุด, hasStaged ตั้งแต่ bootstrap, deploy single-process) · แก้รอบ 3 2026-11-04 (slot timeout/คืน slot ใน finally, คู่มือ retry 429 ให้ e-Court, test sentinel×file-kind, proxy timeout, `hasStagedDoc` ไม่ touch) · แก้รอบ 4 2026-11-04 (test 408 ด้วย fake timers + 413 ไฟล์เดี่ยวเกิน total cap, เลข RAM D7 ตรงกับ D1, เคลียร์ idle+total timer ใน finally) · **implement แล้ว 2026-11-04** (ข้อเพิ่มระหว่าง implement: cap ต่อไฟล์อ่านจาก env `STAGED_MAX_FILE_BYTES` default 100MB — ให้ test ปรับได้โดยไม่ต้องอัด 140MB จริง) · **แก้รอบ 5 2026-11-04 หลังทดสอบจริง: regex `/^[A-Za-z0-9+/]+={0,2}$/` ใน `decodeBase64Strict` ล้น stack บน dev server (RangeError ที่ base64 ~140MB — quantifier ย้อนกลับลึก V8 บางสถานะ fallback interpreter) → เปลี่ยนเป็น negated-class scan `/[^A-Za-z0-9+/=]/` + ตรวจ `=` ท้ายชุดด้วย indexOf — เดินเชิงเส้นไม่มี backtracking semantics เดิมทุกประการ + regression test payload ใหญ่** · **แก้รอบ 6 2026-11-04 ตามคำขอผู้ใช้: external_id เก็บ hash ของ fid แทน fid ดิบ — `lib/ecourt-fid.ts` (HMAC-SHA256 + env `FID_HASH_PEPPER` fail-closed) เป็นจุดเดียวของการสร้าง/เทียบทุกทาง (stage/N8/session D5/N10) + default title เลิกฝัง fid (`ECOURT_DEFAULT_TITLE`) + row เก่าแปลงด้วย `npm run migrate:ecourt-fid` (หยุดแอปก่อนรัน — ดูแผน has-fid-swift-widget) · contract ฝั่ง e-Court ไม่เปลี่ยน (ยังส่ง fid ดิบมา) · externalFileId ของ N10 กลายเป็นค่า hash one-way** · **แก้รอบ 7 2026-11-05 ตาม code review (⚠️ 1 + 💡 5): รูป staged jpg/png ทาง history ตอน staging หมดอายุได้ gate message ไทยแล้ว** (branch non-TIFF ของ PDFViewer เปลี่ยนเป็น fetch เอง → เช็ค `!res.ok` → `StagedGateError` เหมือน PDF/TIFF branch + ผลิต object URL — แก้พร้อมข้อ re-fetch ทุก virtualization remount เพราะ blob ใน memory ไม่ยิง route ซ้ำ) · **cleanup candidateId ใน 23505 path สม่ำเสมอ** (try/finally ครอบ branch — select/put รอบสองโยนก็คืน RAM ผู้แพ้) · หมายเหตุ Swagger: typeId ของ staging รับทาง stage route ทางเดียว (ที่แนบกับ session ลิงก์ fid-only ไม่ถูกใช้) + แก้ค่า default title ใน spec ให้ตรงค่าจริง "เอกสารจาก e-Court" · ลบ dead comment "GET has-check" ของ stageJson · ยอมรับตามแผน: entry กำพร้าจาก DB ล้มหลัง insert 23505 ตอนนี้ถูก finally ครอบแล้ว เหลือเคส route 500 กลางคันที่ TTL กินเอง (ต่ำมาก) — **verify รอบ 7 ผ่าน: `tsc` สะอาด + vitest 59/59 + E2E รูป staged (stage PNG → viewer render ผ่าน objectURL ไม่ยิง route ซ้ำ → restart → ทาง history แสดง "เอกสารพ้นช่ว<E0B988><E0B8A7>
## Context
ทางเข้าเอกสารปัจจุบัน (N8) ผูกกับ `?fileUrl=` — e-Court ต้องมี URL สาธารณะที่ server เราดึงได้ (allowlist `admincourt.go.th`) กรณีที่ e-Court มีเอกสาร**ที่ไม่มี URL ให้ดึง** (เอกสารภายใน/สร้างสด) ต้องส่งเนื้อไฟล์มาเป็น **base64 ผ่าน server-to-server POST** แทน — โจทย์กำหนดว่า **ห้ามเก็บไฟล์ถาวรทุกรูปแบบ** (ไม่เขียน disk/DB/MinIO แม้แต่ไบต์เดียว) แต่ **annotation/bookmark เก็บถาวรปกติ** ตามระบบเดิม
## การตัดสินใจ (ยืนยันกับผู้ใช้ 2026-11-04)
1. **annotation เก็บถาวร** → เอกสาร staging ต้องมี `files` row จริง (FK ของ annotations/bookmarks) — staging เป็นแค่ "แหล่ง bytes ชั่วคราว" ทดแทน `pdf_url` ที่ fetch ได้
2. **base64 มาทาง e-Court เท่านั้น (POST body)** — ฝังใน URL ลิงก์ไม่ได้ (PDF 1MB = base64 ~1.4MB โดน header limit ทันที)
3. **ไม่มี dropzone/ลากไฟล์บนแอป** — ผู้พิพากษาเปิดเอกสารผ่าน e-Court ทางเดียว (ตัดทางเลือก `local:<hash>` ทั้งหมดที่เคยพิจารณา)
4. **แอปมีหน้าที่แค่ "รองรับได้"** — เปิด endpoint + contract ให้ e-Court เรียก ไม่รับผิดชอบ flow ฝั่ง e-Court (เรียกตอนไหน/กี่ครั้ง) · contract ต้องเข้า Swagger UI (`lib/openapi.ts`) เหมือนกลุ่มอื่น
5. **เก็บ bytes ใน RAM ของ process เท่านั้น** (globalThis แบบ `lib/annotate-events.ts`) — sliding TTL + LRU + cap · **ห้าม**ใช้ MinIO/disk แม้แต่แบบมี TTL (ความหมาย "ไม่เก็บไฟล์" เคร่งครัด) · **รันได้เฉพาะ single process — ดู D7 deploy**
6. **auth ใช้ `x-api-key` เทียบ env `DOCVER_API_KEY`** ผ่าน `requireDocverApiKey` (`lib/docver-token.ts` — timing-safe แล้ว: SHA-256 ทั้งสองฝั่ง + `timingSafeEqual`, fail-closed + ข้อความ 401 เดียวกันทั้งไม่ใส่/ผิด) — กลุ่มเดียวกับทางเข้า mint ของ e-Court (`POST /api/note/auth/token`) · (ทางเลือก `EXTERNAL_API_KEY` ของ N10 เป็นฝั่งอ่าน ไม่ใช้กับฝั่งเขียน)
7. **ไม่มี schema migration** — ไม่เพิ่ม column: ใช้ sentinel ใน `pdf_url` คือ **`staged:<uuid>.<ext>`** (มี extension ติดไปเสมอ) เพื่อให้ `fileKindFromUrl`/`imageFormatFromUrl`/`imageContentTypeFromUrl` ทำงานทุกจุดโดยไม่แก้ (extension คือ single source of truth ของชนิดไฟล์ตาม N3)
## Design
### D1 — Endpoint: `POST /api/note/files/stage`
ผู้เรียก: backend e-Court (server-to-server, ไม่มี CORS, ไม่มี session ของแอป)
- **Auth ก่อนแตะอะไรทั้งสิ้น:** `requireDocverApiKey(req)` — env ไม่ตั้ง/ว่าง = 401 fail closed
- **Body (JSON, allowlist):**
| field | จำเป็น | เกณฑ์ |
|---|---|---|
| `fid` | ✅ | รหัสเอกสาร e-Court — เกณฑ์เดียวกับ `parseLoginTokenBody` (cap 200, ห้ามอักขระ `& = + %`) — เป็น dedupe key `ecourt:<hash ของ fid>` ผ่าน `lib/ecourt-fid.ts` (2026-11-04 รอบหลัง: external_id เก็บ HMAC-SHA256(pepper, fid) ไม่มี fid ดิบใน DB — env `FID_HASH_PEPPER` ค่าคงที่ตลอดชีพ) |
| `data` | ✅ | **base64 มาตรฐานต่อเนื่อง** — ห้ามมี whitespace/line break, **ไม่รับ base64url** (`-`/`_`) → validate ด้วย scan บน string ต้นทาง (regex ครั้งเดียว ไม่สร้างสำเนาตัด whitespace) + `len % 4 === 0` + คำนวณความยาวที่ควรได้จากสูตร `len/4×3 − padding` แล้วเทียบกับ `Buffer.length` หลัง decode — Node `Buffer.from(b64)` ยืดหยุ่นเกิน (ได้ garbage เงียบ ๆ) การเทียบความยาวเป็นตัวตัดสิน |
| `typeId` | — | กลุ่มเอกสาร N9 — normalize ผ่าน `normDocumentGroup` เหมือน N8 (ส่งมา = refresh, ไม่ส่ง = คงเดิม) |
| `title` | — | ชื่อแสดง — trim/cap 200 · insert ครั้งแรกไม่ส่ง = `ECOURT_DEFAULT_TITLE` ("เอกสารจาก e-Court" — ไม่ฝัง fid, 2026-11-04) · **re-POST อัปเดต title ได้เฉพาะเมื่อ title ปัจจุบันยังเป็นค่า default** (ครั้งแรกที่ส่ง title จริงชนะ แล้วล็อก — กันค้างค่า default ตลอดชีพเมื่อครั้งแรกลืมส่ง) |
- **อ่าน body แบบ stream + นับไบต์ (Content-Length เพียงพอไม่ได้ — chunked transfer ไม่มี header):** ตรวจ `Content-Length` เป็น fast-path (เกิน → 413 ทันที) แล้วอ่าน `req.body` ด้วย `ReadableStream` reader สะสมนับไบต์ ตัดที่ **base64 payload ≤ ~140MB** (100MB เนื้อไฟล์ — ตัวเลขจาก env `STAGED_MAX_FILE_BYTES` default 100MB, base64 cap = `4×ceil(n/3)+8KB` envelope headroom — ปรับ env ได้เพื่อ test/ops) → เกิน = **413** ก่อนแลกทั้งก้อน
- **จำกัด concurrent request ของ endpoint นี้:** counter บน globalThis (ครอบตั้งแต่ก่อนอ่าน body จนจบ decode) — env `STAGED_MAX_CONCURRENT` **default 1** → เกิน = **429** `Retry-After: 5` — กัน peak RAM หลายสำเนาบวมเกิน cap ตอนยิงพร้อมกัน · **คืน slot ใน `finally` ทุกทางออก** (สำเร็จ/error/client ตัดการเชื่อมต่อ — ผูก `req.signal` abort) — ลืมจุดนี้ = ไฟล์ 100MB บนเน็ตช้ากิน slot นานเป็นนาที จน request ถัดไป 429 ตลอด
- **Read timeout ของ body (กัน slot ค้างแบบที่สอง):** idle **60 วิ** (ไม่มี chunk มาเลย — เน็ตช้าแต่ยังไหลไม่ตัด) และ total **10 นาที** ต่อการอ่านทั้งก้อน (100MB ใน 10 นาที ≈ 170KB/s — ช้ากว่านี้ตัดให้ retry) → เกินอย่างใด = **408** `{"error":"อัปโหลดช้าเกินกำหนดหรือการเชื่อมต่อขาด — ลองส่งใหม่"}` · ตอน implement **เคลียร์ทั้ง idle และ total timer ใน `finally` เสมอ** — ลืมแล้ว timer ค้างหลัง request จบ (โดยเฉพาะ Next dev hot-reload ที่สะสม)
- **Peak RAM ต่อ request (เอกสาร 100MB เต็ม):** chunk สะสม ~140MB + text ต่อจาก decode ~140MB + string `data` หลัง `JSON.parse` ~134MB + `Buffer` 100MB — ชั่วคราว **≈ 420–520MB** (ปลดอ้างอิงทิ้งทีละชั้นหลังใช้เสร็จ = ปลายต่ำ ~420MB, GC ไม่ทันปล่อย = ~520MB; concurrency 1) — **container ที่รัน standalone ควรมี memory ≥ 1.5–2GB** · **วัดจริงแล้ว 2026-11-05** (dev server `next dev` บน Windows, ยิง POST 100MB เต็ม + วัด WorkingSet/PrivateMemory ของ process ทุก 150ms ระหว่าง request — ไม่ใช่ `process.memoryUsage()` ใน route เพราะไม่อยากแต้โค้ด production): **สำเร็จ 200 ใน 4.5 วิ, peak WorkingSet +580MB จาก baseline** (826→1406MB; สูงกว่าประมาณการเล็กน้อยเพราะ WS นับ heap เดิมที่ถูก touch ซ้ำด้วย) ขณะที่ **PrivateMemory (commit จริง) เพิ่มแค่ +92MB** — แปลว่า memory ใหม่ที่ commit จริงต่อ request ≈ staging resident 100MB เท่านั้น ส่วนใหญ่เป็นการ reuse heap ที่ V8 commit ค้างไว้ + GC คืนแบบ lazy (หลังจบ +5s WS ยังค้าง +438MB เหนือ baseline) · **สรุปสำหรับ D7: headroom ≥1.5–2GB ยังเพียงพอ** (ตัวเลขนี้คือ `next dev` ซึ่ง baseline สูง ~826MB อยู่แล้ว — production standalone baseline ต่ำกว่ามาก ให้วัดซ้ำตอน deploy ถ้าต้องการตัวเลข production จริง)
- **ตรวจ magic bytes กำหนดชนิดเอง ไม่เชื่อ client ประกาศ:** PDF (`isPdfBytes` ของ `lib/document-versioning.ts`) / jpeg `FF D8 FF` / png `89 50 4E 47` / tiff `II*`·`MM\0` → ไม่รู้จัก = **400** "ไฟล์ไม่ใช่ PDF หรือรูปภาพที่รองรับ" → ext ของ sentinel มาจากผลตรวจนี้ (`pdf`/`jpg`/`png`/`tif`) · ไฟล์เดี่ยวใหญ่กว่า `STAGED_MAX_TOTAL_BYTES` = **413** ที่ตัว stage เลย (ไม่ไล่ evict เพื่อนแล้วก็ยังไม่พอ)
- **ลำดับงาน (กัน race insert — ดู D3):** resolve id ก่อน → **stage ใต้ id นั้น** → เขียน DB → โดน `23505` → re-stage ใต้ id ผู้ชนะ + ลบของผู้แพ้ · ผลบวกของลำดับนี้: **การตรวจ cap เกิดก่อนแตะ DB** (ไม่สร้าง row ค้างไม่มี bytes) และ DB พังหลัง stage = entry กำพร้าใน RAM ที่ TTL กินเอง ไม่มีอะไรต้องเก็บกวาด (ตรงข้าม N4 ที่ไฟล์เขียนแล้วต้องลบ)
- **ตอบ:** `{fileId}` (uuid ของ row) · e-Court เอา fid เดิมไป mint login token ตามเส้นเดิม → ลิงก์เข้าแอปเหมือนวันนี้
- **ข้อผิดพลาด:** JSON `{"error": <ไทย>}` ทั้งหก 401/400/408/413/429/500 (pattern `docverError`) · **ห้าม log bytes/key ดิบ** — log ได้เฉพาะ fid + ขนาด
- **ไม่ publish SSE / ไม่ `revalidatePath`** — รายการไฟล์ไม่ sync ผ่าน SSE (เหมือน N8/N4) + เป็น route handler ไม่ใช่ Server Action (carve-out invariant #4 — ต้องสลักใน CLAUDE.md ตอน implement)
### D2 — RAM staging: `lib/staged-docs.ts` (pure — unit test ได้ไม่ต้อง DB)
- `Map` บน **`globalThis`** (pattern เดียวกับ `annotate-events.ts`/`db/index.ts` — dev hot-reload จะไม่ duplicate) key = fileId (uuid) ค่า = `{ bytes: Buffer, ext, createdAt, lastAccessAt }`
- **Sentinel URL:** `stagedSourceUrl(fileId, ext)` → `staged:<uuid>.<ext>` · `parseStagedSource(url)` → `{ fileId, ext } | null` (validate uuid + ext ∈ whitelist — ทั้งคู่ server generate เอง การ validate ซ้ำเป็น defense) · `isStagedSource(url)`
- **TTL แบบ sliding จาก last access:** expire เมื่อ `now − lastAccessAt > TTL` — ทุก `get` ที่สำเร็จ = touch (ระหว่าง annotate ไม่มีการอ่าน bytes — ตั้ง default **`STAGED_DOC_TTL_SECONDS = 28800` (8 ชม. เท่า `NOTE_SESSION_TTL`)** ให้ครอบ session ทั้งก้อน, env ปรับได้)
- **Cap + LRU:** ต่อไฟล์ 100MB (ที่ D1 ตัดไว้แล้ว) · **รวมทุกไฟล์ default 256MB** (`STAGED_MAX_TOTAL_BYTES`) · แทรกแล้วเกิน cap = ไล่ evict รายการที่ `lastAccessAt` เก่าสุด (LRU) จนคืนใต้ cap — re-POST fid เดิม = **replace ช่องเดิม** (ไม่งอกใหม่ต่อครั้ง) · **config guard:** module init ถ้า total cap < 100MB = `console.warn` หนึ่งครั้ง (ไฟล์เดี่ยวใหญ่สุดจะโดน 413 ตลอด — ปรับ env หรือตั้งใจใช้กับไฟล์เล็กก็ได้)
- **`getStagedDoc(url)` เทียบ ext ของ sentinel กับ ext ของ entry ด้วย** — ไม่ตรง (row เขียนค้างระหว่างอัปเดตไม่สำเร็จ) = miss เหมือนหมดอายุ → ทางอ่านได้ 410 gate เสมอ ไม่มี sentinel โกหก
- **`hasStagedDoc(url)` แยกจาก `getStagedDoc`** — เช็ค "มีอยู่ไหม" (expire + ext เทียบชุดเดียวกัน) โดย **ไม่ copy ไม่ touch TTL** — สำหรับผู้ที่แค่ตัดสินใจไม่ได้อ่าน bytes จริง อย่าง D5 ตอนแลก token (ไม่ยืดอายุ/ถือ Buffer เปล่า) · `getStagedDoc` = ผู้อ่านจริง (touch + คืน Buffer) — semantics สองตัวแยกกันชัดเจน
- **Lazy sweep ไม่มี timer:** ตรวจ expire ตอน get + เก็บกวาด entry หมดอายุตอน put (กัน `setInterval` ค้างใน dev — บริบทเดียวกับ rule ของ `annotate-events`)
- **single-process** — ข้อจำกัดเดียวกับ SSE pub/sub (invariant #11) — ขยายที่ D7
### D3 — ลงทะเบียน row: `registerStagedFile` ใน `lib/external-import.ts` (กัน race 23505)
สัญญา `registerStagedFile(rawFid, bytes, ext, title?, typeId?)` — **trusted โดย construction** (ผู้เรียกผ่าน `requireDocverApiKey` ที่ route แล้ว) — bytes/ext คือของที่ผ่าน magic-byte check แล้ว:
1. `externalId = ecourtExternalId(fid)` (= `"ecourt:" + HMAC-SHA256(pepper, fid)` — 2026-11-04 รอบหลัง: ไม่มี fid ดิบใน DB) → select ด้วย `external_id`
2. **เจอ** → `winnerId = row.id` → `putStagedDoc(winnerId, bytes, ext)` (replace — last-write-wins ตาม semantics "re-POST = เนื้อล่าสุด")
3. **ไม่เจอ** → `candidateId = randomUUID()` → `putStagedDoc(candidateId, …)` → insert row ด้วย `candidateId`
- insert โดน **`23505`** (คู่ขนาน fid เดียวกัน) → select ใหม่ได้ `winnerId` ของฝ่ายชนะ → **`putStagedDoc(winnerId, bytes, ext)` ทับ** (bytes ของเราแทนของฝ่ายชนะ — last-write-wins เหมือนข้อ 2) → **`deleteStagedDoc(candidateId)`** คืน RAM → `winnerId` ใช้ต่อ
- insert ผ่าน → `winnerId = candidateId`
4. update row เมื่อจำเป็น (รวมกันเป็น patch เดียว): `pdfUrl` sentinel เปลี่ยน (ext ต่าง) / `typeId` เมื่อส่งมา / `title` เมื่อส่งมา**และ**ค่าปัจจุบันยังเป็นค่า default `ECOURT_DEFAULT_TITLE`
5. คืน `{ fileId: winnerId }` — **ไม่มีทางเกิด "row ชี้ sentinel ที่ไม่มี bytes"**: ทุก path จบด้วย staging ใต้ id เดียวกับ row สุดท้ายที่คืน
- **ผสาน flow กับ N8 แบบเดิม:** fid เดิมเปลี่ยนวิธีส่ง (URL ↔ base64) = `pdf_url` เปลี่ยนตามรอบล่าสุด สองทางอยู่ร่วมกันได้ · หลัก "id เปลี่ยน = ไฟล์ใหม่" ใช้เหมือนเดิมทุกประการ
### D4 — ทางอ่าน bytes: inventory ผู้อ่าน `pdf_url` ทั้ง repo (grep 2026-11-04) + แก้ 4 จุด
**ผู้อ่านทั้งหมดใน repo:**
| จุด | ประเภท | อะไรทำ |
|---|---|---|
| `app/api/note/pdf/route.ts:31` | **อ่าน bytes** | แก้ — เติม branch staging ก่อน `isRemoteSource` |
| `app/api/note/annotations/export/route.ts:227` | **อ่าน bytes** | แก้ — จุดเดียวกัน |
| `components/pdf/PDFViewer.tsx:1744,1795` | **อ่าน bytes (ฝั่ง client)** | แก้ — branch รูป + ตัด fallback |
| `lib/server-pdf.ts` (`readPublicPdf`) | อ่าน path | แก้ — guard ปฏิเสธ `staged:` ชัด ๆ |
| `app/api/note/files/[id]/bootstrap/route.ts:73` | ส่งต่อให้ client | ไม่แตะ — viewer ใช้แค่ derive ชนิด/เลือกทางโหลด (แก้แล้วใน PDFViewer) |
| `components/pdf/PDFViewer.tsx:3220`, `app/note/groups/[typeId]/page.tsx:98`, `utils/file-kind.ts` | derive-only | ไม่แตะ — ext ใน sentinel ทำให้ทำงานเอง (ตรวจ `extOf()` แล้ว: ตัด `?#` → segment ท้าย → `lastIndexOf(".")` — sentinel เข้ากันดี แต่**ล็อกด้วย test ตาม Verify ข้อ 2** กัน implementation เปลี่ยนวิธี parse ภายหลัง) |
| `scripts/seed-samples.mjs` | query | ไม่แตะ — จับเฉพาะ `pdf_url like '/samples/%'` |
| `app/api/note/files/open/route.ts` | ทางเข้า | ไม่แตะ — รับ path ในเครื่อง `/…` อย่างเดียว sentinel เข้าไม่ได้ |
| N10 `lib/external-api.ts` | query | ไม่แตะ — ไม่ select ฟิลด์ `pdf_url` |
| `app/note/uploads/[name]/route.ts` | เสิร์ฟไฟล์อัปโหลดเดิม | ไม่แตะ — อ่านชื่อจาก path param ไม่ใช่ `pdf_url` |
**การแก้ 4 จุด:**
1. **`app/api/note/pdf/route.ts`** — ก่อน `isRemoteSource`: `isStagedSource(file.pdfUrl)` → `getStagedDoc(file.pdfUrl)` → miss/หมดอายุ/ext ไม่ตรง = **410** `{"error":"เอกสารพ้นช่วงจัดเตรียม — กลับ e-Court กดลิงก์ใหม่"}` · ได้ bytes แล้วไหลเข้า branch เดิมทั้งหมด (ext ใน sentinel ทำให้ `fileKindFromUrl` แยกรูป/PDF + `imageContentTypeFromUrl` ถูก) → **การซ่อม ToUnicode ไทยทำงานกับ staging อัตโนมัติ** · `fixPdfActualText` **ไม่ mutate input** (pdf-lib `load`→`save` สร้างชุด bytes ใหม่ — ตรวจแล้ว `lib/pdf-actual-text.ts:136-198`) ดังนั้น Buffer ใน Map ปลอดภัยแม้ถูกอ่านพร้อมกันหลาย request
2. **`app/api/note/annotations/export/route.ts`** (F8) — จุดเดียวกัน (`isRemoteSource ? … : readPublicPdf` → เติม staged ก่อนหน้า) → export ฝัง annotation ใช้ได้เลย · TIFF/EXIF ผ่าน `imageDocFromBytes` ตาม N3
3. **`PDFViewer.tsx`** — (ก) branch รูปภาพ `:1744`: `isHttpUrl(pdfUrl)` → เพิ่ม `|| pdfUrl.startsWith("staged:")` ให้โหลดผ่าน `/api/note/pdf?fileId=` (PDF branch ไม่ต้องแตะ — fetch ผ่าน route นี้อยู่แล้ว) · (ข) **ตัด fallback** `fetch(withBasePath(file.pdfUrl))` เมื่อ `staged:` (โหลดจาก sentinel ตรง ๆ ไม่มีความหมาย) — ให้ error จาก route (410) ลอยขึ้นเป็นข้อความ gate ไทยแทน garbage fetch
4. **`lib/server-pdf.ts` — `resolvePublicPdfPath` เพิ่ม guard ต้นฟังก์ชัน:** `pdfUrl.startsWith("staged:")` → `throw new PdfPathError("เอกสารนี้ไม่มีไฟล์บนดิสก์ (staging พ้นช่วงจัดเตรียม)")` — **ผลตรวจโค้ดจริง: วันนี้ `"staged:x.pdf"` ผ่าน guard** (`path.resolve(publicDir, …)` ตกใน publicDir) **แล้วตายด้วย ENOENT เป็น 500 ดิบ** (Windows อาจ EINVAL เพราะ colon ในชื่อ) — เป็น defense in depth ให้ทุก caller ในอนาคตที่ลืมแยกทาง
### D5 — Session route รับลิงก์ fid-only + เช็ค staging ตั้งแต่ต้น (`app/api/note/auth/session/route.ts`)
ตอนนี้ `parsed.fileUrl !== undefined` เท่านั้นที่ลงทะเบียน — เอกสาร staging ลิงก์มีแค่ `t=` (fid อยู่ใน credential): เติม branch **`fileUrl` ไม่มี + มี `fid`** → select row `ecourt:<hash ของ fid>` (lib/ecourt-fid):
- เจอ + `pdfUrl` เป็น staged + **`hasStagedDoc` = true** → คืน `fileId` (ไม่ refresh อะไร — ใช้ `hasStagedDoc` ไม่ใช่ `getStagedDoc` เพราะตอนแลก token แค่ตัดสินใจ ไม่ต้องถือ Buffer/ยืด TTL — การอ่านจริงเกิดที่ viewer อีกที)
- เจอ + เป็น staged + **`hasStagedDoc` = false** (restart/TTL หมด/โดน evict แล้ว) → `fileError: "เอกสารพ้นช่วงจัดเตรียม — กลับ e-Court กดลิงก์ใหม่"` **ตั้งแต่ตอนแลก token** (ไม่ปล่อยเข้า viewer ไปเจอ 410 เอง)
- เจอ + `pdfUrl` เป็น URL/fileUrl ปกติ → คืน `fileId` ตามเดิม (เอกสาร N8 แบบเดิม)
- ไม่เจอ → `fileError: "เอกสารยังไม่ถูกส่งเข้าระบบ — กลับ e-Court กดลิงก์ใหม่"` (คู่ขนานข้อความ N8)
- ผู้ใช้ได้ token ตามปกติทุกกรณี · ทางเข้าจาก history/bookmark (ไม่ผ่าน session exchange) ยังเจอ 410 gate ที่ viewer — ยอมรับ
### D6 — เอกสาร contract
`lib/openapi.ts` เพิ่ม `POST /api/note/files/stage` (path เต็ม `/api/…`, servers เติมแค่ basePath ตาม invariant #12) — ระบุใน description: auth `x-api-key`, base64 มาตรฐานต่อเนื่อง, cap 100MB เนื้อไฟล์ (base64 ~134MB), error 401/400/408/413/429 + **คู่มือ retry ให้ e-Court ชัด ๆ: 408/429/5xx = ยิงซ้ำ (429 ตาม `Retry-After`, ที่เหลือ backoff) และ stage ยิงซ้ำได้ปลอดภัย** (fid เดิม = replace ไม่เกิดซ้ำซ้อน ไม่เสียอะไร) — ไม่เขียนไว้ = e-Court เจอ 429 แล้วเลิกเงียบ ๆ = เอกสารตกหล่น → ปรากฏใน Swagger UI `/api/swagger` พร้อมกลุ่ม template/N10/docver
### D8 — เครื่องมือทดสอบบน `/note/dev` (เพิ่มหลัง implement ตามคำขอผู้ใช้ 2026-11-04)
การ์ด "จำลอง e-Court — ส่งเนื้อไฟล์เข้า staging (base64)" (`components/MockStageForm.tsx`) ให้ dev ของ e-Court กดทดสอบ contract จริงทั้ง 3 ขั้นในหน้าเดียว: เลือกไฟล์ (FileReader→base64 ต่อเนื่อง, ตัด >100MB ก่อนยิง) → `POST /api/note/files/stage` → `POST /api/note/auth/token` ด้วย fid เดิม → ได้ลิงก์ fid-only + ปุ่มเปิด · **ไม่ใช่ dropzone ของแอป** (การตัดสินใจข้อ 3 ยังครบ): อยู่ใต้หน้า `/note/dev` ที่เปิดด้วย `NOTE_SSO_DEV_MINT=1` อย่างเดียว + ต้องใส่ `DOCVER_API_KEY` จริงทุกครั้งจึงไม่เป็นประตูหลัง (เหมือน `MockEcourtForm`)
### D7 — Deploy (ข้อจำกัดที่ต้องรู้ก่อนขึ้น production)
- **Single process เท่านั้น:** staging กับ SSE pub/sub อยู่บน RAM ของ process เดียว — `next start` / standalone `node server.js` **หนึ่ง process** · **ห้าม** PM2 cluster mode / หลาย replica / serverless (Vercel) — พัง**เงียบ**: request ตก instance คนละตัวได้ 410 สุ่ม ๆ / SSE event หายบางตัว ไม่มี error ชัดเจนให้เห็น
- **IIS เป็น proxy หน้าแอป** (deploy ปัจจุบัน): default `maxAllowedContentLength` = **30MB** — ตั้งเป็น ≥ 140MB ใน web.config ของ IIS ไม่งั้น **413 มาจาก IIS เป็นอังกฤษ** ก่อน request ถึงแอป (เกิดเฉพาะเอกสารใหญ่ — ทดสอบตอน deploy ด้วยไฟล์จริง) · **ข้อสองของขั้นตอน deploy:** ถ้ามี **ARR/reverse proxy ซ้อนหน้า IIS อีกชั้น ต้องขยับ request timeout ของ proxy ด้วย** — upload 100MB บนเน็ตช้ากินเวลาเป็นนาที (ARR default 120 วิ ตัดกลางทางได้) ตั้งให้ ≥ total read timeout ของแอป (D1 = 10 นาที) · Next.js route handler เองไม่มี body limit (เฉพาะ Server Actions ที่มี default 1MB — endpoint นี้เป็น route handler ไม่เกี่ยว)
- **Memory:** กราฟ peak ที่ D1 (**≈ 420–520MB ชั่วคราว** + staging resident ≤256MB) — เครื่อง/คอนเทนเนอร์ควรมี headroom ≥ 1.5–2GB
## Edge cases
- **เปิดลิงก์ก่อน e-Court POST ไฟล์** (และ row ไม่เคยมี) → D5 ให้ fileError · token ออกตามปกติ
- **TTL หมด/evict โดน LRU/process restart กลางวัน** → ทางเข้าลิงก์: D5 ตีตั้งแต่แลก token · ทางเข้า history: 410 gate ไทยที่ viewer/export · **annotation/bookmark/หน้าสรุปอยู่ครบ** (ผูก fileId ใน DB ไม่เกี่ยวกับ staging) · แก้ด้วยการเข้าใหม่จาก e-Court (= POST สดทุกครั้งตาม flow ปกติ)
- **POST fid เดียวกันสองครั้งพร้อมกันครั้งแรก** — D3 ลำดับ stage→insert→(23505)→re-stage ใต้ id ผู้ชนะ+ลบของผู้แพ้ → ไม่มี row ชี้ sentinel โดนใส่ · bytes สุดท้าย = ของ request ที่เสร็จทีหลัง (last-write-wins ตาม semantics "re-POST = เนื้อล่าสุด")
- **e-Court re-POST fid เดิมด้วยเนื้อใหม่** → staging replace + row เดิม → annotation เดิม (อาจคลาดตำแหน่งถ้าเลย์เอาต์ขยับ — known risk เดียวกับ N8 ข้อ "e-Court แก้เนื้อไฟล์")
- **fid สลับ base64 → URL (ลิงก์ N8 รอบหลัง)** → `pdf_url` เปลี่ยนเป็น URL แต่ entry เก่ายังค้างใน RAM จน TTL กินเอง — ไม่มีใครอ่าน (row ไม่ชี้แล้ว) ไม่เสียหาย แค่กิน quota ชั่วคราว
- **base64 เสีย (มี whitespace/base64url)/ไม่ใช่ PDF·รูป** → 400 ก่อนแตะ staging · **เกิน 100MB เนื้อ/140MB base64** → 413 (Content-Length ถ้ามี, นับ stream ถ้า chunked) · **ยิงพร้อมกันเกิน `STAGED_MAX_CONCURRENT`** → 429 + `Retry-After` · **client ตัดการเชื่อมต่อ/อ่าน body ช้าเกิน timeout** → abort + **คืน slot ทันทีใน `finally`** → 408 — staging ยังไม่ถูกสร้างเพราะยังไม่ถึงขั้น stage
- **หลายเอกสารจนเกิน total cap** → LRU evict รายการเก่าสุดเงียบ ๆ (ผู้ใช้เจอ gate เมื่อกลับไปเปิด — ทางกลับคือเปิดจาก e-Court ใหม่) · ไฟล์เดี่ยว > total cap → 413 ที่ stage
- **ผู้ถือ `DOCVER_API_KEY` ยิงอัด RAM ไล่ evict เอกสารคนอื่น (LRU) เป็น DoS** — **ความเสี่ยงที่รับไว้**: ผู้ถือ key = backend e-Court ซึ่งเชื่อถือได้ระดับเดียวกับผู้ถืออำนาจ mint login token อยู่แล้ว (ทำอันตรายได้มากกว่านี้โดยไม่ต้องพึ่ง staging) + concurrency 1 ถ่วงอัตรายิง · auth เอง timing-safe แล้ว (`lib/docver-token.ts` — SHA-256 + `timingSafeEqual`)
- **`/api/note/pdf` ไม่มี auth (status quo)** → staged bytes อ่านได้ด้วย fileId (uuid ที่ยากจะเดา) — ความเสี่ยงที่รับไว้เหมือน known risk ข้อ 3 ของ N8 ไม่เพิ่มหรือลด
- **รูป TIFF หลายเฟรม** → ผ่าน pdf route → `utif2` decode ฝั่ง client ตาม N3 เดิม
## Verify (เกณฑ์ตอน implement)
> สถานะหลัง implement 2026-11-04: ข้อ 1–7 **ผ่าน** (`npx tsc --noEmit` สะอาด + vitest 102 เคสของชุด N11/SSO/token ผ่านครบ, `npm test` ทั้ง repo 349/350 — ตัวที่ไม่ผ่านเป็น worker-startup timeout ของ `tests/wordaddin/metadataService.test.ts` ซึ่งผ่านตอนรันเดี่ยว = flake ของ environment ไม่เกี่ยวกับงานนี้) · **E2E บน dev server จริง (แยก port 3001) ผ่านหลัก ๆ ของข้อ 8:** stage ไฟล์ 100MB (base64 139.8M ตัวอักษร) → 200 ใน ~3.4 วินาที + audit log ครบ · ไฟล์เกิน cap 58 ไบต์ → **413** ตาม spec · mint + session fid-only (D5) → fileId ไม่มี fileError · `/api/note/pdf?fileId=` คืน bytes ครบ 100MB (หัว `%PDF-1.7`, ความยาวตรงต้นฉบับ — server ตอบใน ~4 วินาที) · **restart server → session ได้ `fileError` "พ้นช่วงจัดเตรียม" + token ยังออก + ทาง history ได้ 410 JSON ไทย**
>
> **สถานะวันที่ 2026-11-05: ครบทุกข้อ (1–11) ผ่าน** — ส่วนที่ค้างเมื่อวานทดสอบด้วย Playwright (browser จริง) บน dev server port 3000:
> - **ข้อ 8 เต็ม:** stage `thai_letterspacing.pdf` (fid `e2e-staged-thai-01`) → mint → เปิดลิงก์ fid-only `/note?t=…` ผ่าน Playwright → bootstrap แลก token + redirect เข้า viewer · textLayer ไทย 73 spans **เรียงถูกทั้งหมด — "ฉะนั้น" ติดคำเดียว ไม่มี fake space กลางคำ** (ตัวอย่างบั๊กของ patch-pdf-worker) = ToUnicode fix + patch fake-space ทำงานกับ staged bytes ผ่าน `/api/note/pdf` · มาร์ก highlight คลุมข้อความจริง (keyboard H + drag บน textLayer) + วาดเส้น (keyboard D) + bookmark (keyboard B) — **N10 ยืนยันเก็บถาวรครบ** (annotations=2, bookmarks=1) · **export PDF มี annotation ฝังจริง** (pdf-lib อ่านกลับ: rect มาร์ก 200×20pt + เส้น stroke แดง 2.38w ตำแหน่งตรงที่ลาก + outline "คั่นหน้าทดสอบ staging" อ่านไทยครบ) · **restart dev server → ลิงก์ใหม่ (mint ใหม่) ได้แบนเนอร์ "เอกสารพ้นช่วงจัดเตรียม — กลับ e-Court กดลิงก์ใหม่" ตั้งแต่ bootstrap ไม่เข้า viewer + token ยังออก (note-auth มี)** · ทาง history `/api/note/pdf` = 410 JSON ไทย · หน้าสรุปยังครบ Annotation 2 + Bookmark 1 (ชื่อ default ของ highlight = ข้อความที่คลุม — ข้อความไทยจาก staged bytes ถูกต้องอีกชั้น) · **re-POST fid เดิมหลัง restart ได้ fileId เดิม** (`4c618a71-…`) = replace ช่องเดียวตาม D3
> - **ข้อ 9:** `node scripts/make-login-link.mjs --fid e2e-staged-thai-01` (ไม่มี fileUrl, หลัง re-stage) → เปิดลิงก์ → เข้า viewer ได้ + session เก็บใน localStorage + textLayer 73 spans ครบ
> - **ข้อ 10:** `GET /api/swagger` มี `/api/note/files/stage` ใน paths พร้อมคู่มือ retry ("คู่มือ retry"/`Retry-After` ปรากฏในหน้า)
> - **ข้อ 11:** วัดแบบภายนอก (WorkingSet64/PrivateMemorySize64 ของ process ทุก 150ms ระหว่าง POST — ไม่แต้โค้ด production): ไฟล์ 100MB เต็ม → 200 ใน 4.5 วิ, **peak WS +580MB / Private commit +92MB** — บันทึกวิเคราะห์แล้วที่ D1 (headroom ≥1.5–2GB ยังเพียงพอ)
>
> หมายเหตุ environment ระหว่างทดสอบ: dev server สองตัวรันคู่กันใน repo เดียว (ตัวค้างจากก่อนหน้า + ตัวใหม่) ทำให้ `.next` ถูกเขียนทับกันเองจน route manifest เพี้ยน (POST บาง route ตอบ 404 ชั่วขณะแล้วหายเอง) — แก้ด้วยการฆ่าทั้งคู่ + ลบ `.next` + รันตัวเดียว แล้วอาการหาย ไม่ใช่ bug ของโค้ด
1. `tests/lib/staged-docs.test.ts` (pure): put/get กลับตัว · TTL sliding (touch แล้วไม่หมด/ปล่อยแล้วหมด) · **`hasStagedDoc` ไม่ touch (`lastAccessAt` ไม่ขยับ) ไม่ copy** · LRU evict ตาม total cap · replace ช่องเดียวกันต่อ fileId · get miss เมื่อ ext ไม่ตรง · ไฟล์เดี่ยว > total cap → put ปฏิเสธ · `parseStagedSource` ปฏิเสธ uuid/ext เพี้ยน · magic bytes แยก pdf/jpg/png/tif ถูก
2. `tests/utils/file-kind.test.ts` — **sentinel ครบ 4 ext เข้า helper ทั้ง 3 ตัว**: `staged:<uuid>.{pdf,jpg,png,tif}` → `fileKindFromUrl` = "pdf"/"image", `imageFormatFromUrl` = null/jpeg/png/tiff, `imageContentTypeFromUrl` = image/jpeg, image/png, image/tiff (แก้ระหว่าง implement: sentinel **pdf** ไม่ผ่าน `imageContentTypeFromUrl` ในทางปกติ — route ตอบ `application/pdf` เองตอนเป็น PDF, helper คืน `application/octet-stream` สำหรับ non-image ตาม status quo ซึ่งไม่แตะตาม D4) — ล็อกสมมติฐานของ D4 กัน helper เปลี่ยนวิธี parse ภายหลัง
3. `tests/lib/server-pdf.test.ts` — `resolvePublicPdfPath("staged:<uuid>.pdf")` โยน **`PdfPathError`** (ไม่ใช่ ENOENT ทะลุเป็น 500)
4. `tests/lib/external-import-staged.test.ts` — `vi.mock("@/db")`: เจอ row → replace staging + patch ตามเงื่อนไข (ext ต่าง/typeId ส่งมา/title ยังเป็น default) · ไม่เจอ → insert ด้วย candidateId · **insert โดน 23505 → re-stage ใต้ winnerId + ลบ candidateId + คืน winnerId** (assert ลำดับการเรียก)
5. Route tests (`POST` handler เรียกตรง + mock db/staged): **401** ไม่มี key/env ว่าง · **400** base64 มี whitespace/เป็น base64url/ความยาวไม่ลงตัว/magic ไม่รู้จัก/fid มี `&` · **413** body เกิน cap (ทั้งมีและไม่มี Content-Length) · **413 ไฟล์เดี่ยวใหญ่กว่า `STAGED_MAX_TOTAL_BYTES`** (mock store ที่ตั้ง cap ต่ำกว่าขนาดไฟล์) · **429** concurrent · **408 ด้วย fake timers: stream หยุดนิ่งครบ idle 60 วิ และ total ครบ 10 นาที → 408 + คืน slot** · **mock stream โยน/`req.signal` abort กลางทาง → ได้ error และ counter คืนใน `finally` (request ถัดไปไม่โดน 429)** · happy path ได้ `{fileId}` + staging มี bytes ใต้ id นั้น
6. **D5 tests ครบ 4 branch** (session route + mock db/staged): เจอ + `hasStagedDoc` true → ได้ `fileId` · เจอ + false → `fileError` "พ้นช่วงจัดเตรียม" · เจอ + `pdfUrl` เป็น URL ปกติ (เอกสาร N8) → ได้ `fileId` · ไม่เจอ → `fileError` "ยังไม่ถูกส่งเข้าระบบ"
7. `tsc` ผ่าน + vitest ชุดเดิมไม่แตก
8. curl/PowerShell: POST stage ด้วย sample ไทย (`thai_letterspacing.pdf` fixture) → เปิด viewer → textLayer ไทยเรียงถูก (พิสูจน์ว่า ToUnicode fix ทำงานกับ staged bytes) → มาร์ก/วาด + bookmark → export ได้ไฟล์มี annotation → **restart dev server** → เปิดใหม่: ทางลิงก์ได้ fileError ตั้งแต่ bootstrap (D5) · annotation ยังอยู่ครบบนหน้าสรุป
9. ลิงก์จริงเชื่อมกัน: `make-login-link.mjs --fid <เดิม>` (ไม่มี fileUrl) → `/note` bootstrap → เข้า viewer ได้ (D5)
10. Swagger UI `/api/swagger` เห็น endpoint ใหม่ + try it out ได้ (รวมคำอธิบาย retry)
11. วัด peak RAM จริง (`process.memoryUsage()`) กับไฟล์ใกล้ 100MB แล้วบันทึกตัวเลขกลับ D1
## Sync เอกสาร (ตอน implement)
- **CLAUDE.md:** invariant #4 carve-out เพิ่ม `POST /api/note/files/stage` (route handler เขียน `files` ผ่าน `registerStagedFile` — ไม่ publish SSE/`revalidatePath` เหตุผลเดียวกับ N8) · โครงสร้างไฟล์เพิ่ม `lib/staged-docs.ts` + route + อ้างอิงแผนนี้ · ทางเข้าเอกสารใน SSO section กล่าวถึง base64 channel · (พบระหว่างตรวจ: CLAUDE.md บอก `requireDocverApiKey` อยู่ `lib/addin-ticket.ts` แต่จริงอยู่ `lib/docver-token.ts:172` — แก้ drift นี้ด้วย)
- **PLAN-FEAT.md §7:** เพิ่มบรรทัด N11
- **PLAN-SSO.md:** flow ลิงก์ fid-only + fileError ของ staging (D5)
## Out of scope
- dropzone/ลากไฟล์บนแอป + key `local:<hash>` (ตัดตามการตัดสินใจข้อ 3)
- cache bytes ฝั่ง client (IndexedDB) และการ re-stage อัตโนมัติ — ทางกลับคือเปิดใหม่จาก e-Court
- staging ข้าม instance (Redis/LISTEN-NOTIFY) · รับ .docx หรือชนิดอื่นนอกเหนือ PDF/รูปของ N3 · auth ของ `/api/note/pdf` (known risk ตาม status quo) · หน้าจัดการ/ลบรายการ staging · ปรับ `maxAllowedContentLength` ของ IIS ให้เอง (บันทึกไว้ในขั้นตอน deploy ของผู้ดูแลเครื่อง)