refactor(auth): consolidate auth under /api/auth/* endpoints
Some checks failed
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 3s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Failing after 30s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Has been skipped
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Failing after 1s
Some checks failed
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 3s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Failing after 30s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Has been skipped
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Failing after 1s
- ยุบ route auth ที่เคย copy กันจน drift (flow N11 หายจาก /api/auth/session) เหลือโค้ด inline สองเส้นเดียว: POST /api/auth/token (ทางเข้า 3: x-api-key docver / x-api-key purpose:"login" mint ลิงก์ note / ticket ของ add-in) + POST /api/auth/session (แลก credential + register N8 + N11 fid-only) - POST /api/documents/token คงไว้เป็น alias re-export (กลุ่ม docver/e-Court เดิม — contract เดิมเป๊ะ, มี test toBe กัน copy แยกชุดกลับมา) - ถอดเส้นฝั่ง note ทั้งสอบ: /api/note/auth/token + /api/note/auth/session (e-Court ต้องย้ายมาทางเข้ารวม — แจ้งทีม e-Court ก่อน deploy) - utils/auth-store.ts: primitives กลางฝั่ง client (jwtExp/isExpired/ localStorage store + memory fallback) — lib/note-session ใช้ร่วม (expMs decode จาก JWT payload = นาฬิกา server) และวางไว้ให้ wordaddin ย้ายมาใช้รอบหน้าได้ (รอบนี้ไม่แต้ wordaddin ตามข้อตกลง) - นโยบาย token หมดอายุกลาง session: PDFViewer reportActionError จับ auth-error ของ Server Action ทุกจุด → ล้าง session แล้วพาไป /note?sso=expired (จบที่ gate เดียวกับ bootstrap 401) - openapi: ทางเข้า purpose:"login" + schema LoginTokenRequest + ลบ path เส้นที่ถอด + test กันเส้นตายหลุดค้างใน spec - เอกสาร sync: PLAN-SSO §0.6, CLAUDE.md, PLAN-STAGED, .env.example, /note/dev - tests: ย้าย/เพิ่มชุด auth (50 เคสกลุ่ม auth ผ่าน, ทั้งชุด 444 ผ่าน) + e2e ครบทั้ง API (mint/alias/ticket/404 เส้นเก่า/N11/D4) และ browser Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
parent
c57a75e36a
commit
5121970f31
26 changed files with 625 additions and 523 deletions
|
|
@ -36,7 +36,8 @@ MINIO_SECRET_KEY=minioadmin
|
||||||
|
|
||||||
# (ถอดแล้ว 2026-10-30) NOTE_SESSION_SECRET ของรอบ cookie oacCaseSig — แอป note
|
# (ถอดแล้ว 2026-10-30) NOTE_SESSION_SECRET ของรอบ cookie oacCaseSig — แอป note
|
||||||
# เปลี่ยน session เป็น token แบบ add-in แล้ว (access_token purpose=login จาก
|
# เปลี่ยน 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 ของลิงก์ + เซ็น/ตรวจ
|
# DOCVER_TOKEN_SECRET ตัวเดียวทั้งระบบ: เซ็น login-token ของลิงก์ + เซ็น/ตรวจ
|
||||||
# session token ฝั่ง Server Actions และ API ของแอป (lib/note-token.ts)
|
# session token ฝั่ง Server Actions และ API ของแอป (lib/note-token.ts)
|
||||||
|
|
||||||
|
|
@ -75,8 +76,8 @@ FID_HASH_PEPPER=
|
||||||
# ฝั่ง server ตรวจ HMAC ของ ticket: ADDIN_TICKET_SECRET (ไม่ตั้ง = ใช้ env SECRET
|
# ฝั่ง server ตรวจ HMAC ของ ticket: ADDIN_TICKET_SECRET (ไม่ตั้ง = ใช้ env SECRET
|
||||||
# ของกลุ่ม template → ยังไม่ตั้งอีก = "super_secret" ตาม requirement)
|
# ของกลุ่ม template → ยังไม่ตั้งอีก = "super_secret" ตาม requirement)
|
||||||
# ⚠️ production ต้องตั้งค่าจริง — ค่า default รู้กันหมด = ใครก็ปลอม ticket ได้
|
# ⚠️ production ต้องตั้งค่าจริง — ค่า default รู้กันหมด = ใครก็ปลอม ticket ได้
|
||||||
# (ใช้ร่วมกับทาง ticket ของ SSO POST /api/note/auth/session ด้วย — PLAN-SSO.md D12)
|
# (ใช้ร่วมกับทาง ticket ของ SSO POST /api/auth/session ด้วย — PLAN-SSO.md D12)
|
||||||
# 2026-10-30: ทาง ticket ของ SSO (POST /api/note/auth/session) ปฏิเสธค่า default
|
# 2026-10-30: ทาง ticket ของ SSO (POST /api/auth/session) ปฏิเสธค่า default
|
||||||
# นี้เฉพาะใน production — dev ใช้ default ทดสอบโหมด ticket ได้เลย (เครื่องมือ dev
|
# นี้เฉพาะใน production — dev ใช้ default ทดสอบโหมด ticket ได้เลย (เครื่องมือ dev
|
||||||
# อย่าง make-login-link/make-ticket-docx ก็ default ค่าเดียวกัน)
|
# อย่าง make-login-link/make-ticket-docx ก็ default ค่าเดียวกัน)
|
||||||
# 2026-11-04: ฝั่ง add-in ไม่มี secret อีกต่อไป (ถอด NEXT_PUBLIC_ADDIN_TICKET_SECRET
|
# 2026-11-04: ฝั่ง add-in ไม่มี secret อีกต่อไป (ถอด NEXT_PUBLIC_ADDIN_TICKET_SECRET
|
||||||
|
|
|
||||||
18
CLAUDE.md
18
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`)
|
- 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` ใช้ไม่ได้)
|
- **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/แถบหัวหน้าสรุปยกเว้น)
|
- 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/`<img>` 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/`<img>` N3/export) คงไม่มี auth ตาม status quo · dev/ทดสอบ mint ลิงก์เองด้วย `scripts/make-login-link.mjs` (roles ยัง out of scope)
|
||||||
|
|
||||||
## Invariants ที่ต้องรักษา (ห้ามละเมิด)
|
## Invariants ที่ต้องรักษา (ห้ามละเมิด)
|
||||||
|
|
||||||
|
|
@ -29,7 +29,7 @@
|
||||||
9. **Optimistic update ใช้ `useOptimistic` (React 18) ภายใน `startTransition`** — React rollback อัตโนมัติเมื่อ action ล้มเหลว + dedupe ด้วย id ใน `hooks/useAnnotationRealtime.ts` (duplicate ชั่วขณะกับ realtime event หายเอง)
|
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 ทุกหน้าพร้อมกัน
|
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 ก่อน)
|
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 `<a href>`/`workerSrc`/`img.src`/`file.pdfUrl` ตอนโหลดไฟล์/ลิงก์คัดลอก F12 — ส่วน `<Link>`/`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 `<a href>`/`workerSrc`/`img.src`/`file.pdfUrl` ตอนโหลดไฟล์/ลิงก์คัดลอก F12 — ส่วน `<Link>`/`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/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-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-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:<hash ของ fid>` เฉพาะเมื่อ 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/external-import.ts ← N8 registerExternalFile — ลงทะเบียนเอกสารจากลิงก์ e-Court metadata-only (dedupe external_id + refresh pdf_url/typeId **เฉพาะแถว `url:…` เสมอ / แถว `ecourt:<hash ของ fid>` เฉพาะเมื่อ 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 ใช้ร่วม)
|
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/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/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/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/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/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)
|
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)
|
hooks/useHistory.ts ← undo/redo stack ฝั่ง client (F2)
|
||||||
app/actions/annotations.ts ← Server Actions CRUD + validation + publish event
|
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/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/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/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:<hash ของ fid>` (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/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:<hash ของ fid>` (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/[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/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=… ให้หน้าแรกแสดงสาเหตุ
|
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/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/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/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/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/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/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/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]/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` ของ <main> (กฎ coarse 44px ใน globals.css ผูกอยู่กับ selector นี้ ครอบทั้ง `a` และ `button`)
|
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` ของ <main> (กฎ 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/pdf/AnnotationSelectionLayer.tsx ← กรอบ selection + handles ย้าย/ปรับขนาด (F1) + แถบสี/ความหนา + ปุ่มลบ
|
||||||
components/sidebar/AnnotationSidebar.tsx ← + โน้ตคำอธิบาย (note) inline ต่อรายการ (PLAN-FEAT.md §7 N1)
|
components/sidebar/AnnotationSidebar.tsx ← + โน้ตคำอธิบาย (note) inline ต่อรายการ (PLAN-FEAT.md §7 N1)
|
||||||
components/AnnotationTypeIcon.tsx ← ไอคอนตามชนิด annotation (ย้ายจาก sidebar) — ไม่มี "use client" ให้ server component ใช้ร่วมได้ (sidebar + หน้าสรุป)
|
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/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<AnnotationType>` ชนิดของ Annotation — **ทั้งคู่ Set ว่าง = แสดงทั้งหมด (default), ติ๊กอะไรเห็นเฉพาะสิ่งนั้น** คำขอผู้ใช้ 2026-09-24/ผู้บันทึก) + กรองใน memory ด้วย useMemo + facet count/static users derive เอง + layout 2 คอลัมน์ (มือถือ < lg ตัวกรองหุบเป็น `<details>` แบบหน้าสรุป, desktop aside sticky 15rem scroll ภายใน) — render `FacetFilterPanel` สองที่แชร์ state เดียว (`firstGroup` = ไฟล์) + re-export `GroupListItem` = `ExplorerItem`
|
components/groups/GroupExplorer.tsx ← N9 client root ของหน้า /groups/[typeId] (redesign 2026-09-24) — state ตัวกรองก้อนเดียว `FacetFilterState` (first=fileId/`kinds: Set<"bm"|"ann">` ชนิดรายการ + `types: Set<AnnotationType>` ชนิดของ Annotation — **ทั้งคู่ Set ว่าง = แสดงทั้งหมด (default), ติ๊กอะไรเห็นเฉพาะสิ่งนั้น** คำขอผู้ใช้ 2026-09-24/ผู้บันทึก) + กรองใน memory ด้วย useMemo + facet count/static users derive เอง + layout 2 คอลัมน์ (มือถือ < lg ตัวกรองหุบเป็น `<details>` แบบหน้าสรุป, 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 `<details open>` หุบ/กางอิสระ + chevron หมุน 90°, leaf = `<button>` dense ไอคอน+ชื่อ+badge pill — **เลือก button แทน label+input เพราะโดนกฎ coarse 44px ของ `main.listing-page`/`main.summary-page` พอดี** (desktop dense ~28px / จอสัมผัส 44px อัตโนมัติ ไม่ต้องแต้ globals.css), active ผ่าน variant `aria-pressed:` (แถบซ้าย `border-l-primary` + `bg-primary/[0.08]` + ตัวน้ำเงิน; border โปร่งคงไว้ทุกแถวกัน layout shift), multi-select (ชนิดรายการ/ชนิดของ Annotation) มี**กล่อง checkbox visual 14px หน้าไอคอน** (ปกติกรอบเทาโปร่งใน, เลือก = `border-primary bg-primary` + ✓ ขาว; **ติ๊กชนิดของ Annotation แล้วเลือก Annotation ในชนิดรายการให้อัตโนมัติ ส่วน**เลิกเลือก Annotation แล้วล้างชนิดที่ติ๊กค้างไว้** เริ่มใหม่ตาม default ตอนกลับมาติ๊ก — คำขอผู้ใช้ 2026-09-24** — เป็น visual ของ button เดิมไม่ใช่ native input, กฎ coarse 44px ยังครบ; design review 2026-09-24 ตาม convention ของ faceted filter ต่างจาก nav highlight ของ Quasar), ชนิด annotation reuse `AnnotationTypeIcon` ผ่าน `TYPE_ICON_DATA` (data ตัวแทนค่าว่าง), แสดงเฉพาะชนิดที่มี ≥1 รายการ + **ไม่ได้ติ๊ก Annotation (ชนิดรายการ) = กลุ่มชนิด disabled + หุบ หรี่ทั้งกลุ่ม** (`<details open>` ผูก prop เฉพาะ transition — การหุบ/กางด้วยมือไม่ถูกเขียนทับ; คำขอผู้ใช้ 2026-09-24 ติ๊ก Annotation แล้วกลุ่มกลับมาใช้ได้+กางอัตโนมัติ), ผู้บันทึกใช้ spacer คง indentation, count เป็น static facet ไม่หดตามตัวกรองอื่น — export `ExplorerItem` (type รายการรวม bm/ann — `GroupListItem` ของ groups re-export จากที่นี่) + `FacetFilterState`/`createDefaultFacets()` (ห้ามแชร์ object default เดียว — Set ถูก mutate ข้ามหน้าได้)
|
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 `<details open>` หุบ/กางอิสระ + chevron หมุน 90°, leaf = `<button>` dense ไอคอน+ชื่อ+badge pill — **เลือก button แทน label+input เพราะโดนกฎ coarse 44px ของ `main.listing-page`/`main.summary-page` พอดี** (desktop dense ~28px / จอสัมผัส 44px อัตโนมัติ ไม่ต้องแต้ globals.css), active ผ่าน variant `aria-pressed:` (แถบซ้าย `border-l-primary` + `bg-primary/[0.08]` + ตัวน้ำเงิน; border โปร่งคงไว้ทุกแถวกัน layout shift), multi-select (ชนิดรายการ/ชนิดของ Annotation) มี**กล่อง checkbox visual 14px หน้าไอคอน** (ปกติกรอบเทาโปร่งใน, เลือก = `border-primary bg-primary` + ✓ ขาว; **ติ๊กชนิดของ Annotation แล้วเลือก Annotation ในชนิดรายการให้อัตโนมัติ ส่วน**เลิกเลือก Annotation แล้วล้างชนิดที่ติ๊กค้างไว้** เริ่มใหม่ตาม default ตอนกลับมาติ๊ก — คำขอผู้ใช้ 2026-09-24** — เป็น visual ของ button เดิมไม่ใช่ native input, กฎ coarse 44px ยังครบ; design review 2026-09-24 ตาม convention ของ faceted filter ต่างจาก nav highlight ของ Quasar), ชนิด annotation reuse `AnnotationTypeIcon` ผ่าน `TYPE_ICON_DATA` (data ตัวแทนค่าว่าง), แสดงเฉพาะชนิดที่มี ≥1 รายการ + **ไม่ได้ติ๊ก Annotation (ชนิดรายการ) = กลุ่มชนิด disabled + หุบ หรี่ทั้งกลุ่ม** (`<details open>` ผูก prop เฉพาะ transition — การหุบ/กางด้วยมือไม่ถูกเขียนทับ; คำขอผู้ใช้ 2026-09-24 ติ๊ก Annotation แล้วกลุ่มกลับมาใช้ได้+กางอัตโนมัติ), ผู้บันทึกใช้ spacer คง indentation, count เป็น static facet ไม่หดตามตัวกรองอื่น — export `ExplorerItem` (type รายการรวม bm/ann — `GroupListItem` ของ groups re-export จากที่นี่) + `FacetFilterState`/`createDefaultFacets()` (ห้ามแชร์ object default เดียว — Set ถูก mutate ข้ามหน้าได้)
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,24 @@
|
||||||
// POST /api/auth/session (path สั้นของ POST /api/note/auth/session — โค้ดก้อนเดียวกัน
|
// POST /api/auth/session — **ทางเข้าหลัก (และเดียว) ของแอป note** (2026-11-09
|
||||||
// copy มา 2026-11-05) — แลก credential ของลิงก์ e-Court เป็น access_token
|
// รอบสอง: ถอด /api/note/auth/session เส้นเก่าออก — ผู้เรียกต้องย้ายมาเส้นนี้) —
|
||||||
// (ฉบับ token 2026-10-30 ตามคำขอผู้ใช้ "note ใช้แบบ add-in" — แทน GET e-court ที่
|
// แลก credential ของลิงก์ e-Court เป็น access_token (ฉบับ token 2026-10-30 ตามคำขอ
|
||||||
// set cookie คู่ oacCase ซึ่งถอดแล้ว): body {t | ticket_data+HmacSha256, fileUrl?,
|
// ผู้ใช้ "note ใช้แบบ add-in" — แทน GET e-court ที่ set cookie ซึ่งถอดแล้ว):
|
||||||
// typeId?} → verify → ลงทะเบียนเอกสาร N8 แบบ trusted ฝั่ง server เลย (fileId จาก
|
// body {t | ticket_data+HmacSha256, fileUrl?, typeId?} → verify → ลงทะเบียนเอกสาร
|
||||||
// fid ที่เซ็นมา — ไม่ต้องมี link proof อีก) → ออก login-token อายุ NOTE_SESSION_TTL
|
// N8 แบบ trusted ฝั่ง server เลย (fileId จาก fid ที่เซ็นมา) → ออก login-token
|
||||||
// (8 ชม.) ให้ client เก็บ localStorage "note-auth"
|
// อายุ NOTE_SESSION_TTL (8 ชม.) ให้ client เก็บ localStorage "note-auth"
|
||||||
// · 400 body ผิด · 401 {error, reason: expired|invalid} · 500 secret ไม่ตั้ง
|
// · 400 body ผิด · 401 {error, reason: expired|invalid} · 500 secret ไม่ตั้ง
|
||||||
// · audit log เฉพาะ userid (strip อักขระควบคุม) — ห้าม log token/query ดิบ
|
// · audit log เฉพาะ userid (strip อักขระควบคุม) — ห้าม log token/query ดิบ
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
|
import { db } from "@/db";
|
||||||
|
import { files } from "@/db/schema";
|
||||||
import { addinTicketSecret, verifyAddinTicket } from "@/lib/addin-ticket";
|
import { addinTicketSecret, verifyAddinTicket } from "@/lib/addin-ticket";
|
||||||
import { issueDocverToken, verifyDocverLoginToken } from "@/lib/docver-token";
|
import { issueDocverToken, verifyDocverLoginToken } from "@/lib/docver-token";
|
||||||
|
import { ecourtExternalId } from "@/lib/ecourt-fid";
|
||||||
import { registerExternalFile } from "@/lib/external-import";
|
import { registerExternalFile } from "@/lib/external-import";
|
||||||
import { parseSessionExchangeBody } from "@/lib/note-auth";
|
import { parseSessionExchangeBody } from "@/lib/note-auth";
|
||||||
import { NOTE_SESSION_TTL } from "@/lib/note-token";
|
import { NOTE_SESSION_TTL } from "@/lib/note-token";
|
||||||
|
import { hasStagedDoc, isStagedSource } from "@/lib/staged-docs";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
@ -82,6 +87,30 @@ export async function POST(request: Request): Promise<Response> {
|
||||||
});
|
});
|
||||||
if (result.ok) fileId = result.fileId;
|
if (result.ok) fileId = result.fileId;
|
||||||
else fileError = result.error;
|
else fileError = result.error;
|
||||||
|
} else {
|
||||||
|
// N11 (PLAN-STAGED.md D5): ลิงก์ fid-only (เอกสาร staging — เนื้อส่งมาทาง POST
|
||||||
|
// /api/note/files/stage แล้ว ลิงก์มีแค่ t=) — เช็คตั้งแต่ตอนแลก token เลย ไม่ปล่อย
|
||||||
|
// เข้า viewer ไปเจอ 410 เอง · ใช้ hasStagedDoc (ไม่ยืด TTL ไม่ถือ Buffer — การอ่าน
|
||||||
|
// จริงเกิดที่ viewer) · เอกสาร N8 ที่รู้จักแล้ว (pdf_url เป็น URL/path ปกติ) เข้าได้
|
||||||
|
// ทางเดียวกัน · ผู้ใช้ได้ token ตามปกติทุกกรณี
|
||||||
|
try {
|
||||||
|
const [row] = await db
|
||||||
|
.select()
|
||||||
|
.from(files)
|
||||||
|
.where(eq(files.externalId, ecourtExternalId(identity.fid)))
|
||||||
|
.limit(1);
|
||||||
|
if (!row) {
|
||||||
|
fileError = "เอกสารยังไม่ถูกส่งเข้าระบบ — กลับ e-Court กดลิงก์ใหม่";
|
||||||
|
} else if (isStagedSource(row.pdfUrl)) {
|
||||||
|
if (hasStagedDoc(row.pdfUrl)) fileId = row.id;
|
||||||
|
else fileError = "เอกสารพ้นช่วงจัดเตรียม — กลับ e-Court กดลิงก์ใหม่";
|
||||||
|
} else {
|
||||||
|
fileId = row.id;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[sso] ค้นหาเอกสารของ fid ล้ม", err);
|
||||||
|
fileError = "ค้นเอกสารไม่สำเร็จ — ลองอีกครั้ง";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const access_token = issueDocverToken(
|
const access_token = issueDocverToken(
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,25 @@
|
||||||
// PLAN-DOCVER.md §auth — POST /api/auth/token (path สั้นของ POST /api/documents/token
|
// POST /api/auth/token — **ทางเข้า token เดียวของทั้งระบบ** (2026-11-09 รอบสอง:
|
||||||
// — โค้ดก้อนเดียวกัน copy มา 2026-11-05) ออก access_token ของกลุ่ม docver
|
// ถอด route คู่ทั้งหมด — /api/documents/token และ /api/note/auth/token ไม่มีแล้ว
|
||||||
// ทางเข้า 2 ทาง (PLAN-ADDIN-AUTH.md §3 — header x-api-key เป็นตัวตั้ง):
|
// ผู้เรียกเก่าต้องย้ายมาเส้นนี้ · PLAN-DOCVER.md §auth + PLAN-SSO.md §0.6)
|
||||||
// 1) e-Court server-to-server: header "x-api-key" เทียบ env DOCVER_API_KEY
|
// ทางเข้า 3 แบบ (header x-api-key เป็นตัวตั้ง):
|
||||||
// (fail closed เมื่อ env ไม่ตั้ง/ว่าง) + body claims ตรง ๆ (เดิมทุกอย่าง)
|
// 1) e-Court server-to-server (docver): header "x-api-key" เทียบ env DOCVER_API_KEY
|
||||||
// 2) add-in ใน Word: ไม่มี x-api-key + body {ticket_data, HmacSha256} — server
|
// (fail closed เมื่อ env ไม่ตั้ง/ว่าง) + body claims เดิม {userid, username, fid,
|
||||||
|
// template?} → docver-token อายุตาม env DOCVER_TOKEN_TTL_SECONDS (default
|
||||||
|
// 604800 = 7 วัน — token อายุยาวแทนระบบ refresh/renew ของ add-in)
|
||||||
|
// 2) e-Court mint ลิงก์ SSO ของแอป note: x-api-key เช่นเดียวกัน + body
|
||||||
|
// purpose:"login" (+ttl_seconds 60–600 default 300) → login-token อายุสั้น
|
||||||
|
// ไว้ฝังเป็น param t= ของลิงก์ /note?t=… (credential ใน URL ต้องอายุสั้น — D5)
|
||||||
|
// · purpose ค่าอื่น = 400
|
||||||
|
// 3) add-in ใน Word: ไม่มี x-api-key + body {ticket_data, HmacSha256} — server
|
||||||
// ตรวจ HMAC ด้วย env ฝั่ง server (ADDIN_TICKET_SECRET → SECRET →
|
// ตรวจ HMAC ด้วย env ฝั่ง server (ADDIN_TICKET_SECRET → SECRET →
|
||||||
// "super_secret", lib/addin-ticket.ts) แล้วออก token จาก claims ของ ticket
|
// "super_secret", lib/addin-ticket.ts) แล้วออก docver-token จาก claims ของ
|
||||||
// (secret ตัวเซ็น JWT ไม่มีทางไปอยู่ใน bundle ของ add-in — client ไม่ตรวจ HMAC
|
// ticket เอง (secret ตัวเซ็น JWT ไม่มีทางไปอยู่ใน bundle ของ add-in — client
|
||||||
// เองแล้วตาม PLAN-SSO.md D13) · contract 2026-11-04: 400 = ฟอร์แม็ตพัง
|
// ไม่ตรวจ HMAC เองแล้วตาม PLAN-SSO.md D13) · contract 2026-11-04: 400 =
|
||||||
// (รวม missing) / 401 = ลายเซ็นไม่ถูก / 403 = ลายเซ็นถูกแต่ ticket หมดอายุ
|
// ฟอร์แม็ตพัง (รวม missing) / 401 = ลายเซ็นไม่ถูก / 403 = ลายเซ็นถูกแต่ ticket
|
||||||
// (client ที่มี JWT เดิมของ fid นี้ใน session ใช้ต่อเอง — ไม่มีการออกซ้ำ
|
// หมดอายุ (client ที่มี JWT เดิมของ fid นี้ใน session ใช้ต่อเอง)
|
||||||
// จากหลักฐาน DB อีกต่อไป เลิก requirement ขั้น 7 ฉบับ 2026-10-28)
|
// ทุกทางออก JWT HS256 เซ็นด้วย env DOCVER_TOKEN_SECRET · response shape
|
||||||
// ทั้งสองทางออก JWT HS256 เซ็นด้วย env DOCVER_TOKEN_SECRET อายุตาม env
|
// {access_token, token_type, expires_in} ตรง OAuth2 — วันหนึ่งเพิ่ม grant
|
||||||
// DOCVER_TOKEN_TTL_SECONDS (default 604800 = 7 วัน — token อายุยาวแทนระบบ
|
// refresh_token ได้โดยไม่ break · ไม่ import @/db · ห้าม log ค่า key/body ดิบ
|
||||||
// refresh/renew) แล้วนำไปแนบ Authorization: Bearer กับ endpoint อื่นของกลุ่ม
|
|
||||||
// (lib/docver-token.ts) · segment คงที่ "token" ไม่ชน [docId]
|
|
||||||
// (Next ให้ static ชนะ) และ middleware ยกเว้น api/documents/* อยู่แล้ว
|
|
||||||
import { docverError } from "@/lib/document-versioning";
|
import { docverError } from "@/lib/document-versioning";
|
||||||
import {
|
import {
|
||||||
addinTicketSecret,
|
addinTicketSecret,
|
||||||
|
|
@ -30,6 +34,7 @@ import {
|
||||||
requireDocverApiKey,
|
requireDocverApiKey,
|
||||||
type DocverTokenClaims,
|
type DocverTokenClaims,
|
||||||
} from "@/lib/docver-token";
|
} from "@/lib/docver-token";
|
||||||
|
import { parseLoginTokenBody } from "@/lib/note-auth";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
@ -45,7 +50,7 @@ export async function POST(request: Request) {
|
||||||
: postWithTicket(request);
|
: postWithTicket(request);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** ทางเข้า 1 (เดิม) — e-Court server-to-server */
|
/** ทางเข้า 1+2 — e-Court server-to-server (docver / mint login-token ลิงก์ note) */
|
||||||
async function postWithApiKey(request: Request): Promise<Response> {
|
async function postWithApiKey(request: Request): Promise<Response> {
|
||||||
const apiKeyFail = requireDocverApiKey(request);
|
const apiKeyFail = requireDocverApiKey(request);
|
||||||
if (apiKeyFail !== null) return apiKeyFail;
|
if (apiKeyFail !== null) return apiKeyFail;
|
||||||
|
|
@ -56,12 +61,37 @@ async function postWithApiKey(request: Request): Promise<Response> {
|
||||||
} catch {
|
} catch {
|
||||||
return docverError("body ต้องเป็น JSON", 400);
|
return docverError("body ต้องเป็น JSON", 400);
|
||||||
}
|
}
|
||||||
|
// แยกทางตาม purpose: "login" = mint login-token ลิงก์ note · ไม่ส่ง = docver
|
||||||
|
// ตาม contract เดิมเป๊ะ (allowlist userid/username/fid/template) · ค่าอื่น = 400
|
||||||
|
if (typeof raw === "object" && raw !== null && !Array.isArray(raw)) {
|
||||||
|
const record = raw as Record<string, unknown>;
|
||||||
|
if (record.purpose !== undefined) {
|
||||||
|
if (record.purpose !== "login") {
|
||||||
|
return docverError("purpose รับเฉพาะ 'login'", 400);
|
||||||
|
}
|
||||||
|
// strip ออกก่อน parse — parseLoginTokenBody เป็น allowlist 4 field
|
||||||
|
// (purpose อยู่ใน body = ไม่รู้จัก field)
|
||||||
|
const { purpose: _purpose, ...body } = record;
|
||||||
|
const parsed = parseLoginTokenBody(body);
|
||||||
|
if (!parsed.ok) return docverError(parsed.error, 400);
|
||||||
|
return issueToken(
|
||||||
|
{
|
||||||
|
userid: parsed.claims.userid,
|
||||||
|
username: parsed.claims.username,
|
||||||
|
fid: parsed.claims.fid,
|
||||||
|
template: null,
|
||||||
|
},
|
||||||
|
"login",
|
||||||
|
parsed.ttlSeconds,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
const parsed = parseTokenRequestBody(raw);
|
const parsed = parseTokenRequestBody(raw);
|
||||||
if (!parsed.ok) return docverError(parsed.error, 400);
|
if (!parsed.ok) return docverError(parsed.error, 400);
|
||||||
return issueToken(parsed.claims);
|
return issueToken(parsed.claims);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** ทางเข้า 2 — add-in ส่ง ticket ที่ e-Court stamp มากับไฟล์ */
|
/** ทางเข้า 3 — add-in ส่ง ticket ที่ e-Court stamp มากับไฟล์ */
|
||||||
async function postWithTicket(request: Request): Promise<Response> {
|
async function postWithTicket(request: Request): Promise<Response> {
|
||||||
let raw: unknown;
|
let raw: unknown;
|
||||||
try {
|
try {
|
||||||
|
|
@ -89,24 +119,33 @@ async function postWithTicket(request: Request): Promise<Response> {
|
||||||
return issueToken(ticketToDocverClaims(verified.ticket));
|
return issueToken(ticketToDocverClaims(verified.ticket));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** ออก JWT ของ claims ที่ผ่านการยืนยันแล้วจากทั้งสองทาง — ส่วนกลาง */
|
/** ออก JWT ของ claims ที่ผ่านการยืนยันแล้วจากทุกทาง — ส่วนกลาง */
|
||||||
async function issueToken(claims: DocverTokenClaims): Promise<Response> {
|
async function issueToken(
|
||||||
|
claims: DocverTokenClaims,
|
||||||
|
purpose: "docver" | "login" = "docver",
|
||||||
|
ttlSeconds?: number,
|
||||||
|
): Promise<Response> {
|
||||||
// ความผิด config ฝั่ง server ไม่ใช่ความผิดของผู้เรียก — ไม่ตั้ง = 500 (ห้าม log ค่า secret)
|
// ความผิด config ฝั่ง server ไม่ใช่ความผิดของผู้เรียก — ไม่ตั้ง = 500 (ห้าม log ค่า secret)
|
||||||
const secret = process.env.DOCVER_TOKEN_SECRET?.trim();
|
const secret = process.env.DOCVER_TOKEN_SECRET?.trim();
|
||||||
if (secret === undefined || secret === "") {
|
if (secret === undefined || secret === "") {
|
||||||
return docverError("DOCVER_TOKEN_SECRET ยังไม่ถูกตั้งค่า", 500);
|
return docverError("DOCVER_TOKEN_SECRET ยังไม่ถูกตั้งค่า", 500);
|
||||||
}
|
}
|
||||||
|
// docver: TTL จาก env (default 7 วัน) · login: TTL จาก ttl_seconds ของ body
|
||||||
|
// (60–600 สั้นตาม D5 — credential ใน URL ต้องอายุสั้น)
|
||||||
|
let resolvedTtl: number;
|
||||||
|
if (purpose === "login" && ttlSeconds !== undefined) {
|
||||||
|
resolvedTtl = ttlSeconds;
|
||||||
|
} else {
|
||||||
const ttlRaw = Number(process.env.DOCVER_TOKEN_TTL_SECONDS);
|
const ttlRaw = Number(process.env.DOCVER_TOKEN_TTL_SECONDS);
|
||||||
// default 7 วัน (2026-11-04) — token อายุยาวแทนระบบ refresh/renew ของ add-in
|
resolvedTtl = Number.isFinite(ttlRaw) && ttlRaw > 0 ? Math.floor(ttlRaw) : 604_800;
|
||||||
const ttlSeconds =
|
}
|
||||||
Number.isFinite(ttlRaw) && ttlRaw > 0 ? Math.floor(ttlRaw) : 604_800;
|
|
||||||
|
|
||||||
const accessToken = issueDocverToken(claims, secret, ttlSeconds);
|
const accessToken = issueDocverToken(claims, secret, resolvedTtl, purpose);
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
access_token: accessToken,
|
access_token: accessToken,
|
||||||
token_type: "Bearer",
|
token_type: "Bearer",
|
||||||
expires_in: ttlSeconds,
|
expires_in: resolvedTtl,
|
||||||
}),
|
}),
|
||||||
{ headers: { "content-type": "application/json; charset=utf-8" } },
|
{ headers: { "content-type": "application/json; charset=utf-8" } },
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,112 +1,11 @@
|
||||||
// PLAN-DOCVER.md §auth — POST /api/documents/token ออก access_token ของกลุ่ม docver
|
// PLAN-DOCVER.md §auth — POST /api/documents/token ออก access_token ของกลุ่ม
|
||||||
// ทางเข้า 2 ทาง (PLAN-ADDIN-AUTH.md §3 — header x-api-key เป็นตัวตั้ง):
|
// docver (ทางเข้าของ e-Court server-to-server ที่ยังยิง URL เดิมอยู่ — **คงไว้
|
||||||
// 1) e-Court server-to-server: header "x-api-key" เทียบ env DOCVER_API_KEY
|
// ตามคำขอผู้ใช้ 2026-11-09**: ของกลุ่ม add-in/docver ไม่ถอด) · alias ของ
|
||||||
// (fail closed เมื่อ env ไม่ตั้ง/ว่าง) + body claims ตรง ๆ (เดิมทุกอย่าง)
|
// `POST /api/auth/token` ตัวเดียวกันเป๊ะ (re-export) — contract เดิมคงทั้งหมด:
|
||||||
// 2) add-in ใน Word: ไม่มี x-api-key + body {ticket_data, HmacSha256} — server
|
// body ไม่ส่ง `purpose` = docver-token ตาม env DOCVER_TOKEN_TTL_SECONDS ·
|
||||||
// ตรวจ HMAC ด้วย env ฝั่ง server (ADDIN_TICKET_SECRET → SECRET →
|
// segment คงที่ "token" ไม่ชน [docId] (Next ให้ static ชนะ) และ middleware
|
||||||
// "super_secret", lib/addin-ticket.ts) แล้วออก token จาก claims ของ ticket
|
// ยกเว้น api/documents/* อยู่แล้ว
|
||||||
// (secret ตัวเซ็น JWT ไม่มีทางไปอยู่ใน bundle ของ add-in — client ไม่ตรวจ HMAC
|
export { POST } from "@/app/api/auth/token/route";
|
||||||
// เองแล้วตาม PLAN-SSO.md D13) · contract 2026-11-04: 400 = ฟอร์แม็ตพัง
|
|
||||||
// (รวม missing) / 401 = ลายเซ็นไม่ถูก / 403 = ลายเซ็นถูกแต่ ticket หมดอายุ
|
|
||||||
// (client ที่มี JWT เดิมของ fid นี้ใน session ใช้ต่อเอง — ไม่มีการออกซ้ำ
|
|
||||||
// จากหลักฐาน DB อีกต่อไป เลิก requirement ขั้น 7 ฉบับ 2026-10-28)
|
|
||||||
// ทั้งสองทางออก JWT HS256 เซ็นด้วย env DOCVER_TOKEN_SECRET อายุตาม env
|
|
||||||
// DOCVER_TOKEN_TTL_SECONDS (default 604800 = 7 วัน — token อายุยาวแทนระบบ
|
|
||||||
// refresh/renew) แล้วนำไปแนบ Authorization: Bearer กับ endpoint อื่นของกลุ่ม
|
|
||||||
// (lib/docver-token.ts) · segment คงที่ "token" ไม่ชน [docId]
|
|
||||||
// (Next ให้ static ชนะ) และ middleware ยกเว้น api/documents/* อยู่แล้ว
|
|
||||||
import { docverError } from "@/lib/document-versioning";
|
|
||||||
import {
|
|
||||||
addinTicketSecret,
|
|
||||||
parseTicketTokenBody,
|
|
||||||
ticketToDocverClaims,
|
|
||||||
verifyAddinTicketHmac,
|
|
||||||
} from "@/lib/addin-ticket";
|
|
||||||
import { isTicketExpired } from "@/utils/docver-ticket";
|
|
||||||
import {
|
|
||||||
issueDocverToken,
|
|
||||||
parseTokenRequestBody,
|
|
||||||
requireDocverApiKey,
|
|
||||||
type DocverTokenClaims,
|
|
||||||
} from "@/lib/docver-token";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
/** 401 ของทางเข้า ticket — ข้อความเดียวทุกสาเหตุ (HMAC ผิด/รูปผิด) กัน oracle · หมดอายุแยกเป็น 403 */
|
|
||||||
const TICKET_DENIED = "ticket ไม่ถูกต้อง";
|
|
||||||
/** 403 ของทางเข้า ticket — ลายเซ็นถูกแต่หมดอายุ (client มี JWT เดิมใน session ก็ใช้ต่อได้) */
|
|
||||||
const TICKET_EXPIRED = "ticket ของ e-Court หมดอายุ";
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
|
||||||
return request.headers.get("x-api-key") !== null
|
|
||||||
? postWithApiKey(request)
|
|
||||||
: postWithTicket(request);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** ทางเข้า 1 (เดิม) — e-Court server-to-server */
|
|
||||||
async function postWithApiKey(request: Request): Promise<Response> {
|
|
||||||
const apiKeyFail = requireDocverApiKey(request);
|
|
||||||
if (apiKeyFail !== null) return apiKeyFail;
|
|
||||||
|
|
||||||
let raw: unknown;
|
|
||||||
try {
|
|
||||||
raw = await request.json();
|
|
||||||
} catch {
|
|
||||||
return docverError("body ต้องเป็น JSON", 400);
|
|
||||||
}
|
|
||||||
const parsed = parseTokenRequestBody(raw);
|
|
||||||
if (!parsed.ok) return docverError(parsed.error, 400);
|
|
||||||
return issueToken(parsed.claims);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** ทางเข้า 2 — add-in ส่ง ticket ที่ e-Court stamp มากับไฟล์ */
|
|
||||||
async function postWithTicket(request: Request): Promise<Response> {
|
|
||||||
let raw: unknown;
|
|
||||||
try {
|
|
||||||
raw = await request.json();
|
|
||||||
} catch {
|
|
||||||
return docverError("body ต้องเป็น JSON", 400);
|
|
||||||
}
|
|
||||||
const parsed = parseTicketTokenBody(raw);
|
|
||||||
if (!parsed.ok) {
|
|
||||||
// contract 2026-11-04: ฟอร์แม็ตพัง = 400 ทั้ง missing/invalid (เดิม missing เป็น 401)
|
|
||||||
return docverError(parsed.error, 400);
|
|
||||||
}
|
|
||||||
// ตรวจ HMAC ก่อนเสมอ (ตัวตนของผู้ถือไฟล์) — exp ไม่ใช่เกณฑ์ตัดสิทธิ์ ณ จุดนี้
|
|
||||||
const verified = verifyAddinTicketHmac(parsed.ticketData, parsed.hmacHex, addinTicketSecret());
|
|
||||||
if (!verified.ok) return docverError(TICKET_DENIED, 401);
|
|
||||||
|
|
||||||
// ลายเซ็นถูกแต่ ticket หมดอายุ → 403 เสมอ (contract 2026-11-04 — เลิก requirement
|
|
||||||
// ขั้น 7 ฉบับ 2026-10-28: ไม่ตรวจ hasVersionByUser อีกต่อไป เพราะ ticket ฝังอยู่ใน
|
|
||||||
// ไฟล์ ใครได้ไฟล์ก็ได้ ticket การปล่อยให้ ticket เก่าแลก token ใหม่ได้ตลอดไป
|
|
||||||
// เท่ากันคือปล่อยสิทธิ์ตามการครอบครองไฟล์ — ฝั่ง client ที่มี JWT ของ fid นี้
|
|
||||||
// ใน session ที่ยังไม่หมด (7 วัน) ใช้ต่อเอง ไม่มี = ขอไฟล์ใหม่จาก e-Court)
|
|
||||||
if (isTicketExpired(verified.ticket.exp)) {
|
|
||||||
return docverError(TICKET_EXPIRED, 403);
|
|
||||||
}
|
|
||||||
return issueToken(ticketToDocverClaims(verified.ticket));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** ออก JWT ของ claims ที่ผ่านการยืนยันแล้วจากทั้งสองทาง — ส่วนกลาง */
|
|
||||||
async function issueToken(claims: DocverTokenClaims): Promise<Response> {
|
|
||||||
// ความผิด config ฝั่ง server ไม่ใช่ความผิดของผู้เรียก — ไม่ตั้ง = 500 (ห้าม log ค่า secret)
|
|
||||||
const secret = process.env.DOCVER_TOKEN_SECRET?.trim();
|
|
||||||
if (secret === undefined || secret === "") {
|
|
||||||
return docverError("DOCVER_TOKEN_SECRET ยังไม่ถูกตั้งค่า", 500);
|
|
||||||
}
|
|
||||||
const ttlRaw = Number(process.env.DOCVER_TOKEN_TTL_SECONDS);
|
|
||||||
// default 7 วัน (2026-11-04) — token อายุยาวแทนระบบ refresh/renew ของ add-in
|
|
||||||
const ttlSeconds =
|
|
||||||
Number.isFinite(ttlRaw) && ttlRaw > 0 ? Math.floor(ttlRaw) : 604_800;
|
|
||||||
|
|
||||||
const accessToken = issueDocverToken(claims, secret, ttlSeconds);
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
access_token: accessToken,
|
|
||||||
token_type: "Bearer",
|
|
||||||
expires_in: ttlSeconds,
|
|
||||||
}),
|
|
||||||
{ headers: { "content-type": "application/json; charset=utf-8" } },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ export async function GET(request: Request): Promise<Response> {
|
||||||
const file = url.searchParams.get("file") ?? "";
|
const file = url.searchParams.get("file") ?? "";
|
||||||
const mode = url.searchParams.get("mode") === "ticket" ? "ticket" : "jwt";
|
const mode = url.searchParams.get("mode") === "ticket" ? "ticket" : "jwt";
|
||||||
// ชื่อไฟล์ตัวอย่างเท่านั้น — ห้าม path segment/.. (การมีไฟล์จริงตรวจตอน register
|
// ชื่อไฟล์ตัวอย่างเท่านั้น — ห้าม path segment/.. (การมีไฟล์จริงตรวจตอน register
|
||||||
// ใน exchange ของ /api/note/auth/session อยู่แล้ว)
|
// ใน exchange ของ /api/auth/session อยู่แล้ว)
|
||||||
if (
|
if (
|
||||||
file === "" || file.length > 200 ||
|
file === "" || file.length > 200 ||
|
||||||
file.includes("/") || file.includes("\\") || file.includes("..")
|
file.includes("/") || file.includes("\\") || file.includes("..")
|
||||||
|
|
|
||||||
|
|
@ -1,134 +0,0 @@
|
||||||
// POST /api/note/auth/session — แลก credential ของลิงก์ e-Court เป็น access_token
|
|
||||||
// (ฉบับ token 2026-10-30 ตามคำขอผู้ใช้ "note ใช้แบบ add-in" — แทน GET e-court ที่
|
|
||||||
// set cookie คู่ oacCase ซึ่งถอดแล้ว): body {t | ticket_data+HmacSha256, fileUrl?,
|
|
||||||
// typeId?} → verify → ลงทะเบียนเอกสาร N8 แบบ trusted ฝั่ง server เลย (fileId จาก
|
|
||||||
// fid ที่เซ็นมา — ไม่ต้องมี link proof อีก) → ออก login-token อายุ NOTE_SESSION_TTL
|
|
||||||
// (8 ชม.) ให้ client เก็บ localStorage "note-auth"
|
|
||||||
// · 400 body ผิด · 401 {error, reason: expired|invalid} · 500 secret ไม่ตั้ง
|
|
||||||
// · audit log เฉพาะ userid (strip อักขระควบคุม) — ห้าม log token/query ดิบ
|
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
|
|
||||||
import { db } from "@/db";
|
|
||||||
import { files } from "@/db/schema";
|
|
||||||
import { addinTicketSecret, verifyAddinTicket } from "@/lib/addin-ticket";
|
|
||||||
import { issueDocverToken, verifyDocverLoginToken } from "@/lib/docver-token";
|
|
||||||
import { ecourtExternalId } from "@/lib/ecourt-fid";
|
|
||||||
import { registerExternalFile } from "@/lib/external-import";
|
|
||||||
import { parseSessionExchangeBody } from "@/lib/note-auth";
|
|
||||||
import { NOTE_SESSION_TTL } from "@/lib/note-token";
|
|
||||||
import { hasStagedDoc, isStagedSource } from "@/lib/staged-docs";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
function json(body: unknown, status = 200): NextResponse {
|
|
||||||
return NextResponse.json(body, { status, headers: { "cache-control": "no-store" } });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: Request): Promise<Response> {
|
|
||||||
let raw: unknown;
|
|
||||||
try {
|
|
||||||
raw = await request.json();
|
|
||||||
} catch {
|
|
||||||
raw = null;
|
|
||||||
}
|
|
||||||
const parsed = parseSessionExchangeBody(raw);
|
|
||||||
if (!parsed.ok) return json({ error: parsed.error }, 400);
|
|
||||||
|
|
||||||
const secret = process.env.DOCVER_TOKEN_SECRET?.trim();
|
|
||||||
if (secret === undefined || secret === "") {
|
|
||||||
console.error("[sso] DOCVER_TOKEN_SECRET ยังไม่ถูกตั้งค่า");
|
|
||||||
return json({ error: "ระบบยังไม่พร้อมให้เข้าสู่ระบบ" }, 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
let identity: { userid: string; username: string; fid: string };
|
|
||||||
if (parsed.credential.kind === "jwt") {
|
|
||||||
const verified = verifyDocverLoginToken(parsed.credential.token, secret);
|
|
||||||
if (!verified.ok) {
|
|
||||||
return json(
|
|
||||||
{ error: "ลิงก์เข้าสู่ระบบหมดอายุหรือไม่ถูกต้อง", reason: verified.reason },
|
|
||||||
401,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
identity = verified.claims;
|
|
||||||
} else {
|
|
||||||
// ค่า default "super_secret" ของ addinTicketSecret() เป็นที่รู้กันทั้ง repo/
|
|
||||||
// .env.example — ใครก็ปลอม ticket ของ uid ใครก็ได้ จึงปฏิเสธใน production
|
|
||||||
// (fail-closed — กลุ่ม docver/add-in อนุญาต default ตาม requirement อยู่แล้ว
|
|
||||||
// คนละประตู) · **dev ยอมรับค่า default** เพื่อให้ทดสอบทาง ticket รอบเครื่องได้
|
|
||||||
// โดยไม่ต้องตั้ง env ใหม่ — เครื่องมือ dev (make-login-link/make-ticket-docx/
|
|
||||||
// dev-link) ก็ default ค่าเดียวกัน (review 2026-10-30 รอบสอง)
|
|
||||||
if (process.env.NODE_ENV === "production" && addinTicketSecret() === "super_secret") {
|
|
||||||
console.error("[sso] ticket secret ยังเป็นค่า default (ADDIN_TICKET_SECRET/SECRET) — ปฏิเสธทาง ticket");
|
|
||||||
return json({ error: "ระบบยังไม่พร้อมให้เข้าสู่ระบบ" }, 500);
|
|
||||||
}
|
|
||||||
const verified = verifyAddinTicket(
|
|
||||||
parsed.credential.ticketData,
|
|
||||||
parsed.credential.hmacHex,
|
|
||||||
addinTicketSecret(),
|
|
||||||
);
|
|
||||||
if (!verified.ok) {
|
|
||||||
return json({ error: "ลิงก์เข้าสู่ระบบหมดอายุหรือไม่ถูกต้อง", reason: "invalid" }, 401);
|
|
||||||
}
|
|
||||||
identity = { userid: verified.ticket.uid, username: verified.ticket.uname, fid: verified.ticket.fid };
|
|
||||||
}
|
|
||||||
|
|
||||||
// N8: ลงทะเบียนเอกสารตอนแลก token เลย — ฝั่ง server เชื่อ fid จาก credential
|
|
||||||
// ที่ verify แล้วเท่านั้น (trusted) ส่วน ?fileUrl พิมพ์เองบนหน้าเว็บไม่มีทาง
|
|
||||||
// เข้ามาถึงจุดนี้ · ล้ม = ยังออก token ให้ + fileError ให้หน้าแรกแสดง (คู่ขนาน
|
|
||||||
// พฤติกรรมสมัย cookie: session ตั้งก่อน แล้วค่อยแจ้ง error ของไฟล์)
|
|
||||||
let fileId: string | undefined;
|
|
||||||
let fileError: string | undefined;
|
|
||||||
if (parsed.fileUrl !== undefined) {
|
|
||||||
const result = await registerExternalFile(parsed.fileUrl, identity.fid, parsed.typeId, {
|
|
||||||
trusted: true,
|
|
||||||
});
|
|
||||||
if (result.ok) fileId = result.fileId;
|
|
||||||
else fileError = result.error;
|
|
||||||
} else {
|
|
||||||
// N11 (PLAN-STAGED.md D5): ลิงก์ fid-only (เอกสาร staging — เนื้อส่งมาทาง POST
|
|
||||||
// /api/note/files/stage แล้ว ลิงก์มีแค่ t=) — เช็คตั้งแต่ตอนแลก token เลย ไม่ปล่อย
|
|
||||||
// เข้า viewer ไปเจอ 410 เอง · ใช้ hasStagedDoc (ไม่ยืด TTL ไม่ถือ Buffer — การอ่าน
|
|
||||||
// จริงเกิดที่ viewer) · เอกสาร N8 ที่รู้จักแล้ว (pdf_url เป็น URL/path ปกติ) เข้าได้
|
|
||||||
// ทางเดียวกัน · ผู้ใช้ได้ token ตามปกติทุกกรณี
|
|
||||||
try {
|
|
||||||
const [row] = await db
|
|
||||||
.select()
|
|
||||||
.from(files)
|
|
||||||
.where(eq(files.externalId, ecourtExternalId(identity.fid)))
|
|
||||||
.limit(1);
|
|
||||||
if (!row) {
|
|
||||||
fileError = "เอกสารยังไม่ถูกส่งเข้าระบบ — กลับ e-Court กดลิงก์ใหม่";
|
|
||||||
} else if (isStagedSource(row.pdfUrl)) {
|
|
||||||
if (hasStagedDoc(row.pdfUrl)) fileId = row.id;
|
|
||||||
else fileError = "เอกสารพ้นช่วงจัดเตรียม — กลับ e-Court กดลิงก์ใหม่";
|
|
||||||
} else {
|
|
||||||
fileId = row.id;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[sso] ค้นเอกสารของ fid ล้ม", err);
|
|
||||||
fileError = "ค้นเอกสารไม่สำเร็จ — ลองอีกครั้ง";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const access_token = issueDocverToken(
|
|
||||||
{ userid: identity.userid, username: identity.username, fid: identity.fid, template: null },
|
|
||||||
secret,
|
|
||||||
NOTE_SESSION_TTL,
|
|
||||||
"login",
|
|
||||||
);
|
|
||||||
// audit: เฉพาะ userid + เวลา — ห้าม log token/query ดิบ · strip อักขระควบคุม
|
|
||||||
// ก่อนพิมพ์ (claims ไม่จำกัด charset — กันฝัง \n ปลอมบรรทัด log)
|
|
||||||
const auditId = identity.userid.replace(/[\u0000-\u001f\u007f-\u009f]/g, "");
|
|
||||||
console.log(`[sso] e-Court login ok: userid=${auditId}`);
|
|
||||||
return json({
|
|
||||||
access_token,
|
|
||||||
token_type: "Bearer",
|
|
||||||
expires_in: NOTE_SESSION_TTL,
|
|
||||||
userid: identity.userid,
|
|
||||||
username: identity.username,
|
|
||||||
...(fileId !== undefined ? { fileId } : {}),
|
|
||||||
...(fileError !== undefined ? { fileError } : {}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
// PLAN-SSO.md §3.1 — POST /api/note/auth/token: เส้น mint login-token ของแอป note
|
|
||||||
// (ทางเข้า x-api-key ตัวเดียวกับ docver แต่แยกเส้นจาก /api/documents/token — D1
|
|
||||||
// contract docver ไม่ถูกแต้) — สำหรับ backend e-Court ประกอบลิงก์
|
|
||||||
// `/note?…&t=<access_token>` ให้ผู้ใช้คลิก (ฉบับ token 2026-10-30: หน้าแรกเป็น
|
|
||||||
// client bootstrap แลก credential เป็น session token ที่ /api/note/auth/session)
|
|
||||||
// token ที่ได้เป็น purpose="login" ใช้ได้กับการเข้าแอป note เท่านั้น (D4)
|
|
||||||
// ไม่ import @/db · ห้าม log ค่า key/body ดิบ
|
|
||||||
import { docverError } from "@/lib/document-versioning";
|
|
||||||
import { issueDocverToken, requireDocverApiKey } from "@/lib/docver-token";
|
|
||||||
import { parseLoginTokenBody } from "@/lib/note-auth";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export async function POST(request: Request): Promise<Response> {
|
|
||||||
const apiKeyFail = requireDocverApiKey(request);
|
|
||||||
if (apiKeyFail !== null) return apiKeyFail;
|
|
||||||
|
|
||||||
let raw: unknown;
|
|
||||||
try {
|
|
||||||
raw = await request.json();
|
|
||||||
} catch {
|
|
||||||
return docverError("body ต้องเป็น JSON", 400);
|
|
||||||
}
|
|
||||||
const parsed = parseLoginTokenBody(raw);
|
|
||||||
if (!parsed.ok) return docverError(parsed.error, 400);
|
|
||||||
|
|
||||||
// ความผิด config ฝั่ง server ไม่ใช่ความผิดของผู้เรียก — ไม่ตั้ง = 500 (ห้าม log ค่า secret)
|
|
||||||
const secret = process.env.DOCVER_TOKEN_SECRET?.trim();
|
|
||||||
if (secret === undefined || secret === "") {
|
|
||||||
return docverError("DOCVER_TOKEN_SECRET ยังไม่ถูกตั้งค่า", 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
const accessToken = issueDocverToken(
|
|
||||||
{ userid: parsed.claims.userid, username: parsed.claims.username, fid: parsed.claims.fid, template: null },
|
|
||||||
secret,
|
|
||||||
parsed.ttlSeconds,
|
|
||||||
"login",
|
|
||||||
);
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
access_token: accessToken,
|
|
||||||
token_type: "Bearer",
|
|
||||||
expires_in: parsed.ttlSeconds,
|
|
||||||
}),
|
|
||||||
{ headers: { "content-type": "application/json; charset=utf-8" } },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
// disk/DB/MinIO แม้แต่ไบต์เดียว (annotation/bookmark เก็บถาวรตามระบบเดิม — row
|
// disk/DB/MinIO แม้แต่ไบต์เดียว (annotation/bookmark เก็บถาวรตามระบบเดิม — row
|
||||||
// files ที่ registerStagedFile สร้างคือ FK anchor)
|
// files ที่ registerStagedFile สร้างคือ FK anchor)
|
||||||
// · auth ก่อนแตะอะไรทั้งสิ้น: requireDocverApiKey (env DOCVER_API_KEY — กลุ่มเดียว
|
// · auth ก่อนแตะอะไรทั้งสิ้น: requireDocverApiKey (env DOCVER_API_KEY — กลุ่มเดียว
|
||||||
// กับทางเข้า mint POST /api/note/auth/token)
|
// กับทางเข้า mint purpose:"login" ของ POST /api/auth/token)
|
||||||
// · อ่าน body แบบ stream นับไบต์ (Content-Length เป็น fast-path เท่านั้น — chunked
|
// · อ่าน body แบบ stream นับไบต์ (Content-Length เป็น fast-path เท่านั้น — chunked
|
||||||
// ไม่มี header) + idle 60 วิ / total 10 นาที → 408 + concurrency จำกัด
|
// ไม่มี header) + idle 60 วิ / total 10 นาที → 408 + concurrency จำกัด
|
||||||
// (STAGED_MAX_CONCURRENT default 1) → 429 — คืน slot/เคลียร์ timer ใน finally ทุกทางออก
|
// (STAGED_MAX_CONCURRENT default 1) → 429 — คืน slot/เคลียร์ timer ใน finally ทุกทางออก
|
||||||
|
|
|
||||||
|
|
@ -142,7 +142,8 @@ export default async function DevToolsPage({
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mt-1.5 text-sm text-ink-soft">
|
<p className="mt-1.5 text-sm text-ink-soft">
|
||||||
ใส่ API key (<Code>DOCVER_API_KEY</Code>) เพื่อขอ token ผ่าน{" "}
|
ใส่ API key (<Code>DOCVER_API_KEY</Code>) เพื่อขอ token ผ่าน{" "}
|
||||||
<Code>POST /api/note/auth/token</Code> แบบเดียวกับที่ e-Court ทำจริง
|
<Code>POST /api/auth/token</Code> แบบ <Code>purpose:"login"</Code>{" "}
|
||||||
|
แบบเดียวกับที่ e-Court ทำจริง
|
||||||
แล้วระบบจะพาเข้าอ่านเอกสารที่เลือกเลย — ได้ session 8 ชม.
|
แล้วระบบจะพาเข้าอ่านเอกสารที่เลือกเลย — ได้ session 8 ชม.
|
||||||
(ใช้ได้ทั้ง dev และ production)
|
(ใช้ได้ทั้ง dev และ production)
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -162,7 +163,7 @@ export default async function DevToolsPage({
|
||||||
สำหรับเอกสารที่ e-Court <strong className="font-medium">ไม่มี URL ให้ดึง</strong> —
|
สำหรับเอกสารที่ e-Court <strong className="font-medium">ไม่มี URL ให้ดึง</strong> —
|
||||||
เลือกไฟล์แล้วกดปุ่มเดียว ระบบจะทำตาม contract จริงทั้ง 3 ขั้น:{" "}
|
เลือกไฟล์แล้วกดปุ่มเดียว ระบบจะทำตาม contract จริงทั้ง 3 ขั้น:{" "}
|
||||||
<Code>POST /api/note/files/stage</Code> (เนื้อไฟล์เป็น base64) →{" "}
|
<Code>POST /api/note/files/stage</Code> (เนื้อไฟล์เป็น base64) →{" "}
|
||||||
<Code>POST /api/note/auth/token</Code> ด้วย <Code>fid</Code> เดิม → ลิงก์{" "}
|
<Code>POST /api/auth/token</Code> ด้วย <Code>fid</Code> เดิม → ลิงก์{" "}
|
||||||
<Code>/note?t=…</Code> (ไม่มี fileUrl) ให้กดเข้าอ่านได้เลย · ส่ง{" "}
|
<Code>/note?t=…</Code> (ไม่มี fileUrl) ให้กดเข้าอ่านได้เลย · ส่ง{" "}
|
||||||
<Code>fid</Code> เดิมซ้ำ = แทนที่เนื้อล่าสุด (annotation เดิมอยู่ครบ)
|
<Code>fid</Code> เดิมซ้ำ = แทนที่เนื้อล่าสุด (annotation เดิมอยู่ครบ)
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -219,15 +220,15 @@ export default async function DevToolsPage({
|
||||||
</p>
|
</p>
|
||||||
<ol className="mt-4 list-decimal space-y-1.5 pl-5 text-sm text-ink">
|
<ol className="mt-4 list-decimal space-y-1.5 pl-5 text-sm text-ink">
|
||||||
<li>
|
<li>
|
||||||
ฝั่ง e-Court เรียก <Code>POST /api/note/auth/token</Code> พร้อมรหัส{" "}
|
ฝั่ง e-Court เรียก <Code>POST /api/auth/token</Code> พร้อมรหัส{" "}
|
||||||
<Code>x-api-key</Code> (ค่าจาก env <Code>DOCVER_API_KEY</Code>) และข้อมูล:
|
<Code>x-api-key</Code> (ค่าจาก env <Code>DOCVER_API_KEY</Code>) และข้อมูล:
|
||||||
</li>
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
<CodeBlock>{`POST {BASE}/api/note/auth/token
|
<CodeBlock>{`POST {BASE}/api/auth/token
|
||||||
x-api-key: <DOCVER_API_KEY>
|
x-api-key: <DOCVER_API_KEY>
|
||||||
|
|
||||||
{"userid":"<u_userid>","username":"<u_userlogin>",
|
{"userid":"<u_userid>","username":"<u_userlogin>",
|
||||||
"fid":"<fileId>","ttl_seconds":300}`}</CodeBlock>
|
"fid":"<fileId>","ttl_seconds":300,"purpose":"login"}`}</CodeBlock>
|
||||||
<ol start={2} className="mt-3 list-decimal space-y-1.5 pl-5 text-sm text-ink">
|
<ol start={2} className="mt-3 list-decimal space-y-1.5 pl-5 text-sm text-ink">
|
||||||
<li>
|
<li>
|
||||||
ได้ <Code>access_token</Code> กลับมา (อายุ 1–10 นาที) — นำไปใส่ในลิงก์
|
ได้ <Code>access_token</Code> กลับมา (อายุ 1–10 นาที) — นำไปใส่ในลิงก์
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,8 @@ import { withBasePath } from "@/utils/base-path";
|
||||||
/**
|
/**
|
||||||
* ฟอร์ม "จำลอง e-Court" บนหน้า /note/dev สำหรับตอน mint อัตโนมัติปิด
|
* ฟอร์ม "จำลอง e-Court" บนหน้า /note/dev สำหรับตอน mint อัตโนมัติปิด
|
||||||
* (production เสมอตาม devMintEnabled) — ผู้ทดสอบใส่ DOCVER_API_KEY แล้วยิง
|
* (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
|
* access_token มาแล้วพาเข้า /note?fileUrl=…&t=… ให้ NoteHome แลกเป็น session
|
||||||
* ต่อ — ไม่เป็นประตูหลังเพราะต้องถือ key ที่ถูกต้องอยู่แล้ว
|
* ต่อ — ไม่เป็นประตูหลังเพราะต้องถือ key ที่ถูกต้องอยู่แล้ว
|
||||||
* (คำขอผู้ใช้ 2026-11-02: กดเข้าเอกสารจากหน้า /note/dev บน production ให้ได้)
|
* (คำขอผู้ใช้ 2026-11-02: กดเข้าเอกสารจากหน้า /note/dev บน production ให้ได้)
|
||||||
|
|
@ -25,16 +26,18 @@ export default function MockEcourtForm({ files }: { files: string[] }) {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(withBasePath("/api/note/auth/token"), {
|
const res = await fetch(withBasePath("/api/auth/token"), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "x-api-key": apiKey, "content-type": "application/json" },
|
headers: { "x-api-key": apiKey, "content-type": "application/json" },
|
||||||
// fid ผูกไฟล์ที่เลือกแบบเดียวกับ dev-link (dev-<file>) — แต่ละไฟล์
|
// fid ผูกไฟล์ที่เลือกแบบเดียวกับ dev-link (dev-<file>) — แต่ละไฟล์
|
||||||
// ตัวอย่างมี row ecourt: ของตัวเอง ไม่ปนกัน
|
// ตัวอย่างมี row ecourt: ของตัวเอง ไม่ปนกัน · purpose:"login" = ทางเข้า
|
||||||
|
// mint login-token ของ /api/auth/token (ไม่ส่ง = docver-token)
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
userid: userid.trim(),
|
userid: userid.trim(),
|
||||||
username: username.trim(),
|
username: username.trim(),
|
||||||
fid: `dev-${file}`,
|
fid: `dev-${file}`,
|
||||||
ttl_seconds: 600,
|
ttl_seconds: 600,
|
||||||
|
purpose: "login",
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const data = (await res.json().catch(() => null)) as
|
const data = (await res.json().catch(() => null)) as
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,8 @@ import { withBasePath } from "@/utils/base-path";
|
||||||
* ฟอร์ม "จำลอง e-Court — ส่งเนื้อไฟล์เข้า staging" บนหน้า /note/dev (N11 —
|
* ฟอร์ม "จำลอง e-Court — ส่งเนื้อไฟล์เข้า staging" บนหน้า /note/dev (N11 —
|
||||||
* PLAN-STAGED.md · คำขอผู้ใช้ 2026-11-04): กดทดสอบ contract ทั้ง 3 ขั้นตามที่
|
* PLAN-STAGED.md · คำขอผู้ใช้ 2026-11-04): กดทดสอบ contract ทั้ง 3 ขั้นตามที่
|
||||||
* backend e-Court ต้องทำจริงในหน้าเดียว — (1) POST /api/note/files/stage
|
* 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)
|
* (3) ลิงก์ /note?t=… (fid-only ไม่มี fileUrl — D5)
|
||||||
* · ต้องใส่ DOCVER_API_KEY จริงทุกครั้งจึงไม่เป็นประตูหลัง (เหมือน MockEcourtForm)
|
* · ต้องใส่ DOCVER_API_KEY จริงทุกครั้งจึงไม่เป็นประตูหลัง (เหมือน MockEcourtForm)
|
||||||
* · หน้า /note/dev เปิดด้วย env NOTE_SSO_DEV_MINT=1 อย่างเดียว
|
* · หน้า /note/dev เปิดด้วย env NOTE_SSO_DEV_MINT=1 อย่างเดียว
|
||||||
|
|
@ -71,7 +72,8 @@ export default function MockStageForm() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// (2) mint login token ด้วย fid **เดิม** — dedupe key ecourt:<fid> ผูกกัน
|
// (2) mint login token ด้วย fid **เดิม** — dedupe key ecourt:<fid> ผูกกัน
|
||||||
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",
|
method: "POST",
|
||||||
headers: { "x-api-key": apiKey, "content-type": "application/json" },
|
headers: { "x-api-key": apiKey, "content-type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
|
@ -79,6 +81,7 @@ export default function MockStageForm() {
|
||||||
username: username.trim(),
|
username: username.trim(),
|
||||||
fid: fid.trim(),
|
fid: fid.trim(),
|
||||||
ttl_seconds: 600,
|
ttl_seconds: 600,
|
||||||
|
purpose: "login",
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const tokenData = (await tokenRes.json().catch(() => null)) as
|
const tokenData = (await tokenRes.json().catch(() => null)) as
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,8 @@
|
||||||
// หน้าแรก /note — ข้อมูลเกี่ยวกับระบบ (2026-10-30 ตามคำขอผู้ใช้: ระบบทำอะไรได้ /
|
// หน้าแรก /note — ข้อมูลเกี่ยวกับระบบ (2026-10-30 ตามคำขอผู้ใช้: ระบบทำอะไรได้ /
|
||||||
// วิธีเข้าใช้) + bootstrap ของลิงก์ e-Court (contract ปลายทางลิงก์ยังชี้ที่นี่):
|
// วิธีเข้าใช้) + bootstrap ของลิงก์ e-Court (contract ปลายทางลิงก์ยังชี้ที่นี่):
|
||||||
// มี credential ใน URL (t หรือ ticket_data+HmacSha256 — จากลิงก์ที่ e-Court ชี้ตรงมา
|
// มี 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
|
// localStorage "note-auth" (แบบ add-in) → ตัด credential ออกจาก URL ทันที → มี fileId
|
||||||
// = พาเข้า viewer / ไม่มี = แสดงหน้าข้อมูล · ไม่มี credential = หน้า public ธรรมดา
|
// = พาเข้า viewer / ไม่มี = แสดงหน้าข้อมูล · ไม่มี credential = หน้า public ธรรมดา
|
||||||
// (แบนเนอร์ ?sso= ของลิงก์เก่า + fileError ยังรองรับ)
|
// (แบนเนอร์ ?sso= ของลิงก์เก่า + fileError ยังรองรับ)
|
||||||
|
|
@ -20,7 +21,7 @@ import {
|
||||||
|
|
||||||
import AppNav from "@/components/AppNav";
|
import AppNav from "@/components/AppNav";
|
||||||
import { parseSsoLinkParams, ssoBanner, type SsoBanner } from "@/lib/note-auth";
|
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";
|
import { withBasePath } from "@/utils/base-path";
|
||||||
|
|
||||||
type Phase = "boot" | "exchanging" | "ready";
|
type Phase = "boot" | "exchanging" | "ready";
|
||||||
|
|
@ -80,7 +81,7 @@ export default function NoteHome() {
|
||||||
const timeoutId = window.setTimeout(() => controller.abort(), 45_000);
|
const timeoutId = window.setTimeout(() => controller.abort(), 45_000);
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(withBasePath("/api/note/auth/session"), {
|
const res = await fetch(withBasePath("/api/auth/session"), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
|
|
@ -110,7 +111,9 @@ export default function NoteHome() {
|
||||||
token: access_token,
|
token: access_token,
|
||||||
userid,
|
userid,
|
||||||
username,
|
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 (fileError !== undefined) setFileError(fileError);
|
||||||
if (fileId !== undefined) {
|
if (fileId !== undefined) {
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ import {
|
||||||
useAnnotationRealtime,
|
useAnnotationRealtime,
|
||||||
} from "@/hooks/useAnnotationRealtime";
|
} from "@/hooks/useAnnotationRealtime";
|
||||||
import { useHistory } from "@/hooks/useHistory";
|
import { useHistory } from "@/hooks/useHistory";
|
||||||
|
import { clearNoteSession, isNoteAuthExpiredError } from "@/lib/note-session";
|
||||||
import {
|
import {
|
||||||
useCustomSelection,
|
useCustomSelection,
|
||||||
type CustomSelectionCommit,
|
type CustomSelectionCommit,
|
||||||
|
|
@ -2216,6 +2217,22 @@ export default function PDFViewer({
|
||||||
}, 6000);
|
}, 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(
|
async function handleCreateAnnotation(
|
||||||
pageIndex: number,
|
pageIndex: number,
|
||||||
type: AnnotationType,
|
type: AnnotationType,
|
||||||
|
|
@ -2289,8 +2306,10 @@ export default function PDFViewer({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("บันทึก annotation ไม่สำเร็จ", err); // React rollback optimistic row ให้เอง
|
// React rollback optimistic row ให้เอง
|
||||||
|
if (!reportActionError(err, "บันทึก annotation ไม่สำเร็จ")) {
|
||||||
showActionError("บันทึกรายการไม่สำเร็จ — ตรวจสอบการเชื่อมต่อแล้วลองอีกครั้ง");
|
showActionError("บันทึกรายการไม่สำเร็จ — ตรวจสอบการเชื่อมต่อแล้วลองอีกครั้ง");
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
pendingCreatesRef.current.delete(temp.id);
|
pendingCreatesRef.current.delete(temp.id);
|
||||||
}
|
}
|
||||||
|
|
@ -2342,9 +2361,10 @@ export default function PDFViewer({
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("ลบ annotation ไม่สำเร็จ", err);
|
if (!reportActionError(err, "ลบ annotation ไม่สำเร็จ")) {
|
||||||
showActionError("ลบรายการไม่สำเร็จ — ตรวจสอบการเชื่อมต่อแล้วลองอีกครั้ง");
|
showActionError("ลบรายการไม่สำเร็จ — ตรวจสอบการเชื่อมต่อแล้วลองอีกครั้ง");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2386,8 +2406,10 @@ export default function PDFViewer({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("แก้ไข annotation ไม่สำเร็จ", err); // React rollback optimistic row ให้เอง
|
// React rollback optimistic row ให้เอง
|
||||||
|
if (!reportActionError(err, "แก้ไข annotation ไม่สำเร็จ")) {
|
||||||
showActionError("แก้ไขรายการไม่สำเร็จ — ตรวจสอบการเชื่อมต่อแล้วลองอีกครั้ง");
|
showActionError("แก้ไขรายการไม่สำเร็จ — ตรวจสอบการเชื่อมต่อแล้วลองอีกครั้ง");
|
||||||
|
}
|
||||||
// ทิ้ง base ที่คิวเก็บไว้ — เป็น data ที่ action นี้ตั้งใจเขียนแต่ไม่เคยติด DB
|
// ทิ้ง base ที่คิวเก็บไว้ — เป็น data ที่ action นี้ตั้งใจเขียนแต่ไม่เคยติด DB
|
||||||
// ถ้าคงไว้ update ถัดไปจะผสานจน commit การเปลี่ยนที่ล้มไปด้วย และ undo
|
// ถ้าคงไว้ update ถัดไปจะผสานจน commit การเปลี่ยนที่ล้มไปด้วย และ undo
|
||||||
// history กู้สถานะที่ไม่มีอยู่จริง (code review 2026-09-29) — รอบหน้าเริ่ม
|
// history กู้สถานะที่ไม่มีอยู่จริง (code review 2026-09-29) — รอบหน้าเริ่ม
|
||||||
|
|
@ -2458,9 +2480,11 @@ export default function PDFViewer({
|
||||||
const saved = await setAnnotationResolved(token, id, resolved);
|
const saved = await setAnnotationResolved(token, id, resolved);
|
||||||
setAnnotations((prev) => prev.map((a) => (a.id === saved.id ? saved : a)));
|
setAnnotations((prev) => prev.map((a) => (a.id === saved.id ? saved : a)));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("ตั้งสถานะไม่สำเร็จ", err); // React rollback optimistic ให้เอง
|
// React rollback optimistic ให้เอง
|
||||||
|
if (!reportActionError(err, "ตั้งสถานะไม่สำเร็จ")) {
|
||||||
showActionError("ตั้งสถานะไม่สำเร็จ — ตรวจสอบการเชื่อมต่อแล้วลองอีกครั้ง");
|
showActionError("ตั้งสถานะไม่สำเร็จ — ตรวจสอบการเชื่อมต่อแล้วลองอีกครั้ง");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2481,8 +2505,9 @@ export default function PDFViewer({
|
||||||
);
|
);
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("ตอบ comment ไม่สำเร็จ", err);
|
if (!reportActionError(err, "ตอบ comment ไม่สำเร็จ")) {
|
||||||
showActionError("ตอบ comment ไม่สำเร็จ — ตรวจสอบการเชื่อมต่อแล้วลองอีกครั้ง");
|
showActionError("ตอบ comment ไม่สำเร็จ — ตรวจสอบการเชื่อมต่อแล้วลองอีกครั้ง");
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2493,10 +2518,11 @@ export default function PDFViewer({
|
||||||
await deleteReply(token, id);
|
await deleteReply(token, id);
|
||||||
setReplies((prev) => prev.filter((r) => r.id !== id));
|
setReplies((prev) => prev.filter((r) => r.id !== id));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("ลบคำตอบไม่สำเร็จ", err);
|
if (!reportActionError(err, "ลบคำตอบไม่สำเร็จ")) {
|
||||||
showActionError("ลบคำตอบไม่สำเร็จ — ตรวจสอบการเชื่อมต่อแล้วลองอีกครั้ง");
|
showActionError("ลบคำตอบไม่สำเร็จ — ตรวจสอบการเชื่อมต่อแล้วลองอีกครั้ง");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Bookmarks (N2: หมุดหัวข้อใหญ่ + sub-bookmark + โน้ต) ────────────────────
|
// ─── Bookmarks (N2: หมุดหัวข้อใหญ่ + sub-bookmark + โน้ต) ────────────────────
|
||||||
|
|
||||||
|
|
@ -2650,8 +2676,9 @@ export default function PDFViewer({
|
||||||
);
|
);
|
||||||
flashBookmarkSaved();
|
flashBookmarkSaved();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("บันทึก bookmark ไม่สำเร็จ", err);
|
if (!reportActionError(err, "บันทึก bookmark ไม่สำเร็จ")) {
|
||||||
setBookmarkError(err instanceof Error ? err.message : "บันทึกไม่สำเร็จ ลองอีกครั้ง");
|
setBookmarkError(err instanceof Error ? err.message : "บันทึกไม่สำเร็จ ลองอีกครั้ง");
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
bookmarkPendingRef.current = false;
|
bookmarkPendingRef.current = false;
|
||||||
setBookmarkSaving(false);
|
setBookmarkSaving(false);
|
||||||
|
|
@ -2676,7 +2703,7 @@ export default function PDFViewer({
|
||||||
prev.some((b) => b.id === saved.id) ? prev : [...prev, saved],
|
prev.some((b) => b.id === saved.id) ? prev : [...prev, saved],
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("เพิ่ม sub-bookmark ไม่สำเร็จ", err);
|
reportActionError(err, "เพิ่ม sub-bookmark ไม่สำเร็จ");
|
||||||
} finally {
|
} finally {
|
||||||
bookmarkPendingRef.current = false;
|
bookmarkPendingRef.current = false;
|
||||||
}
|
}
|
||||||
|
|
@ -2692,7 +2719,7 @@ export default function PDFViewer({
|
||||||
const saved = await updateBookmark(token, { id, ...patch });
|
const saved = await updateBookmark(token, { id, ...patch });
|
||||||
setBookmarks((prev) => prev.map((b) => (b.id === saved.id ? saved : b)));
|
setBookmarks((prev) => prev.map((b) => (b.id === saved.id ? saved : b)));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("แก้ไข bookmark ไม่สำเร็จ", err);
|
reportActionError(err, "แก้ไข bookmark ไม่สำเร็จ");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2704,7 +2731,7 @@ export default function PDFViewer({
|
||||||
await deleteBookmark(token, id);
|
await deleteBookmark(token, id);
|
||||||
setBookmarks((prev) => prev.filter((b) => b.id !== id));
|
setBookmarks((prev) => prev.filter((b) => b.id !== id));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("ลบ bookmark ไม่สำเร็จ", err);
|
reportActionError(err, "ลบ bookmark ไม่สำเร็จ");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ credential อายุสั้น ฝังอีเมล/แชท/เก็
|
||||||
|
|
||||||
```
|
```
|
||||||
ลิงก์ e-Court ชี้ตรงมา /note?fileUrl=&typeId=&t=… (หรือ ticket_data+HmacSha256)
|
ลิงก์ 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
|
→ server verify credential (ทาง A JWT purpose=login / ทาง B ticket + fail-closed
|
||||||
เมื่อ secret เป็นค่า default "super_secret")
|
เมื่อ secret เป็นค่า default "super_secret")
|
||||||
→ ลงทะเบียนเอกสาร N8 แบบ trusted เลย (fid จาก credential — ไม่ต้องมี link proof)
|
→ ลงทะเบียนเอกสาร N8 แบบ trusted เลย (fid จาก credential — ไม่ต้องมี link proof)
|
||||||
|
|
@ -53,6 +53,17 @@ credential อายุสั้น ฝังอีเมล/แชท/เก็
|
||||||
- **คง status quo เส้น content ที่ browser เปิดแบบไม่ผ่าน fetch()** (ตามคำตอบผู้ใช้ 2026-10-30): SSE (EventSource) / `/api/note/pdf` + `<img>` N3 / export PDF — ไม่บังคับ Bearer (เดิมก็ไม่มี auth) · หน้าสรุป/หน้ากลุ่มยังเป็น server component จึงกลายเป็น public ไปด้วย — ยอมรับชั่วคราวตามมาตรฐานเดียวกัน (จะเปลี่ยนเป็น client+API เมื่อเรียกข้อเกิน)
|
- **คง status quo เส้น content ที่ browser เปิดแบบไม่ผ่าน fetch()** (ตามคำตอบผู้ใช้ 2026-10-30): SSE (EventSource) / `/api/note/pdf` + `<img>` 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 นี้
|
- **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)
|
## 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) |
|
| 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 คนละเรื่อง) |
|
| 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) |
|
| 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 เดิมไม่เปลี่ยน |
|
| 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 ปัจจุบัน) |
|
| 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 |
|
| 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):**
|
**ขั้น 1 (ทาง A) — mint token (server-to-server ของ backend e-Court):**
|
||||||
|
|
||||||
```
|
```
|
||||||
POST {BASE}/api/note/auth/token
|
POST {BASE}/api/auth/token
|
||||||
x-api-key: <DOCVER_API_KEY ที่ใช้อยู่>
|
x-api-key: <DOCVER_API_KEY ที่ใช้อยู่>
|
||||||
content-type: application/json
|
content-type: application/json
|
||||||
|
|
||||||
{"userid":"<u_userid>","username":"<u_userlogin>","fid":"<fileId ของ e-Court>","ttl_seconds":300}
|
{"userid":"<u_userid>","username":"<u_userlogin>","fid":"<fileId ของ e-Court>","ttl_seconds":300,"purpose":"login"}
|
||||||
|
|
||||||
→ 200 {"access_token":"…","token_type":"Bearer","expires_in":300}
|
→ 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 — ประกอบลิงก์ให้ผู้ใช้คลิก (เลือกทางใดทางหนึ่ง — ห้ามส่งสองชุดพร้อมกัน):**
|
**ขั้น 2 — ประกอบลิงก์ให้ผู้ใช้คลิก (เลือกทางใดทางหนึ่ง — ห้ามส่งสองชุดพร้อมกัน):**
|
||||||
|
|
||||||
|
|
@ -143,7 +154,7 @@ content-type: application/json
|
||||||
|
|
||||||
## 3. ฝั่ง server — endpoint ที่เปลี่ยน/เพิ่ม
|
## 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 ดิบ**
|
- 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)
|
- **`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`)
|
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)
|
ผู้ใช้คลิกลิงก์ใน 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)
|
→ server: verify (A: verifyDocverLoginToken / B: verifyAddinTicket + fail-closed default secret)
|
||||||
→ registerExternalFile(fileUrl, fid, typeId, {trusted:true}) → fileId
|
→ registerExternalFile(fileUrl, fid, typeId, {trusted:true}) → fileId
|
||||||
→ 200 {access_token (8 ชม.), userid, username, 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/<rowId> → AnnotateView (client gate)
|
→ มี fileId = /note/annotate/<rowId> → AnnotateView (client gate)
|
||||||
→ GET /api/note/files/<id>/bootstrap (Bearer) → PDFViewer (prop token)
|
→ GET /api/note/files/<id>/bootstrap (Bearer) → PDFViewer (prop token)
|
||||||
→ ทุก Server Action รับ token เป็น param แรก — identity แกะฝั่ง server เสมอ
|
→ ทุก Server Action รับ token เป็น param แรก — identity แกะฝั่ง server เสมอ
|
||||||
```
|
```
|
||||||
|
|
||||||
- ผู้ใช้มี session token เก่าอยู่ในเครื่อง → **ลิงก์ชนะ** (bootstrap เขียนทับใหม่เสมอ — e-Court
|
- ผู้ใช้มี session token เก่าอยู่ในเครื่อง → **ลิงก์ชนะ** (bootstrap เขียนทับใหม่เสมอ — e-Court
|
||||||
เป็นผู้ยืนยันตัวตนปัจจุบัน) · token หมดอายุกลางวัน = action โยน error/หน้า 401 → กลับ e-Court
|
เป็นผู้ยืนยันตัวตนปัจจุบัน) · token หมดอายุกลางวัน = **§0.6: PDFViewer จับ auth-error
|
||||||
กดลิงก์ใหม่ (ไม่มี renew — เว็บไม่มี ticket ฝังไฟล์แบบ add-in)
|
ของ action (`isNoteAuthExpiredError` ผ่าน `reportActionError`) → ล้าง session + พาไป
|
||||||
|
`/note?sso=expired`** → กลับ e-Court กดลิงก์ใหม่ (ไม่มี renew — เว็บไม่มี ticket
|
||||||
|
ฝังไฟล์แบบ add-in)
|
||||||
- ไม่มี credential ใน URL → `/note` = landing public (แบนเนอร์ `?sso=` ของลิงก์เก่ายังรองรับ)
|
- ไม่มี credential ใน URL → `/note` = landing public (แบนเนอร์ `?sso=` ของลิงก์เก่ายังรองรับ)
|
||||||
- samples หน้า `/note/dev` → `GET /api/note/files/open?fileUrl=/samples/…` (ลิงก์แบบ /tmpl, trusted:false — แถว `url:…` เท่านั้น)
|
- 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 ที่
|
- **ลิงก์ 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)
|
- `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)
|
- `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)
|
- `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` — เคสเดิมไม่พัง)
|
1. `npx vitest run` ผ่านทั้งชุด (เพิ่ม `tests/lib/note-auth.test.ts` + เคสใหม่ของ `docver-token` — เคสเดิมไม่พัง)
|
||||||
2. `npx tsc --noEmit` ผ่าน
|
2. `npx tsc --noEmit` ผ่าน
|
||||||
3. curl regression:
|
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=<login token>` → 302 + `Set-Cookie oacCase` + Location เป็น `/note?…` **ไม่มี `t`** + `Cache-Control: no-store`
|
- (ข) `GET /api/note/auth/e-court?fileUrl=…&t=<login token>` → 302 + `Set-Cookie oacCase` + Location เป็น `/note?…` **ไม่มี `t`** + `Cache-Control: no-store`
|
||||||
- (ค) รอให้ TTL หมด (ตั้ง 60 วิ) → 302 `/note?sso=expired`
|
- (ค) รอให้ TTL หมด (ตั้ง 60 วิ) → 302 `/note?sso=expired`
|
||||||
- (ง) เอา token purpose=docver มาใช้ที่ route → `sso=invalid` · เอา login token ไป `GET /api/documents/versions` → 401
|
- (ง) เอา 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` เดิม — เทียบก่อน/หลัง)
|
- (ฎ) หน้าแรกใหม่ 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 ครบ)
|
- (ฏ) 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`)
|
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. ลำดับงาน
|
## 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 (ฐ)
|
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 บางส่วน
|
1. `lib/docver-token.ts` (purpose + ttl) + tests → verify 9.1 บางส่วน
|
||||||
2. `lib/note-auth.ts` + tests (รวม `signOacSession`/`verifyOacSession` ของ D16)
|
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
|
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
|
5. `scripts/make-login-link.mjs` → verify 9.4
|
||||||
6. `lib/openapi.ts` + `.env.example` + `CLAUDE.md` sync → verify 9.1–9.5
|
6. `lib/openapi.ts` + `.env.example` + `CLAUDE.md` sync → verify 9.1–9.5
|
||||||
|
|
||||||
## 11. Out of scope
|
## 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)
|
- ~~แก้ secret รั่ยใน bundle ของ add-in~~ — ย้ายเข้าแผนนี้เป็น **D13** แล้ว (ทาง ticket ทำให้ secret ตัวนี้กลายเป็นจุดอ่อนของ login โดยตรง จึงตัดออกก่อนเปิดทาง ticket)
|
||||||
- revoke/refresh token, logout ระดับ e-Court, roles/สิทธิ์จริง, ผูก session กับ device
|
- revoke/refresh token, logout ระดับ e-Court, roles/สิทธิ์จริง, ผูก session กับ device
|
||||||
- OAuth/OIDC เต็มรูปแบบ (e-Court เป็น IdP) — ไปทางนี้เมื่อมีหลายแอปของศาลต้อง SSO
|
- OAuth/OIDC เต็มรูปแบบ (e-Court เป็น IdP) — ไปทางนี้เมื่อมีหลายแอปของศาลต้อง SSO
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
3. **ไม่มี dropzone/ลากไฟล์บนแอป** — ผู้พิพากษาเปิดเอกสารผ่าน e-Court ทางเดียว (ตัดทางเลือก `local:<hash>` ทั้งหมดที่เคยพิจารณา)
|
3. **ไม่มี dropzone/ลากไฟล์บนแอป** — ผู้พิพากษาเปิดเอกสารผ่าน e-Court ทางเดียว (ตัดทางเลือก `local:<hash>` ทั้งหมดที่เคยพิจารณา)
|
||||||
4. **แอปมีหน้าที่แค่ "รองรับได้"** — เปิด endpoint + contract ให้ e-Court เรียก ไม่รับผิดชอบ flow ฝั่ง e-Court (เรียกตอนไหน/กี่ครั้ง) · contract ต้องเข้า Swagger UI (`lib/openapi.ts`) เหมือนกลุ่มอื่น
|
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**
|
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:<uuid>.<ext>`** (มี extension ติดไปเสมอ) เพื่อให้ `fileKindFromUrl`/`imageFormatFromUrl`/`imageContentTypeFromUrl` ทำงานทุกจุดโดยไม่แก้ (extension คือ single source of truth ของชนิดไฟล์ตาม N3)
|
7. **ไม่มี schema migration** — ไม่เพิ่ม column: ใช้ sentinel ใน `pdf_url` คือ **`staged:<uuid>.<ext>`** (มี extension ติดไปเสมอ) เพื่อให้ `fileKindFromUrl`/`imageFormatFromUrl`/`imageContentTypeFromUrl` ทำงานทุกจุดโดยไม่แก้ (extension คือ single source of truth ของชนิดไฟล์ตาม N3)
|
||||||
|
|
||||||
## Design
|
## 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
|
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 ในอนาคตที่ลืมแยกทาง
|
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:<hash ของ fid>` (lib/ecourt-fid):
|
ตอนนี้ `parsed.fileUrl !== undefined` เท่านั้นที่ลงทะเบียน — เอกสาร staging ลิงก์มีแค่ `t=` (fid อยู่ใน credential): เติม branch **`fileUrl` ไม่มี + มี `fid`** → select row `ecourt:<hash ของ fid>` (lib/ecourt-fid):
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
// PLAN-SSO.md §4 — ส่วนกลางของ SSO ของแอป note (ฉบับ token 2026-10-30 ตามคำขอ
|
// PLAN-SSO.md §4 — ส่วนกลางของ SSO ของแอป note (ฉบับ token 2026-10-30 ตามคำขอ
|
||||||
// ผู้ใช้ "note ใช้แบบ add-in" — เลิก cookie ของรอบ D15/D16): ลิงก์ e-Court พา
|
// ผู้ใช้ "note ใช้แบบ add-in" — เลิก cookie ของรอบ D15/D16): ลิงก์ e-Court พา
|
||||||
// browser มา `/note` พร้อม credential (`t` หรือ `ticket_data`+`HmacSha256`) →
|
// 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 และส่ง
|
// localStorage ("note-auth") แล้วแนบ Authorization: Bearer กับทุก API และส่ง
|
||||||
// token เข้าทุก Server Action (identity แกะฝั่ง server — ห้ามเชื่อ userId จาก
|
// token เข้าทุก Server Action (identity แกะฝั่ง server — ห้ามเชื่อ userId จาก
|
||||||
// client ตรง ๆ)
|
// 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;
|
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
|
/** ttl_seconds ทางเลือก — ช่วง 60–600 วินาที default 300 (credential ใน URL
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
// session ของแอป note ฝั่ง client (ฉบับ token 2026-10-30 ตามคำขอผู้ใช้ "note ใช้
|
// session ของแอป note ฝั่ง client (ฉบับ token 2026-10-30 ตามคำขอผู้ใช้ "note ใช้
|
||||||
// แบบ add-in" — pattern เดียวกับ wordaddin/services/authService): access_token
|
// แบบ add-in" — pattern เดียวกับ wordaddin/services/authService): access_token
|
||||||
// + identity เก็บใน localStorage ("note-auth") หลังแลกจาก POST /api/note/auth/
|
// + identity เก็บใน localStorage ("note-auth") หลังแลกจาก POST /api/auth/session
|
||||||
// session · การเรียก API ของแอปผ่าน noteAuthFetch แนบ Authorization: Bearer
|
// (2026-11-09 — ย้ายมาเส้นสั้น, ทางเข้ารวมของระบบ) · การเรียก API ของแอปผ่าน
|
||||||
// อัตโนมัติ · token หมดอายุแล้วถือว่าไม่มี session (ล้างทิ้ง) — localStorage อาจ
|
// noteAuthFetch แนบ Authorization: Bearer อัตโนมัติ · token หมดอายุแล้วถือว่า
|
||||||
// โยน (private mode/quota) ทุกจุดจึง try/catch
|
// ไม่มี session (ล้างทิ้ง — token ที่ตายแล้วไม่มีทางฟื้น browser ไม่มี ticket
|
||||||
|
// ให้ renew ต้องกลับ e-Court กดลิงก์ใหม่) · storage โยน (private mode/quota) มี
|
||||||
|
// memory fallback ผ่าน utils/auth-store
|
||||||
//
|
//
|
||||||
// client-only — ห้าม import จาก server component/module ฝั่ง server
|
// client-only — ห้าม import จาก server component/module ฝั่ง server
|
||||||
|
import { createAuthStore, isExpired, jwtExp } from "@/utils/auth-store";
|
||||||
import { withBasePath } from "@/utils/base-path";
|
import { withBasePath } from "@/utils/base-path";
|
||||||
|
|
||||||
export type NoteSession = {
|
export type NoteSession = {
|
||||||
|
|
@ -16,12 +19,34 @@ export type NoteSession = {
|
||||||
expMs: number;
|
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 {
|
export function readNoteSession(): NoteSession | null {
|
||||||
try {
|
const raw = store.readRaw();
|
||||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
|
||||||
if (raw === null) return null;
|
if (raw === null) return null;
|
||||||
|
try {
|
||||||
const parsed = JSON.parse(raw) as Partial<NoteSession> | null;
|
const parsed = JSON.parse(raw) as Partial<NoteSession> | null;
|
||||||
if (
|
if (
|
||||||
parsed === null ||
|
parsed === null ||
|
||||||
|
|
@ -33,11 +58,11 @@ export function readNoteSession(): NoteSession | null {
|
||||||
typeof parsed.username !== "string" ||
|
typeof parsed.username !== "string" ||
|
||||||
typeof parsed.expMs !== "number"
|
typeof parsed.expMs !== "number"
|
||||||
) {
|
) {
|
||||||
window.localStorage.removeItem(STORAGE_KEY);
|
store.remove();
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (parsed.expMs <= Date.now()) {
|
if (isExpired(Math.floor(parsed.expMs / 1000))) {
|
||||||
window.localStorage.removeItem(STORAGE_KEY);
|
store.remove();
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return parsed as NoteSession;
|
return parsed as NoteSession;
|
||||||
|
|
@ -47,19 +72,11 @@ export function readNoteSession(): NoteSession | null {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function saveNoteSession(session: NoteSession): void {
|
export function saveNoteSession(session: NoteSession): void {
|
||||||
try {
|
store.writeRaw(JSON.stringify(session));
|
||||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(session));
|
|
||||||
} catch {
|
|
||||||
// private mode/quota — เงียบไว้: ผู้ใช้เข้าผ่านลิงก์ e-Court ใหม่ได้เสมอ
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearNoteSession(): void {
|
export function clearNoteSession(): void {
|
||||||
try {
|
store.remove();
|
||||||
window.localStorage.removeItem(STORAGE_KEY);
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** fetch ของแอปพร้อม Bearer — path เป็น route API ของแอป ("/api/note/…")
|
/** fetch ของแอปพร้อม Bearer — path เป็น route API ของแอป ("/api/note/…")
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
// session ของแอป note ฝั่ง server (ฉบับ token 2026-10-30 ตามคำขอผู้ใช้ "note ใช้
|
// session ของแอป note ฝั่ง server (ฉบับ token 2026-10-30 ตามคำขอผู้ใช้ "note ใช้
|
||||||
// แบบ add-in") — แกะ identity จาก access_token (purpose="login" ออกที่
|
// แบบ 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)
|
// ของแอปใช้ร่วม: token ผิด/หมดอายุ/secret ไม่ตั้ง = null เสมอ (fail closed)
|
||||||
// · ห้าม log ค่า token ดิบ
|
// · ห้าม log ค่า token ดิบ
|
||||||
//
|
//
|
||||||
|
|
|
||||||
187
lib/openapi.ts
187
lib/openapi.ts
|
|
@ -1,7 +1,9 @@
|
||||||
// OpenAPI 3.0 spec ของ API ทดสอบทั้งหมดของ judes-note — เส้นเดียวกับที่ Swagger UI
|
// OpenAPI 3.0 spec ของ API ทดสอบทั้งหมดของ judes-note — เส้นเดียวกับที่ Swagger UI
|
||||||
// แสดงผล: กลุ่ม template (DOCX Content Control) + N10 external API + docver
|
// แสดงผล: กลุ่ม template (DOCX Content Control) + N10 external API + docver
|
||||||
// (จัด version เอกสาร) + auth (ทางเข้า path สั้น /api/auth/* — alias ของ
|
// (จัด version เอกสาร) + auth (ทางเข้ารวม /api/auth/* — 2026-11-09 รอบสอง:
|
||||||
// /api/documents/token กับ /api/note/auth/session เพิ่ม 2026-11-05)
|
// /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) เป็นคนเดียวที่ใช้
|
// route handler GET /api/swagger (app/api/swagger/route.ts) เป็นคนเดียวที่ใช้
|
||||||
// — inline เป็น JSON ลงใน HTML ตอน respond (ไม่มี endpoint spec แยก)
|
// — inline เป็น JSON ลงใน HTML ตอน respond (ไม่มี endpoint spec แยก)
|
||||||
//
|
//
|
||||||
|
|
@ -64,11 +66,15 @@ export const openApiSpec = {
|
||||||
{
|
{
|
||||||
name: "auth",
|
name: "auth",
|
||||||
description:
|
description:
|
||||||
"ทางเข้า auth แบบ **path สั้น** `/api/auth/*` (เพิ่ม 2026-11-05) — endpoint " +
|
"**ทางเข้า auth รวม** `/api/auth/*` (2026-11-09 รอบสอง — เส้นของฝั่ง note " +
|
||||||
"ก้อนเดียวกับของเดิมคนละ URL เลือกใช้ทางใดทางหนึ่งได้:\n" +
|
"กลุ่ม `note/auth/*` ทั้งหมดถอดแล้ว ผู้เรียกเก่าต้องย้ายมาที่นี่):\n" +
|
||||||
"- `POST /api/auth/token` = `POST /api/documents/token` — ออก access_token ของกลุ่ม documents (docver)\n" +
|
"- `POST /api/auth/token` — ออก access_token: x-api-key + body เดิม = " +
|
||||||
"- `POST /api/auth/session` = `POST /api/note/auth/session` — แลก credential ของลิงก์ e-Court " +
|
"docver-token 7 วัน · x-api-key + `purpose:\"login\"` = login-token อายุสั้น " +
|
||||||
"เป็น session token ของแอป note",
|
"(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: {
|
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=<access_token>` ที่ให้ผู้ใช้คลิกจากหน้า 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": {
|
"/api/note/files/stage": {
|
||||||
post: {
|
post: {
|
||||||
tags: ["Judge's Note"],
|
tags: ["Judge's Note"],
|
||||||
|
|
@ -708,8 +646,8 @@ export const openApiSpec = {
|
||||||
"(TTL 8 ชม. นับจากการเข้าใช้ล่าสุด — ห้ามเก็บไฟล์ถาวรทุกรูปแบบตามโจทย์) " +
|
"(TTL 8 ชม. นับจากการเข้าใช้ล่าสุด — ห้ามเก็บไฟล์ถาวรทุกรูปแบบตามโจทย์) " +
|
||||||
"annotation/bookmark เก็บถาวรใน DB ตามระบบเดิม\n\n" +
|
"annotation/bookmark เก็บถาวรใน DB ตามระบบเดิม\n\n" +
|
||||||
"ได้ `{fileId}` แล้วเอา `fid` **เดิม** ไป mint login token ที่ " +
|
"ได้ `{fileId}` แล้วเอา `fid` **เดิม** ไป mint login token ที่ " +
|
||||||
"`POST /api/note/auth/token` → ลิงก์ `/note?t=<access_token>` (ไม่ต้องมี " +
|
"`POST /api/auth/token` แบบ `purpose:\"login\"` → ลิงก์ `/note?t=<access_token>` " +
|
||||||
"`fileUrl`) ให้ผู้ใช้คลิกตามเส้นทางเดิม\n\n" +
|
"(ไม่ต้องมี `fileUrl`) ให้ผู้ใช้คลิกตามเส้นทางเดิม\n\n" +
|
||||||
"- auth: header `x-api-key` (env `DOCVER_API_KEY` — ตัวเดียวกับทางเข้า mint)\n" +
|
"- auth: header `x-api-key` (env `DOCVER_API_KEY` — ตัวเดียวกับทางเข้า mint)\n" +
|
||||||
"- `data`: **base64 มาตรฐานต่อเนื่อง** — ห้าม whitespace/line break และไม่รับ " +
|
"- `data`: **base64 มาตรฐานต่อเนื่อง** — ห้าม whitespace/line break และไม่รับ " +
|
||||||
"base64url (`-`/`_`)\n" +
|
"base64url (`-`/`_`)\n" +
|
||||||
|
|
@ -748,7 +686,7 @@ export const openApiSpec = {
|
||||||
description:
|
description:
|
||||||
"รหัสกลุ่มเอกสาร N9 (ทางเลือก) — ส่งมา = ย้ายกลุ่มตามค่าล่าสุด ไม่ส่ง = คงเดิม · " +
|
"รหัสกลุ่มเอกสาร N9 (ทางเลือก) — ส่งมา = ย้ายกลุ่มตามค่าล่าสุด ไม่ส่ง = คงเดิม · " +
|
||||||
"⚠️ ของเอกสาร staging รับทางเส้นนี้**ทางเดียว** — typeId ที่แนบมากับ body ของ " +
|
"⚠️ ของเอกสาร staging รับทางเส้นนี้**ทางเดียว** — typeId ที่แนบมากับ body ของ " +
|
||||||
"`POST /api/note/auth/session` (ตอนแลกลิงก์ fid-only) ไม่ถูกใช้กับกลุ่มของไฟล์",
|
"`POST /api/auth/session` (ตอนแลกลิงก์ fid-only) ไม่ถูกใช้กับกลุ่มของไฟล์",
|
||||||
},
|
},
|
||||||
title: {
|
title: {
|
||||||
type: "string",
|
type: "string",
|
||||||
|
|
@ -841,18 +779,25 @@ export const openApiSpec = {
|
||||||
"/api/auth/token": {
|
"/api/auth/token": {
|
||||||
post: {
|
post: {
|
||||||
tags: ["auth"],
|
tags: ["auth"],
|
||||||
summary: "ขอ access_token ของกลุ่ม documents (docver) — path สั้นของ /api/documents/token",
|
summary: "ขอ access_token — ทางเข้ารวมของทั้งระบบ (docver / login ลิงก์ note / ticket add-in)",
|
||||||
description:
|
description:
|
||||||
"ทางเข้า **path สั้น** ของ `POST /api/documents/token` — endpoint ก้อนเดียวกัน " +
|
"**ทางเข้า token รวมของทั้งระบบ** (2026-11-09 รอบสอง — ถอดเส้น mint เดิมของฝั่ง note " +
|
||||||
"พฤติกรรมเหมือนกันทุกประการ (เพิ่ม 2026-11-05)\n\n" +
|
"ทาง mint login-token จึงรวมอยู่ที่นี่ทางเดียวผ่าน `purpose:\"login\"` · " +
|
||||||
"ออก `access_token` (JWT HS256) ไว้เรียก endpoint อื่นของกลุ่ม documents — " +
|
"`/api/documents/token` ของกลุ่ม docver เป็น alias ของเส้นนี้ — function เดียวกัน)\n\n" +
|
||||||
"token ฝัง claims `{userid, username, fid, template}` อายุตาม env " +
|
"ออก `access_token` (JWT HS256) — token ฝัง claims `{userid, username, fid, " +
|
||||||
"`DOCVER_TOKEN_TTL_SECONDS` (default 604800 วินาที = 7 วัน — token อายุยาวแทน" +
|
"template}` อายุตามชนิดของ token (D4 แยกสิทธิ์ด้วย claim `purpose`)\n\n" +
|
||||||
"ระบบ refresh/renew ของ add-in)\n\n" +
|
"**ทางเข้า 3 แบบ** — เลือกตามว่าใครเป็นคนเรียก (ดูตัวอย่างใน Request body):\n\n" +
|
||||||
"**ทางเข้า 2 แบบ** — เลือกตามว่าใครเป็นคนเรียก (ดูตัวอย่างใน Request body ได้ทั้งคู่):\n\n" +
|
|
||||||
"1. **ระบบภายนอก (server-to-server)** — แนบ header `x-api-key` (ค่าจาก env " +
|
"1. **ระบบภายนอก (server-to-server)** — แนบ header `x-api-key` (ค่าจาก env " +
|
||||||
"`DOCVER_API_KEY`) แล้วส่ง claims ตรง ๆ ใน body ตาม schema `TokenRequest`\n" +
|
"`DOCVER_API_KEY`) แล้วส่ง claims ตรง ๆ ใน body ตาม schema `TokenRequest` → " +
|
||||||
"2. **add-in ใน Word** — ไม่ต้องมี `x-api-key` ส่ง body `{ticket_data, HmacSha256}` " +
|
"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 " +
|
"ตาม schema `TicketTokenRequest` (คู่ค่าที่ e-Court stamp ไว้ใน custom properties " +
|
||||||
"ของไฟล์ .docx) — server ตรวจลายเซ็น HMAC-SHA256 ด้วย env `ADDIN_TICKET_SECRET` " +
|
"ของไฟล์ .docx) — server ตรวจลายเซ็น HMAC-SHA256 ด้วย env `ADDIN_TICKET_SECRET` " +
|
||||||
'(ไม่ตั้ง → env `SECRET` → `"super_secret"`) แล้วออก token จาก claims ที่อยู่ในตัว ticket เอง ' +
|
'(ไม่ตั้ง → env `SECRET` → `"super_secret"`) แล้วออก token จาก claims ที่อยู่ในตัว ticket เอง ' +
|
||||||
|
|
@ -868,6 +813,7 @@ export const openApiSpec = {
|
||||||
schema: {
|
schema: {
|
||||||
oneOf: [
|
oneOf: [
|
||||||
{ $ref: "#/components/schemas/TokenRequest" },
|
{ $ref: "#/components/schemas/TokenRequest" },
|
||||||
|
{ $ref: "#/components/schemas/LoginTokenRequest" },
|
||||||
{ $ref: "#/components/schemas/TicketTokenRequest" },
|
{ $ref: "#/components/schemas/TicketTokenRequest" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
@ -883,6 +829,11 @@ export const openApiSpec = {
|
||||||
"server-to-server ขั้นต่ำ (template เป็นทางเลือก)": {
|
"server-to-server ขั้นต่ำ (template เป็นทางเลือก)": {
|
||||||
value: { userid: "123", username: "Sorawit Bholsithi", fid: "3" },
|
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)": {
|
"add-in ด้วย ticket จากไฟล์ (ไม่ต้องมี x-api-key)": {
|
||||||
value: {
|
value: {
|
||||||
ticket_data:
|
ticket_data:
|
||||||
|
|
@ -934,20 +885,25 @@ export const openApiSpec = {
|
||||||
tags: ["auth"],
|
tags: ["auth"],
|
||||||
summary: "แลก credential ของลิงก์ e-Court เป็น session token ของแอป note (SSO)",
|
summary: "แลก credential ของลิงก์ e-Court เป็น session token ของแอป note (SSO)",
|
||||||
description:
|
description:
|
||||||
"ทางเข้า **path สั้น** ของ `POST /api/note/auth/session` — endpoint ก้อนเดียวกัน " +
|
"**ทางเข้าหลัก (และเดียว) ของแอป note** (2026-11-09 รอบสอง — เส้น session เดิมของฝั่ง note " +
|
||||||
"พฤติกรรมเหมือนกันทุกประการ (เพิ่ม 2026-11-05) · flow SSO ของแอป note " +
|
"ถอดแล้ว ผู้เรียกเก่าต้องย้ายมาเส้นนี้) · flow SSO ของแอป note " +
|
||||||
"(PLAN-SSO.md §3.2 ฉบับ token): ลิงก์ e-Court พา browser มาหน้า `/note` พร้อม " +
|
"(PLAN-SSO.md §3.2 ฉบับ token): ลิงก์ e-Court พา browser มาหน้า `/note` พร้อม " +
|
||||||
"credential → client เรียกเส้นนี้แลกเป็น access_token เก็บ localStorage " +
|
"credential → client เรียกเส้นนี้แลกเป็น access_token เก็บ localStorage " +
|
||||||
'("note-auth") แล้วแนบ `Authorization: Bearer` กับทุก API/Server Action ของแอป\n\n' +
|
'("note-auth") แล้วแนบ `Authorization: Bearer` กับทุก API/Server Action ของแอป\n\n' +
|
||||||
"- ส่ง credential **ทางเดียว**: `t` (login-token จาก `POST /api/note/auth/token`) " +
|
"- ส่ง credential **ทางเดียว**: `t` (login-token จาก `POST /api/auth/token` " +
|
||||||
"หรือ `ticket_data`+`HmacSha256` (คู่ค่าชุดเดียวกับที่ e-Court stamp ใน custom " +
|
'แบบ `purpose:"login"`) หรือ `ticket_data`+`HmacSha256` (คู่ค่าชุดเดียวกับที่ e-Court ' +
|
||||||
"properties ของไฟล์ .docx ของ add-in) — สองชุดพร้อมกัน = 400 (D12)\n" +
|
"stamp ใน custom properties ของไฟล์ .docx ของ add-in) — สองชุดพร้อมกัน = 400 (D12)\n" +
|
||||||
"- token ที่ได้เป็นชนิด `purpose=login` อายุ 8 ชม. (28800 วินาที) — ใช้ได้กับแอป " +
|
"- 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 ทันที " +
|
"- ส่ง `fileUrl` มาด้วย = ลงทะเบียนเอกสาร N8 แบบ trusted ฝั่ง server ทันที " +
|
||||||
"(fid จาก credential ที่ verify แล้ว) — ล้มก็ยังได้ token พร้อม `fileError` " +
|
"(fid จาก credential ที่ verify แล้ว) — ล้มก็ยังได้ token พร้อม `fileError` " +
|
||||||
"ให้หน้าแรกแสดงสาเหตุ · `fileUrl` ต้องผ่าน allowlist ของ N8 " +
|
"ให้หน้าแรกแสดงสาเหตุ · `fileUrl` ต้องผ่าน allowlist ของ N8 " +
|
||||||
"(admincourt.go.th + env `EXTERNAL_FILE_HOSTS`) หรือเป็น path `/samples/…`\n" +
|
"(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` " +
|
"- **ทาง ticket ใน production**: secret ต้องตั้งจริง (env `ADDIN_TICKET_SECRET` " +
|
||||||
'→ `SECRET`) — ค่า default `"super_secret"` ที่รู้กันทั้งระบบถูกปฏิเสธ (500)',
|
'→ `SECRET`) — ค่า default `"super_secret"` ที่รู้กันทั้งระบบถูกปฏิเสธ (500)',
|
||||||
requestBody: {
|
requestBody: {
|
||||||
|
|
@ -1018,6 +974,9 @@ export const openApiSpec = {
|
||||||
tags: ["documents"],
|
tags: ["documents"],
|
||||||
summary: "ขอ access_token ของกลุ่ม documents (docver)",
|
summary: "ขอ access_token ของกลุ่ม documents (docver)",
|
||||||
description:
|
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 — " +
|
"ออก `access_token` (JWT HS256) ไว้เรียก endpoint อื่นของกลุ่ม documents — " +
|
||||||
"token ฝัง claims `{userid, username, fid, template}` อายุตาม env " +
|
"token ฝัง claims `{userid, username, fid, template}` อายุตาม env " +
|
||||||
"`DOCVER_TOKEN_TTL_SECONDS` (default 604800 วินาที = 7 วัน — token อายุยาวแทน" +
|
"`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: {
|
TokenResponse: {
|
||||||
type: "object",
|
type: "object",
|
||||||
description: "access_token ของกลุ่ม documents (JWT HS256)",
|
description: "access_token ของกลุ่ม documents (JWT HS256)",
|
||||||
|
|
@ -1486,7 +1475,7 @@ export const openApiSpec = {
|
||||||
t: {
|
t: {
|
||||||
type: "string",
|
type: "string",
|
||||||
description:
|
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: {
|
ticket_data: {
|
||||||
type: "string",
|
type: "string",
|
||||||
|
|
@ -1532,14 +1521,14 @@ export const openApiSpec = {
|
||||||
type: "string",
|
type: "string",
|
||||||
format: "uuid",
|
format: "uuid",
|
||||||
description:
|
description:
|
||||||
"มีเมื่อส่ง fileUrl และลงทะเบียนสำเร็จ — id ของเอกสารในระบบนี้ " +
|
"มีเมื่อส่ง fileUrl และลงทะเบียนสำเร็จ หรือ (ลิงก์ fid-only ของ N11) เจอเอกสารของ " +
|
||||||
"(ใช้เข้าหน้าอ่าน /note/annotate/<fileId>)",
|
"fid ที่พร้อมใช้ — id ของเอกสารในระบบนี้ (ใช้เข้าหน้าอ่าน /note/annotate/<fileId>)",
|
||||||
},
|
},
|
||||||
fileError: {
|
fileError: {
|
||||||
type: "string",
|
type: "string",
|
||||||
description:
|
description:
|
||||||
"มีเมื่อลงทะเบียน fileUrl ไม่สำเร็จ (ไม่ผ่าน allowlist ฯลฯ) — token ยังออกให้ " +
|
"มีเมื่อลงทะเบียน fileUrl ไม่สำเร็จ (ไม่ผ่าน allowlist ฯลฯ) หรือลิงก์ fid-only " +
|
||||||
"หน้าแรกแสดงสาเหตุ",
|
"ไม่เจอเอกสาร/พ้นช่วงจัดเตรียม — token ยังออกให้ หน้าแรกแสดงสาเหตุ",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -10,15 +10,16 @@ vi.mock("@/db", async () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// import หลัง vi.mock — @/db ถูกแทนก่อน
|
// 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 { issueDocverToken } from "@/lib/docver-token";
|
||||||
import { ecourtExternalId } from "@/lib/ecourt-fid";
|
import { ecourtExternalId } from "@/lib/ecourt-fid";
|
||||||
import { __clearStagedDocsForTest, putStagedDoc } from "@/lib/staged-docs";
|
import { __clearStagedDocsForTest, putStagedDoc } from "@/lib/staged-docs";
|
||||||
|
|
||||||
// PLAN-STAGED.md D5 — ลิงก์ fid-only (body มีแค่ t=): 4 branch ของการเช็ค staging
|
// PLAN-STAGED.md D5 — ลิงก์ fid-only (body มีแค่ t=): 4 branch ของการเช็ค staging
|
||||||
// ตั้งแต่ตอนแลก token · ใช้ hasStagedDoc จริงของ lib/staged-docs (ไม่ยืด TTL)
|
// ตั้งแต่ตอนแลก 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 FID = "F-STAGED-1";
|
||||||
const FILE_ID = "0d7e6a52-9a71-4d40-bd1c-b2e1a44ab133";
|
const FILE_ID = "0d7e6a52-9a71-4d40-bd1c-b2e1a44ab133";
|
||||||
const PDF_BYTES = Buffer.from("%PDF-1.7 เอกสาร staging");
|
const PDF_BYTES = Buffer.from("%PDF-1.7 เอกสาร staging");
|
||||||
|
|
@ -1,11 +1,15 @@
|
||||||
// POST /api/documents/token — contract ทางเข้า ticket ฉบับ 2026-11-04 + regression ทางเข้า x-api-key
|
// POST /api/auth/token — ทางเข้า token หลักของระบบ (2026-11-09 รอบสอง: ถอด
|
||||||
// route pure ล้วนหลังถอด hasVersionByUser (ไม่ import @/db/minio อีกต่อไป) จึง unit test
|
// /api/note/auth/token — ทาง mint login-token รวมมาที่นี่ผ่าน purpose:"login"
|
||||||
// ได้โดยไม่ต้องตั้ง DATABASE_URL/MINIO_* — เคส 403 ผ่านโดยไม่มี DB เลย = พิสูจน์ว่า route
|
// ส่วน /api/documents/token ของกลุ่ม docver **คงไว้** เป็น alias re-export ของ
|
||||||
// ไม่แตะ DB (เลิก "ตรวจเคยมี version" ของ requirement ขั้น 7 ฉบับ 2026-10-28)
|
// เส้นนี้) · 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 { createHmac } from "node:crypto";
|
||||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
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 */
|
/** secret ตาม chain ของ addinTicketSecret — ลบ env ทั้งสองตัวใน setup เพื่อใช้ค่า default */
|
||||||
const SECRET_DEFAULT = "super_secret";
|
const SECRET_DEFAULT = "super_secret";
|
||||||
|
|
@ -35,7 +39,7 @@ function futureExp(): number {
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeRequest(body: BodyInit, headers?: Record<string, string>): Request {
|
function makeRequest(body: BodyInit, headers?: Record<string, string>): Request {
|
||||||
return new Request("http://localhost/docedit/api/documents/token", {
|
return new Request("http://localhost/docedit/api/auth/token", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "content-type": "application/json", ...headers },
|
headers: { "content-type": "application/json", ...headers },
|
||||||
body,
|
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 () => {
|
it("ticket ถูกทั้งลายเซ็น+อายุ → 200 + JWT claims จาก ticket + expires_in 604800 (default 7 วัน)", async () => {
|
||||||
const { ticketData, ticketHmac } = mintTicket(futureExp(), { fid: "F9", uid: "u1", uname: "นายทดสอบ" });
|
const { ticketData, ticketHmac } = mintTicket(futureExp(), { fid: "F9", uid: "u1", uname: "นายทดสอบ" });
|
||||||
const response = await POST(makeRequest(ticketBody(ticketData, ticketHmac)));
|
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 () => {
|
it("key ถูก + body claims → 200 ได้ token อายุ default ใหม่ 604800", async () => {
|
||||||
process.env.DOCVER_API_KEY = API_KEY;
|
process.env.DOCVER_API_KEY = API_KEY;
|
||||||
const response = await POST(
|
const response = await POST(
|
||||||
|
|
@ -213,3 +217,79 @@ describe("POST /api/documents/token — ทางเข้า x-api-key (regress
|
||||||
expect(response.status).toBe(401);
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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", () => {
|
it("t เดี่ยว + fileUrl/typeId ผ่าน — ได้ credential ทาง jwt", () => {
|
||||||
const parsed = parseSessionExchangeBody({
|
const parsed = parseSessionExchangeBody({
|
||||||
t: "a.b.c",
|
t: "a.b.c",
|
||||||
|
|
|
||||||
|
|
@ -40,12 +40,18 @@ describe("openApiSpec", () => {
|
||||||
expect(broken).toEqual([]);
|
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<string, unknown>;
|
const paths = openApiSpec.paths as Record<string, unknown>;
|
||||||
expect(paths["/api/auth/token"]).toBeDefined();
|
expect(paths["/api/auth/token"]).toBeDefined();
|
||||||
expect(paths["/api/auth/session"]).toBeDefined();
|
expect(paths["/api/auth/session"]).toBeDefined();
|
||||||
// path เดิม (ที่ /api/auth/* alias มา) ต้องยัง document อยู่
|
|
||||||
expect(paths["/api/documents/token"]).toBeDefined();
|
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/");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
85
tests/utils/auth-store.test.ts
Normal file
85
tests/utils/auth-store.test.ts
Normal file
|
|
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
79
utils/auth-store.ts
Normal file
79
utils/auth-store.ts
Normal file
|
|
@ -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<string, unknown>).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<string, string>();
|
||||||
|
|
||||||
|
/** 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
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue