From 5121970f31dcc16c936c6965bdce97658c19572b Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 9 Nov 2026 14:30:29 +0700 Subject: [PATCH] refactor(auth): consolidate auth under /api/auth/* endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ยุบ route auth ที่เคย copy กันจน drift (flow N11 หายจาก /api/auth/session) เหลือโค้ด inline สองเส้นเดียว: POST /api/auth/token (ทางเข้า 3: x-api-key docver / x-api-key purpose:"login" mint ลิงก์ note / ticket ของ add-in) + POST /api/auth/session (แลก credential + register N8 + N11 fid-only) - POST /api/documents/token คงไว้เป็น alias re-export (กลุ่ม docver/e-Court เดิม — contract เดิมเป๊ะ, มี test toBe กัน copy แยกชุดกลับมา) - ถอดเส้นฝั่ง note ทั้งสอบ: /api/note/auth/token + /api/note/auth/session (e-Court ต้องย้ายมาทางเข้ารวม — แจ้งทีม e-Court ก่อน deploy) - utils/auth-store.ts: primitives กลางฝั่ง client (jwtExp/isExpired/ localStorage store + memory fallback) — lib/note-session ใช้ร่วม (expMs decode จาก JWT payload = นาฬิกา server) และวางไว้ให้ wordaddin ย้ายมาใช้รอบหน้าได้ (รอบนี้ไม่แต้ wordaddin ตามข้อตกลง) - นโยบาย token หมดอายุกลาง session: PDFViewer reportActionError จับ auth-error ของ Server Action ทุกจุด → ล้าง session แล้วพาไป /note?sso=expired (จบที่ gate เดียวกับ bootstrap 401) - openapi: ทางเข้า purpose:"login" + schema LoginTokenRequest + ลบ path เส้นที่ถอด + test กันเส้นตายหลุดค้างใน spec - เอกสาร sync: PLAN-SSO §0.6, CLAUDE.md, PLAN-STAGED, .env.example, /note/dev - tests: ย้าย/เพิ่มชุด auth (50 เคสกลุ่ม auth ผ่าน, ทั้งชุด 444 ผ่าน) + e2e ครบทั้ง API (mint/alias/ticket/404 เส้นเก่า/N11/D4) และ browser Co-Authored-By: Claude Code --- .env.example | 7 +- CLAUDE.md | 18 +- app/api/auth/session/route.ts | 43 +++- app/api/auth/token/route.ts | 93 ++++++--- app/api/documents/token/route.ts | 117 +---------- app/api/note/auth/dev-link/route.ts | 2 +- app/api/note/auth/session/route.ts | 134 ------------- app/api/note/auth/token/route.ts | 48 ----- app/api/note/files/stage/route.ts | 2 +- app/note/dev/page.tsx | 11 +- components/MockEcourtForm.tsx | 9 +- components/MockStageForm.tsx | 7 +- components/NoteHome.tsx | 11 +- components/pdf/PDFViewer.tsx | 61 ++++-- docs/PLAN-SSO.md | 47 +++-- docs/PLAN-STAGED.md | 4 +- lib/note-auth.ts | 8 +- lib/note-session.ts | 57 ++++-- lib/note-token.ts | 3 +- lib/openapi.ts | 187 +++++++++--------- ...ed.test.ts => auth-session-staged.test.ts} | 5 +- .../app/api/{documents => auth}/token.test.ts | 96 ++++++++- tests/lib/note-auth.test.ts | 2 +- tests/lib/openapi.test.ts | 12 +- tests/utils/auth-store.test.ts | 85 ++++++++ utils/auth-store.ts | 79 ++++++++ 26 files changed, 625 insertions(+), 523 deletions(-) delete mode 100644 app/api/note/auth/session/route.ts delete mode 100644 app/api/note/auth/token/route.ts rename tests/api/{note-auth-session-staged.test.ts => auth-session-staged.test.ts} (94%) rename tests/app/api/{documents => auth}/token.test.ts (64%) create mode 100644 tests/utils/auth-store.test.ts create mode 100644 utils/auth-store.ts diff --git a/.env.example b/.env.example index 5a283df..e822a2c 100644 --- a/.env.example +++ b/.env.example @@ -36,7 +36,8 @@ MINIO_SECRET_KEY=minioadmin # (ถอดแล้ว 2026-10-30) NOTE_SESSION_SECRET ของรอบ cookie oacCaseSig — แอป note # เปลี่ยน session เป็น token แบบ add-in แล้ว (access_token purpose=login จาก -# POST /api/note/auth/session เก็บใน localStorage "note-auth") จึงใช้ +# POST /api/auth/session ทางเข้าเดียว 2026-11-09 — เส้นเดิม /api/note/auth/session +# ถอดแล้ว เก็บใน localStorage "note-auth") จึงใช้ # DOCVER_TOKEN_SECRET ตัวเดียวทั้งระบบ: เซ็น login-token ของลิงก์ + เซ็น/ตรวจ # session token ฝั่ง Server Actions และ API ของแอป (lib/note-token.ts) @@ -75,8 +76,8 @@ FID_HASH_PEPPER= # ฝั่ง server ตรวจ HMAC ของ ticket: ADDIN_TICKET_SECRET (ไม่ตั้ง = ใช้ env SECRET # ของกลุ่ม template → ยังไม่ตั้งอีก = "super_secret" ตาม requirement) # ⚠️ production ต้องตั้งค่าจริง — ค่า default รู้กันหมด = ใครก็ปลอม ticket ได้ -# (ใช้ร่วมกับทาง ticket ของ SSO POST /api/note/auth/session ด้วย — PLAN-SSO.md D12) -# 2026-10-30: ทาง ticket ของ SSO (POST /api/note/auth/session) ปฏิเสธค่า default +# (ใช้ร่วมกับทาง ticket ของ SSO POST /api/auth/session ด้วย — PLAN-SSO.md D12) +# 2026-10-30: ทาง ticket ของ SSO (POST /api/auth/session) ปฏิเสธค่า default # นี้เฉพาะใน production — dev ใช้ default ทดสอบโหมด ticket ได้เลย (เครื่องมือ dev # อย่าง make-login-link/make-ticket-docx ก็ default ค่าเดียวกัน) # 2026-11-04: ฝั่ง add-in ไม่มี secret อีกต่อไป (ถอด NEXT_PUBLIC_ADDIN_TICKET_SECRET diff --git a/CLAUDE.md b/CLAUDE.md index b9e1d8b..fec05e7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,7 +14,7 @@ - Drizzle ORM + PostgreSQL (Docker container เดียว: `judes-note-db`, image `postgres:17-alpine`, พอร์ต 54322) — **ไม่ใช้ Supabase** + realtime sync ด้วย **SSE (Server-Sent Events)** ผ่าน `app/api/note/annotations/stream` + pub/sub in-process (`lib/annotate-events.ts`) - **wordaddin** — taskpane ของ Word add-in "e-Court Documents Versioning" พอร์ตมาเป็น route `/docedit/wordaddin` ของแอปนี้ (UI = Genesis, ชั้น services Office.js พอร์ตดิบจาก `D:\word-addin-poc\addin`) — **ไม่มี login ยกเลิก Keycloak 2026-10-27**: auth = **ticket ของ e-Court** (`ticket_data` + `HmacSha256` ใน custom properties — add-in ตรวจ HMAC เองฝั่ง client แล้วขอ token ที่ `/api/documents/token` แบบ body ticket เก็บใน localStorage `wordaddin-auth` ตาม fid, `docs/PLAN-ADDIN-AUTH.md`) และ **upload/download เป็นของจริงแล้ว** (ปลด mock 2026-10-27 — `versioningService` ยิง `/api/documents/*` ผ่าน Bearer same-origin; mock เหลือเฉพาะ `?addindemo`) (spec UI: `docs/addin-ui-flow.md`, คู่มือ dev/sideload: `docs/WORDADDIN-DEV.md`); Office.js **self-host** ที่ `public/wordaddin/office-js/` ผ่าน postinstall `scripts/fetch-office-js.mjs` — gitignore (ดาวน์โหลดฉบับล่าสุดจาก CDN ของ Microsoft ตอนติดตั้ง ตัวระบบ deploy ใช้ intranet ล้วน; แพ็กเกจ npm `@microsoft/office-js` เลิกอัปเดตที่ 1.1.110 ก่อน WordApi 1.4/1.5 — runtime ตัวนั้นไม่มี `Document.close` ใช้ไม่ได้) - Tailwind CSS + design system **Genesis** (`docs/DESIGN.md`, 2026-09-23) — palette tokens ใน `tailwind.config.ts` (`primary` เดิม + `primary-hover`/`canvas`/`line`/`ink`/`ink-soft`/`ink-faint`/`success`/`warning`/`error` + ระดับข้อความ `success-ink`/`warning-ink`/`error-ink` ≥5:1 สำหรับข้อความ/ไอคอนสถานะ — token 500 ใช้กับ border/bg tint เท่านั้น) ห้ามใช้ raw palette shades ของ Tailwind (`slate-*`/`amber-*`/`emerald-*`/`red-*` ฯลฯ) อีก; ฟอนต์ display = **General Sans** (self-host จาก Fontshare ผ่าน `next/font/local` — `app/fonts/`, variable weight 200–700, ใช้ intranet ได้), body = **DM Sans** (`next/font/google`), ไทย fallback **Sarabun** ตามลำดับใน stack (Latin ก่อนไทย — `fontFamily`/globals.css); รัศมี: ปุ่ม/input 6px (รวมปุ่ม action เล็กใน popup/composer/dialog — คู่ ยกเลิก/บันทึก ต้องได้รัศมีเดียวกัน), menu/list row ใน dropdown/panel 4px, panel/dropdown 8px, การ์ด 12px; เงาเฉพาะ hover (`shadow-card-hover`/`shadow-btn-glow` — static ไม่มีเงา ยกเว้นชั้น floating: dropdown/popover `shadow-lg` และ pin ลอยบนเอกสาร ดู §App-specific exceptions ของ DESIGN.md); focus indicator = outline น้ำเงินทึบ 2px + offset ผ่าน class `.focus-ring`/`.genesis-input` ใน globals.css (+ สำเนา `FOCUS_RING_CLS` ใน PDFViewer/AnnotationSidebar/AnnotationSelectionLayer/ToolControls — แก้ต้องแก้พร้อมกัน; halo alpha จางเกิน WCAG 1.4.11); น้ำเงินเฉพาะของ interactive (แบรนด์มาร์ก tile JN/แถบหัวหน้าสรุปยกเว้น) -- **SSO จากลิงก์ e-Court — ทางเข้า session ทางเดียว (ฉบับ token 2026-10-30 ตามคำขอผู้ใช้ "note ใช้แบบ add-in" — เลิก cookie ทั้งระบบ, `docs/PLAN-SSO.md` §0.5)**: ลิงก์ 2 รูปแบบ **(A) JWT** — e-Court mint ผ่าน `POST /api/note/auth/token` (ทางเข้า `x-api-key` ตัวเดิม `DOCVER_API_KEY`) ฝังเป็น param `t=` และ **(B) ticket** — `ticket_data`+`HmacSha256` ชุดเดียวกับตัว stamp .docx ของ add-in → ทั้งคู่ชี้ตรงมา **`/note`** ที่ `NoteHome` (client bootstrap) แลก credential เป็น **access_token (purpose=login อายุ 8 ชม. = `NOTE_SESSION_TTL`) ที่ `POST /api/note/auth/session`** → เก็บใน localStorage `note-auth` (แบบ `wordaddin/services/authService`) + ตัด credential ออกจาก URL ทันที + ลงทะเบียนเอกสาร N8 แบบ trusted ฝั่ง server เลย (fileId จาก fid ที่เซ็น — ลิงก์มี `fileUrl`; **ลิงก์ fid-only ไม่มี `fileUrl` = เอกสาร staging ของ N11**: e-Court POST เนื้อ base64 ที่ `POST /api/note/files/stage` ก่อนแล้ว mint token ด้วย fid เดิม — session route เช็ค staging ตั้งแต่แลก token, `docs/PLAN-STAGED.md`) · identity ทุก action/API แกะจาก token ผ่าน `lib/note-token.ts` (env `DOCVER_TOKEN_SECRET` ตัวเดียว — cookie คู่ oacCase/`NOTE_SESSION_SECRET`/middleware/link proof ของรอบ 2026-10-29 **ถอดหมด**) · หน้า protected เป็น shell public + client gate (`AnnotateView` — ไม่มี token = หน้า "กลับ e-Court กดลิงก์ใหม่") ดึงข้อมูลผ่าน `GET /api/note/files/[id]/bootstrap` (Bearer) · เส้นที่ browser เปิดแบบไม่ผ่าน fetch() (SSE/pdf/`` N3/export) คงไม่มี auth ตาม status quo · dev/ทดสอบ mint ลิงก์เองด้วย `scripts/make-login-link.mjs` (roles ยัง out of scope) +- **SSO จากลิงก์ e-Court — ทางเข้า session ทางเดียว (ฉบับ token 2026-10-30 ตามคำขอผู้ใช้ "note ใช้แบบ add-in" — เลิก cookie ทั้งระบบ, `docs/PLAN-SSO.md` §0.5 · ทางเข้ารวม `/api/auth/*` 2026-11-09 §0.6 รอบสอง: **เส้นฝั่ง note `/api/note/auth/*` ถอดหมด** — e-Court ต้องย้ายมา `/api/auth/token` (mint ลิงก์ = `purpose:"login"`) กับ `/api/auth/session` · ส่วน `/api/documents/token` ของกลุ่ม docver **คงไว้** เป็น alias)**: ลิงก์ 2 รูปแบบ **(A) JWT** — e-Court mint ผ่าน `POST /api/auth/token` แบบ `purpose:"login"` (ทางเข้า `x-api-key` ตัวเดิม `DOCVER_API_KEY` — ทางเข้า token รวมของทั้งระบบ; ไม่ส่ง purpose = docver-token) ฝังเป็น param `t=` และ **(B) ticket** — `ticket_data`+`HmacSha256` ชุดเดียวกับตัว stamp .docx ของ add-in → ทั้งคู่ชี้ตรงมา **`/note`** ที่ `NoteHome` (client bootstrap) แลก credential เป็น **access_token (purpose=login อายุ 8 ชม. = `NOTE_SESSION_TTL`) ที่ `POST /api/auth/session`** → เก็บใน localStorage `note-auth` (ผ่าน `lib/note-session.ts` บน primitives ของ `utils/auth-store.ts` — exp decode จาก JWT payload) + ตัด credential ออกจาก URL ทันที + ลงทะเบียนเอกสาร N8 แบบ trusted ฝั่ง server เลย (fileId จาก fid ที่เซ็น — ลิงก์มี `fileUrl`; **ลิงก์ fid-only ไม่มี `fileUrl` = เอกสาร staging ของ N11**: e-Court POST เนื้อ base64 ที่ `POST /api/note/files/stage` ก่อนแล้ว mint token ด้วย fid เดิม — session route เช็ค staging ตั้งแต่แลก token, `docs/PLAN-STAGED.md`) · identity ทุก action/API แกะจาก token ผ่าน `lib/note-token.ts` (env `DOCVER_TOKEN_SECRET` ตัวเดียว — cookie คู่ oacCase/`NOTE_SESSION_SECRET`/middleware/link proof ของรอบ 2026-10-29 **ถอดหมด**) · หน้า protected เป็น shell public + client gate (`AnnotateView` — ไม่มี token = หน้า "กลับ e-Court กดลิงก์ใหม่") ดึงข้อมูลผ่าน `GET /api/note/files/[id]/bootstrap` (Bearer) · **token หมดอายุ = เอาออกจาก session เลย** (ตายแล้วไม่มีทางฟื้น — browser ไม่มี ticket ให้ renew): `readNoteSession` ล้างตอนอ่าน + **กลาง session PDFViewer จับ auth-error ของ action (`isNoteAuthExpiredError` ผ่าน `reportActionError`) → ล้าง session + พาไป `/note?sso=expired`** · เส้นที่ browser เปิดแบบไม่ผ่าน fetch() (SSE/pdf/`` N3/export) คงไม่มี auth ตาม status quo · dev/ทดสอบ mint ลิงก์เองด้วย `scripts/make-login-link.mjs` (roles ยัง out of scope) ## Invariants ที่ต้องรักษา (ห้ามละเมิด) @@ -29,7 +29,7 @@ 9. **Optimistic update ใช้ `useOptimistic` (React 18) ภายใน `startTransition`** — React rollback อัตโนมัติเมื่อ action ล้มเหลว + dedupe ด้วย id ใน `hooks/useAnnotationRealtime.ts` (duplicate ชั่วขณะกับ realtime event หายเอง) 10. **Page Virtualization:** render เฉพาะหน้าใน viewport ± 1 หน้าด้วย IntersectionObserver — หน้าพ้นจอเก็บ placeholder ขนาดจริง (scroll ไม่ jump, bookmark ยังคำนวณตำแหน่งได้) และ unmount canvas/TextLayer/InkLayer คืน memory — ห้าม render ทุกหน้าพร้อมกัน 11. **DB connection:** `DATABASE_URL` ชี้ที่ plain PostgreSQL container `judes-note-db` (`postgresql://postgres:postgres@127.0.0.1:54322/ecourt-note`) + `prepare: false` ใน postgres.js เสมอ — pub/sub ของ SSE **และ RAM staging ของเอกสาร base64 (N11 `lib/staged-docs.ts`)** เป็น in-memory (ใช้ได้เมื่อรัน process เดียว; ถ้ารันหลาย instance ต้องเพิ่ม Redis pub/sub หรือ Postgres LISTEN/NOTIFY ก่อน) -12. **URL ฝั่ง browser ที่สร้างเองต้องผ่าน `withBasePath()`** (`utils/base-path.ts` — เติม basePath `/docedit` **+ app path `/note`** จาก env `NEXT_PUBLIC_BASE_PATH`/`NEXT_PUBLIC_APP_PATH` ประกาศที่ `next.config.mjs` ที่เดียว) ครอบคลุม: `fetch()`/`EventSource`/plain ``/`workerSrc`/`img.src`/`file.pdfUrl` ตอนโหลดไฟล์/ลิงก์คัดลอก F12 — ส่วน ``/`next/navigation` (router.push/replace/redirect) Next เติม basePath `/docedit` ให้เองเท่านั้น **path ที่เขียนถึง route จึงต้องมี `/note` นำหน้าเสมอ** (เช่น `href="/note/annotate"`, `redirect("/note/…")`, `revalidatePath("/note/…")`) **ยกเว้น API — route API อยู่ใต้ `app/api/note/**` (URL `/docedit/api/note/…`): path ที่ขึ้นต้น `/api/` เขียนครบเป็น `/api/note/…` แล้ว `withBasePath` เติมแค่ basePath ไม่มี `/note` ซ้อน — เช่นเดียวกับ static/หน้าของ sibling wordaddin: path ที่ขึ้นต้น `/wordaddin/` (office.js self-host, manifest, icons) เติมแค่ basePath**; `middleware.ts` จับ path ที่ตัด basePath แล้ว (หน้า/ไฟล์ของแอปขึ้นต้น `/note/…` ส่วน API ขึ้นต้น `/api/note/…` — รายการยกเว้นใน matcher: ของแอปนำหน้า `note/` เช่น `note/uploads`, API เป็น `api/note`, `wordaddin` (taskpane ของ Word add-in — รันใน webview ของ Word ไม่มี cookie oacCase และมี auth ของตัวเองแยกจากแอป note), ส่วน `fonts`/`favicon` อยู่ public/ รากไม่มี prefix — NextURL ใส่ basePath กลับตอน redirect เอง); `pdf_url` ใน DB เก็บ logical path ไม่รวม prefix เติมตอนใช้: ฝั่ง browser ผ่าน `withBasePath` / ฝั่ง disk ผ่าน `resolvePublicPdfPath` (`lib/server-pdf.ts` root ที่ `public/note/` — statics ของแอปอยู่ใต้ `public/note/**` ทั้งหมดยกเว้นฟอนต์ export) +12. **URL ฝั่ง browser ที่สร้างเองต้องผ่าน `withBasePath()`** (`utils/base-path.ts` — เติม basePath `/docedit` **+ app path `/note`** จาก env `NEXT_PUBLIC_BASE_PATH`/`NEXT_PUBLIC_APP_PATH` ประกาศที่ `next.config.mjs` ที่เดียว) ครอบคลุม: `fetch()`/`EventSource`/plain ``/`workerSrc`/`img.src`/`file.pdfUrl` ตอนโหลดไฟล์/ลิงก์คัดลอก F12 — ส่วน ``/`next/navigation` (router.push/replace/redirect) Next เติม basePath `/docedit` ให้เองเท่านั้น **path ที่เขียนถึง route จึงต้องมี `/note` นำหน้าเสมอ** (เช่น `href="/note/annotate"`, `redirect("/note/…")`, `revalidatePath("/note/…")`) **ยกเว้น API — route API อยู่ใต้ `app/api/note/**` (URL `/docedit/api/note/…`) และกลุ่มที่อยู่นอก `/api/note`: `app/api/auth/**` (token/session รวม — PLAN-SSO.md §0.6, URL `/docedit/api/auth/…`) + `app/api/documents/**` (docver) + `app/api/template/**` + `app/api/swagger` — path ที่ขึ้นต้น `/api/` เขียนครบตามกลุ่มของมัน (เช่น `/api/note/…`, `/api/auth/…`, `/api/documents/…`) แล้ว `withBasePath` เติมแค่ basePath ไม่มี `/note` ซ้อน — เช่นเดียวกับ static/หน้าของ sibling wordaddin: path ที่ขึ้นต้น `/wordaddin/` (office.js self-host, manifest, icons) เติมแค่ basePath**; `middleware.ts` จับ path ที่ตัด basePath แล้ว (หน้า/ไฟล์ของแอปขึ้นต้น `/note/…` ส่วน API ขึ้นต้น `/api/note/…` — รายการยกเว้นใน matcher: ของแอปนำหน้า `note/` เช่น `note/uploads`, API เป็น `api/note`, `wordaddin` (taskpane ของ Word add-in — รันใน webview ของ Word ไม่มี cookie oacCase และมี auth ของตัวเองแยกจากแอป note), ส่วน `fonts`/`favicon` อยู่ public/ รากไม่มี prefix — NextURL ใส่ basePath กลับตอน redirect เอง); `pdf_url` ใน DB เก็บ logical path ไม่รวม prefix เติมตอนใช้: ฝั่ง browser ผ่าน `withBasePath` / ฝั่ง disk ผ่าน `resolvePublicPdfPath` (`lib/server-pdf.ts` root ที่ `public/note/` — statics ของแอปอยู่ใต้ `public/note/**` ทั้งหมดยกเว้นฟอนต์ export) ## โครงสร้างไฟล์ (ตามแผน) @@ -49,7 +49,7 @@ lib/annotate-events.ts ← pub/sub in-process (actions publish → SSE route sub lib/server-pdf.ts ← path guard อ่าน PDF จาก public/note/ (export ใช้; N11 2026-11-04: `resolvePublicPdfPath` ปฏิเสธ sentinel `staged:` ด้วย `PdfPathError` ชัด ๆ ก่อน resolve — defense in depth ให้ caller ในอนาคตที่ลืมแยกทาง) lib/note-auth.ts ← SSO ของแอป note ส่วนกลาง — pure (PLAN-SSO.md §4 ฉบับ token 2026-10-30: ไม่ import @/db/node:crypto ให้ client component ใช้ร่วมได้): parseSsoLinkParams (bootstrap ของ NoteHome — allowlist t/ticket_data/HmacSha256/fileUrl/typeId + cap ยาว, สองชุดพร้อมกัน = invalid ตาม D12) + parseSessionExchangeBody (body ของ POST /api/note/auth/session — เกณฑ์เดียวกัน) + parseLoginTokenBody (body ของเส้น mint: allowlist userid/username/fid/ttl_seconds 60–600 default 300 + ห้ามอักขระ & = + % ใน identity) + devMintEnabled/parseDevLinkBody (dev-link) + ssoBanner (แบนเนอร์ 4 ค่า) — unit test tests/lib/note-auth.test.ts lib/note-token.ts ← session ฝั่ง server ของแอป note (2026-10-30): NOTE_SESSION_TTL (8 ชม.) + noteUserFromToken/noteUserFromRequest (verifyDocverLoginToken + env DOCVER_TOKEN_SECRET — ใช้ใน requireUser ของ actions และ API ของแอป; import lib/docver-token ซึ่งใช้ node:crypto จึง**ห้ามให้ client แตะ**) -lib/note-session.ts ← session ฝั่ง client (2026-10-30 แบบ add-in): localStorage "note-auth" {token, userid, username, expMs} — readNoteSession/saveNoteSession/clearNoteSession (หมดอายุ = ล้างคืน null, localStorage โยน = try/catch ทุกจุด) + noteAuthFetch แนบ Authorization: Bearer อัตโนมัติ +lib/note-session.ts ← session ฝั่ง client (2026-10-30 แบบ add-in · 2026-11-09 ใช้ primitives ของ utils/auth-store): localStorage "note-auth" {token, userid, username, expMs} — readNoteSession/saveNoteSession/clearNoteSession (หมดอายุ = ล้างคืน null ตามนโยบาย §0.6 "token ตายเอาออกจาก session เลย", storage โยน = memory fallback) + noteAuthFetch แนบ Authorization: Bearer อัตโนมัติ + noteExpMs (exp จาก JWT payload แม่นกว่า expires_in) + isNoteAuthExpiredError (PDFViewer จับ auth-error กลาง session ผ่าน reportActionError → ล้าง session + พาไป /note?sso=expired) lib/external-import.ts ← N8 registerExternalFile — ลงทะเบียนเอกสารจากลิงก์ e-Court metadata-only (dedupe external_id + refresh pdf_url/typeId **เฉพาะแถว `url:…` เสมอ / แถว `ecourt:` เฉพาะเมื่อ opts.trusted — link proof จาก SSO route, review 2026-10-30: ผู้มี session พิมพ์ `?fileUrl&fileId` เองเปิดดูของที่รู้จักแล้วได้อย่างเดียว สร้างแถวใหม่/refresh ไม่ได้ (เคยใช้ repoint pdf_url ของเอกสารคนอื่น)** — เรียกระหว่าง render หน้า /note และ /note/annotate ไม่ใช่ Server Action, ดู carve-out ใน invariant #4; `firstSearchParam` อยู่ที่ `utils/external-file.ts` แทน) + **N11 registerStagedFile** (2026-11-04 — ลำดับ resolve id → `putStagedDoc` ใต้ id นั้น (ตรวจ cap ก่อนแตะ DB) → insert; ชน `23505` = re-stage ใต้ id ผู้ชนะ + `deleteStagedDoc` ของผู้แพ้ = last-write-wins, **ไม่มีทางเกิด row ชี้ sentinel ที่ไม่มี bytes**; patch เดียว: pdfUrl sentinel ใหม่เมื่อ ext ต่าง / typeId เมื่อส่งมา / title เฉพาะค่าปัจจุบันยังเป็นค่า default `ECOURT_DEFAULT_TITLE`; เรียกจาก stage route เท่านั้น — carve-out invariant #4 เหมือน N8) · external_id ของ e-Court สร้างผ่าน lib/ecourt-fid.ts ทั้งสองฟังก์ชัน lib/remote-file.ts ← N8 ดึงไฟล์รีโมตจาก allowlist (validate ซ้ำหลัง redirect + จำกัด 100MB + timeout 60s — /api/note/pdf กับ export ใช้ร่วม) @@ -74,6 +74,7 @@ utils/pdf-export.ts ← แปลงพิกัดจอ⇄PDF (/Rotate) + wr utils/user.ts ← **ถอดทั้งไฟล์ 2026-10-30 (ฉบับ token)** — เดิม parse/build cookie oacCase + OAC_SIG_COOKIE/SESSION_MAX_AGE ของรอบ cookie utils/external-file.ts ← N8 validation ลิงก์ไฟล์รีโมต (allowlist "admincourt.go.th" + env EXTERNAL_FILE_HOSTS, suffix match, https เท่านั้น ห้าม user:pass@) + titleFromExternalUrl (fallback = **ECOURT_DEFAULT_TITLE** — ตัดพารามิเตอร์ fallbackId ออก 2026-11-04 กัน fid ดิบหลุดลง title) + isHttpUrl + normDocumentGroup (N9 normalize รหัสกลุ่ม — ใช้ที่เดียวคือ N8 `registerExternalFile`) + ECOURT_DEFAULT_TITLE (ค่า default title กลางของเอกสาร e-Court "เอกสารจาก e-Court" — N8 fallback + N11 insert/เงื่อนไขปลดล็อก title ของ registerStagedFile, ไม่ฝัง fid) · pure (~~externalIdFor~~ ถอดแล้ว 2026-11-04 — external_id ของ e-Court สร้างผ่าน lib/ecourt-fid.ts แบบ hash แทน) utils/base-path.ts ← withBasePath() เติม basePath /docedit + app path /note ให้ URL ฝั่ง browser ที่ Next ไม่เติมให้เอง (ค่าจาก next.config.mjs ผ่าน env — invariant #12) +utils/auth-store.ts ← primitives กลางของ session store ฝั่ง client (2026-11-09, PLAN-SSO.md §0.6 — pure ไม่ import อะไร): jwtExp (decode exp จาก JWT payload) + isExpired (margin 30 วิ) + createAuthStore (localStorage + memory fallback ต่อ key) — ฝั่ง note ใช้ผ่าน lib/note-session.ts · วางที่ utils/ เพื่อให้ wordaddin import ได้ตามกติกา client-only zone (add-in ย้าย ticketService มาใช้รอบหน้า) · unit test tests/utils/auth-store.test.ts utils/docver-ticket.ts ← ticket ของ add-in ส่วน pure แชร์ client/server (2026-10-27, PLAN-ADDIN-AUTH.md §4 — ไม่ import อะไรเลย · client ไม่คำนวณ HMAC อีกต่อไปหลังถอด client HMAC 2026-11-04 ตาม PLAN-SSO.md D13 — ถอด timingSafeEqualHex/HmacSha256Hex ออก): parseDocverTicket (allowlist exp/fid/tid/uid/uname + cap ยาวตาม endpoint + ห้ามอักขระ & = + % ใน uid/uname — เกณฑ์เดียวกับ parseLoginTokenBody: อักขระที่ตีความต่างกันตาม context ง่าย, 2026-10-30) + isTicketExpired + normalizeHexDigest — unit test tests/utils/docver-ticket.test.ts utils/file-kind.ts ← derive ชนิดไฟล์ pdf/image จาก extension ของ path (N3 — ใช้ร่วม viewer/export/หน้ารายการ/seed) utils/upload-name.ts ← N4 ถอดรหัสชื่อไฟล์จาก FormData ของ Server Action กลับเป็น UTF-8 ก่อนใช้ (undici ถอด header Content-Disposition เป็น latin1 ทำชื่อไทยเพี้ยน "คำ…" — encode กลับ byte แล้วถอด UTF-8; guard: ไม่มีตัว U+0080–U+00FF = ไม่แตะ, ถอดแล้วได้ U+FFFD = เก็บของเดิม, 2026-10-05) @@ -83,8 +84,9 @@ hooks/useAnnotationRealtime.ts ← EventSource ต่อ SSE stream + dedupe hooks/useHistory.ts ← undo/redo stack ฝั่ง client (F2) app/actions/annotations.ts ← Server Actions CRUD + validation + publish event app/actions/auth.ts ← **ถอดทั้งไฟล์ 2026-10-30 (ฉบับ token)** — logout เป็นปุ่ม client ล้าง localStorage (components/LogoutButton) ไม่มี server action ให้เรียก -app/api/note/auth/token/route.ts ← **SSO เส้น mint login-token** (PLAN-SSO.md §3.1 — x-api-key ผ่าน requireDocverApiKey ตัวเดิม + body allowlist userid/username/fid/ttl_seconds (60–600 default 300) → issueDocverToken purpose="login"; ไม่ import @/db) -app/api/note/auth/session/route.ts ← **แลก credential ของลิงก์ e-Court เป็น access_token** (PLAN-SSO.md §3.2 ฉบับ token 2026-10-30 — แทน GET e-court ที่ set cookie ซึ่งถอดแล้ว): body {t | ticket_data+HmacSha256, fileUrl?, typeId?} → verify (JWT: verifyDocverLoginToken แยก expired/invalid / ticket: verifyAddinTicket + **ปฏิเสธค่า default "super_secret" เฉพาะ production** — ค่ารู้กันทั้ง repo = ปลอม session ได้ (dev ยอมรับ default ให้ทดสอบได้; กลุ่ม docver/add-in ใช้ default ได้ตาม requirement) → มี fileUrl = registerExternalFile trusted (fid จาก credential) · **ไม่มี fileUrl (ลิงก์ fid-only — N11 2026-11-04, PLAN-STAGED.md D5): select `ecourt:` (lib/ecourt-fid) แล้วตัดสินตั้งแต่แลก token — staged + `hasStagedDoc` (ไม่ touch TTL) = คืน fileId / staged ไม่มี bytes = fileError "พ้นช่วงจัดเตรียม" / URL ปกติ (N8) = คืน fileId / ไม่เจอ = fileError "ยังไม่ถูกส่งเข้าระบบ"** → คืน {access_token (8 ชม.), userid, username, fileId?, fileError?} · audit log เฉพาะ userid (strip อักขระควบคุม) ห้าม log token ดิบ · ไม่ publish SSE +app/api/auth/token/route.ts ← **ทางเข้า token เดียวของทั้งระบบ** (2026-11-09 รอบสอง — โค้ดเต็ม inline ใน route ตามแบบเดิม; **เส้นฝั่ง note `/api/note/auth/token` ถอดแล้ว**) ทางเข้า 3: x-api-key + body เดิม = docver-token 7 วัน (env DOCVER_TOKEN_TTL_SECONDS) / x-api-key + `purpose:"login"`(+ttl_seconds 60–600 default 300) = login-token อายุสั้น mint ลิงก์ note (PLAN-SSO.md §0.6) / ticket ของ add-in (contract 2026-11-04: 400 ฟอร์แม็ต / 401 ลายเซ็น / 403 ticket หมดอายุ) · JWT HS256 เซ็นด้วย DOCVER_TOKEN_SECRET · ไม่ import @/db · unit test tests/app/api/auth/token.test.ts +app/api/documents/token/route.ts ← **alias re-export ของ /api/auth/token** (`export { POST } from "@/app/api/auth/token/route"` — function เดียวกันเป๊ะ, test `toBe` ยึดไว้) — **คงไว้ตามคำขอผู้ใช้ 2026-11-09** เพราะเป็นทางเข้าของกลุ่ม docver/e-Court เดิม (contract เดิมเป๊ะ: body ไม่มี purpose = docver) +app/api/auth/session/route.ts ← **ทางเข้า session เดียวของแอป note** (2026-11-09 รอบสอง — โค้ดเต็ม inline; **เส้นเดิม `/api/note/auth/session` ถอดแล้ว** — ก่อนหน้านี้เคย copy กันจน drift จน flow N11 หายจากเส้นสั้น): แลก credential ของลิงก์ e-Court เป็น access_token — body {t | ticket_data+HmacSha256, fileUrl?, typeId?} → verify (JWT: verifyDocverLoginToken แยก expired/invalid / ticket: verifyAddinTicket + **ปฏิเสธค่า default "super_secret" เฉพาะ production** — ค่ารู้กันทั้ง repo = ปลอม session ได้ (dev ยอมรับ default ให้ทดสอบได้) → มี fileUrl = registerExternalFile trusted (fid จาก credential) · **ไม่มี fileUrl (ลิงก์ fid-only — N11 2026-11-04, PLAN-STAGED.md D5): select `ecourt:` (lib/ecourt-fid) แล้วตัดสินตั้งแต่แลก token — staged + `hasStagedDoc` (ไม่ touch TTL) = คืน fileId / staged ไม่มี bytes = fileError "พ้นช่วงจัดเตรียม" / URL ปกติ (N8) = คืน fileId / ไม่เจอ = fileError "ยังไม่ถูกส่งเข้าระบบ"** → คืน {access_token (8 ชม.), userid, username, fileId?, fileError?} · audit log เฉพาะ userid (strip อักขระควบคุม) ห้าม log token ดิบ · ไม่ publish SSE · unit test tests/api/auth-session-staged.test.ts app/api/note/files/[id]/bootstrap/route.ts ← ข้อมูลเริ่มต้นของ viewer (file + annotations + bookmarks + replies + user เป็น DTO) ให้ AnnotateView ดึงหลังมี token — Bearer บังคับ (401), query ชุดเดียวกับหน้า annotate สมัย server component app/api/note/files/stage/route.ts ← **N11 POST stage เนื้อไฟล์ base64 จาก e-Court** (`docs/PLAN-STAGED.md` D1 2026-11-04 — เอกสารที่ไม่มี URL ให้ดึง): auth `requireDocverApiKey` (env `DOCVER_API_KEY`) ก่อนแตะอะไรทั้งสิ้น → อ่าน body แบบ stream นับไบต์ (Content-Length fast-path + chunked นับจริง, cap base64 ~140MB → 413) + idle 60 วิ/total 10 นาที/`req.signal` abort → 408 + concurrency `STAGED_MAX_CONCURRENT` default 1 → 429 `Retry-After: 5` (**คืน slot+เคลียร์ timer ใน finally ทุกทางออก**) → JSON allowlist fid/data/typeId/title (fid เกณฑ์ parseLoginTokenBody) → base64 มาตรฐานต่อเนื่อง strict (regex + len%4 + เทียบความยาวหลัง decode — Node Buffer.from ยืดหยุ่นเกิน) → magic bytes (`detectStagedExt`) เป็นตัวกำหนด ext → registerStagedFile → `{fileId}` · error ทั้งหก 401/400/408/413/429/500 เป็น JSON ไทย · ห้าม log bytes/key ดิบ (เฉพาะ fid+ขนาด) · contract อยู่ Swagger UI (`lib/openapi.ts` — มีคู่มือ retry: 408/429/5xx ยิงซ้ำได้ stage ซ้ำปลอดภัย) · ไม่ publish SSE/`revalidatePath` (carve-out invariant #4) app/api/note/files/open/route.ts ← **GET** /api/note/files/open?fileUrl=/samples/… — ทางเปิดเอกสารตัวอย่างของหน้า /note/dev แบบ "A Link Get(new)" ของ /tmpl (2026-10-30): ลิงก์เปล่ายิง GET ไม่มี Bearer (navigation ใส่ header ไม่ได้) จึง**รับเฉพาะ path ในเครื่องขึ้นต้น "/" เท่านั้น** (https เด็ดขาด + ห้าม ..) → registerExternalFile trusted:false (แถว url:… เท่านั้น — ไม่รับ fileId แถว ecourt: เกิดไม่ได้) → 302 เข้า viewer · ผิด = 302 /note?fileError=… ให้หน้าแรกแสดงสาเหตุ @@ -96,8 +98,8 @@ app/api/swagger/route.ts ← **GET /api/swagger = หน้า Swagger UI** (202 app/api/template/{test,extract,render}/route.ts ← กลุ่ม API docx template ของ wordaddin (WIP — GET test ตรวจ service / POST extract ดึงค่า Content Control / POST render = template(.docx)+data(JSON ไฟล์หรือ field dataJson) คืน .docx ใหม่; logic รวมอยู่ app/api/template/lib-tmpl.ts เป็นแชร์ไลบรารี pizzip+xpath; ไฟล์ทดสอบ/โน้ตอยู่ app/api/template/test/ — ไม่แตะ DB, URL /docedit/api/template/…) app/api/documents/token/route.ts + versions/route.ts + versions/[versionId]/download-link/route.ts ← **กลุ่ม API docver จัด version เอกสาร** (docs/PLAN-DOCVER.md — URL /docedit/api/documents/…, auth ของกลุ่มเอง 2026-10-22: POST token ขอ access_token ด้วย header `x-api-key` + env `DOCVER_API_KEY` (lib/docver-token.ts — fail closed 401 ไม่ตั้ง/ว่าง เหมือน N10) แล้ว **endpoint ที่เหลือทุกตัวบังคับ `Authorization: Bearer`** JWT HS256 เซ็นด้วย env `DOCVER_TOKEN_SECRET` (TTL `DOCVER_TOKEN_TTL_SECONDS` **default 604800 = 7 วัน** — แก้ 2026-11-04 จาก 3600 ให้ token อายุยาวแทนระบบ refresh ของ add-in) — identity userid/username + templateId แกะจาก claims ไม่รับจาก request อีกต่อไป; ส่ง userId/userName/templateId ใน form = 400 ไม่รู้จัก field · **2026-10-27 POST token มีทางเข้าที่สองสำหรับ add-in ใน Word** (PLAN-ADDIN-AUTH.md — contract 2026-11-04): ไม่มี x-api-key + body `{ticket_data, HmacSha256}` — lib/addin-ticket.ts ตรวจ HMAC (secret env ADDIN_TICKET_SECRET → SECRET → default "super_secret") แล้วออก token จาก claims ของ ticket เอง โดยแยก status: **400 ฟอร์แม็ตพัง (รวม missing) / 401 ลายเซ็นไม่ถูก / 403 ลายเซ็นถูกแต่ ticket หมดอายุ (client มี JWT เดิมของ fid ใน session ใช้ต่อ)** — route ไม่ import `@/db`/minio เลย (unit test tests/app/api/documents/token.test.ts) · **2026-10-26 ถอด segment `[docId]` ออกจาก URL ทุก endpoint — docId = claim `fid` (string) จาก token ตรง ๆ (บังคับตั้งแต่ขอ token)**): POST versions = multipart `file`(.docx บังคับ ≤100MB เช็ค size ก่อน buffer → 413)+`pdf`(optional ไฟล์เปล่า = ไม่มี)+message/clientVersionId/fileName (**form field `fields` ยกเลิก 2026-10-30 ส่งมา = 400 — fields ของ version แกะจากไฟล์ .docx ฝั่ง server เก็บทั้งก้อนเหมือน return ของ `/api/template/extract` เลย** (`{cc, bookmark, properties, verify}` ผ่าน `extractDocumentSnapshot` ของ lib/lib-tmpl.ts ตัวเดียวกับ route นั้น · verify.HmacSha256 ตรวจด้วย secret ของ add-in ticket ฝั่ง server ผ่าน `addinTicketSecret()` · เห็น CC ทุกชนิดเสมอไม่ขึ้นกับ WordApi ของ host แบบตอน add-in อ่านเอง; แกะไม่ได้ = 400 · row เก่าก่อน 2026-10-30 เก็บ map ดิบ — `FieldsSnapshot`/`readSnapshotField` ของ add-in แยกรูปเอง) → สร้างเอกสารอัตโนมัติถ้า docId ใหม่, MinIO ก่อน DB (DB ล้มหลัง put = เก็บกวาด object แบบ N4) → `{hasPdf, versionId, versionNumber}` (เลขจาก server เป็นค่าจริง) / GET versions = DocumentVersionDTO[] ใหม่→เก่า (ไม่เคยมี docId = 404) / GET download-link?format=docx|pdf = presigned URL อายุสั้น (เช็ค docId+versionId คู่กันกันโยน id ข้ามเอกสาร; ขอ pdf ที่ version ไม่มี = 404) — carve-out invariant #4: ไม่ publish SSE ไม่ revalidatePath app/note/uploads/[name]/route.ts ← เสิร์ฟไฟล์อัปโหลดจาก public/note/uploads/ (N4 — Next production เห็นไฟล์ static เฉพาะที่มีตอน startup ไฟล์ที่เขียน runtime ต้องเสิร์ฟผ่าน route) -app/note/page.tsx ← **หน้าแรก = ข้อมูลเกี่ยวกับระบบ + client bootstrap ของลิงก์ e-Court** (2026-10-30 รอบสอง ตามคำขอผู้ใช้ — server shell บาง ๆ เนื้อหาทั้งหมดอยู่ใน `components/NoteHome.tsx`): "ระบบนี้ทำอะไรได้" (การ์ด 4 ใบ: มาร์ก/วาด, ความเห็น/ทำงานร่วม, คั่นหน้า/สรุป, เอกสารจาก e-Court) + วิธีเข้าใช้ (ลิงก์ e-Court, session 8 ชม.) + ลิงก์ /note/dev · มี credential ใน URL (t/ticket) = แลก token ที่ POST /api/note/auth/session → เก็บ localStorage → ตัด credential ทิ้ง → มี fileId พาเข้า viewer · แบนเนอร์ sso/fileError · public -app/note/dev/page.tsx ← **เครื่องมือทดสอบ** (ย้ายมาจากหน้าแรก 2026-10-30): เปิดเอกสารตัวอย่าง (PLAN-SSO.md D14 2026-10-29 — dropdown 2 ระดับกลุ่ม→ไฟล์ รายการ fixed จาก public/note/samples/ ไม่ query DB + รายการลิงก์ GET แบบ /tmpl "A Link Get(new)" ชี้ `/api/note/files/open?fileUrl=/samples/…` เมื่อ mint ปิด / dev-link เมื่อ mint เปิด + section Flow ของแอป — 2026-10-30 รอบสอง) + **ฟอร์ม `MockEcourtForm` "จำลอง e-Court — ขอ token ด้วย API key" (2026-11-02, แสดงทุก environment)** — ใส่ `DOCVER_API_KEY` + uid/uname + เลือกไฟล์ → ยิง `POST /api/note/auth/token` (ทางเข้าจริงเหมือน e-Court) → redirect เข้า `/note?fileUrl=…&t=…` ให้ bootstrap แลก session ต่อ · **ฟอร์ม `MockStageForm` "จำลอง e-Court — ส่งเนื้อไฟล์เข้า staging (base64)" (N11 2026-11-04, PLAN-STAGED.md D8)** — เลือกไฟล์ (FileReader→base64, ตัด >100MB ฝั่ง client) → ยิง stage + mint ด้วย fid เดิม → ลิงก์ fid-only + ปุ่มเปิด · ต้องถือ `DOCVER_API_KEY` จริงจึงไม่ใช่ dropzone/ประตูหลัง · **คู่มือทางเข้า SSO 2 แบบ + flow แต่ละแบบ** (การ์ดทาง A JWT/ทาง B ticket + กล่อง dev `make-login-link` — ตัวอย่างเป็นรูปแบบ+ขั้นตอนเท่านั้น **ห้าม mint จริงจากหน้า public**) + ฟอร์ม mint ลิงก์ `DevLoginLinkForm` · **ทางเข้าหน้าคุมด้วย env `NOTE_SSO_DEV_MINT=1` อย่างเดียว (2026-11-02 — ใช้ได้ทุก environment รวม production ที่ตั้ง env ไว้) · 0/ไม่ตั้ง = `notFound()` 404 ทั้งหน้า ไม่ใช่หน้า public อีกต่อไป** · แบนเนอร์ `?sso=`/`?fileUrl=` ไม่อยู่ที่หน้านี้ (คงอยู่ที่ `/note` ตาม contract) +app/note/page.tsx ← **หน้าแรก = ข้อมูลเกี่ยวกับระบบ + client bootstrap ของลิงก์ e-Court** (2026-10-30 รอบสอง ตามคำขอผู้ใช้ — server shell บาง ๆ เนื้อหาทั้งหมดอยู่ใน `components/NoteHome.tsx`): "ระบบนี้ทำอะไรได้" (การ์ด 4 ใบ: มาร์ก/วาด, ความเห็น/ทำงานร่วม, คั่นหน้า/สรุป, เอกสารจาก e-Court) + วิธีเข้าใช้ (ลิงก์ e-Court, session 8 ชม.) + ลิงก์ /note/dev · มี credential ใน URL (t/ticket) = แลก token ที่ POST /api/auth/session (2026-11-09 — expMs จาก noteExpMs decode JWT payload) → เก็บ localStorage → ตัด credential ทิ้ง → มี fileId พาเข้า viewer · แบนเนอร์ sso/fileError · public +app/note/dev/page.tsx ← **เครื่องมือทดสอบ** (ย้ายมาจากหน้าแรก 2026-10-30): เปิดเอกสารตัวอย่าง (PLAN-SSO.md D14 2026-10-29 — dropdown 2 ระดับกลุ่ม→ไฟล์ รายการ fixed จาก public/note/samples/ ไม่ query DB + รายการลิงก์ GET แบบ /tmpl "A Link Get(new)" ชี้ `/api/note/files/open?fileUrl=/samples/…` เมื่อ mint ปิด / dev-link เมื่อ mint เปิด + section Flow ของแอป — 2026-10-30 รอบสอง) + **ฟอร์ม `MockEcourtForm` "จำลอง e-Court — ขอ token ด้วย API key" (2026-11-02, แสดงทุก environment)** — ใส่ `DOCVER_API_KEY` + uid/uname + เลือกไฟล์ → ยิง `POST /api/auth/token` แบบ `purpose:"login"` (ทางเข้ารวม 2026-11-09 — เหมือนที่ e-Court ทำจริง) → redirect เข้า `/note?fileUrl=…&t=…` ให้ bootstrap แลก session ต่อ · **ฟอร์ม `MockStageForm` "จำลอง e-Court — ส่งเนื้อไฟล์เข้า staging (base64)" (N11 2026-11-04, PLAN-STAGED.md D8)** — เลือกไฟล์ (FileReader→base64, ตัด >100MB ฝั่ง client) → ยิง stage + mint ด้วย fid เดิม (ทาง `purpose:"login"` เช่นกัน) → ลิงก์ fid-only + ปุ่มเปิด · ต้องถือ `DOCVER_API_KEY` จริงจึงไม่ใช่ dropzone/ประตูหลัง · **คู่มือทางเข้า SSO 2 แบบ + flow แต่ละแบบ** (การ์ดทาง A JWT/ทาง B ticket + กล่อง dev `make-login-link` — ตัวอย่างเป็นรูปแบบ+ขั้นตอนเท่านั้น **ห้าม mint จริงจากหน้า public**) + ฟอร์ม mint ลิงก์ `DevLoginLinkForm` · **ทางเข้าหน้าคุมด้วย env `NOTE_SSO_DEV_MINT=1` อย่างเดียว (2026-11-02 — ใช้ได้ทุก environment รวม production ที่ตั้ง env ไว้) · 0/ไม่ตั้ง = `notFound()` 404 ทั้งหน้า ไม่ใช่หน้า public อีกต่อไป** · แบนเนอร์ `?sso=`/`?fileUrl=` ไม่อยู่ที่หน้านี้ (คงอยู่ที่ `/note` ตาม contract) app/note/groups/[typeId]/page.tsx ← N9 หน้าเอกสารในกลุ่ม (server component force-dynamic — files where type_id orderBy createdAt, ไม่มี = 404; ~~หมายเหตุ "แสดงเฉพาะเอกสารที่เคยเปิดในระบบนี้"~~ ถอดออกตามคำขอผู้ใช้ 2026-09-24; **redesign 2026-09-24 ตามคำขอผู้ใช้: ไม่มี navbar** — explorer 2 คอลัมน์ ตัวกรองซ้าย + list เดียวรวม bookmark+annotation ทั้งกลุ่มไม่แยกไฟล์: query ทั้งหมดฝั่ง server แล้วส่ง `GroupFileSummary[]` + `GroupListItem[]` (เรียง ไฟล์ตาม createdAt → หน้า → bookmark ก่อน annotation → createdAt) ให้ `GroupExplorer` กรองใน memory, แถวรายการ deep link `?ann=`/`?page=` เหมือนหน้าสรุป F12 + ไฟล์ที่มาต้น meta line) app/note/annotate/[id]/page.tsx ← shell บาง ๆ (ตรวจ UUID → `components/pdf/AnnotateView`) — ฉบับ token 2026-10-30: client gate ตรวจ token ใน localStorage แล้วดึง initial data ผ่าน GET /api/note/files/[id]/bootstrap (Bearer) → PDFViewer (prop token); ไม่มี token/401 = หน้า "กลับ e-Court กดลิงก์ใหม่" app/note/annotate/[id]/summary/page.tsx ← หน้าสรุป Annotation+Bookmark ของไฟล์เดียว (server component — query ทั้งหมดฝั่ง server แล้วส่ง `ExplorerItem[]` เรียง หน้า → หน้าเดียวกัน bookmark ก่อน annotation (bookmark ต่อหน้าเรียงตาม yOffset N5 เหมือนเดิม, annotation ตาม createdAt) ให้ `SummaryExplorer` กรองใน memory; design "แฟ้มสำนวน" 2026-09-17 แถบหัวน้ำเงิน + การ์ดรายหน้าขอบ folio + รายการตามหน้าจุดไข่ปลา → **redesign 2026-09-24 ตามคำขอผู้ใช้เป็น explorer 2 คอลัมน์แบบหน้ากลุ่ม N9 โดยมิติแรกของตัวกรองเป็น "หน้า" แทน "ไฟล์"** — แถบหัวน้ำเงิน + action bar (กลับหน้าอ่าน — ปุ่มส่งออก CSV F16/ส่งออก Bookmark JSON N7 ถอด 2026-09-25 แทนด้วย N10 API) + empty state คงเดิม, ไฟล์ว่าง = ไม่ mount explorer เลย; generateMetadata ตั้งชื่อแท็บ browser ตามคดี; ตอนพิมพ์ header เป็นกรอบเส้นน้ำเงิน + แผงตัวกรอง/แถบ action ซ่อนด้วย print:hidden — กฎ @media print อยู่ใน globals.css) — อย่าถอด class `summary-page` ของ
(กฎ coarse 44px ใน globals.css ผูกอยู่กับ selector นี้ ครอบทั้ง `a` และ `button`) @@ -114,7 +116,7 @@ components/pdf/TextboxStampLayer.tsx ← overlay TEXTBOX + STAMP (F17/F18) components/pdf/AnnotationSelectionLayer.tsx ← กรอบ selection + handles ย้าย/ปรับขนาด (F1) + แถบสี/ความหนา + ปุ่มลบ components/sidebar/AnnotationSidebar.tsx ← + โน้ตคำอธิบาย (note) inline ต่อรายการ (PLAN-FEAT.md §7 N1) components/AnnotationTypeIcon.tsx ← ไอคอนตามชนิด annotation (ย้ายจาก sidebar) — ไม่มี "use client" ให้ server component ใช้ร่วมได้ (sidebar + หน้าสรุป) -components/NoteHome.tsx ← client root ของหน้าแรก /note (2026-10-30) — bootstrap ลิงก์ e-Court: parseSsoLinkParams จาก URL ปัจจุบัน → POST /api/note/auth/session (แลก token + trusted register) → saveNoteSession + history.replaceState ตัด credential → router.replace เข้า viewer/แสดง landing · แบนเนอร์ sso 4 ค่า + fileError แสดงที่นี่ +components/NoteHome.tsx ← client root ของหน้าแรก /note (2026-10-30) — bootstrap ลิงก์ e-Court: parseSsoLinkParams จาก URL ปัจจุบัน → POST /api/auth/session (แลก token + trusted register — 2026-11-09 ทางเข้ารวม §0.6) → saveNoteSession (expMs ผ่าน noteExpMs) + history.replaceState ตัด credential → router.replace เข้า viewer/แสดง landing · แบนเนอร์ sso 4 ค่า + fileError แสดงที่นี่ components/DocumentPicker.tsx ← **ถอดทั้งไฟล์ 2026-10-30 (รอบสอง)** — section เปิดเอกสารตัวอย่างของ /note/dev เปลี่ยนเป็นรายการลิงก์ GET ตรง ๆ แบบ /tmpl (ชี้ /api/note/files/open เอง) ไม่ต้องมี component ครอบ components/groups/GroupExplorer.tsx ← N9 client root ของหน้า /groups/[typeId] (redesign 2026-09-24) — state ตัวกรองก้อนเดียว `FacetFilterState` (first=fileId/`kinds: Set<"bm"|"ann">` ชนิดรายการ + `types: Set` ชนิดของ Annotation — **ทั้งคู่ Set ว่าง = แสดงทั้งหมด (default), ติ๊กอะไรเห็นเฉพาะสิ่งนั้น** คำขอผู้ใช้ 2026-09-24/ผู้บันทึก) + กรองใน memory ด้วย useMemo + facet count/static users derive เอง + layout 2 คอลัมน์ (มือถือ < lg ตัวกรองหุบเป็น `
` แบบหน้าสรุป, desktop aside sticky 15rem scroll ภายใน) — render `FacetFilterPanel` สองที่แชร์ state เดียว (`firstGroup` = ไฟล์) + re-export `GroupListItem` = `ExplorerItem` components/explorer/FacetFilterPanel.tsx ← แผงตัวกรอง facet **กลางใช้ร่วม 2 หน้า** — ย้ายมาจาก FilterControls ของหน้ากลุ่ม 2026-09-24 ให้หน้าสรุปใช้ร่วม (มิติแรกส่งเข้าทาง prop `firstGroup`: groups = "ไฟล์", สรุป = "หน้า" พร้อม `optionsScroll` ให้ scroll ภายในกลุ่ม max-h-64 กันหลายสิบหน้าดันกลุ่มอื่นออกจอ; อีก 3 กลุ่ม ชนิดรายการ/ชนิดของ Annotation/ผู้บันทึก เหมือนกันทุกหน้า) — **tree menu 2 ระดับสไตล์ Quasar docs** (q-tree--dense no-connectors — design รอบสอง 2026-09-24 ตามคำขอผู้ใช้, ธีม Genesis พื้นสว่าง) — **ชนิดรายการ (Bookmark/Annotation) เป็น checkbox คู่ที่ไม่เลือก = แสดงทั้งหมด (default) ติ๊กอะไรเห็นเฉพาะสิ่งนั้น** (direct selection ตามคำขอผู้ใช้ 2026-09-24 รอบสอง — แบบเดิมเปิดครบทั้งคู่ต้องปิดเอง = สับสน; เคยย้ายไป segmented บนหัว content แล้วย้ายกลับตามคำขอผู้ใช้) = parent `
` หุบ/กางอิสระ + chevron หมุน 90°, leaf = `