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 = `
@@ -219,15 +220,15 @@ export default async function DevToolsPage({
-
- ฝั่ง e-Court เรียก
POST /api/note/auth/token พร้อมรหัส{" "}
+ ฝั่ง e-Court เรียก POST /api/auth/token พร้อมรหัส{" "}
x-api-key (ค่าจาก env DOCVER_API_KEY) และข้อมูล:
- {`POST {BASE}/api/note/auth/token
+ {`POST {BASE}/api/auth/token
x-api-key:
{"userid":"","username":"",
- "fid":"","ttl_seconds":300}`}
+ "fid":"","ttl_seconds":300,"purpose":"login"}`}
-
ได้
access_token กลับมา (อายุ 1–10 นาที) — นำไปใส่ในลิงก์
diff --git a/components/MockEcourtForm.tsx b/components/MockEcourtForm.tsx
index b8f5983..9431fa4 100644
--- a/components/MockEcourtForm.tsx
+++ b/components/MockEcourtForm.tsx
@@ -7,7 +7,8 @@ import { withBasePath } from "@/utils/base-path";
/**
* ฟอร์ม "จำลอง e-Court" บนหน้า /note/dev สำหรับตอน mint อัตโนมัติปิด
* (production เสมอตาม devMintEnabled) — ผู้ทดสอบใส่ DOCVER_API_KEY แล้วยิง
- * POST /api/note/auth/token (เส้นเดียวกับที่ backend e-Court ใช้จริง) ได้
+ * POST /api/auth/token แบบ purpose:"login" (ทางเข้ารวม 2026-11-09 — เส้นเดียว
+ * กับที่ backend e-Court ใช้จริง) ได้
* access_token มาแล้วพาเข้า /note?fileUrl=…&t=… ให้ NoteHome แลกเป็น session
* ต่อ — ไม่เป็นประตูหลังเพราะต้องถือ key ที่ถูกต้องอยู่แล้ว
* (คำขอผู้ใช้ 2026-11-02: กดเข้าเอกสารจากหน้า /note/dev บน production ให้ได้)
@@ -25,16 +26,18 @@ export default function MockEcourtForm({ files }: { files: string[] }) {
setBusy(true);
setError(null);
try {
- const res = await fetch(withBasePath("/api/note/auth/token"), {
+ const res = await fetch(withBasePath("/api/auth/token"), {
method: "POST",
headers: { "x-api-key": apiKey, "content-type": "application/json" },
// fid ผูกไฟล์ที่เลือกแบบเดียวกับ dev-link (dev-) — แต่ละไฟล์
- // ตัวอย่างมี row ecourt: ของตัวเอง ไม่ปนกัน
+ // ตัวอย่างมี row ecourt: ของตัวเอง ไม่ปนกัน · purpose:"login" = ทางเข้า
+ // mint login-token ของ /api/auth/token (ไม่ส่ง = docver-token)
body: JSON.stringify({
userid: userid.trim(),
username: username.trim(),
fid: `dev-${file}`,
ttl_seconds: 600,
+ purpose: "login",
}),
});
const data = (await res.json().catch(() => null)) as
diff --git a/components/MockStageForm.tsx b/components/MockStageForm.tsx
index cbf8227..b8aac55 100644
--- a/components/MockStageForm.tsx
+++ b/components/MockStageForm.tsx
@@ -8,7 +8,8 @@ import { withBasePath } from "@/utils/base-path";
* ฟอร์ม "จำลอง e-Court — ส่งเนื้อไฟล์เข้า staging" บนหน้า /note/dev (N11 —
* PLAN-STAGED.md · คำขอผู้ใช้ 2026-11-04): กดทดสอบ contract ทั้ง 3 ขั้นตามที่
* backend e-Court ต้องทำจริงในหน้าเดียว — (1) POST /api/note/files/stage
- * (base64 ของไฟล์ที่เลือก) → (2) POST /api/note/auth/token ด้วย fid เดิม →
+ * (base64 ของไฟล์ที่เลือก) → (2) POST /api/auth/token แบบ purpose:"login"
+ * ด้วย fid เดิม (ทางเข้ารวม 2026-11-09) →
* (3) ลิงก์ /note?t=… (fid-only ไม่มี fileUrl — D5)
* · ต้องใส่ DOCVER_API_KEY จริงทุกครั้งจึงไม่เป็นประตูหลัง (เหมือน MockEcourtForm)
* · หน้า /note/dev เปิดด้วย env NOTE_SSO_DEV_MINT=1 อย่างเดียว
@@ -71,7 +72,8 @@ export default function MockStageForm() {
}
// (2) mint login token ด้วย fid **เดิม** — dedupe key ecourt: ผูกกัน
- const tokenRes = await fetch(withBasePath("/api/note/auth/token"), {
+ // (purpose:"login" = ทางเข้า mint login-token ของ /api/auth/token)
+ const tokenRes = await fetch(withBasePath("/api/auth/token"), {
method: "POST",
headers: { "x-api-key": apiKey, "content-type": "application/json" },
body: JSON.stringify({
@@ -79,6 +81,7 @@ export default function MockStageForm() {
username: username.trim(),
fid: fid.trim(),
ttl_seconds: 600,
+ purpose: "login",
}),
});
const tokenData = (await tokenRes.json().catch(() => null)) as
diff --git a/components/NoteHome.tsx b/components/NoteHome.tsx
index 1845fab..5000204 100644
--- a/components/NoteHome.tsx
+++ b/components/NoteHome.tsx
@@ -3,7 +3,8 @@
// หน้าแรก /note — ข้อมูลเกี่ยวกับระบบ (2026-10-30 ตามคำขอผู้ใช้: ระบบทำอะไรได้ /
// วิธีเข้าใช้) + bootstrap ของลิงก์ e-Court (contract ปลายทางลิงก์ยังชี้ที่นี่):
// มี credential ใน URL (t หรือ ticket_data+HmacSha256 — จากลิงก์ที่ e-Court ชี้ตรงมา
-// ที่ /note) = แลกเป็น access_token ที่ POST /api/note/auth/session แล้วเก็บ
+// ที่ /note) = แลกเป็น access_token ที่ POST /api/auth/session (ทางเข้ารวม
+// 2026-11-09 — ย้ายมาเส้นสั้นจาก /api/note/auth/session) แล้วเก็บ
// localStorage "note-auth" (แบบ add-in) → ตัด credential ออกจาก URL ทันที → มี fileId
// = พาเข้า viewer / ไม่มี = แสดงหน้าข้อมูล · ไม่มี credential = หน้า public ธรรมดา
// (แบนเนอร์ ?sso= ของลิงก์เก่า + fileError ยังรองรับ)
@@ -20,7 +21,7 @@ import {
import AppNav from "@/components/AppNav";
import { parseSsoLinkParams, ssoBanner, type SsoBanner } from "@/lib/note-auth";
-import { saveNoteSession } from "@/lib/note-session";
+import { noteExpMs, saveNoteSession } from "@/lib/note-session";
import { withBasePath } from "@/utils/base-path";
type Phase = "boot" | "exchanging" | "ready";
@@ -80,7 +81,7 @@ export default function NoteHome() {
const timeoutId = window.setTimeout(() => controller.abort(), 45_000);
void (async () => {
try {
- const res = await fetch(withBasePath("/api/note/auth/session"), {
+ const res = await fetch(withBasePath("/api/auth/session"), {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
@@ -110,7 +111,9 @@ export default function NoteHome() {
token: access_token,
userid,
username,
- expMs: Date.now() + expires_in * 1000,
+ // exp จาก JWT payload (นาฬิกา server) แม่นกว่า expires_in — fallback
+ // expires_in เมื่อ decode ไม่ได้
+ expMs: noteExpMs(access_token, expires_in),
});
if (fileError !== undefined) setFileError(fileError);
if (fileId !== undefined) {
diff --git a/components/pdf/PDFViewer.tsx b/components/pdf/PDFViewer.tsx
index d053210..5418ee7 100644
--- a/components/pdf/PDFViewer.tsx
+++ b/components/pdf/PDFViewer.tsx
@@ -58,6 +58,7 @@ import {
useAnnotationRealtime,
} from "@/hooks/useAnnotationRealtime";
import { useHistory } from "@/hooks/useHistory";
+import { clearNoteSession, isNoteAuthExpiredError } from "@/lib/note-session";
import {
useCustomSelection,
type CustomSelectionCommit,
@@ -2216,6 +2217,22 @@ export default function PDFViewer({
}, 6000);
}
+ /** ท้ายร่วมของ catch ของ Server Action ทุกตัว (2026-11-09): ล้มเพราะ token หมด
+ * อายุ/ไม่ผ่าน (message sentinel ของ requireUser) = ล้าง session แล้วพากลับหน้า
+ * แรก ?sso=expired — token ที่ตายแล้วไม่มีทางฟื้น (browser ไม่มี ticket ให้ renew
+ * แบบ add-in) โชว์ toast ต่อก็กดอะไรไม่ได้อีก สู้จบที่ gate เดียวกับตอน bootstrap
+ * 401 ของ AnnotateView · คืน true เมื่อจัดการแล้ว กรณีอื่น log (เดิม) แล้วคืน false
+ * ให้ caller โชว์ error ของตัวเองต่อ */
+ function reportActionError(err: unknown, logLabel: string): boolean {
+ console.error(logLabel, err);
+ if (!isNoteAuthExpiredError(err)) return false;
+ clearNoteSession();
+ // withBasePath("") = /docedit/note — แบนเนอร์ ?sso=expired ของ NoteHome ต่อ (D11:
+ // ห้ามส่ง "/note" เข้า withBasePath)
+ window.location.replace(`${withBasePath("")}?sso=expired`);
+ return true;
+ }
+
async function handleCreateAnnotation(
pageIndex: number,
type: AnnotationType,
@@ -2289,8 +2306,10 @@ export default function PDFViewer({
},
});
} catch (err) {
- console.error("บันทึก annotation ไม่สำเร็จ", err); // React rollback optimistic row ให้เอง
- showActionError("บันทึกรายการไม่สำเร็จ — ตรวจสอบการเชื่อมต่อแล้วลองอีกครั้ง");
+ // React rollback optimistic row ให้เอง
+ if (!reportActionError(err, "บันทึก annotation ไม่สำเร็จ")) {
+ showActionError("บันทึกรายการไม่สำเร็จ — ตรวจสอบการเชื่อมต่อแล้วลองอีกครั้ง");
+ }
} finally {
pendingCreatesRef.current.delete(temp.id);
}
@@ -2342,8 +2361,9 @@ export default function PDFViewer({
});
}
} catch (err) {
- console.error("ลบ annotation ไม่สำเร็จ", err);
- showActionError("ลบรายการไม่สำเร็จ — ตรวจสอบการเชื่อมต่อแล้วลองอีกครั้ง");
+ if (!reportActionError(err, "ลบ annotation ไม่สำเร็จ")) {
+ showActionError("ลบรายการไม่สำเร็จ — ตรวจสอบการเชื่อมต่อแล้วลองอีกครั้ง");
+ }
}
});
}
@@ -2386,8 +2406,10 @@ export default function PDFViewer({
},
});
} catch (err) {
- console.error("แก้ไข annotation ไม่สำเร็จ", err); // React rollback optimistic row ให้เอง
- showActionError("แก้ไขรายการไม่สำเร็จ — ตรวจสอบการเชื่อมต่อแล้วลองอีกครั้ง");
+ // React rollback optimistic row ให้เอง
+ if (!reportActionError(err, "แก้ไข annotation ไม่สำเร็จ")) {
+ showActionError("แก้ไขรายการไม่สำเร็จ — ตรวจสอบการเชื่อมต่อแล้วลองอีกครั้ง");
+ }
// ทิ้ง base ที่คิวเก็บไว้ — เป็น data ที่ action นี้ตั้งใจเขียนแต่ไม่เคยติด DB
// ถ้าคงไว้ update ถัดไปจะผสานจน commit การเปลี่ยนที่ล้มไปด้วย และ undo
// history กู้สถานะที่ไม่มีอยู่จริง (code review 2026-09-29) — รอบหน้าเริ่ม
@@ -2458,8 +2480,10 @@ export default function PDFViewer({
const saved = await setAnnotationResolved(token, id, resolved);
setAnnotations((prev) => prev.map((a) => (a.id === saved.id ? saved : a)));
} catch (err) {
- console.error("ตั้งสถานะไม่สำเร็จ", err); // React rollback optimistic ให้เอง
- showActionError("ตั้งสถานะไม่สำเร็จ — ตรวจสอบการเชื่อมต่อแล้วลองอีกครั้ง");
+ // React rollback optimistic ให้เอง
+ if (!reportActionError(err, "ตั้งสถานะไม่สำเร็จ")) {
+ showActionError("ตั้งสถานะไม่สำเร็จ — ตรวจสอบการเชื่อมต่อแล้วลองอีกครั้ง");
+ }
}
});
}
@@ -2481,8 +2505,9 @@ export default function PDFViewer({
);
return true;
} catch (err) {
- console.error("ตอบ comment ไม่สำเร็จ", err);
- showActionError("ตอบ comment ไม่สำเร็จ — ตรวจสอบการเชื่อมต่อแล้วลองอีกครั้ง");
+ if (!reportActionError(err, "ตอบ comment ไม่สำเร็จ")) {
+ showActionError("ตอบ comment ไม่สำเร็จ — ตรวจสอบการเชื่อมต่อแล้วลองอีกครั้ง");
+ }
return false;
}
}
@@ -2493,8 +2518,9 @@ export default function PDFViewer({
await deleteReply(token, id);
setReplies((prev) => prev.filter((r) => r.id !== id));
} catch (err) {
- console.error("ลบคำตอบไม่สำเร็จ", err);
- showActionError("ลบคำตอบไม่สำเร็จ — ตรวจสอบการเชื่อมต่อแล้วลองอีกครั้ง");
+ if (!reportActionError(err, "ลบคำตอบไม่สำเร็จ")) {
+ showActionError("ลบคำตอบไม่สำเร็จ — ตรวจสอบการเชื่อมต่อแล้วลองอีกครั้ง");
+ }
}
}
@@ -2650,8 +2676,9 @@ export default function PDFViewer({
);
flashBookmarkSaved();
} catch (err) {
- console.error("บันทึก bookmark ไม่สำเร็จ", err);
- setBookmarkError(err instanceof Error ? err.message : "บันทึกไม่สำเร็จ ลองอีกครั้ง");
+ if (!reportActionError(err, "บันทึก bookmark ไม่สำเร็จ")) {
+ setBookmarkError(err instanceof Error ? err.message : "บันทึกไม่สำเร็จ ลองอีกครั้ง");
+ }
} finally {
bookmarkPendingRef.current = false;
setBookmarkSaving(false);
@@ -2676,7 +2703,7 @@ export default function PDFViewer({
prev.some((b) => b.id === saved.id) ? prev : [...prev, saved],
);
} catch (err) {
- console.error("เพิ่ม sub-bookmark ไม่สำเร็จ", err);
+ reportActionError(err, "เพิ่ม sub-bookmark ไม่สำเร็จ");
} finally {
bookmarkPendingRef.current = false;
}
@@ -2692,7 +2719,7 @@ export default function PDFViewer({
const saved = await updateBookmark(token, { id, ...patch });
setBookmarks((prev) => prev.map((b) => (b.id === saved.id ? saved : b)));
} catch (err) {
- console.error("แก้ไข bookmark ไม่สำเร็จ", err);
+ reportActionError(err, "แก้ไข bookmark ไม่สำเร็จ");
}
}
@@ -2704,7 +2731,7 @@ export default function PDFViewer({
await deleteBookmark(token, id);
setBookmarks((prev) => prev.filter((b) => b.id !== id));
} catch (err) {
- console.error("ลบ bookmark ไม่สำเร็จ", err);
+ reportActionError(err, "ลบ bookmark ไม่สำเร็จ");
}
});
}
diff --git a/docs/PLAN-SSO.md b/docs/PLAN-SSO.md
index c4e1d61..8471c58 100644
--- a/docs/PLAN-SSO.md
+++ b/docs/PLAN-SSO.md
@@ -37,7 +37,7 @@ credential อายุสั้น ฝังอีเมล/แชท/เก็
```
ลิงก์ e-Court ชี้ตรงมา /note?fileUrl=&typeId=&t=… (หรือ ticket_data+HmacSha256)
- → NoteHome (client bootstrap หน้าแรก) POST /api/note/auth/session
+ → NoteHome (client bootstrap หน้าแรก) POST /api/auth/session (§0.6 — เดิม /api/note/auth/session)
→ server verify credential (ทาง A JWT purpose=login / ทาง B ticket + fail-closed
เมื่อ secret เป็นค่า default "super_secret")
→ ลงทะเบียนเอกสาร N8 แบบ trusted เลย (fid จาก credential — ไม่ต้องมี link proof)
@@ -53,6 +53,17 @@ credential อายุสั้น ฝังอีเมล/แชท/เก็
- **คง status quo เส้น content ที่ browser เปิดแบบไม่ผ่าน fetch()** (ตามคำตอบผู้ใช้ 2026-10-30): SSE (EventSource) / `/api/note/pdf` + `
` N3 / export PDF — ไม่บังคับ Bearer (เดิมก็ไม่มี auth) · หน้าสรุป/หน้ากลุ่มยังเป็น server component จึงกลายเป็น public ไปด้วย — ยอมรับชั่วคราวตามมาตรฐานเดียวกัน (จะเปลี่ยนเป็น client+API เมื่อเรียกข้อเกิน)
- **contract กับ e-Court เปลี่ยนเฉพาะปลายทางลิงก์**: `{BASE}/note?…` แทน `{BASE}/api/note/auth/e-court?…` (mint ทาง A และ ticket ทาง B ไม่เปลี่ยน) — ส่วนที่เหลือของแผน (§1 D-rows, §3.1, §6, §8) ให้อ่านผ่านเลนส์ของ §0.5 นี้
+## 0.6 ฉบับทางเข้ารวม `/api/auth/*` (2026-11-09 — คำขอผู้ใช้ "note กับ add-in ใช้ auth เดียวกัน")
+
+**ยุบ route คู่ที่เคย copy กันให้เป็นทางเข้าเดียว** (จุดกระตุ้น: `/api/auth/session` ที่ copy มา 2026-11-05 ตัด flow N11 ของลิงก์ fid-only ทิ้ง — drift จริง) · **รอบสอง (วันเดียวกัน ตามคำขอผู้ใช้ "ทำให้มีแค่ /api/auth/token + /api/auth/session" + "ของที่เกี่ยวกับ add-in ไม่ต้องลบ")**: **ถอดเส้นฝั่ง note ทั้งสอง (`/api/note/auth/token`, `/api/note/auth/session`) ออกถาวร** — โค้ดกลับมา inline ใน route ของ `/api/auth/*` แบบเดิม (ไม่มี lib handler แยก) · **`/api/documents/token` ของกลุ่ม docver คงไว้** เป็น alias re-export ของ `/api/auth/token` (function เดียวกันเป๊ะ — ของ add-in/e-Court เดิมไม่ถอด) · e-Court ที่ยังยิงเส้น note เก่าต้องย้าย:
+
+- **`POST /api/auth/token` = ทางเข้า token เดียวของทั้งระบบ** (โค้ดเต็มใน route): x-api-key + body เดิม = docver-token 7 วัน (alias `/api/documents/token` — contract เดิมเป๊ะ) · x-api-key + **`purpose:"login"`** (+`ttl_seconds` 60–600) = login-token อายุสั้น (แทนเส้น mint เดิมของ §3.1) · ticket ของ add-in เหมือนเดิม
+- **`POST /api/auth/session` = ทางเข้า session เดียวของแอป note** (โค้ดเต็มใน route ฉบับครบ flow N11) · `NoteHome` ยิงเส้นนี้
+- **TTL ยังแยกตาม purpose** (ตามข้อตกลงกับผู้ใช้ "แยกไว้ก่อน"): login ลิงก์ 60–600 วิ · session 8 ชม. · docver 7 วัน — secret เดียวกัน `DOCVER_TOKEN_SECRET` แยกสิทธิ์ด้วย `purpose` (D4) · response shape `{access_token, token_type, expires_in}` ตรง OAuth2 อยู่แล้ว วันหนึ่งเพิ่ม grant `refresh_token` ได้โดยไม่ break (จดใน §11)
+- **โค้ด client กลาง**: `utils/auth-store.ts` (pure — localStorage store + memory fallback + `jwtExp`/`isExpired`) — ฝั่ง note ใช้ผ่าน `lib/note-session.ts` (exp ตอนนี้ decode จาก JWT payload = นาฬิกา server) · **วางไว้ที่ `utils/` เพื่อให้ wordaddin import ได้ตามกติกา client-only zone** — add-in ย้าย `ticketService` มาใช้รอบหน้า
+- **นโยบาย token หมดอายุ (คำถามผู้ใช้): เอาออกจาก session เลย** — token ที่ตายแล้วไม่มีทางฟื้น (browser ไม่มี ticket ให้ renew แบบ add-in; renew ของ add-in ก็ไปทาง ticket ไม่ใช่ token เก่า): `readNoteSession` ล้างทิ้งตอนอ่าน (เดิม) + **กลาง session: Server Action ล้มด้วย auth (message sentinel ของ `requireUser` — helper `isNoteAuthExpiredError` + `reportActionError` ใน PDFViewer) = `clearNoteSession` + พาไป `/note?sso=expired`** จบที่แบนเนอร์เดียวกับตอน bootstrap 401 ไม่ปล่อย error toast ค้างให้กดอะไรไม่ได้อีก
+- กัน drift กลับมา: `/api/documents/token` เป็น re-export ของ `/api/auth/token` (function เดียวกันเป๊ะ — มี test `toBe` ยึดไว้ใน `tests/app/api/auth/token.test.ts` กัน copy แยกชุดอีก)
+
## 1. ข้อตกลงที่ตัดสินใจแล้ว (2026-10-28)
| # | เรื่อง | ข้อตกลง |
@@ -60,7 +71,7 @@ credential อายุสั้น ฝังอีเมล/แชท/เก็
| D1 | ตัวพา identity | 2 รูปแบบ (D12): **(A)** JWT จากทางเข้า `x-api-key` ของ **`POST /api/note/auth/token` — เส้น mint ใหม่ของแผนนี้ แยกจาก `/api/documents/token` ของกลุ่ม docver** (คำขอผู้ใช้ 2026-10-29 — ไม่แต้ contract docver ใช้ lib `docver-token` เดิม) ฝังเป็น param เดียว `t=` · **(B)** `ticket_data`+`HmacSha256` (ชุดเดียวกับตัว stamp .docx ของ add-in) |
| D2 | จุดลงจอ | **route handler `GET /api/note/auth/e-court`** — set cookie ได้ตามกติกา Next (page render ทำไม่ได้) + middleware ยกเว้น `api/note` อยู่แล้ว (route นี้เองไม่ต้องแต้ middleware — ชุดที่แต้ตาม D15 เป็นเรื่องหน้าแรก public คนละเรื่อง) |
| D3 | session | แปลงเป็น cookie `oacCase` รูปเดิมผ่าน `buildOacCaseCookie(userid, username)` (identity ไหลใน cookie เดิม: `userid`→u_userid, `username`→u_userlogin) **+ คู่ `oacCaseSig` ตาม D16 เสมอ** — cookie เดิมลอยตัวไม่มีลายเซ็น = ใครก็ปลอม `u_userid` ข้าม SSO ได้ทั้งหมด · `lib/session.ts` ต้องแต้แล้ว (verify signature — ดู D16) |
-| D4 | แยกสิทธิ์ token | เพิ่ม claim `purpose: "docver" \| "login"` เซ็นด้วย secret เดียวกัน (`DOCVER_TOKEN_SECRET`) — **mint login token ได้จาก `POST /api/note/auth/token` เท่านั้น** ส่วน `/api/documents/token` ไม่รับ/ไม่ออก `purpose` อีก (ส่ง field มา = 400 ตาม allowlist เดิม) · `requireDocverBearer` รับเฉพาะ `docver` (undefined = docver เพื่อ token เดิมในมือ user ตอน roll ไม่ตาย), landing route รับเฉพาะ `login` — กัน token ใน URL ถูกจับไปใช้กับ API versioning (scope escalation) |
+| D4 | แยกสิทธิ์ token | เพิ่ม claim `purpose: "docver" \| "login"` เซ็นด้วย secret เดียวกัน (`DOCVER_TOKEN_SECRET`) — **mint login token ได้จาก `POST /api/note/auth/token` เท่านั้น** ส่วน `/api/documents/token` ไม่รับ/ไม่ออก `purpose` อีก (ส่ง field มา = 400 ตาม allowlist เดิม) · `requireDocverBearer` รับเฉพาะ `docver` (undefined = docver เพื่อ token เดิมในมือ user ตอน roll ไม่ตาย), landing route รับเฉพาะ `login` — กัน token ใน URL ถูกจับไปใช้กับ API versioning (scope escalation) · **อัปเดต 2026-11-09 (§0.6): mint login token ทาง `POST /api/auth/token` ด้วย `purpose:"login"` ใน body ได้เพิ่ม (ค่าอื่น = 400) — เส้น mint เดิม `/api/note/auth/token` ถอดแล้วในรอบสอง** |
| D5 | TTL ของ login token | body `ttl_seconds` ทางเลือกช่วง 60–600 default 300 (ของเส้น mint ใหม่ — เป็น login-token เอกสิทธิ์ ไม่ต้องส่ง `purpose` ใน body) — credential ใน URL ต้องอายุสั้น · ทางเข้า docver TTL ตาม env เดิมไม่เปลี่ยน |
| D6 | อายุ session | cookie 8 ชม. เหมือน mock login — token สั้นเฉพาะตอน "ขอเข้า" (แบบ SSO code flow) · ไม่มี revoke (เท่า status quo ปัจจุบัน) |
| D7 | mock login | **ถอดออกทั้งเส้น** (MOCK_USERS + Demo1234 + LoginForm + action `login`) — ลิงก์ e-Court คือทางเข้าเดียวของ production · **หน้า `/note/login` ถอดทั้งหน้า ไม่เหลือหน้าข้อมูล** (ปรับแผน 2026-10-29 — ปลายทางข้อความต่าง ๆ ย้ายไปแบนเนอร์ของหน้าแรก D14) · dev/ทดสอบ mint ลิงก์ด้วยสคริปต์ D10 |
@@ -106,16 +117,16 @@ credential อายุสั้น ฝังอีเมล/แชท/เก็
**ขั้น 1 (ทาง A) — mint token (server-to-server ของ backend e-Court):**
```
-POST {BASE}/api/note/auth/token
+POST {BASE}/api/auth/token
x-api-key:
content-type: application/json
-{"userid":"","username":"","fid":"","ttl_seconds":300}
+{"userid":"","username":"","fid":"","ttl_seconds":300,"purpose":"login"}
→ 200 {"access_token":"…","token_type":"Bearer","expires_in":300}
```
-(เส้นนี้เป็น login-token เอกสิทธิ์ — ไม่ต้องส่ง `purpose` ใน body · แยกจาก `/api/documents/token` ของกลุ่ม docver ไม่ปนกัน)
+(ทางเข้ารวม §0.6 — `purpose:"login"` บอกว่าต้องการ login-token · **เส้น mint เดิม `POST /api/note/auth/token` ถอดแล้ว (2026-11-09 รอบสอง) — e-Court ที่ยังยิง URL เดิมต้องย้ายมาเส้นนี้** · ไม่ส่ง `purpose` ที่ `/api/auth/token` = docver-token ของกลุ่ม documents — อย่าสับสนสองอย่างนี้ใน codebase เดียวกัน)
**ขั้น 2 — ประกอบลิงก์ให้ผู้ใช้คลิก (เลือกทางใดทางหนึ่ง — ห้ามส่งสองชุดพร้อมกัน):**
@@ -143,7 +154,7 @@ content-type: application/json
## 3. ฝั่ง server — endpoint ที่เปลี่ยน/เพิ่ม
-### 3.1 `POST /api/note/auth/token` (ใหม่ — mint login token ทางเข้า x-api-key, ไม่ import `@/db`)
+### 3.1 `POST /api/note/auth/token` (mint login token — **ถอดแล้ว 2026-11-09 รอบสอง**: ทางเข้ารวมอยู่ที่ `POST /api/auth/token` แบบ `purpose:"login"` ตาม §0.6 · เนื้อหาข้างล่างเป็น contract ที่ย้ายไปเส้นรวมทั้งหมด)
| เคส | พฤติกรรม |
|---|---|
@@ -155,7 +166,7 @@ content-type: application/json
- reuse จาก lib เดิมทั้งหมด: `requireDocverApiKey` (เทียบ `DOCVER_API_KEY`) + `issueDocverToken` (ส่ง purpose="login" + ttl เข้าไป) — **ห้าม log ค่า key/body ดิบ**
- **`POST /api/documents/token` ของกลุ่ม docver ไม่ถูกแต้จากแผนนี้แล้ว** — contract เดิมคงเดิมทั้งหมด (ส่ง `purpose`/`ttl_seconds` มา = 400 ตาม allowlist เดิม) เหลือแค่ระดับ lib: `issueDocverToken` default แนบ `purpose:"docver"` ให้ token ใหม่โดย route docver ไม่ต้องแต้ และ `requireDocverBearer` (ทุก endpoint กลุ่ม docver) reject `purpose === "login"` — 401 ข้อความเดิม (กันเอา login token ไปยิง API versioning — D4)
-### 3.2 `POST /api/note/auth/session` (ฉบับ token 2026-10-30 — แทน GET e-court ที่ถอดแล้ว)
+### 3.2 `POST /api/auth/session` (ฉบับ token 2026-10-30 — แทน GET e-court ที่ถอดแล้ว · **เส้นเดิม `/api/note/auth/session` ถอดแล้ว 2026-11-09 รอบสอง** — ทางเข้า session เดียว)
body: `{t} | {ticket_data, HmacSha256}` + optional `{fileUrl, typeId}` (allowlist 5 field — `parseSessionExchangeBody`)
@@ -197,19 +208,21 @@ readNoteSession() / saveNoteSession() / clearNoteSession() / noteAuthFetch(path,
```
ผู้ใช้คลิกลิงก์ใน e-Court → browser ไป /note?fileUrl=&typeId=&t=… (หรือ ticket)
- → NoteHome (client bootstrap) POST /api/note/auth/session {credential + fileUrl + typeId}
+ → NoteHome (client bootstrap) POST /api/auth/session {credential + fileUrl + typeId} (§0.6 เส้นสั้น)
→ server: verify (A: verifyDocverLoginToken / B: verifyAddinTicket + fail-closed default secret)
→ registerExternalFile(fileUrl, fid, typeId, {trusted:true}) → fileId
→ 200 {access_token (8 ชม.), userid, username, fileId?}
- → client: saveNoteSession (localStorage "note-auth") + strip credential ออกจาก URL
+ → client: saveNoteSession (localStorage "note-auth" — expMs จาก jwtExp ของ JWT payload) + strip credential ออกจาก URL
→ มี fileId = /note/annotate/ → AnnotateView (client gate)
→ GET /api/note/files//bootstrap (Bearer) → PDFViewer (prop token)
→ ทุก Server Action รับ token เป็น param แรก — identity แกะฝั่ง server เสมอ
```
- ผู้ใช้มี session token เก่าอยู่ในเครื่อง → **ลิงก์ชนะ** (bootstrap เขียนทับใหม่เสมอ — e-Court
- เป็นผู้ยืนยันตัวตนปัจจุบัน) · token หมดอายุกลางวัน = action โยน error/หน้า 401 → กลับ e-Court
- กดลิงก์ใหม่ (ไม่มี renew — เว็บไม่มี ticket ฝังไฟล์แบบ add-in)
+ เป็นผู้ยืนยันตัวตนปัจจุบัน) · token หมดอายุกลางวัน = **§0.6: PDFViewer จับ auth-error
+ ของ action (`isNoteAuthExpiredError` ผ่าน `reportActionError`) → ล้าง session + พาไป
+ `/note?sso=expired`** → กลับ e-Court กดลิงก์ใหม่ (ไม่มี renew — เว็บไม่มี ticket
+ ฝังไฟล์แบบ add-in)
- ไม่มี credential ใน URL → `/note` = landing public (แบนเนอร์ `?sso=` ของลิงก์เก่ายังรองรับ)
- samples หน้า `/note/dev` → `GET /api/note/files/open?fileUrl=/samples/…` (ลิงก์แบบ /tmpl, trusted:false — แถว `url:…` เท่านั้น)
- **ลิงก์ fid-only ของเอกสาร staging (N11 2026-11-04):** e-Court POST เนื้อ base64 ที่
@@ -221,7 +234,8 @@ readNoteSession() / saveNoteSession() / clearNoteSession() / noteAuthFetch(path,
**สร้าง**
-- `app/api/note/auth/token/route.ts` — §3.1 mint login token (runtime nodejs, force-dynamic)
+- **(§0.6 2026-11-09 — รอบสอง)** `app/api/auth/token/route.ts` + `app/api/auth/session/route.ts` โค้ดเต็ม inline (เส้นฝั่ง note `/api/note/auth/token` + `/api/note/auth/session` **ถอดทั้งคู่**; lib handler ที่ใช้ชั่วระหว่างรอบแรกถอดตาม) · `app/api/documents/token/route.ts` = alias re-export ของ `/api/auth/token` (กลุ่ม docver คงไว้ตามคำขอผู้ใช้) · `utils/auth-store.ts` — primitives กลางฝั่ง client (localStorage + memory fallback + `jwtExp`/`isExpired`) + `tests/utils/auth-store.test.ts` + `tests/app/api/auth/token.test.ts` + `tests/api/auth-session-staged.test.ts`
+- `app/api/note/auth/token/route.ts` — §3.1 mint login token (runtime nodejs, force-dynamic) — **ถอดแล้ว 2026-11-09 §0.6 รอบสอง (ทางเข้ารวมที่ `/api/auth/token`)**
- `app/api/note/auth/e-court/route.ts` — §3.2 (runtime nodejs, force-dynamic)
- `lib/note-auth.ts` — §4 (รวม `signOacSession`/`verifyOacSession` ของ D16) + `tests/lib/note-auth.test.ts` (รวมเคส: คู่ถูกต้องผ่าน · แก้ payload 1 ตัวอักษร/แก้ exp เฉย ๆ ไม่ผ่าน · exp อดีตไม่ผ่าน · ไม่มี secret = false)
- `scripts/make-login-link.mjs` — `node scripts/make-login-link.mjs --uid oom --uname สรวิชญ์ --fid F123 --fileUrl https://… --typeId T12 [--ttl 300] [--mode jwt|ticket] [--secret <ตาม mode: DOCVER_TOKEN_SECRET | ADDIN_TICKET_SECRET>]` → พิมพ์ลิงก์เต็ม (jwt = node:crypto HS256 ตรงตาม `lib/docver-token.ts` · ticket = HMAC ตรงตาม `lib/addin-ticket.ts` โดย exp มาจาก `--ttl` default 300)
@@ -288,7 +302,7 @@ readNoteSession() / saveNoteSession() / clearNoteSession() / noteAuthFetch(path,
1. `npx vitest run` ผ่านทั้งชุด (เพิ่ม `tests/lib/note-auth.test.ts` + เคสใหม่ของ `docver-token` — เคสเดิมไม่พัง)
2. `npx tsc --noEmit` ผ่าน
3. curl regression:
- - (ก) mint: `x-api-key` + `{userid,username,fid,ttl_seconds:300}` ที่ **`/api/note/auth/token`** → ได้ token ที่ decode แล้วมี `purpose:"login"` · ส่ง `purpose` มาด้วย = 400
+ - (ก) mint: `x-api-key` + `{userid,username,fid,ttl_seconds:300,purpose:"login"}` ที่ **`/api/auth/token`** → ได้ token ที่ decode แล้วมี `purpose:"login"` (§0.6 — เส้น mint เดิม `/api/note/auth/token` ถอดแล้ว)
- (ข) `GET /api/note/auth/e-court?fileUrl=…&t=` → 302 + `Set-Cookie oacCase` + Location เป็น `/note?…` **ไม่มี `t`** + `Cache-Control: no-store`
- (ค) รอให้ TTL หมด (ตั้ง 60 วิ) → 302 `/note?sso=expired`
- (ง) เอา token purpose=docver มาใช้ที่ route → `sso=invalid` · เอา login token ไป `GET /api/documents/versions` → 401
@@ -299,20 +313,23 @@ readNoteSession() / saveNoteSession() / clearNoteSession() / noteAuthFetch(path,
- (ฎ) หน้าแรกใหม่ D14/D15 (ปรับตาม 2026-10-30): ไม่มี session เปิด `/note` = เห็น landing เรียบ และ `/note/dev` = เห็น dropdown 2 ระดับ (public ทั้งคู่ ไม่โดน redirect) · เลือกกลุ่ม → ไฟล์ แล้วกด "เปิดเอกสาร" บน `/note/dev` โดยไม่มี session = เด้งกลับ `/note?sso=required` มีแบนเนอร์ · มี session = ลง viewer ของไฟล์ที่เลือก · middleware ไม่จับ `/note` และ `/note/dev` แต่ยังจับ `/note/annotate/…` · **ไม่มี session เปิด `/note?fileUrl=…&fileId=<ที่มีอยู่แล้ว>` ตรง ๆ = redirect `sso=required` และ DB ไม่เปลี่ยน** (ไม่มี row ใหม่, `pdf_url`/`type_id` เดิม — เทียบก่อน/หลัง)
- (ฏ) D16 session signing: ผ่านลิงก์จริงแล้วตั้ง cookie เดียวแบบเดิม (`oacCase` ไม่มี `oacCaseSig` — คัดลอกค่าจาก DevTools ใส่เอง) → เข้า annotate ไม่ได้ redirect `sso=required` · มีคู่ครบแต่แก้ `u_userid` ใน `oacCase` 1 ตัวอักษร → ไม่ผ่านเช่นกัน · แก้เฉพาะ `exp` ใน `oacCaseSig` ยืดอายุออก → ไม่ผ่าน (exp ถูกเซ็น) · response ของ auth route มี `Set-Cookie` **สองตัว** + `Secure` เมื่อรัน production (ตรวจจาก response header) · `NOTE_SESSION_SECRET` ว่าง → auth route ตอบ `sso=error` และทุกหน้ามองเป็นไม่มี session · `getSessionUser` async แล้ว `npx tsc --noEmit` ผ่าน (call site ครบ)
4. ทดสอบเบราว์เซอร์ด้วย `scripts/make-login-link.mjs`: วางลิงก์ → ลง viewer ชื่อผู้ใช้ = `uname` · เปิดลิงก์ uid คนละคนต่อ → session สลับตามลิงก์ · ลิงก์หมดอายุ → กลับหน้าแรกพร้อมแบนเนอร์ "ลิงก์หมดอายุ — กลับ e-Court กดเปิดใหม่" · เข้า `/note` ตรง ๆ ไม่มี session → landing เรียบ (public ไม่มีแบนเนอร์) · ที่ `/note/dev` กดปุ่ม "เปิดเอกสาร" → แบนเนอร์ "กรุณาเข้าสู่ระบบผ่าน e-Court" (`sso=required`)
-5. รัน `plan-reviewer` + `code-reviewer` ตามกติกา `.claude/`
+5. **(§0.6)** ทางเข้ารวม: mint `purpose:"login"` ที่ `/api/auth/token` → ลิงก์ `/note?t=` → NoteHome แลกที่ `/api/auth/session` (network tab) → เข้า viewer · alias `/api/documents/token` body เดิมยังผ่าน (กลุ่ม docver คงไว้) · **เส้น note เก่า `/api/note/auth/token` + `/api/note/auth/session` ตอบ 404** · `/api/auth/session` กับลิงก์ fid-only ของ staging → ได้ `fileId` (N11 ครบ) · **หมดอายุกลาง session**: แก้ `expMs` ใน localStorage เป็นอดีต → สร้าง annotation → ถูกพาไป `/note?sso=expired` + localStorage `note-auth` ถูกล้าง (ไม่ใช่ error toast เปล่า ๆ)
+6. รัน `plan-reviewer` + `code-reviewer` ตามกติกา `.claude/`
## 10. ลำดับงาน
0. **add-in D13** — ~~ถอด client HMAC check + `NEXT_PUBLIC_ADDIN_TICKET_SECRET` ออกจาก bundle (+ ปรับ tests/docs)~~ **✅ ทำแล้ว 2026-11-04** (พร้อม refactor ใหญ่ของ PLAN-ADDIN-AUTH.md: contract 400/401/403 + TTL 7 วัน + fallback onReady) → verify 9.3 (ฐ)
1. `lib/docver-token.ts` (purpose + ttl) + tests → verify 9.1 บางส่วน
2. `lib/note-auth.ts` + tests (รวม `signOacSession`/`verifyOacSession` ของ D16)
-3. `app/api/note/auth/token/route.ts` (mint) → verify 9.3 (ก)(จ) แล้ว `app/api/note/auth/e-court/route.ts` → verify 9.3 (ข–ง)
+3. `app/api/note/auth/token/route.ts` (mint — ภายหลังถอดตาม §0.6 รอบสอง) → verify 9.3 (ก)(จ) แล้ว `app/api/note/auth/e-court/route.ts` → verify 9.3 (ข–ง)
4. ถอด mock login ทั้งเส้น (`LoginForm`/action `login`/`MOCK_USERS` + **หน้า `/note/login` ทั้งหน้า**) + rework หน้าแรก `/note` ใหม่ (D14) + middleware (D15) + งาน session signing ของ D16 (`utils/user.ts`/`lib/session.ts`/logout/หน้า caller `await`) → verify 9.3 (ฉ)(ฎ)(ฏ) + 9.4
5. `scripts/make-login-link.mjs` → verify 9.4
6. `lib/openapi.ts` + `.env.example` + `CLAUDE.md` sync → verify 9.1–9.5
## 11. Out of scope
+- **add-in ย้ายมาใช้โค้ดกลางของ §0.6** (รอบหน้า — ผู้ใช้กำหนด "รอบนี้เอาแค่ของ note มาใช้ก่อน"): `wordaddin/services/ticketService.ts` internals → `utils/auth-store.ts` + prune token หมดอายุออกจาก map ตอนโหลด + ลบ entry ของ fid หลัง renew จาก ticket ล้ม — wire contract/flow/UI ไม่เปลี่ยน (`utils/auth-store` ออกแบบรองรับอยู่แล้ว)
+- **refresh token** — วันหนึ่งอยากให้ browser/add-in ต่ออายุโดยไม่กลับ e-Court: เพิ่ม grant ใหม่ที่ `/api/auth/token` (มาตรฐาน OAuth2 + rotation) — response shape ปัจจุบันรองรับอยู่แล้ว · ข้อควรชั่ง: refresh token ใน localStorage ของ browser = ยืด session โดยไม่มีผู้ใช้ยืนยัน ขัดเหตุผลความปลอดภัยของ TTL 8 ชม. เดิม
- ~~แก้ secret รั่ยใน bundle ของ add-in~~ — ย้ายเข้าแผนนี้เป็น **D13** แล้ว (ทาง ticket ทำให้ secret ตัวนี้กลายเป็นจุดอ่อนของ login โดยตรง จึงตัดออกก่อนเปิดทาง ticket)
- revoke/refresh token, logout ระดับ e-Court, roles/สิทธิ์จริง, ผูก session กับ device
- OAuth/OIDC เต็มรูปแบบ (e-Court เป็น IdP) — ไปทางนี้เมื่อมีหลายแอปของศาลต้อง SSO
diff --git a/docs/PLAN-STAGED.md b/docs/PLAN-STAGED.md
index dc2478c..12fa244 100644
--- a/docs/PLAN-STAGED.md
+++ b/docs/PLAN-STAGED.md
@@ -13,7 +13,7 @@
3. **ไม่มี dropzone/ลากไฟล์บนแอป** — ผู้พิพากษาเปิดเอกสารผ่าน e-Court ทางเดียว (ตัดทางเลือก `local:` ทั้งหมดที่เคยพิจารณา)
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 เป็นฝั่งอ่าน ไม่ใช้กับฝั่งเขียน)
+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/auth/token` แบบ `purpose:"login"` — ทางเข้ารวม 2026-11-09 ตาม PLAN-SSO.md §0.6, เส้นเดิม `/api/note/auth/token` ถอดแล้ว) · (ทางเลือก `EXTERNAL_API_KEY` ของ N10 เป็นฝั่งอ่าน ไม่ใช้กับฝั่งเขียน)
7. **ไม่มี schema migration** — ไม่เพิ่ม column: ใช้ sentinel ใน `pdf_url` คือ **`staged:.`** (มี extension ติดไปเสมอ) เพื่อให้ `fileKindFromUrl`/`imageFormatFromUrl`/`imageContentTypeFromUrl` ทำงานทุกจุดโดยไม่แก้ (extension คือ single source of truth ของชนิดไฟล์ตาม N3)
## Design
@@ -91,7 +91,7 @@
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`)
+### D5 — Session route รับลิงก์ fid-only + เช็ค staging ตั้งแต่ต้น (`/api/auth/session` ทางเข้าเดียว — เส้นเดิม `/api/note/auth/session` ถอดแล้ว 2026-11-09 ตาม PLAN-SSO.md §0.6 รอบสอง)
ตอนนี้ `parsed.fileUrl !== undefined` เท่านั้นที่ลงทะเบียน — เอกสาร staging ลิงก์มีแค่ `t=` (fid อยู่ใน credential): เติม branch **`fileUrl` ไม่มี + มี `fid`** → select row `ecourt:` (lib/ecourt-fid):
diff --git a/lib/note-auth.ts b/lib/note-auth.ts
index 466d872..8735913 100644
--- a/lib/note-auth.ts
+++ b/lib/note-auth.ts
@@ -1,7 +1,8 @@
// PLAN-SSO.md §4 — ส่วนกลางของ SSO ของแอป note (ฉบับ token 2026-10-30 ตามคำขอ
// ผู้ใช้ "note ใช้แบบ add-in" — เลิก cookie ของรอบ D15/D16): ลิงก์ e-Court พา
// browser มา `/note` พร้อม credential (`t` หรือ `ticket_data`+`HmacSha256`) →
-// client แลกเป็น access_token ที่ POST /api/note/auth/session → เก็บใน
+// client แลกเป็น access_token ที่ POST /api/auth/session (ทางเข้ารวม 2026-11-09
+// ตาม PLAN-SSO.md §0.6 — เส้นเดิม /api/note/auth/session ถอดแล้ว) → เก็บใน
// localStorage ("note-auth") แล้วแนบ Authorization: Bearer กับทุก API และส่ง
// token เข้าทุก Server Action (identity แกะฝั่ง server — ห้ามเชื่อ userId จาก
// client ตรง ๆ)
@@ -80,7 +81,7 @@ export function parseSsoLinkParams(url: URL): SsoLinkParams {
}
// ---------------------------------------------------------------------------
-// body ของ POST /api/note/auth/session (แลก credential → access_token)
+// body ของ POST /api/auth/session (แลก credential → access_token — เดิม /api/note/auth/session)
// ---------------------------------------------------------------------------
const SESSION_EXCHANGE_LIMITS = SSO_PARAM_LIMITS;
@@ -154,7 +155,8 @@ export function parseSessionExchangeBody(raw: unknown): SessionExchangeBody {
}
// ---------------------------------------------------------------------------
-// body ของ POST /api/note/auth/token (เส้น mint login-token — D1/D5)
+// body ของทางเข้า mint login-token (ทาง purpose:"login" ของ POST /api/auth/token —
+// เส้นเดิม POST /api/note/auth/token ถอดแล้ว · D1/D5, PLAN-SSO.md §0.6)
// ---------------------------------------------------------------------------
/** ttl_seconds ทางเลือก — ช่วง 60–600 วินาที default 300 (credential ใน URL
diff --git a/lib/note-session.ts b/lib/note-session.ts
index 2f0af48..847eaae 100644
--- a/lib/note-session.ts
+++ b/lib/note-session.ts
@@ -1,11 +1,14 @@
// session ของแอป note ฝั่ง client (ฉบับ token 2026-10-30 ตามคำขอผู้ใช้ "note ใช้
// แบบ add-in" — pattern เดียวกับ wordaddin/services/authService): access_token
-// + identity เก็บใน localStorage ("note-auth") หลังแลกจาก POST /api/note/auth/
-// session · การเรียก API ของแอปผ่าน noteAuthFetch แนบ Authorization: Bearer
-// อัตโนมัติ · token หมดอายุแล้วถือว่าไม่มี session (ล้างทิ้ง) — localStorage อาจ
-// โยน (private mode/quota) ทุกจุดจึง try/catch
+// + identity เก็บใน localStorage ("note-auth") หลังแลกจาก POST /api/auth/session
+// (2026-11-09 — ย้ายมาเส้นสั้น, ทางเข้ารวมของระบบ) · การเรียก API ของแอปผ่าน
+// noteAuthFetch แนบ Authorization: Bearer อัตโนมัติ · token หมดอายุแล้วถือว่า
+// ไม่มี session (ล้างทิ้ง — token ที่ตายแล้วไม่มีทางฟื้น browser ไม่มี ticket
+// ให้ renew ต้องกลับ e-Court กดลิงก์ใหม่) · storage โยน (private mode/quota) มี
+// memory fallback ผ่าน utils/auth-store
//
// client-only — ห้าม import จาก server component/module ฝั่ง server
+import { createAuthStore, isExpired, jwtExp } from "@/utils/auth-store";
import { withBasePath } from "@/utils/base-path";
export type NoteSession = {
@@ -16,12 +19,34 @@ export type NoteSession = {
expMs: number;
};
-const STORAGE_KEY = "note-auth";
+const store = createAuthStore("note-auth");
+
+/** message sentinel ของ requireUser (app/actions/annotations.ts) — Server Action
+ * throw ข้าม network เหลือ message อย่างเดียว จึงตรวจด้วย prefix ของข้อความ */
+const AUTH_EXPIRED_SENTINEL = "session หมดอายุหรือไม่ถูกต้อง";
+
+/** action/API ล้มเพราะ token หมดอายุ/ไม่ผ่าน (เทียบ message ของ requireUser) —
+ * caller (PDFViewer) จับได้ = ล้าง session แล้วพาไปหน้า gate ไม่ปล่อย error ค้าง */
+export function isNoteAuthExpiredError(err: unknown): boolean {
+ return (
+ err instanceof Error && err.message.startsWith(AUTH_EXPIRED_SENTINEL)
+ );
+}
+
+/** exp (มิลลิวินาที) ของ session ใหม่ — decode จาก JWT payload (นาฬิกา server
+ * แม่นกว่า expires_in ที่คำนวณจากนาฬิกา client) · fallback expires_in เมื่อ decode
+ * ไม่ได้ · expires_in ผิดปกติ (undefined/Infinity — NaN จะทำ isExpired เป็น false
+ * เสมอจน session ไม่ตายตามอายุ) = คืน 0 ให้ถือว่าหมดอายุทันทีแทนค่าขยะ */
+export function noteExpMs(token: string, expires_in: number): number {
+ const exp = jwtExp(token);
+ if (exp !== undefined) return exp * 1000;
+ return Number.isFinite(expires_in) ? Date.now() + expires_in * 1000 : 0;
+}
export function readNoteSession(): NoteSession | null {
+ const raw = store.readRaw();
+ if (raw === null) return null;
try {
- const raw = window.localStorage.getItem(STORAGE_KEY);
- if (raw === null) return null;
const parsed = JSON.parse(raw) as Partial | null;
if (
parsed === null ||
@@ -33,11 +58,11 @@ export function readNoteSession(): NoteSession | null {
typeof parsed.username !== "string" ||
typeof parsed.expMs !== "number"
) {
- window.localStorage.removeItem(STORAGE_KEY);
+ store.remove();
return null;
}
- if (parsed.expMs <= Date.now()) {
- window.localStorage.removeItem(STORAGE_KEY);
+ if (isExpired(Math.floor(parsed.expMs / 1000))) {
+ store.remove();
return null;
}
return parsed as NoteSession;
@@ -47,19 +72,11 @@ export function readNoteSession(): NoteSession | null {
}
export function saveNoteSession(session: NoteSession): void {
- try {
- window.localStorage.setItem(STORAGE_KEY, JSON.stringify(session));
- } catch {
- // private mode/quota — เงียบไว้: ผู้ใช้เข้าผ่านลิงก์ e-Court ใหม่ได้เสมอ
- }
+ store.writeRaw(JSON.stringify(session));
}
export function clearNoteSession(): void {
- try {
- window.localStorage.removeItem(STORAGE_KEY);
- } catch {
- // ignore
- }
+ store.remove();
}
/** fetch ของแอปพร้อม Bearer — path เป็น route API ของแอป ("/api/note/…")
diff --git a/lib/note-token.ts b/lib/note-token.ts
index 1bd849d..8f377be 100644
--- a/lib/note-token.ts
+++ b/lib/note-token.ts
@@ -1,6 +1,7 @@
// session ของแอป note ฝั่ง server (ฉบับ token 2026-10-30 ตามคำขอผู้ใช้ "note ใช้
// แบบ add-in") — แกะ identity จาก access_token (purpose="login" ออกที่
-// POST /api/note/auth/session อายุ NOTE_SESSION_TTL) ให้ Server Actions และ API
+// POST /api/auth/session — เส้นเดิม /api/note/auth/session ถอดแล้ว 2026-11-09
+// อายุ NOTE_SESSION_TTL) ให้ Server Actions และ API
// ของแอปใช้ร่วม: token ผิด/หมดอายุ/secret ไม่ตั้ง = null เสมอ (fail closed)
// · ห้าม log ค่า token ดิบ
//
diff --git a/lib/openapi.ts b/lib/openapi.ts
index 3f66d6f..7459043 100644
--- a/lib/openapi.ts
+++ b/lib/openapi.ts
@@ -1,7 +1,9 @@
// OpenAPI 3.0 spec ของ API ทดสอบทั้งหมดของ judes-note — เส้นเดียวกับที่ Swagger UI
// แสดงผล: กลุ่ม template (DOCX Content Control) + N10 external API + docver
-// (จัด version เอกสาร) + auth (ทางเข้า path สั้น /api/auth/* — alias ของ
-// /api/documents/token กับ /api/note/auth/session เพิ่ม 2026-11-05)
+// (จัด version เอกสาร) + auth (ทางเข้ารวม /api/auth/* — 2026-11-09 รอบสอง:
+// /api/auth/token รวมทางเข้า mint login-token ด้วย purpose:"login" ·
+// /api/note/auth/token กับ /api/note/auth/session ถอดแล้ว · /api/documents/token
+// ของกลุ่ม docver คงไว้เป็น alias ของ /api/auth/token)
// route handler GET /api/swagger (app/api/swagger/route.ts) เป็นคนเดียวที่ใช้
// — inline เป็น JSON ลงใน HTML ตอน respond (ไม่มี endpoint spec แยก)
//
@@ -64,11 +66,15 @@ export const openApiSpec = {
{
name: "auth",
description:
- "ทางเข้า auth แบบ **path สั้น** `/api/auth/*` (เพิ่ม 2026-11-05) — endpoint " +
- "ก้อนเดียวกับของเดิมคนละ URL เลือกใช้ทางใดทางหนึ่งได้:\n" +
- "- `POST /api/auth/token` = `POST /api/documents/token` — ออก access_token ของกลุ่ม documents (docver)\n" +
- "- `POST /api/auth/session` = `POST /api/note/auth/session` — แลก credential ของลิงก์ e-Court " +
- "เป็น session token ของแอป note",
+ "**ทางเข้า auth รวม** `/api/auth/*` (2026-11-09 รอบสอง — เส้นของฝั่ง note " +
+ "กลุ่ม `note/auth/*` ทั้งหมดถอดแล้ว ผู้เรียกเก่าต้องย้ายมาที่นี่):\n" +
+ "- `POST /api/auth/token` — ออก access_token: x-api-key + body เดิม = " +
+ "docver-token 7 วัน · x-api-key + `purpose:\"login\"` = login-token อายุสั้น " +
+ "(mint ลิงก์ `/note?t=`) · ticket ของ add-in\n" +
+ "- `POST /api/auth/session` — แลก credential ของลิงก์ e-Court เป็น session token " +
+ "ของแอป note\n" +
+ "- `POST /api/documents/token` — alias ของกลุ่ม docver (e-Court เดิม คงไว้ — " +
+ "function เดียวกับ `/api/auth/token`)",
},
],
paths: {
@@ -630,74 +636,6 @@ export const openApiSpec = {
},
},
},
- "/api/note/auth/token": {
- post: {
- tags: ["Judge's Note"],
- summary: "mint login-token สำหรับลิงก์ SSO เข้าแอป note (PLAN-SSO.md)",
- description:
- "ออก `access_token` ชนิด `purpose=login` ไว้ฝังเป็น param เดียว `t=` ของลิงก์ " +
- "`/note?fileUrl=…&t=` ที่ให้ผู้ใช้คลิกจากหน้า e-Court " +
- "(ทางเข้า session ทางเดียวของแอป note — หน้าแรกแลก credential เป็น session " +
- "token ที่ POST /api/note/auth/session แบบ add-in)\n\n" +
- "- token ใช้ได้กับ**การเข้าแอป note เท่านั้น** — เอาไปยิง `/api/documents/*` " +
- "ของกลุ่ม docver ไม่ได้ (401)\n" +
- "- แนะนำให้ mint แบบ real-time ตอน render หน้า/ปุ่มให้ user ปัจจุบันเท่านั้น — " +
- "**ห้ามเก็บ token ลง DB / ฝังอีเมล / แชท** (อายุสั้นตาม ttl_seconds)\n" +
- "- `userid`/`username` = ค่าเดียวกับ `u_userid`/`u_userlogin` ของ e-Court " +
- "(สิทธิ์เจ้าของ annotation ของแอป note ผูกกับ userid — ส่งผิดคน = ข้อมูลผูกคนผิด)\n" +
- "- `fileUrl` ของลิงก์ปลายทางต้องผ่าน allowlist ของ N8 (admincourt.go.th + `EXTERNAL_FILE_HOSTS`)",
- security: [{ ApiKeyAuth: [] }],
- requestBody: {
- required: true,
- content: {
- "application/json": {
- schema: {
- type: "object",
- required: ["userid", "username", "fid"],
- properties: {
- userid: { type: "string", description: "u_userid ของ e-Court (1–100 ตัวอักษร)" },
- username: { type: "string", description: "u_userlogin ของ e-Court (1–200 ตัวอักษร)" },
- fid: { type: "string", description: "fileId ของ e-Court — ใช้เป็น fileId ของ N8 เสมอ (1–200 ตัวอักษร)" },
- ttl_seconds: {
- type: "integer",
- minimum: 60,
- maximum: 600,
- default: 300,
- description: "อายุ token ทางเลือกช่วง 60–600 วินาที (credential อยู่ใน URL ต้องอายุสั้น)",
- },
- },
- },
- examples: {
- "mint ให้ผู้ใช้ oom เปิดไฟล์ F123": {
- value: { userid: "oom", username: "สรวิชญ์", fid: "F123", ttl_seconds: 300 },
- },
- },
- },
- },
- },
- responses: {
- 200: {
- description: "login-token พร้อมฝังเป็น param t ของลิงก์ e-court",
- content: {
- "application/json": {
- schema: { $ref: "#/components/schemas/TokenResponse" },
- },
- },
- },
- 400: { $ref: "#/components/responses/BadRequest" },
- 401: {
- description:
- "ไม่ได้ส่ง header x-api-key หรือค่าไม่ถูกต้อง (env DOCVER_API_KEY — ข้อความเดียวกันทุกสาเหตุ)",
- content: {
- "application/json": {
- schema: { $ref: "#/components/schemas/Error" },
- },
- },
- },
- 500: { $ref: "#/components/responses/ServerError" },
- },
- },
- },
"/api/note/files/stage": {
post: {
tags: ["Judge's Note"],
@@ -708,8 +646,8 @@ export const openApiSpec = {
"(TTL 8 ชม. นับจากการเข้าใช้ล่าสุด — ห้ามเก็บไฟล์ถาวรทุกรูปแบบตามโจทย์) " +
"annotation/bookmark เก็บถาวรใน DB ตามระบบเดิม\n\n" +
"ได้ `{fileId}` แล้วเอา `fid` **เดิม** ไป mint login token ที่ " +
- "`POST /api/note/auth/token` → ลิงก์ `/note?t=` (ไม่ต้องมี " +
- "`fileUrl`) ให้ผู้ใช้คลิกตามเส้นทางเดิม\n\n" +
+ "`POST /api/auth/token` แบบ `purpose:\"login\"` → ลิงก์ `/note?t=` " +
+ "(ไม่ต้องมี `fileUrl`) ให้ผู้ใช้คลิกตามเส้นทางเดิม\n\n" +
"- auth: header `x-api-key` (env `DOCVER_API_KEY` — ตัวเดียวกับทางเข้า mint)\n" +
"- `data`: **base64 มาตรฐานต่อเนื่อง** — ห้าม whitespace/line break และไม่รับ " +
"base64url (`-`/`_`)\n" +
@@ -748,7 +686,7 @@ export const openApiSpec = {
description:
"รหัสกลุ่มเอกสาร N9 (ทางเลือก) — ส่งมา = ย้ายกลุ่มตามค่าล่าสุด ไม่ส่ง = คงเดิม · " +
"⚠️ ของเอกสาร staging รับทางเส้นนี้**ทางเดียว** — typeId ที่แนบมากับ body ของ " +
- "`POST /api/note/auth/session` (ตอนแลกลิงก์ fid-only) ไม่ถูกใช้กับกลุ่มของไฟล์",
+ "`POST /api/auth/session` (ตอนแลกลิงก์ fid-only) ไม่ถูกใช้กับกลุ่มของไฟล์",
},
title: {
type: "string",
@@ -841,18 +779,25 @@ export const openApiSpec = {
"/api/auth/token": {
post: {
tags: ["auth"],
- summary: "ขอ access_token ของกลุ่ม documents (docver) — path สั้นของ /api/documents/token",
+ summary: "ขอ access_token — ทางเข้ารวมของทั้งระบบ (docver / login ลิงก์ note / ticket add-in)",
description:
- "ทางเข้า **path สั้น** ของ `POST /api/documents/token` — endpoint ก้อนเดียวกัน " +
- "พฤติกรรมเหมือนกันทุกประการ (เพิ่ม 2026-11-05)\n\n" +
- "ออก `access_token` (JWT HS256) ไว้เรียก endpoint อื่นของกลุ่ม documents — " +
- "token ฝัง claims `{userid, username, fid, template}` อายุตาม env " +
- "`DOCVER_TOKEN_TTL_SECONDS` (default 604800 วินาที = 7 วัน — token อายุยาวแทน" +
- "ระบบ refresh/renew ของ add-in)\n\n" +
- "**ทางเข้า 2 แบบ** — เลือกตามว่าใครเป็นคนเรียก (ดูตัวอย่างใน Request body ได้ทั้งคู่):\n\n" +
+ "**ทางเข้า token รวมของทั้งระบบ** (2026-11-09 รอบสอง — ถอดเส้น mint เดิมของฝั่ง note " +
+ "ทาง mint login-token จึงรวมอยู่ที่นี่ทางเดียวผ่าน `purpose:\"login\"` · " +
+ "`/api/documents/token` ของกลุ่ม docver เป็น alias ของเส้นนี้ — function เดียวกัน)\n\n" +
+ "ออก `access_token` (JWT HS256) — token ฝัง claims `{userid, username, fid, " +
+ "template}` อายุตามชนิดของ token (D4 แยกสิทธิ์ด้วย claim `purpose`)\n\n" +
+ "**ทางเข้า 3 แบบ** — เลือกตามว่าใครเป็นคนเรียก (ดูตัวอย่างใน Request body):\n\n" +
"1. **ระบบภายนอก (server-to-server)** — แนบ header `x-api-key` (ค่าจาก env " +
- "`DOCVER_API_KEY`) แล้วส่ง claims ตรง ๆ ใน body ตาม schema `TokenRequest`\n" +
- "2. **add-in ใน Word** — ไม่ต้องมี `x-api-key` ส่ง body `{ticket_data, HmacSha256}` " +
+ "`DOCVER_API_KEY`) แล้วส่ง claims ตรง ๆ ใน body ตาม schema `TokenRequest` → " +
+ "docver-token อายุตาม env `DOCVER_TOKEN_TTL_SECONDS` (default 604800 วินาที = " +
+ "7 วัน — token อายุยาวแทนระบบ refresh/renew ของ add-in) ใช้กับ endpoint กลุ่ม " +
+ "documents\n" +
+ "2. **e-Court mint ลิงก์ SSO ของแอป note** — `x-api-key` เช่นเดียวกัน + body ตาม " +
+ 'schema `LoginTokenRequest` (บอกด้วย `purpose:"login"` + `ttl_seconds` 60–600 ' +
+ "default 300) → login-token อายุสั้นไว้ฝังเป็น param `t=` ของลิงก์ `/note?t=…` " +
+ "(credential อยู่ใน URL ต้องอายุสั้น — D5) · ไม่ส่ง `purpose` = ทางเข้า 1 ตามเดิม " +
+ "พอดี\n" +
+ "3. **add-in ใน Word** — ไม่ต้องมี `x-api-key` ส่ง body `{ticket_data, HmacSha256}` " +
"ตาม schema `TicketTokenRequest` (คู่ค่าที่ e-Court stamp ไว้ใน custom properties " +
"ของไฟล์ .docx) — server ตรวจลายเซ็น HMAC-SHA256 ด้วย env `ADDIN_TICKET_SECRET` " +
'(ไม่ตั้ง → env `SECRET` → `"super_secret"`) แล้วออก token จาก claims ที่อยู่ในตัว ticket เอง ' +
@@ -868,6 +813,7 @@ export const openApiSpec = {
schema: {
oneOf: [
{ $ref: "#/components/schemas/TokenRequest" },
+ { $ref: "#/components/schemas/LoginTokenRequest" },
{ $ref: "#/components/schemas/TicketTokenRequest" },
],
},
@@ -883,6 +829,11 @@ export const openApiSpec = {
"server-to-server ขั้นต่ำ (template เป็นทางเลือก)": {
value: { userid: "123", username: "Sorawit Bholsithi", fid: "3" },
},
+ "mint login-token ลิงก์ SSO ของแอป note (purpose=login)": {
+ value: { userid: "oom", username: "สรวิชญ์", fid: "F123", ttl_seconds: 300, purpose: "login" },
+ description:
+ "ออก login-token อายุสั้น — ฝังเป็น t= ของลิงก์ /note?fileUrl=…&t=…",
+ },
"add-in ด้วย ticket จากไฟล์ (ไม่ต้องมี x-api-key)": {
value: {
ticket_data:
@@ -934,20 +885,25 @@ export const openApiSpec = {
tags: ["auth"],
summary: "แลก credential ของลิงก์ e-Court เป็น session token ของแอป note (SSO)",
description:
- "ทางเข้า **path สั้น** ของ `POST /api/note/auth/session` — endpoint ก้อนเดียวกัน " +
- "พฤติกรรมเหมือนกันทุกประการ (เพิ่ม 2026-11-05) · flow SSO ของแอป note " +
+ "**ทางเข้าหลัก (และเดียว) ของแอป note** (2026-11-09 รอบสอง — เส้น session เดิมของฝั่ง note " +
+ "ถอดแล้ว ผู้เรียกเก่าต้องย้ายมาเส้นนี้) · flow SSO ของแอป note " +
"(PLAN-SSO.md §3.2 ฉบับ token): ลิงก์ e-Court พา browser มาหน้า `/note` พร้อม " +
"credential → client เรียกเส้นนี้แลกเป็น access_token เก็บ localStorage " +
'("note-auth") แล้วแนบ `Authorization: Bearer` กับทุก API/Server Action ของแอป\n\n' +
- "- ส่ง credential **ทางเดียว**: `t` (login-token จาก `POST /api/note/auth/token`) " +
- "หรือ `ticket_data`+`HmacSha256` (คู่ค่าชุดเดียวกับที่ e-Court stamp ใน custom " +
- "properties ของไฟล์ .docx ของ add-in) — สองชุดพร้อมกัน = 400 (D12)\n" +
+ "- ส่ง credential **ทางเดียว**: `t` (login-token จาก `POST /api/auth/token` " +
+ 'แบบ `purpose:"login"`) หรือ `ticket_data`+`HmacSha256` (คู่ค่าชุดเดียวกับที่ e-Court ' +
+ "stamp ใน custom properties ของไฟล์ .docx ของ add-in) — สองชุดพร้อมกัน = 400 (D12)\n" +
"- token ที่ได้เป็นชนิด `purpose=login` อายุ 8 ชม. (28800 วินาที) — ใช้ได้กับแอป " +
- "note เท่านั้น เอาไปยิง `/api/documents/*` ของกลุ่ม docver ไม่ได้ (401)\n" +
+ "note เท่านั้น เอาไปยิง `/api/documents/*` ของกลุ่ม docver ไม่ได้ (401) · หมด " +
+ "กลาง session แล้ว client ล้างทิ้งแล้วพากลับ e-Court (token ที่ตายแล้วไม่มีทางฟื้น)\n" +
"- ส่ง `fileUrl` มาด้วย = ลงทะเบียนเอกสาร N8 แบบ trusted ฝั่ง server ทันที " +
"(fid จาก credential ที่ verify แล้ว) — ล้มก็ยังได้ token พร้อม `fileError` " +
"ให้หน้าแรกแสดงสาเหตุ · `fileUrl` ต้องผ่าน allowlist ของ N8 " +
"(admincourt.go.th + env `EXTERNAL_FILE_HOSTS`) หรือเป็น path `/samples/…`\n" +
+ "- **ไม่ส่ง `fileUrl` (ลิงก์ fid-only — เอกสาร staging ของ N11)**: ค้นเอกสารของ " +
+ "`fid` ตั้งแต่ตอนแลก token — staging ที่ยังมีเนื้อ (RAM) หรือเอกสาร N8 ที่รู้จัก " +
+ "แล้ว = ได้ `fileId` เข้า viewer ได้เลย · ไม่เจอ/พ้นช่วงจัดเตรียม = `fileError` " +
+ "ให้กลับ e-Court กดลิงก์ใหม่ (ผู้ใช้ยังได้ token ตามปกติทุกกรณี)\n" +
"- **ทาง ticket ใน production**: secret ต้องตั้งจริง (env `ADDIN_TICKET_SECRET` " +
'→ `SECRET`) — ค่า default `"super_secret"` ที่รู้กันทั้งระบบถูกปฏิเสธ (500)',
requestBody: {
@@ -1018,6 +974,9 @@ export const openApiSpec = {
tags: ["documents"],
summary: "ขอ access_token ของกลุ่ม documents (docver)",
description:
+ "**alias ของ `POST /api/auth/token`** — function เดียวกันเป๊ะ (re-export · " +
+ "2026-11-09: คงไว้ตามคำขอผู้ใช้เพราะเป็นทางเข้าของกลุ่ม docver/e-Court เดิม; " +
+ "contract เดิมคงทั้งหมด — body ไม่ส่ง `purpose` = docver-token)\n\n" +
"ออก `access_token` (JWT HS256) ไว้เรียก endpoint อื่นของกลุ่ม documents — " +
"token ฝัง claims `{userid, username, fid, template}` อายุตาม env " +
"`DOCVER_TOKEN_TTL_SECONDS` (default 604800 วินาที = 7 วัน — token อายุยาวแทน" +
@@ -1464,6 +1423,36 @@ export const openApiSpec = {
},
},
},
+ LoginTokenRequest: {
+ type: "object",
+ description:
+ "ทางเข้า mint login-token ของแอป note (x-api-key เช่นเดียวกับ TokenRequest — " +
+ "บอกความต้องการด้วย purpose:\"login\") · ไม่ส่ง purpose = ทางเข้า docver ของ " +
+ "TokenRequest ข้อ 1 · key อื่นนอกจาก 5 ตัวนี้ = 400 · token ที่ได้เป็น " +
+ "purpose=login ใช้ได้กับการเข้าแอป note เท่านั้น (D4)",
+ required: ["userid", "username", "fid", "purpose"],
+ properties: {
+ userid: { type: "string", description: "u_userid ของ e-Court (1–100 ตัวอักษร)" },
+ username: { type: "string", description: "u_userlogin ของ e-Court (1–200 ตัวอักษร)" },
+ fid: {
+ type: "string",
+ description:
+ "fileId ของ e-Court — ใช้เป็น fileId ของ N8 เสมอ (1–200 ตัวอักษร)",
+ },
+ ttl_seconds: {
+ type: "integer",
+ minimum: 60,
+ maximum: 600,
+ default: 300,
+ description: "อายุ token ทางเลือกช่วง 60–600 วินาที (credential อยู่ใน URL ต้องอายุสั้น)",
+ },
+ purpose: {
+ type: "string",
+ enum: ["login"],
+ description: 'ค่าเดียวที่รับ — "login" (ค่าอื่น = 400)',
+ },
+ },
+ },
TokenResponse: {
type: "object",
description: "access_token ของกลุ่ม documents (JWT HS256)",
@@ -1486,7 +1475,7 @@ export const openApiSpec = {
t: {
type: "string",
description:
- "login-token (JWT purpose=login) จาก POST /api/note/auth/token — ≤ 8192 ตัวอักษร",
+ 'login-token (JWT purpose=login) จาก POST /api/auth/token แบบ purpose:"login" — ≤ 8192 ตัวอักษร',
},
ticket_data: {
type: "string",
@@ -1532,14 +1521,14 @@ export const openApiSpec = {
type: "string",
format: "uuid",
description:
- "มีเมื่อส่ง fileUrl และลงทะเบียนสำเร็จ — id ของเอกสารในระบบนี้ " +
- "(ใช้เข้าหน้าอ่าน /note/annotate/)",
+ "มีเมื่อส่ง fileUrl และลงทะเบียนสำเร็จ หรือ (ลิงก์ fid-only ของ N11) เจอเอกสารของ " +
+ "fid ที่พร้อมใช้ — id ของเอกสารในระบบนี้ (ใช้เข้าหน้าอ่าน /note/annotate/)",
},
fileError: {
type: "string",
description:
- "มีเมื่อลงทะเบียน fileUrl ไม่สำเร็จ (ไม่ผ่าน allowlist ฯลฯ) — token ยังออกให้ " +
- "หน้าแรกแสดงสาเหตุ",
+ "มีเมื่อลงทะเบียน fileUrl ไม่สำเร็จ (ไม่ผ่าน allowlist ฯลฯ) หรือลิงก์ fid-only " +
+ "ไม่เจอเอกสาร/พ้นช่วงจัดเตรียม — token ยังออกให้ หน้าแรกแสดงสาเหตุ",
},
},
},
diff --git a/tests/api/note-auth-session-staged.test.ts b/tests/api/auth-session-staged.test.ts
similarity index 94%
rename from tests/api/note-auth-session-staged.test.ts
rename to tests/api/auth-session-staged.test.ts
index 4949f22..cb10736 100644
--- a/tests/api/note-auth-session-staged.test.ts
+++ b/tests/api/auth-session-staged.test.ts
@@ -10,15 +10,16 @@ vi.mock("@/db", async () => {
});
// import หลัง vi.mock — @/db ถูกแทนก่อน
-import { POST } from "@/app/api/note/auth/session/route";
+import { POST } from "@/app/api/auth/session/route";
import { issueDocverToken } from "@/lib/docver-token";
import { ecourtExternalId } from "@/lib/ecourt-fid";
import { __clearStagedDocsForTest, putStagedDoc } from "@/lib/staged-docs";
// PLAN-STAGED.md D5 — ลิงก์ fid-only (body มีแค่ t=): 4 branch ของการเช็ค staging
// ตั้งแต่ตอนแลก token · ใช้ hasStagedDoc จริงของ lib/staged-docs (ไม่ยืด TTL)
+// (2026-11-09: ย้ายมา /api/auth/session ทางเข้าเดียว — /api/note/auth/session ถอดแล้ว)
-const URL_ = "http://localhost/docedit/api/note/auth/session";
+const URL_ = "http://localhost/docedit/api/auth/session";
const FID = "F-STAGED-1";
const FILE_ID = "0d7e6a52-9a71-4d40-bd1c-b2e1a44ab133";
const PDF_BYTES = Buffer.from("%PDF-1.7 เอกสาร staging");
diff --git a/tests/app/api/documents/token.test.ts b/tests/app/api/auth/token.test.ts
similarity index 64%
rename from tests/app/api/documents/token.test.ts
rename to tests/app/api/auth/token.test.ts
index 803a271..545a92b 100644
--- a/tests/app/api/documents/token.test.ts
+++ b/tests/app/api/auth/token.test.ts
@@ -1,11 +1,15 @@
-// POST /api/documents/token — contract ทางเข้า ticket ฉบับ 2026-11-04 + regression ทางเข้า x-api-key
-// route pure ล้วนหลังถอด hasVersionByUser (ไม่ import @/db/minio อีกต่อไป) จึง unit test
-// ได้โดยไม่ต้องตั้ง DATABASE_URL/MINIO_* — เคส 403 ผ่านโดยไม่มี DB เลย = พิสูจน์ว่า route
-// ไม่แตะ DB (เลิก "ตรวจเคยมี version" ของ requirement ขั้น 7 ฉบับ 2026-10-28)
+// POST /api/auth/token — ทางเข้า token หลักของระบบ (2026-11-09 รอบสอง: ถอด
+// /api/note/auth/token — ทาง mint login-token รวมมาที่นี่ผ่าน purpose:"login"
+// ส่วน /api/documents/token ของกลุ่ม docver **คงไว้** เป็น alias re-export ของ
+// เส้นนี้) · contract ทางเข้า ticket ฉบับ 2026-11-04 + ทาง x-api-key แยกตาม
+// body purpose · route pure ล้วน (ไม่ import @/db/minio) จึง unit test ได้โดย
+// ไม่ต้องตั้ง DATABASE_URL/MINIO_* — เคส 403 ผ่านโดยไม่มี DB เลย = พิสูจน์ว่า
+// route ไม่แตะ DB
import { createHmac } from "node:crypto";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
-import { POST } from "@/app/api/documents/token/route";
+import { POST } from "@/app/api/auth/token/route";
+import { POST as documentsTokenPOST } from "@/app/api/documents/token/route";
/** secret ตาม chain ของ addinTicketSecret — ลบ env ทั้งสองตัวใน setup เพื่อใช้ค่า default */
const SECRET_DEFAULT = "super_secret";
@@ -35,7 +39,7 @@ function futureExp(): number {
}
function makeRequest(body: BodyInit, headers?: Record): Request {
- return new Request("http://localhost/docedit/api/documents/token", {
+ return new Request("http://localhost/docedit/api/auth/token", {
method: "POST",
headers: { "content-type": "application/json", ...headers },
body,
@@ -78,7 +82,7 @@ afterEach(() => {
}
});
-describe("POST /api/documents/token — ทางเข้า ticket (contract 2026-11-04)", () => {
+describe("POST /api/auth/token — ทางเข้า ticket (contract 2026-11-04)", () => {
it("ticket ถูกทั้งลายเซ็น+อายุ → 200 + JWT claims จาก ticket + expires_in 604800 (default 7 วัน)", async () => {
const { ticketData, ticketHmac } = mintTicket(futureExp(), { fid: "F9", uid: "u1", uname: "นายทดสอบ" });
const response = await POST(makeRequest(ticketBody(ticketData, ticketHmac)));
@@ -176,7 +180,7 @@ describe("POST /api/documents/token — ทางเข้า ticket (contract 2
});
});
-describe("POST /api/documents/token — ทางเข้า x-api-key (regression ไม่เปลี่ยน)", () => {
+describe("POST /api/auth/token — ทางเข้า x-api-key แบบ docver (contract เดิมของ /api/documents/token)", () => {
it("key ถูก + body claims → 200 ได้ token อายุ default ใหม่ 604800", async () => {
process.env.DOCVER_API_KEY = API_KEY;
const response = await POST(
@@ -213,3 +217,79 @@ describe("POST /api/documents/token — ทางเข้า x-api-key (regress
expect(response.status).toBe(401);
});
});
+
+describe("POST /api/auth/token — ทางเข้า purpose:'login' (mint login-token ลิงก์ SSO ของ note)", () => {
+ it("x-api-key + purpose:login + ttl_seconds → 200 login-token อายุตาม ttl (template:null)", async () => {
+ process.env.DOCVER_API_KEY = API_KEY;
+ const response = await POST(
+ makeRequest(JSON.stringify({ userid: "u1", username: "นายทดสอบ", fid: "F1", ttl_seconds: 120, purpose: "login" }), {
+ "x-api-key": API_KEY,
+ }),
+ );
+ expect(response.status).toBe(200);
+ const data = (await response.json()) as { access_token: string; expires_in: number };
+ expect(data.expires_in).toBe(120);
+ const payload = jwtPayload(data.access_token);
+ expect(payload.purpose).toBe("login");
+ expect(payload.template).toBeNull();
+ expect((payload.exp as number) - (payload.iat as number)).toBe(120);
+ });
+
+ it("purpose:login ไม่ส่ง ttl_seconds → default 300 (D5)", async () => {
+ process.env.DOCVER_API_KEY = API_KEY;
+ const response = await POST(
+ makeRequest(JSON.stringify({ userid: "u1", username: "n", fid: "F1", purpose: "login" }), {
+ "x-api-key": API_KEY,
+ }),
+ );
+ expect(response.status).toBe(200);
+ expect(((await response.json()) as { expires_in: number }).expires_in).toBe(300);
+ });
+
+ it("purpose ค่าอื่น (docver/ตัวเลข) → 400 (จากทาง x-api-key ไม่ใช่ทาง ticket)", async () => {
+ process.env.DOCVER_API_KEY = API_KEY;
+ for (const purpose of ["docver", "admin", 42]) {
+ const response = await POST(
+ makeRequest(JSON.stringify({ userid: "u1", username: "n", fid: "F1", purpose }), {
+ "x-api-key": API_KEY,
+ }),
+ );
+ expect(response.status, `purpose=${JSON.stringify(purpose)}`).toBe(400);
+ }
+ });
+
+ it("purpose:login + template / ttl นอกช่วง 60–600 → 400 (allowlist ของ parseLoginTokenBody)", async () => {
+ process.env.DOCVER_API_KEY = API_KEY;
+ for (const body of [
+ JSON.stringify({ userid: "u1", username: "n", fid: "F1", template: "T12", purpose: "login" }),
+ JSON.stringify({ userid: "u1", username: "n", fid: "F1", ttl_seconds: 601, purpose: "login" }),
+ JSON.stringify({ userid: "u1", username: "n", fid: "F1", ttl_seconds: 59, purpose: "login" }),
+ ]) {
+ const response = await POST(
+ makeRequest(body, { "x-api-key": API_KEY }),
+ );
+ expect(response.status, body).toBe(400);
+ }
+ });
+});
+
+describe("alias /api/documents/token (กลุ่ม docver คงไว้ — กัน copy กลับมา drift อีก)", () => {
+ it("export function เดียวกันกับ /api/auth/token (re-export)", () => {
+ expect(documentsTokenPOST).toBe(POST);
+ });
+
+ it("body เดิมของ e-Court (ไม่มี purpose) ผ่านเส้นเก่ายังได้ docver-token ตาม contract เดิม", async () => {
+ process.env.DOCVER_API_KEY = API_KEY;
+ const response = await documentsTokenPOST(
+ new Request("http://localhost/docedit/api/documents/token", {
+ method: "POST",
+ headers: { "content-type": "application/json", "x-api-key": API_KEY },
+ body: JSON.stringify({ userid: "u1", username: "n", fid: "F1", template: "T12" }),
+ }),
+ );
+ expect(response.status).toBe(200);
+ const payload = jwtPayload(((await response.json()) as { access_token: string }).access_token);
+ expect(payload.purpose).toBe("docver");
+ expect(payload.template).toBe("T12");
+ });
+});
diff --git a/tests/lib/note-auth.test.ts b/tests/lib/note-auth.test.ts
index 59e8a31..edc4ba3 100644
--- a/tests/lib/note-auth.test.ts
+++ b/tests/lib/note-auth.test.ts
@@ -60,7 +60,7 @@ describe("parseSsoLinkParams (D12)", () => {
});
});
-describe("parseSessionExchangeBody (POST /api/note/auth/session)", () => {
+describe("parseSessionExchangeBody (POST /api/auth/session)", () => {
it("t เดี่ยว + fileUrl/typeId ผ่าน — ได้ credential ทาง jwt", () => {
const parsed = parseSessionExchangeBody({
t: "a.b.c",
diff --git a/tests/lib/openapi.test.ts b/tests/lib/openapi.test.ts
index bd9610e..543384e 100644
--- a/tests/lib/openapi.test.ts
+++ b/tests/lib/openapi.test.ts
@@ -40,12 +40,18 @@ describe("openApiSpec", () => {
expect(broken).toEqual([]);
});
- it("กลุ่ม auth: path สั้น /api/auth/* ครบทั้งคู่ และ path เดิมที่เป็นต้นทางยังอยู่", () => {
+ it("กลุ่ม auth: /api/auth/* ครบทั้งคู่ + /api/documents/token (alias กลุ่ม docver) — เส้นของฝั่ง note ถอดแล้ว", () => {
const paths = openApiSpec.paths as Record;
expect(paths["/api/auth/token"]).toBeDefined();
expect(paths["/api/auth/session"]).toBeDefined();
- // path เดิม (ที่ /api/auth/* alias มา) ต้องยัง document อยู่
expect(paths["/api/documents/token"]).toBeDefined();
- expect(paths["/api/note/auth/token"]).toBeDefined();
+ expect(paths["/api/note/auth/token"]).toBeUndefined();
+ expect(paths["/api/note/auth/session"]).toBeUndefined();
+ });
+
+ it("ไม่มีเส้น /api/note/auth/* (ถอดแล้ว 2026-11-09) หลุดค้างใน paths หรือ description ใด ๆ ของ spec", () => {
+ // JSON.stringify ทั้ง spec ครอบทุก description/example — จับชื่อเส้นตายได้ทุกจุด
+ // (regression จาก code review 2026-11-09 รอบสอง: คู่มือ next-step ของ stage route เคยชี้เส้นตาย)
+ expect(JSON.stringify(openApiSpec)).not.toContain("/api/note/auth/");
});
});
diff --git a/tests/utils/auth-store.test.ts b/tests/utils/auth-store.test.ts
new file mode 100644
index 0000000..1c45825
--- /dev/null
+++ b/tests/utils/auth-store.test.ts
@@ -0,0 +1,85 @@
+// @vitest-environment jsdom
+// utils/auth-store — primitives กลางของ session store ฝั่ง client (2026-11-09:
+// ฝั่ง note ใช้ผ่าน lib/note-session.ts · ฝั่ง add-in วางไว้ย้ายมารอบหน้า)
+import { describe, expect, it, vi } from "vitest";
+
+import { createAuthStore, isExpired, jwtExp } from "@/utils/auth-store";
+
+function b64url(obj: unknown): string {
+ return btoa(JSON.stringify(obj)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
+}
+
+describe("jwtExp", () => {
+ it("decode exp จาก JWT payload 3 segment (ไม่สน signature)", () => {
+ const token = `${b64url({ alg: "HS256", typ: "JWT" })}.${b64url({ exp: 1790000000 })}.sig`;
+ expect(jwtExp(token)).toBe(1_790_000_000);
+ });
+
+ it("payload ไม่มี exp / ไม่ใช่ตัวเลข / token ไม่มี 3 segment / base64 พัง → undefined", () => {
+ expect(jwtExp(`${b64url({ a: 1 })}.sig`)).toBeUndefined();
+ expect(jwtExp(`h.${b64url({ exp: "soon" })}.sig`)).toBeUndefined();
+ expect(jwtExp("not-a-jwt")).toBeUndefined();
+ expect(jwtExp("a.!!!.c")).toBeUndefined();
+ });
+});
+
+describe("isExpired", () => {
+ it("exp อนาคต = false · อดีต = true", () => {
+ const now = Math.floor(Date.now() / 1000);
+ expect(isExpired(now + 600)).toBe(false);
+ expect(isExpired(now - 600)).toBe(true);
+ });
+
+ it("margin — หมดอายุไปแล้วแต่ยังไม่เกิน margin = false (ช่วงผ่อนกัน clock skew) · เลิน margin = true", () => {
+ const now = Math.floor(Date.now() / 1000);
+ expect(isExpired(now - 20, 30_000)).toBe(false); // หมดแล้ว 20 วิ ยังในช่วงผ่อน 30 วิ
+ expect(isExpired(now - 40, 30_000)).toBe(true); // หมดเกิน 40 วิ เลย margin
+ expect(isExpired(now - 20, 0)).toBe(true); // ไม่มี margin = ตายทันทีที่ถึง exp
+ });
+});
+
+describe("createAuthStore", () => {
+ it("เขียน/อ่าน/ลบค่าดิบผ่าน localStorage ตาม key", () => {
+ const store = createAuthStore("test-auth-key");
+ expect(store.readRaw()).toBeNull();
+ store.writeRaw('{"a":1}');
+ expect(store.readRaw()).toBe('{"a":1}');
+ store.remove();
+ expect(store.readRaw()).toBeNull();
+ });
+
+ it("คนละ key ไม่ปนกัน", () => {
+ const a = createAuthStore("test-key-a");
+ const b = createAuthStore("test-key-b");
+ a.writeRaw("A");
+ b.writeRaw("B");
+ expect(a.readRaw()).toBe("A");
+ expect(b.readRaw()).toBe("B");
+ a.remove();
+ expect(b.readRaw()).toBe("B");
+ });
+
+ it("localStorage.setItem โยน (quota/private mode) — ยังเขียน/อ่านได้ผ่าน memory fallback", () => {
+ const spy = vi
+ .spyOn(Storage.prototype, "setItem")
+ .mockImplementation(() => { throw new DOMException("quota exceeded", "QuotaExceededError"); });
+ const store = createAuthStore("test-quota");
+ store.writeRaw('{"q":1}');
+ expect(store.readRaw()).toBe('{"q":1}');
+ spy.mockRestore();
+ });
+
+ it("ค่าใหม่ใน memory ชนะค่าเก่าที่ค้างใน localStorage (code review 2026-11-09)", () => {
+ // เขียนครั้งแรกสำเร็จทั้งคู่ → localStorage = "old" / memory = "old"
+ const store = createAuthStore("test-quota-stale");
+ store.writeRaw("old");
+ // ครั้งถัดมา localStorage ล้ม → memory = "new" แต่ localStorage ยังค้าง "old"
+ const spy = vi
+ .spyOn(Storage.prototype, "setItem")
+ .mockImplementation(() => { throw new DOMException("quota exceeded", "QuotaExceededError"); });
+ store.writeRaw("new");
+ spy.mockRestore();
+ // ต้องได้ค่าใหม่จาก memory ไม่ใช่ค่าเก่าตาย ๆ จาก localStorage
+ expect(store.readRaw()).toBe("new");
+ });
+});
diff --git a/utils/auth-store.ts b/utils/auth-store.ts
new file mode 100644
index 0000000..6a98bdc
--- /dev/null
+++ b/utils/auth-store.ts
@@ -0,0 +1,79 @@
+// auth-store — primitives กลางของ session store ฝั่ง client ใช้ร่วมกันหลายแอป
+// (สร้าง 2026-11-09 รอบ "รวม auth ทางเข้าเดียว /api/auth/*": ฝั่ง note ใช้ผ่าน
+// lib/note-session.ts — ฝั่ง add-in วางไว้ย้ายมาใช้รอบหน้า · วางที่ utils/
+// เพราะเป็นโซนเดียวที่ wordaddin นำเข้าได้ตามกติกา client-only zone)
+//
+// pure client — ไม่ import อะไรเลย · ไม่มี secret/การตรวจ signature ใด ๆ
+// (jwtExp decode payload อย่างเดียวเพื่ออ่าน exp — การตรวจ signature เป็นของ server)
+
+/** ถอด exp (unix วินาที) จาก JWT payload โดยไม่ตรวจ signature — undefined = decode ไม่ได้ */
+export function jwtExp(token: string): number | undefined {
+ const payload = token.split(".")[1];
+ if (!payload) return undefined;
+ try {
+ const bytes = Uint8Array.from(
+ atob(payload.replace(/-/g, "+").replace(/_/g, "/")),
+ (char) => char.charCodeAt(0),
+ );
+ const json: unknown = JSON.parse(new TextDecoder().decode(bytes));
+ const exp =
+ typeof json === "object" && json !== null
+ ? (json as Record).exp
+ : undefined;
+ return typeof exp === "number" && Number.isFinite(exp) ? exp : undefined;
+ } catch {
+ return undefined;
+ }
+}
+
+/** exp (unix วินาที) หมดอายุแล้วหรือยัง — margin กันขอบเขตตกหล่นระหว่างนาฬิกาสองฝั่ง */
+export function isExpired(expSeconds: number, marginMs = 30_000): boolean {
+ return expSeconds * 1000 <= Date.now() - marginMs;
+}
+
+/** raw store ต่อ key — ผู้ใช้แต่ละแอป parse/validate shape เอง */
+export type AuthRawStore = {
+ /** ค่าดิบจาก localStorage — fallback ที่ memory ของ session นี้เมื่อ storage โดน block · null = ไม่มีค่า */
+ readRaw(): string | null;
+ writeRaw(value: string): void;
+ remove(): void;
+};
+
+/** memory fallback ต่อ key — localStorage โยน (private mode/quota/webview บางตัว) ก็ยังใช้ได้ใน session เดียว · ⚠️ module นี้ถูก evaluate ตอน SSR ด้วย (Map แชร์ข้าม request บน server process) — ห้ามเรียก write/remove ระหว่าง render ฝั่ง server ให้เรียกจาก effect/handler ฝั่ง browser เท่านั้น ไม่งั้น session ปนกันข้ามผู้ใช้เงียบ ๆ */
+const memoryFallback = new Map();
+
+/** store ของหนึ่ง key — ทุกทาง try/catch ไม่โยนออกมาเด็ดขาด */
+export function createAuthStore(key: string): AuthRawStore {
+ return {
+ readRaw(): string | null {
+ // memory ก่อนเสมอ — writeRaw เขียน memory ก่อน localStorage ค่าใน memory จึง
+ // ใหม่กว่าหรือเท่ากับ localStorage เสมอ: quota ล้มกลาง session แล้ว localStorage
+ // ค้างค่าเก่า ต้องไม่อ่านค่าเก่าตาย ๆ กลับมาแทนค่าใหม่ที่ยังอยู่ใน memory
+ // (code review 2026-11-09) · tab ใหม่ memory ว่าง = อ่าน localStorage ตามปกติ
+ if (typeof window === "undefined") return memoryFallback.get(key) ?? null;
+ try {
+ return memoryFallback.get(key) ?? window.localStorage.getItem(key);
+ } catch {
+ return memoryFallback.get(key) ?? null;
+ }
+ },
+ writeRaw(value: string): void {
+ memoryFallback.set(key, value);
+ if (typeof window === "undefined") return;
+ try {
+ window.localStorage.setItem(key, value);
+ } catch {
+ // private mode/quota — memory ของ session นี้ยังถืออยู่
+ }
+ },
+ remove(): void {
+ memoryFallback.delete(key);
+ if (typeof window === "undefined") return;
+ try {
+ window.localStorage.removeItem(key);
+ } catch {
+ // ignore
+ }
+ },
+ };
+}