eCourt-note/docs/PLAN-STAGED.md
Your Name a02e983ba8 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

48 KiB
Raw Blame History

แผน 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 แสดง "เอกสารพ้นช่วงจัดเตรียม — กลับ e-Court กดลิงก์ใหม่" ไม่ใช่ generic error) · verify ข้อ 8–11 ค้างทดสอบด้วยมือ — ดูหัวข้อ Verify · เกี่ยวข้อง: N8 (docs/PLAN-ECOURT.md), N3 (ไฟล์รูปภาพ), PLAN-SSO.md (ลิงก์ e-Court ฉบับ token)

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 ของผู้ดูแลเครื่อง)