eCourt-note/docs/PLAN-ECOURT.md

113 lines
26 KiB
Markdown
Raw Permalink Normal View History

# แผน N8 — เปิดเอกสารจากลิงก์ e-Court `?fileUrl=&fileId=&typeId=` (metadata-only + ดึงไฟล์สด)
> อ้างอิง: PLAN-FEAT.md §7 **N8** · สร้าง 2026-09-18 ตามข้อตกลงกับผู้ใช้ (สัญญาลิงก์ `fileUrl`+`fileId`, **ไม่เก็บสำเนาไฟล์ เอาแค่ชื่อ**, id เปลี่ยน = ไฟล์ใหม่) · อัปเดต 2026-09-22: เพิ่ม param `typeId` จัดกลุ่มเอกสารที่เกี่ยวเนื่องกัน (ส่งต่อให้ **N9** `docs/PLAN-DOCSET.md`) + แก้สถานะการ implement ตามที่ตรวจพบจริง (ด้านล่าง)
## Context
ระบบ e-Court (admcasedey.admincourt.go.th) จะลิงก์เข้ามาที่ app รูปแบบ:
```
https://ecourt.chamomind.com/docedit/docedit/note?fileUrl=<URL-encoded URL ไฟล์ PDF>&fileId=<id เอกสาร e-Court>&typeId=<รหัสกลุ่มเอกสารที่เกี่ยวเนื่องกัน>
```
ตัวอย่าง URL ไฟล์: `https://admcasedey.admincourt.go.th/admCaseEfiling/sysDocument/Attach/Temp/9/tmp_efile_tu_2_64373_0_2026-09-01_13-55-31_s.pdf`
ก่อนหน้านี้ไม่มีหน้าไหนอ่าน `searchParams` เลย (ตรวจแล้ว) — หน้า `/` เมิน query ทิ้งจึงดู "ว่างเปล่า" และสถาปัตยกรรมเดิมรองรับเฉพาะไฟล์ในเครื่อง (`files.pdf_url` = path ใน `public/` + path guard ใน `lib/server-pdf.ts`)
## การตัดสินใจ (ยืนยันกับผู้ใช้แล้ว 2026-09-18)
1. **สัญญาลิงก์ = `fileUrl` จำเป็น + `fileId` แนะนำ** — มี fileId ใช้เป็น dedupe key (`external_id = "ecourt:<fileId>"`), ไม่มี fallback เป็น `"url:<href>"` · เพิ่ม 2026-09-22: **`typeId` แนะนำ** (ผู้ใช้เลือกชื่อนี้แทน caseId) — รหัสกลุ่มเอกสารที่เกี่ยวเนื่องกัน (เช่น คดีเดียวกัน) เอกสารที่ e-Court ส่ง `typeId` เดียวกัน = กลุ่มเดียวกัน (แสดงผลใน N9)
2. **ไม่เก็บสำเนาไฟล์ เอาแค่ชื่อ** — ลง DB แค่ metadata (title จาก basename ของ URL + external_id + ตัว URL เก็บใน `pdf_url`) **ไม่ดาวน์โหลดเขียนลง `public/uploads/`** ตอนเปิดดู/export ให้ server ดึงจาก e-Court สด ๆ ผ่าน route ของเรา (กัน CORS + คุม SSRF ที่จุดเดียว)
3. **หลักจัดการเวอร์ชัน: id เปลี่ยน = ไฟล์ใหม่**
| สถานการณ์ | ผลลัพธ์ |
|---|---|
| `fileId` เดิม + URL เปลี่ยน (Temp link อายุใหม่) | row เดิม — ได้ annotation/bookmark เดิมครบ + `pdf_url` ถูก refresh เป็น URL ใหม่อัตโนมัติ |
| `fileId` เปลี่ยน (e-Court ออก id ใหม่ต่อเวอร์ชัน) | row ใหม่ = เอกสารใหม่เริ่ม blank **ไม่สืบทอดของเวอร์ชันก่อน** (ไฟล์เก่ายังอยู่ในรายการ เปิดย้อนดูได้) — กัน annotation เก่าไปวาดตกตำแหน่งคลาดบนเนื้อหาที่ขยับ (พิกัดเป็น % ของหน้า) |
## Design
### D1 — Entry point + สร้าง/หา row
- `app/page.tsx` รับ `searchParams` (Next 14 = plain object ค่า `string | string[]` — helper `firstParam`): มี `fileUrl` → เรียก `registerExternalFile` → สำเร็จ `redirect("/annotate/<id>")` (เรียก**นอก try/catch เสมอ** — `redirect()` โยน `NEXT_REDIRECT`) / ไม่สำเร็จ render กล่อง error ไทย `role="alert"` เหนือ section "เพิ่มเอกสาร" (หน้ารายการยังแสดง ไม่ blank)
- `lib/external-import.ts` — **server module ธรรมดา ไม่ใช่ "use server" action** (เรียกจาก server component; ไม่เปิดเป็น endpoint ให้ client ยิง): `registerExternalFile(rawFileUrl, rawFileId?)`
1. `validateExternalFileUrl` → fail คืนข้อความไทย
2. `externalIdFor(fileId, url)` → `select ... where external_id = key limit 1` → เจอ: ถ้า `pdf_url ≠ URL ใหม่` → `update files set pdf_url` (refresh Temp link) + ถ้าลิงก์ครั้งนี้มี `typeId` → `update files set type_id` ตามค่าล่าสุดด้วย (e-Court เปลี่ยน `typeId` = ย้ายกลุ่มตามค่าล่าสุด; **ลิงก์ไม่ส่ง `typeId` มา = คงค่าเดิม ห้ามเคลียร์**) แล้วคืน id เดิม
3. ไม่เจอ → `insert { id: randomUUID(), title: titleFromExternalUrl(url), pdfUrl: url.href, externalId, typeId? }` — `typeId` normalize: trim → ว่าง = ไม่เก็บ (NULL), cap 200 ตัวอักษร (เทียบ title)
4. **catch postgres `23505`** (insert ชน unique จาก 2 แท็บเปิดพร้อมกันครั้งแรก) → select ใหม่คืน row เดิม (ไม่มีไฟล์บนดิสก์ = ไม่มี cleanup)
- **ไม่ `revalidatePath`** — เรียกระหว่าง render ของ server component ไม่ใช่ use case ที่รองรับ และไม่จำเป็น (`/` เป็น `force-dynamic` + redirect ออกทันที) ไม่มี SSE event (รายการไฟล์ไม่ได้ sync ผ่าน SSE — เหมือน N4)
- Schema: `files` เพิ่ม `external_id text NULL UNIQUE` + `type_id text NULL` (N9) + index `files_type_id_idx` — unique บน nullable ยอมหลาย NULL → row seed/อัปโหลดเดิม (`external_id = NULL`) ไม่ชนกัน
### D2 — Validation + allowlist (pure: `utils/external-file.ts`)
- `externalHosts()` — default `["admincourt.go.th"]` (ครอบทั้ง `admcasedey.` และ `judgement-api.`), override ด้วย env `EXTERNAL_FILE_HOSTS` (comma-separated) — **อ่านตอน call ไม่ใช่ module load** เพื่อ test ได้
- `isAllowedExternalHost(host)` — suffix match: `host === entry || host.endsWith("." + entry)` (กัน `admincourt.go.th.evil.com` และ `xadmincourt.go.th`)
- `validateExternalFileUrl(raw)` — `new URL` parse ได้ + `https:` เท่านั้น + ไม่มี `user:pass@` + host ผ่าน allowlist → ไม่ผ่านคืน error code (`invalid_url` / `not_https` / `has_credentials` / `host_not_allowed`)
- `titleFromExternalUrl(url, fallbackId?)` — basename ของ pathname แล้ว `decodeURIComponent` (try/catch `URIError`) ตัดนามสกุล cap 200 ตัวอักษร; URL ไม่มีชื่อไฟล์ (path ลงท้าย `/`) → fallback `fallbackId` หรือ `"เอกสารจาก e-Court"`
- `externalIdFor(fileId?, url)` — `"ecourt:" + fileId` (trim) หรือ `"url:" + url.href`
- `sanitizeNextPath(s)` — ใช้ได้เฉพาะขึ้นต้น `/` และไม่ใช่ `//` / `/\` (กัน open-redirect ตอน login วนกลับ) — ไม่ผ่านคืน `null`
- `isHttpUrl(s)` — absolute `http(s)://` หรือไม่ (ใช้ทั้งฝั่ง server เช็ค `pdf_url` เป็นรีโมต และฝั่ง client เลือกทางโหลดรูป)
### D3 — ดึงไฟล์รีโมต (server-only: `lib/remote-file.ts`)
- `isRemoteSource(pdfUrl)` = `isHttpUrl(pdfUrl)`
- `fetchSourceBytes(url)` — validate ซ้ำอีกชั้น (defense in depth) → `fetch(url, { redirect: "follow", signal: AbortSignal.timeout(60_000) })` → ไม่ใช่ 2xx = `RemoteFileError` → **re-validate `res.url` หลัง redirect** ว่า host ยังอยู่ใน allowlist (กัน redirect chain เด้งไป host อื่น) → pre-check `Content-Length` + นับ byte ระหว่าง stream ด้วย `ReadableStream` reader ถ้าเกิน **100MB** (เท่า limit อัปโหลด N4) ยกเลิกแล้ว error → คืน `Buffer`
- จุดใช้ 2 ที่ (pattern เดียวกัน เปลี่ยนเฉพาะแหล่ง bytes ก่อนเข้า pipeline เดิม):
- `app/api/pdf/route.ts` — `isRemoteSource(file.pdfUrl) ? await fetchSourceBytes(...) : readPublicPdf(file.pdfUrl)` แล้ว branch รูป (`imageContentTypeFromUrl`)/PDF (`fixPdfActualText`) ทำงานต่อเหมือนเดิม
- `app/api/annotations/export/route.ts` (F8) — จุดเดียวกันก่อน `imageDocFromBytes`/`PDFDocument.load`
- export CSV (F16) / bookmarks JSON (N7) / หน้าสรุป (F15) ไม่อ่าน bytes อยู่แล้ว → ไม่แตะ
### D4 — Viewer กับ absolute URL ใน `pdf_url`
- `utils/base-path.ts` — `withBasePath` คืนค่าเดิมเมื่อเป็น absolute `^https?://` (ไม่งั้น prefix ทับจนพัง: `/docedit/notehttps://...`)
- PDF branch ของ viewer โหลดผ่าน `/api/pdf?fileId=` อยู่แล้ว (same-origin — ไม่กระทบ); fallback `fetch(withBasePath(file.pdfUrl))` ทำงานผ่าน bypass (อาจโดน CORS ของปลายทางแต่เป็นแค่ fallback)
- **รูปภาพ (N3)** โหลด static `pdfUrl` ตรง ๆ — ไฟล์รีโมตต้องเปลี่ยนไปโหลดผ่าน `withBasePath("/api/pdf?fileId=" + file.id)` แทน (route คืน bytes รูป + content-type อยู่แล้ว) — ใช้ `isHttpUrl` แยกทาง
### D5 — Login ไม่ทำ query หาย (deep link ของคนยังไม่ login)
- `middleware.ts` เดิม `url.search = ""` ตอน redirect `/login` → query หายทั้งหมด — แก้: เก็บ `next = pathname + search` ก่อนล้าง แล้ว `url.searchParams.set("next", next)` (เฉพาะ `next !== "/"`)
- `app/login/page.tsx` รับ `searchParams` → `sanitizeNextPath` → `if (user) redirect(next ?? "/")` + ส่ง prop `next` ให้ `LoginForm` → `router.replace(next ?? "/")` (middleware เห็น path ตัด basePath แล้ว, NextURL/router ใส่ basePath คืนเอง — invariant #12)
## Edge cases
- **URL ไม่มีนามสกุลไฟล์** → `fileKindFromUrl` default "pdf" (pdf.js ลองเปิด — เปิดไม่ได้ค่อยแสดง load error ตามข้อความเดิมของ viewer)
- **เนื้อไฟล์เพี้ยน/เป็น HTML error page** — ไม่มีการตรวจ magic bytesตอน register (ไม่ได้ดาวน์โหลด): PDF → `fixPdfActualText`/pdf.js fail → error ข้อความเดิมของ viewer/route, รูป → `img.decode()`/`utif2` fail ฝั่ง client — ยอมรับได้เพราะแหล่งเชื่อถือได้ (allowlist)
- **Temp link หมดอายุ** — เปิด viewer ไม่ได้ (annotation ยังอยู่ครบ) → เข้าใหม่ผ่านลิงก์ e-Court แล้ว `pdf_url` ถูก refresh กลับมาใช้ได้
- **e-Court แก้เนื้อไฟล์ (URL เดิม/id เดิม)** — ได้เนื้อล่าสุดเสมอ; ถ้าเลย์เอาต์เลื่อน annotation เก่า (พิกัด %) อาจคลาดตำแหน่ง — ยอมรับตามท่า "ไม่เก็บสำเนา"
- **Request คู่ขนาน URL/id เดียวกันครั้งแรก** — unique `external_id` + catch 23505 → select ใหม่คืน row เดิม
- **e-Court ไม่ encode ค่า query** — ค่าที่มี `&` ข้างใน URL จะถูกตัด (parse ตาม standard); ตัวอย่างลิงก์จริงใช้ `?`/`-` ข้างใน path ไม่มี `&` จึงผ่านแบบไม่ encode ได้อยู่แล้ว — แนะนำฝั่ง e-Court encode เสมอ
- **หน้ารายการ `/`** — ไฟล์นำเข้าแสดงเหมือนไฟล์อัปโหลด (badge ชนิดจาก extension ของ URL), กด Annotate = เปิดผ่าน route ดึงสดเหมือนกัน
- **ลิงก์ครั้งหลังไม่มี `typeId`** — ไฟล์คงกลุ่มเดิม (update เฉพาะเมื่อลิงก์ส่งมา) — ย้ายกลุ่มเกิดเฉพาะเมื่อ e-Court ส่ง `typeId` คนละค่ามากับลิงก์รอบใหม่
## รอดำเนินการ (plan-reviewer 2026-09-18 — ผู้ใช้สั่งยังไม่ต้องแก้โค้ด บันทึกไว้ในแผนก่อน)
> อัปเดต 2026-09-22 (หลัง implement รอบใหม่): ข้อ 1, 2 และ 4 **ทำแล้ว** — เหลือข้อ 3 เป็นความเสี่ยงที่รับไว้
1. **hardening — re-check หลัง redirect ไม่ครบชุด:** `lib/remote-file.ts` ตรวจ `res.url` หลัง `redirect: "follow"` แค่ hostname (`isAllowedExternalHost`) ยังไม่เช็ค protocol — ถ้า host ใน allowlist redirect ไป `http://` (host เดิม) จะได้ bytes ผ่าน plaintext ทั้งที่ต้นทางบังคับ https → แก้โดยรัน `validateExternalFileUrl(res.url)` เต็มชุดแทนการเช็ค hostname เดี่ยว
2. **เบา — else-branch ไม่มี guard ไฟล์เปล่า:** ใน `fetchSourceBytes` เมื่อ `res.body` เป็น null (เช่น HTTP 204) จะคืน Buffer เปล่าโดยไม่ผ่าน check `total === 0` ของ path หลัก → bytes เปล่าไหลไป pipeline แล้วพังเป็น error กลาง ๆ ไม่ใช่ข้อความไทย
3. **known risk — `/api/pdf` + `/api/annotations/export` เป็น proxy แบบไม่ต้อง login:** routes ถูก middleware ยกเว้น (`api`) อยู่แล้ว — ใครไม่มี session แต่รู้ `fileId` (uuid จริงใน DB) ดึง bytes เอกสารคดีผ่าน server เราได้ (ไม่ใช่ SSRF อิสระ: URL มาจาก DB + allowlist) — auth จริงเป็น out of scope ของ N8 อยู่แล้ว แต่ให้ตั้งชื่อไว้ชัด ๆ ว่าเป็นความเสี่ยงที่รับไว้
4. **เอกสาร — invariant #4 ใน CLAUDE.md ยังไม่มี carve-out:** `registerExternalFile` เขียน DB นอก Server Action + ไม่ `revalidatePath` (เหตุผล: เรียกระหว่าง render ของ server component + redirect ออกทันที + หน้า `/` force-dynamic — สมเหตุสมผล) — เหลือ sync ตัวอักษรของ invariant ให้ตรงกับ data-layer.md/แผนนี้
**สถานะการ implement (ตรวจซ้ำ 2026-09-22 — ยกเลิกบันทึกเดิม):** เอกสารฉบับก่อนของแผนนี้อ้างว่า implement และทดสอบผ่านแล้ว (tsc ผ่าน, vitest 153/153, ทดสอบ dev server ครบทุกเคส) — **ตรวจสอบจริงพบว่าโค้ดไม่มีอยู่ใน repo/DB เลย**: ไม่มี `lib/external-import.ts` / `utils/external-file.ts` / `lib/remote-file.ts` ใน branch ใด (ตรวจ `git log --all -S "external-import"` ว่างเปล่า), `db/schema.ts` และ DB จริง (`\d files`) ไม่มี column `external_id`, vitest ปัจจุบัน 139 tests — ผู้ใช้ยืนยัน (2026-09-22) ว่ายังไม่เคย implement / โค้ดหาย → **ถือว่าแผนนี้ยังไม่ถูก implement ทั้งหมด ต้องทำใหม่ตั้งแต่ต้นตามดีไซน์ด้านบน** และพับ 4 ข้อรอดำเนินการด้านบนเข้าไปทำพร้อมกันตอน implement
**ผลตรวจดีไซน์ที่ยังใช้ได้จากรอบ 2026-09-18:** invariant #1 (ไม่เขียนไฟล์), #12 (withBasePath + bypass), SSRF guard หลัก (allowlist/https/credentials/size/timeout), สถานะ 23505, middleware matcher `"/"` (root ขอ basePath ทะลุ middleware จริง — แก้ถูกจุด ไม่มี path เดิมหลุด/โดนเพิ่ม), sanitizeNextPath กัน open-redirect
**Implement รอบใหม่ (2026-09-22 บ่าย — ตามคำขอผู้ใช้กลางงาน "กดแล้วไปหน้า ?fileUrl=&fileId=&typeId"):** ลงมือจริงแล้วใน branch นี้:
- **D1** ✅ `lib/external-import.ts` (`registerExternalFile` — dedupe `external_id` + refresh `pdf_url`/`type_id` + catch 23505) + schema `files.external_id` (UNIQUE nullable, migration `drizzle/0002`/`0003` idempotent) + entry point: หน้า `/` อ่าน `?fileUrl=&fileId=&typeId=` แล้ว `redirect("/annotate/<id>")` + กล่อง error ไทย `role="alert"`
- **D2** ✅ `utils/external-file.ts` — `externalHosts` (env `EXTERNAL_FILE_HOSTS` อ่านตอน call) / suffix-match allowlist / https+credentials / `titleFromExternalUrl` / `externalIdFor` / `isHttpUrl`
- **D3** ✅ `lib/remote-file.ts` + branch รีโมตใน `/api/pdf` และ `/api/annotations/export` — **รวม hardening ข้อ 1–2 ของ plan-reviewer** (re-validate ชุดเต็มหลัง redirect + guard body เปล่า/0 ไบต์)
- **D4** ✅ `withBasePath` ผ่าน absolute http(s) ไปเฉย ๆ + รูปรีโมตของ viewer โหลดผ่าน `/api/pdf?fileId=`
- **เกินแผน (ตามคำขอผู้ใช้):** `registerExternalFile` รับ **path ในเครื่อง** (`/samples/…` `/uploads/…` — decode + whitelist + path guard ว่าไฟล์มีจริง) เพิ่มจาก https เพื่อทดสอบ grouping ใน dev + การ์ดจำลองลิงก์ `components/EcourtLinkForm.tsx` บนหน้ารายการ (กรอก 3 ช่อง กด "เปิดเอกสาร" แล้ว `router.push("/annotate?fileUrl=&fileId=&typeId=")` + แถบแสดงลิงก์เต็ม absolute ตามที่พิมพ์สด ๆ)
- **ปรับต่อเนื่อง (2026-09-22):** ถอด column `type_name` ออกจาก `files` ตามคำขอผู้ใช้ — สัญญาลิงก์เหลือ `typeId` เดี่ยว แสดงผลด้วยรหัสกลุ่มโดยตรง (migration `drizzle/0004` = `DROP COLUMN IF EXISTS`) · entry point ที่สอง **`/annotate?fileUrl=&fileId=&typeId=`** (`app/annotate/page.tsx` — การ์ด `EcourtLinkForm` พามาที่นี่ + แสดงลิงก์เต็มสด ๆ) · `registerExternalFile` เติม `https://` ให้ URL ที่ไม่มี scheme · env `EXTERNAL_FILE_HOSTS` ใช้เพิ่มโฮสต์ทดสอบใน dev (ตอนนี้ `.env` มี rd.usda.gov, pdfobject.com)
- **D5 ✅ (2026-09-22 เย็น — ปิดท้ายแผน):** login วนกลับพร้อม query — `middleware.ts` เก็บ `next = pathname + search` แนบไป `/login` (เฉพาะ `next !== "/"`) · `app/login/page.tsx` รับ `searchParams.next` → `sanitizeNextPath` (`utils/external-file.ts` — ขึ้นต้น `/` เท่านั้น และไม่ใช่ `//`/`/\` กัน open-redirect, unit test `tests/utils/external-file.test.ts`) → มี session `redirect(next ?? "/")` + ส่ง prop `next` ที่ sanitize แล้วให้ `LoginForm` → `router.replace(next ?? "/")` · sync สเปกลง `PLAN-FEAT.md` §7 N8 แล้ว
- **Verify:** `tsc` ผ่าน + Playwright ผ่านครบ (login → กรอกการ์ด → redirect เข้า `/annotate/<uuid>` → canvas render จริง / ลิงก์รอบสอง `typeId` ต่างค่าได้ id เดิม = dedupe+ย้ายกลุ่ม / `https://evil.com` = กล่อง error "ไม่อยู่ในรายการอนุญาต" / **D5:** ยังไม่ login เปิด `/annotate?fileUrl=…` → ได้ `/login?next=<path+query ครบ>` → login ผ่านแล้วกลับมาลงทะเบียนเข้า viewer + canvas render, session ครบ + เปิด `/login?next=//evil.com` → จบที่ `/` ของ app ไม่ออกนอกโฮสต์) + ทดสอบแล้วลบ row ทดสอบตาม `external_id` เป๊ะ
- **Gap-analysis รอบ 2 (plan-reviewer 2026-09-22 เย็น — หลัง D5 + sync แผน):** D1–D5 ผ่านทุกข้อ · invariant #1/#4/#12 ไม่ถูกละเมิด · hardening ข้อ 1–2 และ 4 ยืนยันทำแล้ว ข้อ 3 ยังเป็น known risk ตามที่รับไว้ · แก้ตามรายงานแล้ว 2 ข้อ: **(4)** `firstSearchParam` ย้ายจาก `lib/external-import.ts` → `utils/external-file.ts` (pure — เดิม import จาก module ที่ลาก `db`/`fs` ตามเข้าหน้า login) และ **(5)** ตัวอักษร carve-out invariant #4 + รายการ `lib/external-import.ts` ในโครงสร้างไฟล์ของ CLAUDE.md ครบทั้ง entry หน้า `/` และ `/annotate` · **บันทึกไว้พิจารณา (ยังไม่แก้):** **(1)** `app/annotate/page.tsx` ไม่มี defense `getSessionUser` ซ้อน middleware เหมือนหน้า `/` และ viewer (ปลอดภัยตราบใดที่ matcher ของ middleware ยังจับ path นี้) **(2)** runtime error จาก DB ตอน `registerExternalFile` (connection ล่ม / rethrow หลัง 23505) ทะลุออกจาก render เป็น 500 ทั้งหน้า — โค้ดจัดการเฉพาะ validation fail (แยก catch ที่ไม่ใช่ `NEXT_REDIRECT` ได้) **(3)** `validateLocalPath` เช็ค `..` บนค่า raw ก่อน `decodeURIComponent` (`%2e%2e%2f` ผ่านการเช็ค — ไม่ใช่ช่องโหว่ที่ใช้งานได้เพราะ path guard ของ `resolvePublicPdfPath` ดักทับอยู่ แต่ลำดับที่ถูกคือ decode ก่อนแล้วเช็คบนค่า decoded)
## Out of scope ของ N8
- auth/credential ต่อ e-Court (ถ้า `admcasedey` ต้องมี session จริง ต้องกลับมาเพิ่มการส่งต่อ token/cookie)
- ระบบหลายเวอร์ชันต่อเอกสาร + คัดลอก/สืบทอด annotation ข้ามเวอร์ชัน (id เปลี่ยน = ไฟล์ใหม่ ตามตารางด้านบน)
- หน้าจัดการ/ลบ/แก้ชื่อไฟล์นำเข้าใน UI, แคชสำเนาไฟล์รีโมตลงดิสก์, ไฟล์รีโมตจากโฮสต์นอก allowlist
- หน้าจัดการกลุ่ม/แก้ `typeId` ด้วยมือใน UI ของเรา (กลุ่มมาจากลิงก์ e-Court อย่างเดียว — N9)
- API ดึงข้อมูลกลับให้ e-Court (**N10** `docs/PLAN-API.md` — แผนแยก)