From c0833c00f719c6d6da4b1905be5325552aab9af6 Mon Sep 17 00:00:00 2001 From: Suphonchai Phoonsawat Date: Wed, 4 Nov 2026 21:18:19 +0700 Subject: [PATCH 1/6] Refactor authentication flow and ticket handling in add-in - Removed client-side HMAC verification; server now handles all HMAC checks. - Updated ticket processing logic to improve UX and security. - Adjusted API request handling to return discriminated results based on HTTP status. - Enhanced error messages for better clarity on authentication failures. - Added unit tests for the new token endpoint to ensure compliance with updated contract. - Cleaned up unused code and comments related to previous HMAC handling. --- .env.example | 8 +- AGENTS.md | 8 +- CLAUDE.md | 8 +- app/api/documents/token/route.ts | 43 +-- components/wordaddin/TaskpaneApp.tsx | 17 +- components/wordaddin/WordAddinGate.tsx | 31 +- docs/PLAN-ADDIN-AUTH.md | 142 ++++--- docs/PLAN-DOCVER.md | 4 +- docs/PLAN-SSO.md | 10 +- docs/WORDADDIN-DEV.md | 9 +- docs/addin-ui-flow.md | 25 +- lib/addin-ticket.ts | 9 +- lib/docver-store.ts | 16 - lib/openapi.ts | 28 +- tests/app/api/documents/token.test.ts | 215 +++++++++++ tests/lib/addin-ticket.test.ts | 5 +- tests/utils/docver-ticket.test.ts | 10 - tests/wordaddin/ticket-auth-flow.test.ts | 429 ++++++++++++++-------- tests/wordaddin/versioningService.test.ts | 3 +- utils/docver-ticket.ts | 25 +- wordaddin/config.ts | 8 +- wordaddin/services/apiClient.ts | 6 +- wordaddin/services/authService.ts | 174 ++++----- wordaddin/services/ticketService.ts | 85 ++--- 24 files changed, 858 insertions(+), 460 deletions(-) create mode 100644 tests/app/api/documents/token.test.ts diff --git a/.env.example b/.env.example index d8250bd..5765e56 100644 --- a/.env.example +++ b/.env.example @@ -61,7 +61,7 @@ MINIO_SECRET_KEY=minioadmin # add-in ticket auth (2026-10-27, docs/PLAN-ADDIN-AUTH.md): add-in ใน Word ขอ token # ที่ POST /api/documents/token ด้วย body {ticket_data, HmacSha256} ที่ e-Court stamp -# มากับไฟล์ — secret ต้องตรงกับตัว stamp ของ e-Court ทั้งสองฝั่ง +# มากับไฟล์ — secret ต้องตรงกันสองฝั่ง: e-Court (ตัว stamp) กับ server นี้ (ตัว verify) # ฝั่ง server ตรวจ HMAC ของ ticket: ADDIN_TICKET_SECRET (ไม่ตั้ง = ใช้ env SECRET # ของกลุ่ม template → ยังไม่ตั้งอีก = "super_secret" ตาม requirement) # ⚠️ production ต้องตั้งค่าจริง — ค่า default รู้กันหมด = ใครก็ปลอม ticket ได้ @@ -69,8 +69,6 @@ MINIO_SECRET_KEY=minioadmin # 2026-10-30: ทาง ticket ของ SSO (POST /api/note/auth/session) ปฏิเสธค่า default # นี้เฉพาะใน production — dev ใช้ default ทดสอบโหมด ticket ได้เลย (เครื่องมือ dev # อย่าง make-login-link/make-ticket-docx ก็ default ค่าเดียวกัน) +# 2026-11-04: ฝั่ง add-in ไม่มี secret อีกต่อไป (ถอด NEXT_PUBLIC_ADDIN_TICKET_SECRET +# ตาม PLAN-SSO.md D13 — การตรวจ HMAC เป็นของ server อย่างเดียว) # ADDIN_TICKET_SECRET=super_secret -# ฝั่ง add-in (inline ลง bundle ตอน build — ใช้เป็นแค่ UX gate ตัดเคสไฟล์ถูกแก้ก่อนยิง -# network ส่วนความมั่นคงจริงอยู่ที่ฝั่ง server ข้างบน · ตามคำขอผู้ใช้ 2026-10-29 -# แก้ add-in เป็นงานแยก — ยังคงไว้ตาม PLAN-ADDIN-AUTH.md D3) -# NEXT_PUBLIC_ADDIN_TICKET_SECRET=super_secret diff --git a/AGENTS.md b/AGENTS.md index ba98285..a3f9174 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,12 +54,12 @@ lib/external-api.ts ← N10 ส่วนกลางของ route — parseQ lib/openapi.ts ← OpenAPI 3.0 spec ของ API ทดสอบ (กลุ่ม template + N10 + docver — 2026-10-06) — แหล่งเดียวของ Swagger UI: GET /api/template inline ลง HTML ตรง ๆ ไม่มี endpoint spec แยก; URL ใน paths เป็น path เต็ม /api/… (servers เติมแค่ basePath ตาม invariant #12) — แก้ contract ของ API ต้องมาแก้ไฟล์นี้ให้ตรงด้วย lib/document-versioning.ts ← docver pure validation (ห้าม import อะไรที่ลาก @/db หรือ minio มาด้วย — unit test tests/lib/document-versioning.test.ts ต้องรันได้เหมือน lib/external-api-auth.ts): parseVersionUploadForm (allowlist field — identity ไม่รับจาก form แล้ว แกะจาก token ผ่าน lib/docver-token.ts · **field `fields` ก็ถอดออก 2026-10-30** server แกะจากไฟล์เองที่ route ผ่าน lib/lib-tmpl.ts + message ≤2048), isDocxBytes/isPdfBytes (magic bytes), parseDownloadFormat/parseVersionIdParam, contentDispositionValue (ASCII fallback + filename*=UTF-8'') + docverError สร้าง Response JSON {"error": ไทย} ใช้ร่วมทุก route ของกลุ่ม (parseDocId ถอดแล้ว 2026-10-26 — docId ไม่มาจาก path param) lib/docver-token.ts ← docver token + auth ของกลุ่ม /api/documents/* (2026-10-22 — pure module ห้าม import อะไรที่ลาก @/db หรือ minio มาด้วย เหมือน lib/document-versioning.ts; unit test tests/lib/docver-token.test.ts ต้องรันได้โดยไม่ต้องตั้ง env) -lib/addin-ticket.ts ← add-in ticket auth ฝั่ง server ของทางเข้าที่สองของ POST /api/documents/token (2026-10-27, docs/PLAN-ADDIN-AUTH.md — pure เหมือน lib/docver-token.ts; unit test tests/lib/addin-ticket.test.ts): addinTicketSecret (env ADDIN_TICKET_SECRET → SECRET → "super_secret") + verifyAddinTicketHmac (HMAC timing-safe บน ticket_data ดิบ + parse **ไม่ตรวจ exp** — route ทางเข้า ticket ใช้ตัวนี้แล้วตัดสิน exp เอง: ticket หมดอายุยังออก token ต่อเมื่อ fid นั้นเคยมี version โดย uid เดียวกัน ผ่าน `hasVersionByUser` ของ lib/docver-store.ts — requirement ขั้น 7 แก้บั๊ก 2026-10-28, DB พัง = fail closed 401) + verifyAddinTicket (HMAC+exp เต็มชุด — ผู้เรียกเดิม) + ticketToDocverClaims (uid→userid, uname→username, tid→template) + parseTicketTokenBody (allowlist ticket_data/HmacSha256 — missing → 401 / invalid → 400): issueDocverToken/verifyDocverToken (JWT HS256 มือเขียน node:crypto ล้วน — บังคับ alg=HS256 กัน alg-confusion, timingSafeEqual หลัง hash ทั้งสองฝั่ง, exp บังคับ) + requireDocverApiKey (header x-api-key เทียบ env DOCVER_API_KEY — fail closed 401 ไม่ตั้ง/ว่าง, 401 เดียวกันทั้งไม่ใส่/ผิด กัน enumerate, ห้าม log ค่า key/header ดิบ) + requireDocverBearer (Authorization: Bearer → claims {userid, username, fid, template} — secret จาก env DOCVER_TOKEN_SECRET ไม่ตั้ง = 401, ทุกกรณี 401 ข้อความเดียวกัน) + parseTokenRequestBody (allowlist userid/username/fid/template — **fid = fileId ของ e-Court (string, บังคับ 2026-10-26)** เป็น docId ของทุก operation ตรง ๆ · verify ไม่รับ token ที่ไม่มี fid) +lib/addin-ticket.ts ← add-in ticket auth ฝั่ง server ของทางเข้าที่สองของ POST /api/documents/token (2026-10-27, docs/PLAN-ADDIN-AUTH.md — pure เหมือน lib/docver-token.ts; unit test tests/lib/addin-ticket.test.ts): addinTicketSecret (env ADDIN_TICKET_SECRET → SECRET → "super_secret") + verifyAddinTicketHmac (HMAC timing-safe บน ticket_data ดิบ + parse **ไม่ตรวจ exp** — route ทางเข้า ticket ใช้ตัวนี้แล้วตัดสิน exp เอง: ลายเซ็นถูกแต่หมดอายุ = **403** — contract 2026-11-04 เลิก `hasVersionByUser`/requirement ขั้น 7 ของ 2026-10-28) + verifyAddinTicket (HMAC+exp เต็มชุด — ผู้เรียกเดิม) + ticketToDocverClaims (uid→userid, uname→username, tid→template) + parseTicketTokenBody (allowlist ticket_data/HmacSha256 — missing กับ invalid → **400 ทั้งคู่** contract 2026-11-04): issueDocverToken/verifyDocverToken (JWT HS256 มือเขียน node:crypto ล้วน — บังคับ alg=HS256 กัน alg-confusion, timingSafeEqual หลัง hash ทั้งสองฝั่ง, exp บังคับ) + requireDocverApiKey (header x-api-key เทียบ env DOCVER_API_KEY — fail closed 401 ไม่ตั้ง/ว่าง, 401 เดียวกันทั้งไม่ใส่/ผิด กัน enumerate, ห้าม log ค่า key/header ดิบ) + requireDocverBearer (Authorization: Bearer → claims {userid, username, fid, template} — secret จาก env DOCVER_TOKEN_SECRET ไม่ตั้ง = 401, ทุกกรณี 401 ข้อความเดียวกัน) + parseTokenRequestBody (allowlist userid/username/fid/template — **fid = fileId ของ e-Court (string, บังคับ 2026-10-26)** เป็น docId ของทุก operation ตรง ๆ · verify ไม่รับ token ที่ไม่มี fid) lib/docver-store.ts ← docver server-side MinIO + DB (env MINIO_* อ่านตอนเรียกไม่ใช่ module load; client + cached ensureBucket เก็บบน globalThis แบบ db/index.ts): object key `docver//document.docx|pdf` (uuid ล้วน ไม่มี input ผู้ใช้), putVersionObjects (docx ก่อน — pdf ล้ม = ลบ docx คืนแล้วโยนต่อ), removeVersionObjects (best-effort), presignVersionUrl (TTL env DOCVER_LINK_TTL_SECONDS), insertVersionRecord (**tx เดียว**: upsert documents ON CONFLICT DO NOTHING → SELECT id FOR UPDATE → max(versionNumber)+1 → insert — จ่ายเลข version ปลอดภัยเรียกคู่ขนาน, UNIQUE(document_id, version_number) เป็น safety net) wordaddin/ ← **client-only zone ของ Word add-in** (ห้าม import จาก lib/ หรือถูก import โดย server component — พอร์ตดิบจาก D:\word-addin-poc\addin\src, spec: docs/addin-ui-flow.md, คู่มือ dev: docs/WORDADDIN-DEV.md) wordaddin/config.ts ← env NEXT_PUBLIC_ADDIN_API_BASE_URL ("" = ยังไม่ต่อ backend จริง) + NEXT_PUBLIC_ADDIN_MOCK_USERNAME (ชื่อ fallback ของ ?addindemo/ไฟล์ที่มี token ไม่มี owner) wordaddin/models/versionRecord.ts ← types VersionRecord/FieldsSnapshot + readSnapshotField (พอร์ตดิบ) -wordaddin/services/ ← documentService/fileService/metadataService/autoSaveService/versionFlow/apiClient/errors พอร์ตดิบทั้งไฟล์ + **templateService** (แท็บที่ 2 "เครื่องมือ Template" 2026-10-19 — list/select/applyTestValues + แทรกย่อหน้า-ตัวแบ่งบรรทัด-ตัวแบ่งหน้า ที่ cursor (page break มี fallback OOXML กัน E_FAIL), Office.js ล้วน — ⚠️ `contentControls` ธรรมดาเห็นเฉพาะ rich text CC: feature-detect WordApi 1.5 ใช้ `getContentControls()` เห็นทุกชนิด — helper `supportedContentControls`/`supportsAllContentControlTypes` อยู่ที่ **metadataService** แชร์กับ `readContentControlFields` (fields ที่แนบตอนสร้าง version เก็บ CC ทุกตัวแก้ 2026-10-28 — เดิมเหลือแค่ rich ตัวเดียว), ห้าม top-level Office access) · **authService + ticketService = ticket auth ของ e-Court ทางเดียว ไม่มี login แล้ว** (ยกเลิก Keycloak → pass-through token/owner → ticket 2026-10-27, `docs/PLAN-ADDIN-AUTH.md` ตาม `docs/requirement/addin-workflow.md` — surface เหลือ signInForDemo (dev-only) / signInWithTicket / getAccessToken / renewTokenFromTicket / getUsername; ไฟล์ที่ stamp custom properties `ticket_data`+`HmacSha256` มา = `detect()` ของ TaskpaneApp เรียก signInWithTicket: ตรวจ HMAC ฝั่ง client (Web Crypto + NEXT_PUBLIC_ADDIN_TICKET_SECRET — UX gate) → session localStorage `wordaddin-auth` ตาม uid (user ใหม่ = เคลียร์ทั้งก้อน) → **ต้องการ token เมื่อไหร่ยิงขอ `/api/documents/token` เสมอ แล้วให้ server ตัดสิน exp เอง (แก้ 2026-10-28 ตาม requirement ขั้น 7: ticket หมดอายุแต่ fid เคยมี version โดย uid เดียวกัน = ออกให้ — กลับมาทำเอกสารเดิมใช้ต่อได้แม้ token เก่าหมด/ localStorage หาย)** ผูก fid; ไม่มี ticket/HMAC ไม่ตรง/หมดอายุ+ไม่เคยทำงาน = phase `no-access` แยกข้อความตามสาเหตุ; apiClient โดน 401 = renew จาก ticket 1 ครั้ง (ไม่ตัด exp ฝั่ง client) ยังไม่ผ่าน = AuthRequiredError → no-access) · **versioningService = ของจริง** (ปลด mock 2026-10-27 — uploadVersion/getVersions/getDownloadLink ยิงกลุ่ม `/api/documents/*` ผ่าน apiClient (Bearer same-origin ผ่าน withBasePath — `NEXT_PUBLIC_ADDIN_API_BASE_URL` ตั้งเฉพาะวัน deploy คนละ origin ต้องรวม basePath); เอกสารระบุด้วย fid ใน token — พารามิเตอร์ documentId/templateId เหลือตาม signature เดิมไม่ส่งถึง server และ **ห้ามส่ง userId/userName/templateId ใน form (contract 2026-10-22 ส่งมา = 400) เช่นกันกับ `fields` (contract 2026-10-30 — server แกะจากไฟล์เองเก็บทั้งก้อนเหมือน return ของ `/api/template/extract` `{cc, bookmark, properties, verify}`, versionFlow จึงไม่มีขั้นอ่าน CC ฝั่ง client แล้ว)**; mock เหลือเฉพาะโหมด `?addindemo` ผ่าน `authService.isDemoMode()` — memory + metadata persist ลง localStorage ต่อ documentId, download-link คืน about:blank; unit test tests/wordaddin/versioningService.test.ts คุมทั้งสองโหมด) +wordaddin/services/ ← documentService/fileService/metadataService/autoSaveService/versionFlow/apiClient/errors พอร์ตดิบทั้งไฟล์ + **templateService** (แท็บที่ 2 "เครื่องมือ Template" 2026-10-19 — list/select/applyTestValues + แทรกย่อหน้า-ตัวแบ่งบรรทัด-ตัวแบ่งหน้า ที่ cursor (page break มี fallback OOXML กัน E_FAIL), Office.js ล้วน — ⚠️ `contentControls` ธรรมดาเห็นเฉพาะ rich text CC: feature-detect WordApi 1.5 ใช้ `getContentControls()` เห็นทุกชนิด — helper `supportedContentControls`/`supportsAllContentControlTypes` อยู่ที่ **metadataService** แชร์กับ `readContentControlFields` (fields ที่แนบตอนสร้าง version เก็บ CC ทุกตัวแก้ 2026-10-28 — เดิมเหลือแค่ rich ตัวเดียว), ห้าม top-level Office access) · **authService + ticketService = ticket auth ของ e-Court ทางเดียว ไม่มี login แล้ว** (ยกเลิก Keycloak → pass-through token/owner → ticket 2026-10-27, `docs/PLAN-ADDIN-AUTH.md` ตาม `docs/requirement/addin-workflow.md` — surface เหลือ signInForDemo (dev-only) / signInWithTicket / getAccessToken / renewTokenFromTicket / getUsername; ไฟล์ที่ stamp custom properties `ticket_data`+`HmacSha256` มา = `detect()` ของ TaskpaneApp เรียก signInWithTicket (**flow 2026-11-04 ตาม PLAN-SSO.md D13 — client ไม่ตรวจ HMAC เอง ไม่มี secret ใน bundle ถอด NEXT_PUBLIC_ADDIN_TICKET_SECRET**): pre-check ฝั่ง client (missing/ฟอร์แม็ตพัง · หมดอายุ+ไม่มี JWT ของ fid ใน session = เตะโดยไม่ยิง API) → **ยิง `/api/documents/token` ทุกครั้งที่เปิดเอกสาร** (ต้อง online) แล้วแยกตาม contract: 200 = JWT ใหม่ (user ใหม่ = เคลียร์ session `wordaddin-auth` ทั้งก้อน/คนเดิม = update userName+tokens[fid]) · 400/401 = ฟอร์แม็ต/ลายเซ็นพัง no-access · 403 = ลายเซ็นถูกแต่ ticket หมดอายุ มี JWT ของ fid ที่ยังไม่หมดใน session ก็ใช้ต่อ (เลิก hasVersionByUser — ไม่มี JWT ต้องขอไฟล์ใหม่จาก e-Court); ticketService.requestDocverToken คืน discriminated result {ok:true entry}/{kind: network|http status|malformed} ไม่โยน Error; apiClient โดน 401 = renewTokenFromTicket 1 ครั้ง (server ตอบ 403 = false) ยังไม่ผ่าน = AuthRequiredError → no-access; token TTL 7 วัน) · **versioningService = ของจริง** (ปลด mock 2026-10-27 — uploadVersion/getVersions/getDownloadLink ยิงกลุ่ม `/api/documents/*` ผ่าน apiClient (Bearer same-origin ผ่าน withBasePath — `NEXT_PUBLIC_ADDIN_API_BASE_URL` ตั้งเฉพาะวัน deploy คนละ origin ต้องรวม basePath); เอกสารระบุด้วย fid ใน token — พารามิเตอร์ documentId/templateId เหลือตาม signature เดิมไม่ส่งถึง server และ **ห้ามส่ง userId/userName/templateId ใน form (contract 2026-10-22 ส่งมา = 400) เช่นกันกับ `fields` (contract 2026-10-30 — server แกะจากไฟล์เองเก็บทั้งก้อนเหมือน return ของ `/api/template/extract` `{cc, bookmark, properties, verify}`, versionFlow จึงไม่มีขั้นอ่าน CC ฝั่ง client แล้ว)**; mock เหลือเฉพาะโหมด `?addindemo` ผ่าน `authService.isDemoMode()` — memory + metadata persist ลง localStorage ต่อ documentId, download-link คืน about:blank; unit test tests/wordaddin/versioningService.test.ts คุมทั้งสองโหมด) utils/coordinates.ts ← pixel⇄% + getPointerPercent + clamp + mergeRectsByLine utils/geometry.ts ← bbox/translate/scale/hitTestPath ของ annotation (pure, หน่วย %) utils/annotation-display.ts ← label/ข้อความย่อต่อชนิด + TYPE_SHORT/TYPE_ORDER ชื่อย่อ/ลำดับชนิด (บรรทัดสรุปหน้าของหน้าสรุป + แผงตัวกรอง explorer — ย้ายจาก 2 ที่ซ้ำกันมารวมที่นี่ 2026-09-24) + ค่า default TEXTBOX/STAMP + describeAnnotationChange สรุปสิ่งที่เปลี่ยนของ version history F19 (sidebar/สรุป/groups/CSV ใช้ร่วม) @@ -68,7 +68,7 @@ utils/pdf-export.ts ← แปลงพิกัดจอ⇄PDF (/Rotate) + wr utils/user.ts ← parse/build ค่า cookie oacCase → OacUser {userId: u_userid, userName: u_userlogin} (pure module — middleware ใช้ร่วมได้) utils/external-file.ts ← N8 validation ลิงก์ไฟล์รีโมต (allowlist "admincourt.go.th" + env EXTERNAL_FILE_HOSTS, suffix match, https เท่านั้น ห้าม user:pass@) + titleFromExternalUrl/externalIdFor/isHttpUrl + normDocumentGroup (N9 normalize รหัสกลุ่ม — ใช้ร่วม N8/N4, pure) utils/base-path.ts ← withBasePath() เติม basePath /docedit + app path /note ให้ URL ฝั่ง browser ที่ Next ไม่เติมให้เอง (ค่าจาก next.config.mjs ผ่าน env — invariant #12) -utils/docver-ticket.ts ← ticket ของ add-in ส่วน pure แชร์ client/server (2026-10-27, PLAN-ADDIN-AUTH.md §4 — ไม่ import อะไรเลย ให้สองฝั่งฉีด crypto ของตัวเอง: lib/addin-ticket.ts = node:crypto / wordaddin/services/ticketService.ts = Web Crypto): parseDocverTicket (allowlist exp/fid/tid/uid/uname + cap ยาวตาม endpoint) + isTicketExpired + normalizeHexDigest + timingSafeEqualHex + type HmacSha256Hex — 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) + isTicketExpired + normalizeHexDigest — unit test tests/utils/docver-ticket.test.ts utils/file-kind.ts ← derive ชนิดไฟล์ pdf/image จาก extension ของ path (N3 — ใช้ร่วม viewer/export/หน้ารายการ/seed) utils/upload-name.ts ← N4 ถอดรหัสชื่อไฟล์จาก FormData ของ Server Action กลับเป็น UTF-8 ก่อนใช้ (undici ถอด header Content-Disposition เป็น latin1 ทำชื่อไทยเพี้ยน "คำ…" — encode กลับ byte แล้วถอด UTF-8; guard: ไม่มีตัว U+0080–U+00FF = ไม่แตะ, ถอดแล้วได้ U+FFFD = เก็บของเดิม, 2026-10-05) utils/tiff-client.ts ← decode TIFF ทุกเฟรม → PNG blob URL ฝั่ง client (N3) @@ -83,7 +83,7 @@ app/api/note/annotations/export/route.ts ← export PDF ฝัง annotation app/api/note/external/documents/route.ts ← N10 API ภายนอกเส้นเดียว (POST + JSON body {fileId และ/หรือ typeId, filter:{type, userId, annotationTypes}} — auth ผ่าน requireExternalApiKey ก่อนแตะ DB, ถาม fileId = ค้น files.external_id = "ecourt:" ไม่เจอ 404 แม้ส่ง typeId มาด้วย / ถาม typeId = ทุกไฟล์ในกลุ่ม กลุ่มว่าง = 200 documents:[] / ส่งทั้งคู่ = AND กรอง externalId+typeId ใน query เดียว เหลือเอกสารเดียว ไม่ตรงคู่ = 404 แยกสาเหตุในข้อความ (2026-09-25 เดิมเคยเป็น OR แล้วเป็น 400); response รูปเดียว {documents:[bundle…]} ผ่าน loadDocumentBundles ทั้งสอง mode; ครอบ try/catch ทุก DB call — ล้ม = log ฝั่ง server + 500 JSON ตาม contract ไม่ปล่อยเป็น 500 ของ framework; read-only ไม่ publish SSE ไม่ revalidatePath — snapshot ณ ตอนเรียก) app/api/swagger/route.ts ← **GET /api/swagger = หน้า Swagger UI** (2026-10-06 — document ของ API ทุกกลุ่ม: spec inline จาก lib/openapi.ts + asset จาก public/note/swagger-ui/ ของ postinstall scripts/copy-swagger-ui.mjs, persistAuthorization เก็บ x-api-key ไว้ใช้ข้าม reload — เปิดกว้างไม่ต้อง login) 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 3600) — identity userid/username + templateId แกะจาก claims ไม่รับจาก request อีกต่อไป; ส่ง userId/userName/templateId ใน form = 400 ไม่รู้จัก field · **2026-10-27 POST token มีทางเข้าที่สองสำหรับ add-in ใน Word** (PLAN-ADDIN-AUTH.md): ไม่มี x-api-key + body `{ticket_data, HmacSha256}` — lib/addin-ticket.ts ตรวจ HMAC+exp (secret env ADDIN_TICKET_SECRET → SECRET → default "super_secret") แล้วออก token จาก claims ของ ticket เอง · **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/page.tsx ← รายการไฟล์จัด section ตามกลุ่ม type_id + "ไม่มีกลุ่ม" ท้ายสุด (N9 — server component) 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) diff --git a/CLAUDE.md b/CLAUDE.md index 5f39139..b902645 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,12 +58,12 @@ lib/external-api.ts ← N10 ส่วนกลางของ route — parseQ lib/openapi.ts ← OpenAPI 3.0 spec ของ API ทดสอบ (กลุ่ม template + N10 + docver — 2026-10-06) — แหล่งเดียวของ Swagger UI: GET /api/template inline ลง HTML ตรง ๆ ไม่มี endpoint spec แยก; URL ใน paths เป็น path เต็ม /api/… (servers เติมแค่ basePath ตาม invariant #12) — แก้ contract ของ API ต้องมาแก้ไฟล์นี้ให้ตรงด้วย lib/document-versioning.ts ← docver pure validation (ห้าม import อะไรที่ลาก @/db หรือ minio มาด้วย — unit test tests/lib/document-versioning.test.ts ต้องรันได้เหมือน lib/external-api-auth.ts): parseVersionUploadForm (allowlist field — identity ไม่รับจาก form แล้ว แกะจาก token ผ่าน lib/docver-token.ts · **field `fields` ก็ถอดออก 2026-10-30** server แกะจากไฟล์เองที่ route ผ่าน lib/lib-tmpl.ts + message ≤2048), isDocxBytes/isPdfBytes (magic bytes), parseDownloadFormat/parseVersionIdParam, contentDispositionValue (ASCII fallback + filename*=UTF-8'') + docverError สร้าง Response JSON {"error": ไทย} ใช้ร่วมทุก route ของกลุ่ม (parseDocId ถอดแล้ว 2026-10-26 — docId ไม่มาจาก path param) lib/docver-token.ts ← docver token + auth ของกลุ่ม /api/documents/* (2026-10-22 — pure module ห้าม import อะไรที่ลาก @/db หรือ minio มาด้วย เหมือน lib/document-versioning.ts; unit test tests/lib/docver-token.test.ts ต้องรันได้โดยไม่ต้องตั้ง env) — 2026-10-29 (PLAN-SSO.md D4): claims มี `purpose?: "docver"|"login"` (undefined = docver — token เก่าตอน roll ไม่ตาย), `issueDocverToken` รับ purpose ทาง param (default "docver"), `requireDocverBearer` ปฏิเสธ login-token (กัน scope escalation), **`verifyDocverLoginToken`** แยก reason expired/invalid + บังคับ purpose=login สำหรับ SSO route -lib/addin-ticket.ts ← add-in ticket auth ฝั่ง server ของทางเข้าที่สองของ POST /api/documents/token (2026-10-27, docs/PLAN-ADDIN-AUTH.md — pure เหมือน lib/docver-token.ts; unit test tests/lib/addin-ticket.test.ts): addinTicketSecret (env ADDIN_TICKET_SECRET → SECRET → "super_secret") + verifyAddinTicketHmac (HMAC timing-safe บน ticket_data ดิบ + parse **ไม่ตรวจ exp** — route ทางเข้า ticket ใช้ตัวนี้แล้วตัดสิน exp เอง: ticket หมดอายุยังออก token ต่อเมื่อ fid นั้นเคยมี version โดย uid เดียวกัน ผ่าน `hasVersionByUser` ของ lib/docver-store.ts — requirement ขั้น 7 แก้บั๊ก 2026-10-28, DB พัง = fail closed 401) + verifyAddinTicket (HMAC+exp เต็มชุด — ผู้เรียกเดิม) + ticketToDocverClaims (uid→userid, uname→username, tid→template) + parseTicketTokenBody (allowlist ticket_data/HmacSha256 — missing → 401 / invalid → 400): issueDocverToken/verifyDocverToken (JWT HS256 มือเขียน node:crypto ล้วน — บังคับ alg=HS256 กัน alg-confusion, timingSafeEqual หลัง hash ทั้งสองฝั่ง, exp บังคับ) + requireDocverApiKey (header x-api-key เทียบ env DOCVER_API_KEY — fail closed 401 ไม่ตั้ง/ว่าง, 401 เดียวกันทั้งไม่ใส่/ผิด กัน enumerate, ห้าม log ค่า key/header ดิบ) + requireDocverBearer (Authorization: Bearer → claims {userid, username, fid, template} — secret จาก env DOCVER_TOKEN_SECRET ไม่ตั้ง = 401, ทุกกรณี 401 ข้อความเดียวกัน) + parseTokenRequestBody (allowlist userid/username/fid/template — **fid = fileId ของ e-Court (string, บังคับ 2026-10-26)** เป็น docId ของทุก operation ตรง ๆ · verify ไม่รับ token ที่ไม่มี fid) +lib/addin-ticket.ts ← add-in ticket auth ฝั่ง server ของทางเข้าที่สองของ POST /api/documents/token (2026-10-27, docs/PLAN-ADDIN-AUTH.md — pure เหมือน lib/docver-token.ts; unit test tests/lib/addin-ticket.test.ts): addinTicketSecret (env ADDIN_TICKET_SECRET → SECRET → "super_secret") + verifyAddinTicketHmac (HMAC timing-safe บน ticket_data ดิบ + parse **ไม่ตรวจ exp** — route ทางเข้า ticket ใช้ตัวนี้แล้วตัดสิน exp เอง: ลายเซ็นถูกแต่หมดอายุ = **403** — contract 2026-11-04 เลิก `hasVersionByUser`/requirement ขั้น 7 ของ 2026-10-28) + verifyAddinTicket (HMAC+exp เต็มชุด — ผู้เรียกเดิม) + ticketToDocverClaims (uid→userid, uname→username, tid→template) + parseTicketTokenBody (allowlist ticket_data/HmacSha256 — missing กับ invalid → **400 ทั้งคู่** contract 2026-11-04): issueDocverToken/verifyDocverToken (JWT HS256 มือเขียน node:crypto ล้วน — บังคับ alg=HS256 กัน alg-confusion, timingSafeEqual หลัง hash ทั้งสองฝั่ง, exp บังคับ) + requireDocverApiKey (header x-api-key เทียบ env DOCVER_API_KEY — fail closed 401 ไม่ตั้ง/ว่าง, 401 เดียวกันทั้งไม่ใส่/ผิด กัน enumerate, ห้าม log ค่า key/header ดิบ) + requireDocverBearer (Authorization: Bearer → claims {userid, username, fid, template} — secret จาก env DOCVER_TOKEN_SECRET ไม่ตั้ง = 401, ทุกกรณี 401 ข้อความเดียวกัน) + parseTokenRequestBody (allowlist userid/username/fid/template — **fid = fileId ของ e-Court (string, บังคับ 2026-10-26)** เป็น docId ของทุก operation ตรง ๆ · verify ไม่รับ token ที่ไม่มี fid) lib/docver-store.ts ← docver server-side MinIO + DB (env MINIO_* อ่านตอนเรียกไม่ใช่ module load; client + cached ensureBucket เก็บบน globalThis แบบ db/index.ts): object key `docver//document.docx|pdf` (uuid ล้วน ไม่มี input ผู้ใช้), putVersionObjects (docx ก่อน — pdf ล้ม = ลบ docx คืนแล้วโยนต่อ), removeVersionObjects (best-effort), presignVersionUrl (TTL env DOCVER_LINK_TTL_SECONDS), insertVersionRecord (**tx เดียว**: upsert documents ON CONFLICT DO NOTHING → SELECT id FOR UPDATE → max(versionNumber)+1 → insert — จ่ายเลข version ปลอดภัยเรียกคู่ขนาน, UNIQUE(document_id, version_number) เป็น safety net) wordaddin/ ← **client-only zone ของ Word add-in** (ห้าม import จาก lib/ หรือถูก import โดย server component — พอร์ตดิบจาก D:\word-addin-poc\addin\src, spec: docs/addin-ui-flow.md, คู่มือ dev: docs/WORDADDIN-DEV.md) wordaddin/config.ts ← env NEXT_PUBLIC_ADDIN_API_BASE_URL ("" = ยังไม่ต่อ backend จริง) + NEXT_PUBLIC_ADDIN_MOCK_USERNAME (ชื่อ fallback ของ ?addindemo/ไฟล์ที่มี token ไม่มี owner) wordaddin/models/versionRecord.ts ← types VersionRecord/FieldsSnapshot + readSnapshotField (พอร์ตดิบ) -wordaddin/services/ ← documentService/fileService/metadataService/autoSaveService/versionFlow/apiClient/errors พอร์ตดิบทั้งไฟล์ + **templateService** (แท็บที่ 2 "เครื่องมือ Template" 2026-10-19 — list/select/applyTestValues + แทรกย่อหน้า-ตัวแบ่งบรรทัด-ตัวแบ่งหน้า ที่ cursor (page break มี fallback OOXML กัน E_FAIL), Office.js ล้วน — ⚠️ `contentControls` ธรรมดาเห็นเฉพาะ rich text CC: feature-detect WordApi 1.5 ใช้ `getContentControls()` เห็นทุกชนิด — helper `supportedContentControls`/`supportsAllContentControlTypes` อยู่ที่ **metadataService** แชร์กับ `readContentControlFields` (fields ที่แนบตอนสร้าง version เก็บ CC ทุกตัวแก้ 2026-10-28 — เดิมเหลือแค่ rich ตัวเดียว), ห้าม top-level Office access) · **authService + ticketService = ticket auth ของ e-Court ทางเดียว ไม่มี login แล้ว** (ยกเลิก Keycloak → pass-through token/owner → ticket 2026-10-27, `docs/PLAN-ADDIN-AUTH.md` ตาม `docs/requirement/addin-workflow.md` — surface เหลือ signInForDemo (dev-only) / signInWithTicket / getAccessToken / renewTokenFromTicket / getUsername; ไฟล์ที่ stamp custom properties `ticket_data`+`HmacSha256` มา = `detect()` ของ TaskpaneApp เรียก signInWithTicket: ตรวจ HMAC ฝั่ง client (Web Crypto + NEXT_PUBLIC_ADDIN_TICKET_SECRET — UX gate) → session localStorage `wordaddin-auth` ตาม uid (user ใหม่ = เคลียร์ทั้งก้อน) → **ต้องการ token เมื่อไหร่ยิงขอ `/api/documents/token` เสมอ แล้วให้ server ตัดสิน exp เอง (แก้ 2026-10-28 ตาม requirement ขั้น 7: ticket หมดอายุแต่ fid เคยมี version โดย uid เดียวกัน = ออกให้ — กลับมาทำเอกสารเดิมใช้ต่อได้แม้ token เก่าหมด/ localStorage หาย)** ผูก fid; ไม่มี ticket/HMAC ไม่ตรง/หมดอายุ+ไม่เคยทำงาน = phase `no-access` แยกข้อความตามสาเหตุ; apiClient โดน 401 = renew จาก ticket 1 ครั้ง (ไม่ตัด exp ฝั่ง client) ยังไม่ผ่าน = AuthRequiredError → no-access) · **versioningService = ของจริง** (ปลด mock 2026-10-27 — uploadVersion/getVersions/getDownloadLink ยิงกลุ่ม `/api/documents/*` ผ่าน apiClient (Bearer same-origin ผ่าน withBasePath — `NEXT_PUBLIC_ADDIN_API_BASE_URL` ตั้งเฉพาะวัน deploy คนละ origin ต้องรวม basePath); เอกสารระบุด้วย fid ใน token — พารามิเตอร์ documentId/templateId เหลือตาม signature เดิมไม่ส่งถึง server และ **ห้ามส่ง userId/userName/templateId ใน form (contract 2026-10-22 ส่งมา = 400) เช่นกันกับ `fields` (contract 2026-10-30 — server แกะจากไฟล์เองเก็บทั้งก้อนเหมือน return ของ `/api/template/extract` `{cc, bookmark, properties, verify}`, versionFlow จึงไม่มีขั้นอ่าน CC ฝั่ง client แล้ว)**; mock เหลือเฉพาะโหมด `?addindemo` ผ่าน `authService.isDemoMode()` — memory + metadata persist ลง localStorage ต่อ documentId, download-link คืน about:blank; unit test tests/wordaddin/versioningService.test.ts คุมทั้งสองโหมด) +wordaddin/services/ ← documentService/fileService/metadataService/autoSaveService/versionFlow/apiClient/errors พอร์ตดิบทั้งไฟล์ + **templateService** (แท็บที่ 2 "เครื่องมือ Template" 2026-10-19 — list/select/applyTestValues + แทรกย่อหน้า-ตัวแบ่งบรรทัด-ตัวแบ่งหน้า ที่ cursor (page break มี fallback OOXML กัน E_FAIL), Office.js ล้วน — ⚠️ `contentControls` ธรรมดาเห็นเฉพาะ rich text CC: feature-detect WordApi 1.5 ใช้ `getContentControls()` เห็นทุกชนิด — helper `supportedContentControls`/`supportsAllContentControlTypes` อยู่ที่ **metadataService** แชร์กับ `readContentControlFields` (fields ที่แนบตอนสร้าง version เก็บ CC ทุกตัวแก้ 2026-10-28 — เดิมเหลือแค่ rich ตัวเดียว), ห้าม top-level Office access) · **authService + ticketService = ticket auth ของ e-Court ทางเดียว ไม่มี login แล้ว** (ยกเลิก Keycloak → pass-through token/owner → ticket 2026-10-27, `docs/PLAN-ADDIN-AUTH.md` ตาม `docs/requirement/addin-workflow.md` — surface เหลือ signInForDemo (dev-only) / signInWithTicket / getAccessToken / renewTokenFromTicket / getUsername; ไฟล์ที่ stamp custom properties `ticket_data`+`HmacSha256` มา = `detect()` ของ TaskpaneApp เรียก signInWithTicket (**flow 2026-11-04 ตาม PLAN-SSO.md D13 — client ไม่ตรวจ HMAC เอง ไม่มี secret ใน bundle ถอด NEXT_PUBLIC_ADDIN_TICKET_SECRET**): pre-check ฝั่ง client (missing/ฟอร์แม็ตพัง · หมดอายุ+ไม่มี JWT ของ fid ใน session = เตะโดยไม่ยิง API) → **ยิง `/api/documents/token` ทุกครั้งที่เปิดเอกสาร** (ต้อง online) แล้วแยกตาม contract: 200 = JWT ใหม่ (user ใหม่ = เคลียร์ session `wordaddin-auth` ทั้งก้อน/คนเดิม = update userName+tokens[fid]) · 400/401 = ฟอร์แม็ต/ลายเซ็นพัง no-access · 403 = ลายเซ็นถูกแต่ ticket หมดอายุ มี JWT ของ fid ที่ยังไม่หมดใน session ก็ใช้ต่อ (เลิก hasVersionByUser — ไม่มี JWT ต้องขอไฟล์ใหม่จาก e-Court); ticketService.requestDocverToken คืน discriminated result {ok:true entry}/{kind: network|http status|malformed} ไม่โยน Error; apiClient โดน 401 = renewTokenFromTicket 1 ครั้ง (server ตอบ 403 = false) ยังไม่ผ่าน = AuthRequiredError → no-access; token TTL 7 วัน) · **versioningService = ของจริง** (ปลด mock 2026-10-27 — uploadVersion/getVersions/getDownloadLink ยิงกลุ่ม `/api/documents/*` ผ่าน apiClient (Bearer same-origin ผ่าน withBasePath — `NEXT_PUBLIC_ADDIN_API_BASE_URL` ตั้งเฉพาะวัน deploy คนละ origin ต้องรวม basePath); เอกสารระบุด้วย fid ใน token — พารามิเตอร์ documentId/templateId เหลือตาม signature เดิมไม่ส่งถึง server และ **ห้ามส่ง userId/userName/templateId ใน form (contract 2026-10-22 ส่งมา = 400) เช่นกันกับ `fields` (contract 2026-10-30 — server แกะจากไฟล์เองเก็บทั้งก้อนเหมือน return ของ `/api/template/extract` `{cc, bookmark, properties, verify}`, versionFlow จึงไม่มีขั้นอ่าน CC ฝั่ง client แล้ว)**; mock เหลือเฉพาะโหมด `?addindemo` ผ่าน `authService.isDemoMode()` — memory + metadata persist ลง localStorage ต่อ documentId, download-link คืน about:blank; unit test tests/wordaddin/versioningService.test.ts คุมทั้งสองโหมด) utils/coordinates.ts ← pixel⇄% + getPointerPercent + clamp + mergeRectsByLine utils/geometry.ts ← bbox/translate/scale/hitTestPath ของ annotation (pure, หน่วย %) utils/annotation-display.ts ← label/ข้อความย่อต่อชนิด + TYPE_SHORT/TYPE_ORDER ชื่อย่อ/ลำดับชนิด (บรรทัดสรุปหน้าของหน้าสรุป + แผงตัวกรอง explorer — ย้ายจาก 2 ที่ซ้ำกันมารวมที่นี่ 2026-09-24) + ค่า default TEXTBOX/STAMP + describeAnnotationChange สรุปสิ่งที่เปลี่ยนของ version history F19 (sidebar/สรุป/groups/CSV ใช้ร่วม) @@ -72,7 +72,7 @@ utils/pdf-export.ts ← แปลงพิกัดจอ⇄PDF (/Rotate) + wr utils/user.ts ← **ถอดทั้งไฟล์ 2026-10-30 (ฉบับ token)** — เดิม parse/build cookie oacCase + OAC_SIG_COOKIE/SESSION_MAX_AGE ของรอบ cookie utils/external-file.ts ← N8 validation ลิงก์ไฟล์รีโมต (allowlist "admincourt.go.th" + env EXTERNAL_FILE_HOSTS, suffix match, https เท่านั้น ห้าม user:pass@) + titleFromExternalUrl/externalIdFor/isHttpUrl + normDocumentGroup (N9 normalize รหัสกลุ่ม — ใช้ที่เดียวคือ N8 `registerExternalFile`, pure; ~~sanitizeNextPath~~ ถอดแล้วพร้อมหน้า login 2026-10-29) utils/base-path.ts ← withBasePath() เติม basePath /docedit + app path /note ให้ URL ฝั่ง browser ที่ Next ไม่เติมให้เอง (ค่าจาก next.config.mjs ผ่าน env — invariant #12) -utils/docver-ticket.ts ← ticket ของ add-in ส่วน pure แชร์ client/server (2026-10-27, PLAN-ADDIN-AUTH.md §4 — ไม่ import อะไรเลย ให้สองฝั่งฉีด crypto ของตัวเอง: lib/addin-ticket.ts = node:crypto / wordaddin/services/ticketService.ts = Web Crypto): parseDocverTicket (allowlist exp/fid/tid/uid/uname + cap ยาวตาม endpoint + ห้ามอักขระ & = + % ใน uid/uname — เกณฑ์เดียวกับ parseLoginTokenBody: อักขระที่ตีความต่างกันตาม context ง่าย, 2026-10-30) + isTicketExpired + normalizeHexDigest + timingSafeEqualHex + type HmacSha256Hex — 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/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/tiff-client.ts ← decode TIFF ทุกเฟรม → PNG blob URL ฝั่ง client (N3) @@ -91,7 +91,7 @@ app/api/note/annotations/export/route.ts ← export PDF ฝัง annotation app/api/note/external/documents/route.ts ← N10 API ภายนอกเส้นเดียว (POST + JSON body {fileId และ/หรือ typeId, filter:{type, userId, annotationTypes}} — auth ผ่าน requireExternalApiKey ก่อนแตะ DB, ถาม fileId = ค้น files.external_id = "ecourt:" ไม่เจอ 404 แม้ส่ง typeId มาด้วย / ถาม typeId = ทุกไฟล์ในกลุ่ม กลุ่มว่าง = 200 documents:[] / ส่งทั้งคู่ = AND กรอง externalId+typeId ใน query เดียว เหลือเอกสารเดียว ไม่ตรงคู่ = 404 แยกสาเหตุในข้อความ (2026-09-25 เดิมเคยเป็น OR แล้วเป็น 400); response รูปเดียว {documents:[bundle…]} ผ่าน loadDocumentBundles ทั้งสอง mode; ครอบ try/catch ทุก DB call — ล้ม = log ฝั่ง server + 500 JSON ตาม contract ไม่ปล่อยเป็น 500 ของ framework; read-only ไม่ publish SSE ไม่ revalidatePath — snapshot ณ ตอนเรียก) app/api/swagger/route.ts ← **GET /api/swagger = หน้า Swagger UI** (2026-10-06 — document ของ API ทุกกลุ่ม: spec inline จาก lib/openapi.ts + asset จาก public/note/swagger-ui/ ของ postinstall scripts/copy-swagger-ui.mjs, persistAuthorization เก็บ x-api-key ไว้ใช้ข้าม reload — เปิดกว้างไม่ต้อง login) 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 3600) — identity userid/username + templateId แกะจาก claims ไม่รับจาก request อีกต่อไป; ส่ง userId/userName/templateId ใน form = 400 ไม่รู้จัก field · **2026-10-27 POST token มีทางเข้าที่สองสำหรับ add-in ใน Word** (PLAN-ADDIN-AUTH.md): ไม่มี x-api-key + body `{ticket_data, HmacSha256}` — lib/addin-ticket.ts ตรวจ HMAC+exp (secret env ADDIN_TICKET_SECRET → SECRET → default "super_secret") แล้วออก token จาก claims ของ ticket เอง · **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/page.tsx ← **หน้าแรก = ข้อมูลเกี่ยวกับระบบ + client bootstrap ของลิงก์ e-Court** (2026-10-30 รอบสอง ตามคำขอผู้ใช้ — server shell บาง ๆ เนื้อหาทั้งหมดอยู่ใน `components/NoteHome.tsx`): "ระบบนี้ทำอะไรได้" (การ์ด 4 ใบ: มาร์ก/วาด, ความเห็น/ทำงานร่วม, คั่นหน้า/สรุป, เอกสารจาก e-Court) + วิธีเข้าใช้ (ลิงก์ e-Court, session 8 ชม.) + ลิงก์ /note/dev · มี credential ใน URL (t/ticket) = แลก token ที่ POST /api/note/auth/session → เก็บ localStorage → ตัด credential ทิ้ง → มี fileId พาเข้า viewer · แบนเนอร์ sso/fileError · public app/note/dev/page.tsx ← **เครื่องมือทดสอบ** (ย้ายมาจากหน้าแรก 2026-10-30): เปิดเอกสารตัวอย่าง (PLAN-SSO.md D14 2026-10-29 — dropdown 2 ระดับกลุ่ม→ไฟล์ รายการ fixed จาก public/note/samples/ ไม่ query DB + รายการลิงก์ GET แบบ /tmpl "A Link Get(new)" ชี้ `/api/note/files/open?fileUrl=/samples/…` เมื่อ mint ปิด / dev-link เมื่อ mint เปิด + section Flow ของแอป — 2026-10-30 รอบสอง) + **ฟอร์ม `MockEcourtForm` "จำลอง e-Court — ขอ token ด้วย API key" (2026-11-02, แสดงทุก environment)** — ใส่ `DOCVER_API_KEY` + uid/uname + เลือกไฟล์ → ยิง `POST /api/note/auth/token` (ทางเข้าจริงเหมือน e-Court) → redirect เข้า `/note?fileUrl=…&t=…` ให้ bootstrap แลก session ต่อ · **คู่มือทางเข้า 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) diff --git a/app/api/documents/token/route.ts b/app/api/documents/token/route.ts index 7385650..9542f01 100644 --- a/app/api/documents/token/route.ts +++ b/app/api/documents/token/route.ts @@ -5,11 +5,15 @@ // 2) add-in ใน Word: ไม่มี x-api-key + body {ticket_data, HmacSha256} — server // ตรวจ HMAC ด้วย env ฝั่ง server (ADDIN_TICKET_SECRET → SECRET → // "super_secret", lib/addin-ticket.ts) แล้วออก token จาก claims ของ ticket -// (secret ตัวเซ็น JWT ไม่มีทางไปอยู่ใน bundle ของ add-in) — ticket หมดอายุ -// ยังออกให้ต่อเมื่อ fid นี้เคยมี version โดย uid เดียวกัน (requirement ขั้น 7) +// (secret ตัวเซ็น JWT ไม่มีทางไปอยู่ใน bundle ของ add-in — client ไม่ตรวจ HMAC +// เองแล้วตาม 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 3600) แล้วนำไปแนบ Authorization: Bearer กับ -// endpoint อื่นของกลุ่ม (lib/docver-token.ts) · segment คงที่ "token" ไม่ชน [docId] +// 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 { @@ -18,7 +22,6 @@ import { ticketToDocverClaims, verifyAddinTicketHmac, } from "@/lib/addin-ticket"; -import { hasVersionByUser } from "@/lib/docver-store"; import { isTicketExpired } from "@/utils/docver-ticket"; import { issueDocverToken, @@ -30,8 +33,10 @@ import { export const runtime = "nodejs"; export const dynamic = "force-dynamic"; -/** 401 ของทางเข้า ticket — ข้อความเดียวทุกสาเหตุ (HMAC ผิด/รูปผิด/หมดอายุ/ไม่ส่งมา) กัน oracle */ -const TICKET_DENIED = "ticket ไม่ถูกต้องหรือหมดอายุ"; +/** 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 @@ -65,25 +70,20 @@ async function postWithTicket(request: Request): Promise { } const parsed = parseTicketTokenBody(raw); if (!parsed.ok) { - // missing = ไม่มี ticket มาเล่น ๆ (401 เดียวกับตอน verify ไม่ผ่าน) - // invalid = รูป body ผิดจริง ๆ ให้ 400 ตาม convention กัน typo เงียบ ๆ - return docverError(parsed.error, parsed.kind === "invalid" ? 400 : 401); + // 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 หมดอายุ → ยังออก token ให้ต่อเมื่อ "เอกสารนี้เคยทำงานโดย uid เดียวกัน" - // (requirement ขั้น 7: กลับมาทำเอกสารเดิมผ่านได้แม้ ticket หมดอายุ — พิสูจน์จาก - // document_versions ของกลุ่มนี้เอง) · DB พัง = fail closed เป็น 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)) { - let workedBefore = false; - try { - workedBefore = await hasVersionByUser(verified.ticket.fid, verified.ticket.uid); - } catch { - workedBefore = false; - } - if (!workedBefore) return docverError(TICKET_DENIED, 401); + return docverError(TICKET_EXPIRED, 403); } return issueToken(ticketToDocverClaims(verified.ticket)); } @@ -96,8 +96,9 @@ async function issueToken(claims: DocverTokenClaims): Promise { 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) : 3600; + Number.isFinite(ttlRaw) && ttlRaw > 0 ? Math.floor(ttlRaw) : 604_800; const accessToken = issueDocverToken(claims, secret, ttlSeconds); return new Response( diff --git a/components/wordaddin/TaskpaneApp.tsx b/components/wordaddin/TaskpaneApp.tsx index f3ec027..269d695 100644 --- a/components/wordaddin/TaskpaneApp.tsx +++ b/components/wordaddin/TaskpaneApp.tsx @@ -114,6 +114,10 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) => /** ข้อความบนหน้า no-access — ต่างกันตามสาเหตุ (ไม่มี token ในไฟล์ / token หมดอายุ) */ const [noAccessNotice, setNoAccessNotice] = useState(null); + /** no-access ที่ "ลองใหม่" ช่วยได้ — เฉพาะเคสอ่านไฟล์ไม่สำเร็จ (เช่น office.js มาช้า + * บนเครื่องช้า หรือ Word สะดุดชั่วคราว) กดแล้ววน detect() ใหม่ · เคสอื่น (ticket + * หมดอายุ/ลายเซ็นไม่ผ่าน/ขอ token ไม่ได้) retry ไม่ช่วย ต้องขอไฟล์ใหม่/เน็ตกลับมา */ + const [noAccessRetryable, setNoAccessRetryable] = useState(false); const [starting, setStarting] = useState(false); const [saving, setSaving] = useState(false); @@ -183,6 +187,7 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) => const detect = useCallback(async () => { setPhase("checking"); setBootError(null); + setNoAccessRetryable(false); try { let properties: metadataService.TrackedDocumentProperties | null = null; try { @@ -191,7 +196,8 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) => properties = null; } if (!properties) { - setNoAccessNotice("อ่านข้อมูลเอกสารไม่สำเร็จ — ลองปิดแล้วเปิด task pane อีกครั้ง"); + setNoAccessNotice("อ่านข้อมูลเอกสารไม่สำเร็จ — ลองกด \"ลองใหม่\" หรือปิดแล้วเปิด task pane อีกครั้ง"); + setNoAccessRetryable(true); setPhase("no-access"); return; } @@ -469,6 +475,15 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) =>

{noAccessNotice ?? "ไม่พบ ticket ของ e-Court ในไฟล์ — ขณะนี้ add-in ใช้ได้กับเอกสารที่ส่งมาจาก e-Court เท่านั้น"}

+ {noAccessRetryable ? ( + + ) : null} ) : null} diff --git a/components/wordaddin/WordAddinGate.tsx b/components/wordaddin/WordAddinGate.tsx index 5df16c3..2deeb10 100644 --- a/components/wordaddin/WordAddinGate.tsx +++ b/components/wordaddin/WordAddinGate.tsx @@ -22,8 +22,14 @@ import TaskpaneApp from "./TaskpaneApp"; /** office.js self-host จาก postinstall scripts/fetch-office-js.mjs (intranet ไม่ใช้ CDN) */ const OFFICE_JS_SRC = withBasePath("/wordaddin/office-js/office.js"); -/** Office.onReady callback ไม่ยิงภายใน 5 วิหลังฉีด script ก็ proceed (กัน webview แปลก ๆ — โหลดสำเร็จแต่ onReady เงียบ) */ +/** + * ช่วง poll ของ fallback — ทุก 5 วิเช็คว่า global `Office` โผล่แล้วหรือยัง: + * โผล่แล้วแต่ onReady เงียบ (webview แปลก ๆ) = proceed ตามเจตนาเดิม · ยังไม่โผล่ + * (เครื่อง/เน็ตช้า — script ยังโหลดไม่เสร็จ) = รอต่อรอบหน้า ไม่ proceed บอด ๆ + */ const ONREADY_FALLBACK_MS = 5_000; +/** เพดานรวมหลัง inject script — ครบแล้วยังไม่มี Office = ไปหน้า error (มีปุ่มลองใหม่) */ +const ONREADY_HARD_CAP_MS = 60_000; /** * โหลด office.js ครั้งเดียวต่อ page เป็น singleton promise ระดับ module — @@ -43,7 +49,28 @@ function ensureOfficeJs(): Promise { return; } const script = document.createElement("script"); - const fallbackTimer = setTimeout(() => resolve(true), ONREADY_FALLBACK_MS); + const startedAt = Date.now(); + let fallbackTimer: ReturnType; + const armFallback = () => { + fallbackTimer = setTimeout(() => { + // Office โผล่แล้ว (script evaluate แล้ว) = proceed ได้เลย ไม่รอ onReady — + // กัน webview ที่โหลดสำเร็จแต่ callback เงียบ (เจตนาเดิมของ fallback) + if (typeof Office !== "undefined") { + resolve(true); + return; + } + // ยังไม่มี Office global = เครื่องช้า script ยังไม่โหลดเสร็จ — รอต่อ (re-arm) + // จนครบเพดานค่อยยอมแพ้ไปหน้า error กัน proceed แล้ว Word.run พังกลางคัน + if (Date.now() - startedAt >= ONREADY_HARD_CAP_MS) { + officeReadyPromise = null; + script.remove(); + resolve(false); + return; + } + armFallback(); + }, ONREADY_FALLBACK_MS); + }; + armFallback(); // ตั้งก่อนเสียบ src เหมือนเดิม — ครอบทั้ง network fetch + evaluate + รอ onReady const onReady = () => { // เคลียร์ timer เฉพาะเมื่อ callback ของ Office.onReady ยิงจริง — ถ้าเคลียร์ตั้งแต่ onload // แล้ว onReady เงียบ fallback จะตายไปก่อนทำหน้าที่ (ค้าง loading ตลอดไป) diff --git a/docs/PLAN-ADDIN-AUTH.md b/docs/PLAN-ADDIN-AUTH.md index b2afb9f..e4097c6 100644 --- a/docs/PLAN-ADDIN-AUTH.md +++ b/docs/PLAN-ADDIN-AUTH.md @@ -1,6 +1,12 @@ # PLAN-ADDIN-AUTH — Add-in Ticket Authen (`ticket_data` + `HmacSha256`) > วันที่ตกลงแผน: 2026-10-27 · สถานะ: แผนรออนุมัติ · requirement ต้นทาง: `docs/requirement/addin-workflow.md` +> **แก้ใหญ่ 2026-11-04** (ตาม code review ของผู้ใช้): ถอด client HMAC + `NEXT_PUBLIC_ADDIN_TICKET_SECRET` +> ออก (execute **D13 ของ PLAN-SSO.md** — secret ใน bundle สกัดได้ = ปลอม ticket ผ่าน server ได้จริง +> ไม่ใช่แค่ UX gate อย่าง D3 เดิม) · ทางเข้า ticket ของ /token แยก status ชัดเจน +> **200 / 400 ฟอร์แม็ตพัง / 401 ลายเซ็นไม่ถูก / 403 ลายเซ็นถูกแต่หมดอายุ** (เลิก hasVersionByUser +> ของ requirement ขั้น 7 ฉบับ 2026-10-28) · token TTL default 604800 (7 วัน) แทนระบบ refresh · +> flow ฝั่ง addin เป็น pre-check + ยิง /token ทุกครั้งที่เปิดเอกสาร ## 0. บริบท/เป้าหมาย @@ -8,7 +14,9 @@ `token`/`owner` ลงไฟล์) เป็น **ticket** ตาม requirement: - e-Court stamp custom properties 2 ค่าในไฟล์ .docx: `ticket_data` (JSON) + `HmacSha256` -- addin ตรวจ HMAC เองฝั่ง client (gate เชิง UX — ตัดเคสไฟล์ถูกแก้ก่อนยิง network) +- addin ตรวจ **ฟอร์แม็ต + exp** เองฝั่ง client เป็น UX gate — การตรวจลายเซ็น (HMAC) เป็นของ + **server อย่างเดียว** ยิง /token ทุกครั้งที่เปิดเอกสาร (แก้ 2026-11-04 — เดิมตรวจ HMAC ฝั่ง client + ด้วย secret ใน bundle) - **addin เป็นฝ่ายขอ access_token** ที่ `POST /api/documents/token` เก็บใน localStorage ตาม fid (session ต่อ userId) — ไม่ใช่ e-Court อีกต่อไป - ถอดระบบเก่า `token`/`owner` ออกทั้งหมด (implement ไว้ 2026-10-27 ยังไม่มีไฟล์จริงใช้ — @@ -20,10 +28,10 @@ |---|---|---| | D1 | addin ยืนยันกับ `/token` อย่างไร | **ขยาย `POST /api/documents/token` รับ ticket เพิ่ม** — body `{ticket_data, HmacSha256}` server ตรวจ HMAC+exp ด้วย secret ใน env ฝั่ง server ก่อนออก token · ทางเข้า `x-api-key` เดิม (e-Court server-to-server) ใช้ต่อคู่กัน | | D2 | โค้ดซ้ำสองฝั่ง | แชร์ส่วน pure ผ่าน **`utils/docver-ticket.ts`** (โซนกลาง client ใช้ได้ — ไม่แตะกติกา "wordaddin ห้าม import จาก `lib/`") · ฝั่ง crypto ฉีดเข้าทาง parameter: client = Web Crypto, server = `node:crypto` | -| D3 | client-side HMAC | ทำตาม requirement (sign เทียบเองก่อน) โดย secret มาจาก `NEXT_PUBLIC_ADDIN_TICKET_SECRET` — **ยอมรับว่าค่าใน bundle สกัดได้** จึงถือเป็นแค่ UX gate, ความมั่นคงจริงอยู่ที่ server verify | +| D3 | client-side HMAC | ~~ทำตาม requirement (sign เทียบเองก่อน) โดย secret มาจาก `NEXT_PUBLIC_ADDIN_TICKET_SECRET`~~ **แทนที่ 2026-11-04 ตาม PLAN-SSO.md D13**: client ไม่ตรวจ HMAC — เหลือ parse + exp เป็น UX gate การตรวจ "ไฟล์ถูกแก้" เกิดตอน server ตอบ 401 · ถอด `NEXT_PUBLIC_ADDIN_TICKET_SECRET` ออกจาก bundle ทั้งหมด (secret ใน bundle ต้องตรงกับของ server เสมอจึงผ่าน gate = แกะ bundle ก็ปลอม ticket ผ่าน server ได้จริง) | | D4 | รูปแบบ HMAC | sign บน **string ดิบของ `ticket_data` ตามที่อ่านได้จาก property** (ห้าม trim/normalize) · digest เป็น **hex lowercase 64 ตัว** — การเทียบ normalize เป็น lowercase ก่อน (ฝั่ง e-Court stamp ตัวพิมพ์ใหญ่ก็ผ่าน) | | D5 | `tid` | = `template` ของ claims (`tid` optional → `template: null`) · map `uid→userid`, `uname→username`, `fid` ตรง ๆ | -| D6 | token หมดอายุ (TTL ~1 ชม.) แต่ ticket ยังไม่หมด | เก็บ `exp` ของ token (decode จาก JWT) — ก่อนใช้ถ้าหมดอายุและ ticket ยังไม่หมด → **renew อัตโนมัติ** · โดน 401 กลาง session → renew จาก ticket 1 ครั้งแล้ว retry ก่อนไป no-access | +| D6 | token หมดอายุ | **แก้ 2026-11-04: TTL default 604800 (7 วัน)** แทนระบบ refresh/renew — renew จาก ticket (โดน 401 กลาง session → renew 1 ครั้งแล้ว retry) เหลือเป็นเส้นทางรองที่แทบไม่ถูกใช้ · ticket หมดอายุแล้ว server ตอบ 403 → renew ไม่ได้ → ใช้ JWT เดิมใน session ต่อ | | D7 | localStorage | key เดียว `wordaddin-auth` เก็บ JSON `{v:1, userId, userName, tokens: {[fid]: {token, exp}}}` · เจอ user ใหม่ = **เคลียร์ทั้งก้อน** (รวม token ทุก fid ของ user เก่า) · webview block localStorage = fallback memory (pattern `versioningService`) | | D8 | "disable Add-in ทั้งหมด" | = phase `no-access` ที่มีอยู่ โดยแยกข้อความตามสาเหตุ: ไม่พบ ticket / ไฟล์ถูกแก้ไข (HMAC) / ticket หมดอายุ | | D9 | ช่วง mock (`NEXT_PUBLIC_ADDIN_API_BASE_URL` ว่าง) | addin ยิงขอ token จริงที่ same-origin `withBasePath("/api/documents/token")` (ไม่ผูกกับ apiBaseUrl ของ versioning) — versioning ยัง mock ต่อ | @@ -47,8 +55,9 @@ HmacSha256 = = HMAC-SHA256(secret, ticket_data | `tid` | string | optional · ≤100 · template ต้นทาง (ไม่มี = `template: null`) | - field อื่นปนมา = reject (allowlist ตาม convention ของ repo) -- secret ปัจจุบัน = `"super_secret"` — **e-Court + `ADDIN_TICKET_SECRET` (server) + - `NEXT_PUBLIC_ADDIN_TICKET_SECRET` (client) ต้องตรงกันทั้งสาม** +- secret ปัจจุบัน = `"super_secret"` — **e-Court (ตัว stamp) + `ADDIN_TICKET_SECRET` (server + ของเรา ตัว verify) ต้องตรงกันสองฝั่ง** (แก้ 2026-11-04 — เดิมต้องตรงสามฝั่งรวม + `NEXT_PUBLIC_ADDIN_TICKET_SECRET` ฝั่ง client ซึ่งถอดออกแล้ว) - ⚠️ **custom property ของ Word จำกัด value ~255 ตัวอักษร** — JSON ของ `ticket_data` ต้องออกแบบสั้น (fid/tid/uname ยาวมากจะทะลุ; ฝั่ง addin เจอค่าเกิน = ถือว่าไฟล์เสียหาย → no-access) @@ -59,13 +68,16 @@ Branch ตาม header (ตัวตั้ง = `x-api-key`): | เคส | พฤติกรรม | |---|---| | มี header `x-api-key` | **ทางเดิมทุกอย่าง** — `requireDocverApiKey` + `parseTokenRequestBody` (body allowlist `userid`/`username`/`fid`/`template`, ส่ง field ticket ปนมา = 400 ตาม allowlist เดิม) | -| ไม่มี `x-api-key` + body `{ticket_data: string, HmacSha256: string}` | **ทางใหม่** — `verifyAddinTicketHmac` (HMAC+parse ไม่ตรวจ exp) → map claims → `issueDocverToken` → response รูปเดียวกัน `{access_token, token_type, expires_in}` | -| ไม่มี `x-api-key` + body ไม่ใช่ object / มี field อื่นปน / field ไม่ใช่ string | 400 (รูปผิดก่อนถึงการ verify) | -| HMAC ผิด / parse ticket ไม่ผ่าน | 401 **ข้อความเดียวกันทุกสาเหตุ** (กัน oracle — addin รู้สาเหตุจริงจากการตรวจเองฝั่ง client อยู่แล้ว) | -| ticket **หมดอายุ** แต่ HMAC ถูก | แก้ 2026-10-28 (requirement ขั้น 7): ตรวจ DB ว่า `fid` นี้เคยมี version โดย `uid` เดียวกัน (`hasVersionByUser` ของ `lib/docver-store.ts`) → เคย = ออก token ให้ต่อ · ไม่เคย (หรือ DB พัง = fail closed) = 401 ข้อความเดียวกัน | -| ไม่มี `x-api-key` และไม่มี ticket fields เลย | 401 | +| ไม่มี `x-api-key` + body `{ticket_data: string, HmacSha256: string}` ถูกทั้งคู่ | **ทางใหม่** — `verifyAddinTicketHmac` (HMAC+parse ไม่ตรวจ exp) → `isTicketExpired` → `issueDocverToken` → 200 response รูปเดียวกัน `{access_token, token_type, expires_in}` | +| body ไม่ใช่ JSON / ไม่ใช่ object / มี field อื่นปน / field ไม่ใช่ string / **ไม่มี ticket fields เลย (missing)** | **400** (ฟอร์แม็ตพัง — แก้ 2026-11-04: missing ย้ายจาก 401 มารวม 400) | +| HMAC ผิด / parse ticket ไม่ผ่าน | 401 `"ticket ไม่ถูกต้อง"` **ข้อความเดียวกันทุกสาเหตุ** (กัน oracle) | +| ticket **หมดอายุ** แต่ HMAC ถูก | **403 `"ticket ของ e-Court หมดอายุ"** (แก้ 2026-11-04 — เลิก requirement ขั้น 7 ฉบับ 2026-10-28: ถอด `hasVersionByUser` ออก ไม่ตรวจ DB อีกต่อไป เพราะ ticket ฝังในไฟล์ ใครได้ไฟล์ก็ได้ ticket การปล่อยให้ ticket เก่าแลก token ใหม่ได้ตลอดไปเท่ากับปล่อยสิทธิ์ตามการครอบครองไฟล์ · client ที่มี JWT ของ fid นี้ใน session ที่ยังไม่หมดใช้ต่อเอง) | | `DOCVER_TOKEN_SECRET` ไม่ตั้ง | 500 (เดิม) | +- token TTL: env `DOCVER_TOKEN_TTL_SECONDS` **default 604800 (7 วัน)** (แก้ 2026-11-04 จาก 3600) +- route ไม่ import `@/db`/minio เลยหลังถอด DB check — unit test ได้โดยไม่ต้องตั้ง DATABASE_URL + (`tests/app/api/documents/token.test.ts`) + - การเทียบ digest ฝั่ง server: hash ทั้งสองฝั่งด้วย SHA-256 แล้ว `timingSafeEqual` — pattern `verifyDocverToken` ของ `lib/docver-token.ts` เป๊ะ - middleware ยกเว้น `api/documents/*` อยู่แล้ว — ไม่ต้องแต้ @@ -76,36 +88,37 @@ Branch ตาม header (ตัวตั้ง = `x-api-key`): export interface DocverTicket { exp: number; fid: string; tid: string | null; uid: string; uname: string } export function parseDocverTicket(raw: string | unknown): { ok: true; ticket: DocverTicket } | { ok: false; error: string } export function isTicketExpired(exp: number, nowSec?: number): boolean // exp <= now -export function normalizeHexDigest(value: string): string | null // trim+lowercase+ตรวจ ^[0-9a-f]{64}$ -export function timingSafeEqualHex(a: string, b: string): boolean // constant-time บน hex ที่ normalize แล้ว -export type HmacSha256Hex = (message: string, secret: string) => Promise // adapter ที่สองฝั่งฉีด +export function normalizeHexDigest(value: string): string | null // trim+lowercase+ตรวจ ^[0-9a-f]{64}$ (ใช้ฝั่ง server) ``` +(แก้ 2026-11-04: ถอด `timingSafeEqualHex` + `HmacSha256Hex` adapter ออก — client ไม่คำนวณ +HMAC แล้วจึงไม่ต้องฉีดตัวคำนวณ, timing-safe จริงอยู่ที่ `node:crypto` ฝั่ง server) + - `lib/` (server) กับ `wordaddin/` (client) ใช้ `parseDocverTicket`/`isTicketExpired` ตัวเดียวกัน — schema drift กันได้จากฝั่งเดียว -## 5. ฝั่ง addin — flow ตอนเปิดเอกสาร (map ขั้นตอน 1–7 ของ requirement) +## 5. ฝั่ง addin — flow ตอนเปิดเอกสาร (แก้ 2026-11-04 — pre-check + ยิง /token ทุกครั้ง) `TaskpaneApp.detect()` → `authService.signInWithTicket({ticketData, ticketHmac})`: ``` 1. อ่าน properties ticket_data + HmacSha256 (readTrackedProperties) -2. ขาดข้อใดข้อหนึ่ง / ค่ายาวเกิน 255 → no-access "ไม่พบ ticket ของ e-Court ในไฟล์" - parseDocverTicket ไม่ผ่าน → no-access "ไฟล์ถูกแก้ไข" - HMAC (Web Crypto + NEXT_PUBLIC_ADDIN_TICKET_SECRET) ไม่ตรง → no-access "ไฟล์ถูกแก้ไข" -3. session = localStorage["wordaddin-auth"] (fallback memory) -4. ไม่มี session หรือ uid ≠ session.userId (user ใหม่): - · ขอ token จาก /token เลย — **server เป็นคนตัดสิน exp** (แก้ 2026-10-28: ticket หมดอายุ - แต่ fid นี้เคยมี version โดย uid เดียวกัน = ออกให้ → คนที่กลับมาทำเอกสารเดิม - ผ่านได้แม้ session หาย/เคลียร์ · 401 + ticket หมดอายุ → no-access "ticket หมดอายุ") - · สำเร็จ → เคลียร์ session เก่า → เก็บ session ใหม่ {userId: uid, …} -5. uid ตรงกัน (คนเดิม): - · tokens[fid] มีและยังไม่หมดอายุ (กลับมาทำเอกสารเดิม) → ผ่านเลย ไม่ยิง /token - · tokens[fid] มีแต่ token หมดอายุ → ยิง renew เสมอ (ticket ยังไม่หมด = ออกให้เสมอ · - หมดแล้ว = ออกเฉพาะ "เคยทำงาน" ตามข้อ 4) → fail = เก็บ token เก่าไว้ - (ถ้าเน่าจริง apiClient จะ 401 → renew 1 ครั้ง → ยัง fail = no-access ตอนใช้งาน) - · tokens[fid] ไม่มี (เอกสารใหม่) → ขอ token เก็บเพิ่มเหมือนข้อ 4 -6. ตั้ง state ภายใน (fid/token/username/ticket ดิบไว้ renew) → ไหลต่อ phase +2. pre-check ฝั่ง client (ไม่ยิง network): + · ขาดข้อใดข้อหนึ่ง / ค่ายาวเกิน 255 → no-access "ไม่พบ ticket ของ e-Court ในไฟล์" + · parseDocverTicket ไม่ผ่าน → no-access "รูปแบบ ticket ในไฟล์ไม่ถูกต้อง" + · ticket หมดอายุ และ ไม่มี JWT ของ fid นี้ใน session (uid ตรงกัน) → no-access + "ticket หมดอายุ" (มีใน session = มีโอกาสเป็นไฟล์เก่าที่เคยแก้ → ไปให้ server ตัดสินต่อ) +3. ยิง POST /api/documents/token **ทุกครั้งที่เปิดเอกสาร** (ตรวจลายเซ็นที่ server — + client ไม่มี secret) แล้วแยกทางตาม status: + · 200 → ได้ JWT ใหม่: uid ≠ session.userId (user ใหม่) = เคลียร์ session เก่าทั้งก้อน + สร้างใหม่ · คนเดิม = update userName + tokens[fid] + · 403 (ลายเซ็นถูกแต่ ticket หมดอายุ) → มี JWT ของ fid ใน session ที่ยังไม่หมด + (uid ตรง) = refresh userName แล้ว**ใช้ JWT เดิมต่อ** · ไม่มี/หมดแล้ว = no-access + "ticket หมดอายุ" + · 400 → no-access "รูปแบบ ticket ไม่ถูกต้อง" · 401 → no-access "ตรวจสอบลายเซ็น (HMAC) + กับระบบไม่ผ่าน" (ทั้งคู่ = ไฟล์ถูกแก้/เสียหาย ต้องขอไฟล์ใหม่) + · network/5xx/ตอบผิดรูป → no-access "ขอ token จากระบบไม่สำเร็จ" (ลองใหม่ได้เมื่อเน็ตกลับ) +4. ตั้ง state ภายใน (fid/username/ticket ดิบไว้ renew) → ไหลต่อ phase untracked/tracking เดิม (SavedByAddIn → history) ไม่เปลี่ยน ``` @@ -123,22 +136,33 @@ export type HmacSha256Hex = (message: string, secret: string) => Promise - `lib/addin-ticket.ts` — server-only: `addinTicketSecret()` (env `ADDIN_TICKET_SECRET` trim หรือ default `"super_secret"`) + `verifyAddinTicket(ticketData, hmacHex, secret, nowSec?)` (HMAC `node:crypto` + timing-safe + parse + exp) + `ticketToDocverClaims(ticket)` — ห้าม import `@/db`/minio (unit test รันได้เปล่า ๆ) -- `wordaddin/services/ticketService.ts` — client: `verifyTicketHmacWeb` (crypto.subtle), session store +- `wordaddin/services/ticketService.ts` — client: ~~`verifyTicketHmacWeb` (crypto.subtle)~~ + (ถอด 2026-11-04), session store (`loadSession`/`saveSession`/`clearSession` — try/catch + memory fallback), `requestDocverToken` - (POST `withBasePath("/api/documents/token")` body ticket) + (POST `withBasePath("/api/documents/token")` body ticket — แก้ 2026-11-04: คืน discriminated + result แยก network/http status/malformed ไม่โยน Error) - `scripts/make-ticket-docx.mjs` — §9 - `tests/utils/docver-ticket.test.ts`, `tests/lib/addin-ticket.test.ts` (vitest — pattern `tests/lib/docver-token.test.ts`) **แต้** - `app/api/documents/token/route.ts` — branch ตาม §3 -- `wordaddin/config.ts` — `ticketSecret: process.env.NEXT_PUBLIC_ADDIN_TICKET_SECRET ?? "super_secret"` +- `wordaddin/config.ts` — ~~`ticketSecret: process.env.NEXT_PUBLIC_ADDIN_TICKET_SECRET ?? "super_secret"`~~ + (ถอด 2026-11-04 — เหลือ `apiBaseUrl` + `mockUsername`) - `wordaddin/services/metadataService.ts` — ถอด `ECOURT_TOKEN_PROPERTY`/`ECOURT_OWNER_PROPERTY` → `TICKET_DATA_PROPERTY = "ticket_data"` + `TICKET_HMAC_PROPERTY = "HmacSha256"` (`readTrackedProperties` อ่านคู่นี้แทน `ecourtToken`/`ecourtOwner`) -- `wordaddin/services/authService.ts` — rewrite ตาม §5 +- `wordaddin/services/authService.ts` — rewrite ตาม §5 (แก้ 2026-11-04: pre-check + แยก status + 200/400/401/403 ผ่าน result ของ requestDocverToken) - `wordaddin/services/apiClient.ts` — renew-retry ตอน 401 -- `components/wordaddin/TaskpaneApp.tsx` — `detect()` เรียก `signInWithTicket` + ข้อความ no-access 3 แบบ -- `lib/openapi.ts` — POST /token มี 2 รูป body (oneOf: identity+x-api-key / ticket) +- `components/wordaddin/TaskpaneApp.tsx` — `detect()` เรียก `signInWithTicket` + ข้อความ no-access + (แก้ 2026-11-04: no-access เคส "อ่านข้อมูลเอกสารไม่สำเร็จ" มีปุ่ม "ลองใหม่" เรียก detect() ซ้ำ) +- `components/wordaddin/WordAddinGate.tsx` — แก้ 2026-11-04: fallback onReady เดิม 5 วิแล้ว proceed + บอด ๆ ทำให้เครื่องช้าจบ no-access ไม่มีทางออก — ตอนนี้ poll ทุก 5 วิว่า global `Office` โผล่หรือยัง + (โผล่แล้ว = proceed ตามเจตนาเดิม · ยังไม่โผล่ = รอต่อ) จนถึง hard cap 60 วิ ค่อยไปหน้า error +- `lib/openapi.ts` — POST /token มี 2 รูป body (oneOf: identity+x-api-key / ticket) + คำอธิบาย + 400/401/403 ตาม §3 (แก้ 2026-11-04) +- `tests/app/api/documents/token.test.ts` — ใหม่ 2026-11-04: route unit test ครอบ contract + (ทำได้เพราะ route ไม่ import @/db แล้ว) - `.env.example`, `docs/WORDADDIN-DEV.md`, `docs/addin-ui-flow.md`, `docs/PLAN-DOCVER.md` (§token ชี้มาที่นี่), `CLAUDE.md` **ถอด** @@ -150,7 +174,7 @@ export type HmacSha256Hex = (message: string, secret: string) => Promise | env | default | หมายเหตุ | |---|---|---| | `ADDIN_TICKET_SECRET` | `SECRET` ของกลุ่ม template → `"super_secret"` | server ตรวจ HMAC ทาง ticket ของ /token — อ่านตามลำดับ `ADDIN_TICKET_SECRET` → `SECRET` (deployment เดียวกับตัว stamp ของกลุ่ม template มักตั้งไว้แล้ว) → `"super_secret"` · **production ต้องตั้งค่าใหม่** (ค่า default รู้กันหมด = ปลอม ticket ได้) และต้องตรงกับฝั่ง e-Court · ห้าม log ค่าดิบ | -| `NEXT_PUBLIC_ADDIN_TICKET_SECRET` | `"super_secret"` | ฝั่ง addin (UX gate) — inline ลง bundle · ต้องตรงกับฝั่ง e-Court เช่นกัน | +| ~~`NEXT_PUBLIC_ADDIN_TICKET_SECRET`~~ | — | **ถอด 2026-11-04** (PLAN-SSO.md D13) — ฝั่ง add-in ไม่มี secret อีกต่อไป | > หมายเหตุจากตอน implement: ตัว stamp ticket จริงคือกลุ่ม template > (`app/api/template/render/route.ts` → `applyTemplate` — sort key ตามตัวอักษรหลังเติม @@ -163,16 +187,24 @@ export type HmacSha256Hex = (message: string, secret: string) => Promise ## 8. Edge cases -- **property ยาวเกิน 255** → ไฟล์เสียหาย → no-access (Word ตัดค่าทิ้งจะ parse/HMAC ไม่ผ่านอยู่แล้ว — +- **property ยาวเกิน 255** → ไฟล์เสียหาย → no-access (Word ตัดค่าทิ้งจะ parse ไม่ผ่านอยู่แล้ว — เช็คความยาวเพื่อข้อความที่ชัดกว่า) -- **localStorage ถูก block** (webview บางตัว/private mode) → session อยู่ใน memory ต่อ session เดียว - (เปิดใหม่เริ่มใหม่ ขอ token ใหม่ — และหลังแก้ 2026-10-28 ขอใหม่ได้**แม้ ticket หมดอายุ** - ตราบใดที่เอกสารนี้เคยมี version โดย uid เดียวกัน: server ออกให้จากหลักฐานใน DB - ไม่ต้องพึ่ง token เก่าในเครื่องอีกต่อไป — ข้อจำกัดเหลือเฉพาะเอกสารที่เคยเปิดแต่ - **ยังไม่เคยสร้าง version** ซึ่งไม่มีร่องรอยฝั่ง server ต้องขอไฟล์ใหม่จาก e-Court) +- **เปิดเอกสาร = ต้อง online ทุกครั้ง** (ยิง /token เพื่อตรวจลายเซ็นที่ server — แก้ 2026-11-04) + — offline ได้ no-access "ขอ token ไม่สำเร็จ" · ระหว่าง session ใช้ต่อได้เพราะ JWT อายุ 7 วัน + (apiClient ไม่ยิง /token จนกว่าจะโดน 401) +- **ticket หมดอายุ + มี JWT ของ fid ใน session ที่ยังไม่หมด** → ผ่านด้วย JWT เดิม (server ตอบ 403) + · **ticket หมดอายุ + ไม่มี session / JWT หมดด้วย / localStorage หาย** → ต้องขอไฟล์ใหม่จาก + e-Court (แก้ 2026-11-04 — เดิม server ออก token ให้จากหลักฐาน DB `hasVersionByUser` ซึ่งถอดแล้ว) +- **localStorage ถูก block** (webview บางตัว/private mode) → session อยู่ใน memory ต่อ task pane + instance เดียว: ปิด-เปิดใหม่ + ticket หมดอายุ = no-access (ไม่มี JWT ในเครื่อง) · ticket ยังไม่หมด + = ขอใหม่ได้ปกติ - **uname ไทย** — เป็น string ตรง ๆ ไม่ผ่าน base64 ไม่มีปัญหา mojibake (ต่างจาก decode JWT payload) - **exp == now** → หมดอายุ -- **401 จาก endpoint อื่นระหว่างใช้งาน** → renew 1 ครั้ง → retry → ยังไม่ผ่าน → no-access +- **401 จาก endpoint อื่นระหว่างใช้งาน** → renew 1 ครั้ง (server ตอบ 200 = ได้ใหม่ · 403 = ticket + หมด → false) → retry → ยังไม่ผ่าน → no-access +- **เครื่องโหลดช้า จน office.js ยังไม่ evaluate ตอน taskpane เริ่ม** → WordAddinGate รอต่อ (poll + ทุก 5 วิ) จนมี global `Office` ถึง mount TaskpaneApp — ไม่ proceed บอด ๆ แล้ว (แก้ 2026-11-04); + ถ้า detect อ่านไฟล์ไม่ได้จริง หน้า no-access มีปุ่ม "ลองใหม่" เรียก detect() ซ้ำ - **Word เปิดหลายเอกสารพร้อมกัน (user เดียวกัน fid ต่างกัน)** — tokens map รองรับ; current fid เป็น state ต่อ taskpane instance (memory) — แยกกันอยู่แล้ว - **ไฟล์ทั่วไปไม่มี ticket** → no-access เหมือนเดิม (ข้อความเดิมปรับคำ) @@ -191,17 +223,21 @@ Dev tool รันมือ `node scripts/make-ticket-docx.mjs [opti ## 10. Verify criteria -1. `npx vitest run` ผ่านทั้งชุด (ไฟล์ใหม่ + เดิม — โดยเฉพาะ `tests/lib/docver-token.test.ts` ไม่พัง) -2. `npx tsc --noEmit` ผ่าน +1. `npx vitest run` ผ่านทั้งชุด (รวม `tests/app/api/documents/token.test.ts` — route contract + 400/401/403/200 ที่เพิ่ม 2026-11-04 และ `tests/wordaddin/ticket-auth-flow.test.ts` ฉบับใหม่) +2. `npx tsc --noEmit` ผ่าน (error เดิมของ `piscina`/`docx-templates` ไม่เกี่ยวกับแผนนี้) 3. curl regression ที่ /token: (ก) `x-api-key` + identity body ออก token ได้เหมือนเดิม - (ข) ticket body ออก token ได้ (ค) HMAC ผิดกับ exp หมด = 401 ข้อความเดียวกัน (ง) body ปนกัน = 400 + (ข) ticket body ออก token ได้ `expires_in` = 604800 (ค) HMAC ผิด = 401 ข้อความเดียว + (ง) body ปนกัน/ไม่ส่ง ticket = 400 (จ) exp อดีตลายเซ็นถูก = **403** 4. ทดสอบใน Word จริง (sideload ตาม `docs/WORDADDIN-DEV.md`) ด้วยไฟล์จาก make-ticket-docx: - ไฟล์ถูกต้อง → ใช้ได้ ชื่อผู้ใช้ = uname · แก้ `ticket_data` 1 ตัวอักษร → no-access "ไฟล์ถูกแก้ไข" · - exp อดีตของเอกสารที่ไม่เคยสร้าง version → no-access "ticket หมดอายุ" · เปิดเอกสารเดิมซ้ำ → - ไม่ยิง /token ซ้ำ (ใช้ token เดิม) · **ticket หมดอายุ + เอกสารเคยสร้าง version ≥1 โดย uid เดียวกัน → - เปิดใหม่ใช้ต่อได้แม้เคลียร์ localStorage `wordaddin-auth` ก่อนเปิด (server ออก token ใหม่ — แก้ 2026-10-28)** · - ตั้ง `DOCVER_TOKEN_TTL_SECONDS` สั้น ๆ แล้วเปิดซ้ำหลังหมดอายุ → renew อัตโนมัติใช้ต่อได้ · - เปิดไฟล์ uid คนละคน → session เดิมถูกเคลียร์ + ไฟล์ถูกต้อง → ใช้ได้ ชื่อผู้ใช้ = uname · **dev log เห็น POST /token ทุกครั้งที่เปิด task pane** · + แก้ `ticket_data` 1 ตัวอักษร → no-access ข้อความลายเซ็น (server 401) · + **ticket หมดอายุ + มี session (JWT 7 วันยังไม่หมด) → ผ่านด้วย JWT เดิม (Network เห็น 403)** · + **ticket หมดอายุ + เคลียร์ localStorage `wordaddin-auth` → no-access "ticket หมดอายุ"** (แก้ 2026-11-04: + ไม่มีทางกลับจากหลักฐาน DB อีก) · หยุด dev server → no-access "ขอ token ไม่สำเร็จ" · + ตั้ง `DOCVER_TOKEN_TTL_SECONDS` สั้น ๆ แล้วใช้จน JWT หมด → apiClient renew อัตโนมัติ (ticket ยังไม่หมด) · + เปิดไฟล์ uid คนละคน → session เดิมถูกเคลียร์ · browser ธรรมดา → no-access "อ่านข้อมูลเอกสารไม่สำเร็จ" + + กด "ลองใหม่" ได้ 5. รัน `plan-reviewer` + `code-reviewer` ตามกติกา `.claude/` ## 11. ลำดับงาน diff --git a/docs/PLAN-DOCVER.md b/docs/PLAN-DOCVER.md index 166d583..8dea3bc 100644 --- a/docs/PLAN-DOCVER.md +++ b/docs/PLAN-DOCVER.md @@ -62,7 +62,7 @@ JSON body (allowlist — key อื่น = 400 กัน typo): - env `DOCVER_TOKEN_SECRET` ไม่ตั้ง/ว่าง = **500** "DOCVER_TOKEN_SECRET ยังไม่ได้ตั้งค่า" (ความผิด config ฝั่ง server ไม่ใช่ความผิดของผู้เรียก — และห้าม log ค่า secret) - response 200: `{ "access_token": string, "token_type": "Bearer", "expires_in": number }` - — JWT HS256 stateless มี `iat`/`exp` (TTL env `DOCVER_TOKEN_TTL_SECONDS` default 3600) + — JWT HS256 stateless มี `iat`/`exp` (TTL env `DOCVER_TOKEN_TTL_SECONDS` default 604800 = 7 วัน — แก้ 2026-11-04 จาก 3600 ให้ token อายุยาวแทนระบบ refresh/renew ของ add-in) · verify บังคับ alg=HS256 (กัน alg-confusion) และเทียบ signature แบบ timing-safe - **endpoint อื่นของกลุ่มนี้ทั้งหมดต้องแนบ `Authorization: Bearer `** — ไม่มี header / รูปผิด / signature ผิด / หมดอายุ / secret ไม่ตั้ง = 401 ข้อความไทย @@ -261,7 +261,7 @@ read-only ของ N10 แต่ตัวนี้เขียนได้) | `DOCVER_LINK_TTL_SECONDS` | `900` | อายุ presigned URL | | `DOCVER_API_KEY` | — บังคับ | API key ของ `POST /api/documents/token` (header `x-api-key`) — ไม่ตั้ง/ว่าง = 401 fail closed · เทียบ timing-safe · ห้าม log ค่าดิบ · GET /latest ใช้ตัวนี้**หรือ** `EXTERNAL_API_KEY` ตัวใดตัวหนึ่ง | | `DOCVER_TOKEN_SECRET` | — บังคับ | secret เซ็น/ตรวจ JWT HS256 — ไม่ตั้ง: /token = 500 · endpoint อื่น = 401 · ห้าม log ค่าดิบ | -| `DOCVER_TOKEN_TTL_SECONDS` | `3600` | อายุ access_token (วินาที) | +| `DOCVER_TOKEN_TTL_SECONDS` | `604800` | อายุ access_token (วินาที) — default 7 วัน (แก้ 2026-11-04 จาก 3600) | | `TICKET_SEC` | `300` | TTL (วินาที) ของ `exp` ที่ stamp ตอน GET /latest type=docx — env เดียวกับกลุ่ม template (`app/api/template/*`) | | `PRIVATE_KEY_HEX` | — optional | private key Ed25519 (hex seed) — ตั้งแล้ว GET /latest type=docx จะ stamp prop `Ed25519` เพิ่ม · ไม่ตั้ง = ไม่ใส่ (chain ของ add-in verify แค่ `HmacSha256`) · env เดียวกับกลุ่ม template | | `ADDIN_TICKET_SECRET` | chain → `SECRET` → `"super_secret"` | secret ของ HMAC ที่ stamp ที่ /latest — ใช้ `addinTicketSecret()` ให้ตรงฝั่ง verify ของ /token ทาง ticket (ดู `docs/PLAN-ADDIN-AUTH.md`) | diff --git a/docs/PLAN-SSO.md b/docs/PLAN-SSO.md index 28b9731..821c480 100644 --- a/docs/PLAN-SSO.md +++ b/docs/PLAN-SSO.md @@ -25,7 +25,7 @@ middleware รวมเป็น**หน้าแรก `/note` ที่เป ยิงเส้น mint ใหม่ ส่วนทาง B ใช้ตัว stamp เดียวกับ .docx ของ add-in - ticket (B) เก็บไว้เพราะ mint ได้**แบบ offline** — ครอบคลุมเคสที่ e-Court สร้างลิงก์ล่วงหน้า/ ไม่ยิง API ตอน render · แลกมาด้วยเงื่อนไขบังคับ **D13** (ตัด secret ออกจาก bundle ของ add-in - ก่อน — ไม่งั้นใครแกะ bundle ก็ปลอม ticket เข้า login ได้) + ก่อน — ไม่งั้นใครแกะ bundle ก็ปลอม ticket เข้า login ได้ · **ทำแล้ว 2026-11-04**) **ผลพลอยได้:** ไม่มีเงื่อนไขค้างกับทีม e-Court อีกต่อไป — ทางไหนที่เขาทำได้ตามบริบทหน้านั้น ๆ ก็ใช้ได้ (แนะนำ A เมื่อ mint real-time ได้, B เมื่อสร้างลิงก์ล่วงหน้า) · ลิงก์ทั้งสองแบบ @@ -69,7 +69,7 @@ credential อายุสั้น ฝังอีเมล/แชท/เก็ | D10 | เครื่องมือทดสอบ | `scripts/make-login-link.mjs` mint token ออฟไลน์ (node:crypto HS256 จาก `DOCVER_TOKEN_SECRET`) แล้วพิมพ์ลิงก์เต็ม — pattern เดียวกับ `make-ticket-docx.mjs` | | D11 | fid | route ใช้ fid จาก credential เป็น `fileId` ของ N8 **เสมอ** (ค่าที่เซ็นแล้ว มาจาก token/ticket ไม่ใช่ param) — ลิงก์ไม่ต้องส่ง `fileId` และ param นี้ถ้าส่งมาก็ถูกแทนที่ กัน mismatch ระหว่างค่าเซ็นกับค่าปลอมแปลงได้ | | D12 | สองทางเข้าพร้อมกัน | route แยกตาม param: มี `t` = ทาง JWT · ไม่มี `t` แต่มี `ticket_data`+`HmacSha256` ครบ = ทาง ticket · **มีทั้งสองชุด = `sso=invalid`** (fail closed กันกำกวม) · ทั้งสองทางจบที่ cookie + redirect เดียวกัน · ทาง ticket ไม่ใช้ `tid` (template ต้นทาง — ไม่เกี่ยวกับ login) | -| D13 | เงื่อนไขบังคับของทาง ticket | **ตัด client HMAC check + `NEXT_PUBLIC_ADDIN_TICKET_SECRET` ออกจาก add-in ก่อนเปิดทาง ticket** — secret ตัวเดียวกันจะกลายเป็นจุดอ่อนของ login โดยตรง (แกะ bundle = ปลอม ticket เข้าได้) · add-in เหลือ parse + exp เป็น UX gate, การตรวจ "ไฟล์ถูกแก้" เกิดตอน server ตอบ 401 (แทนที่ D3 ของ PLAN-ADDIN-AUTH.md) · โบนัส: ทางเข้า ticket ที่ `/token` ของ add-in ก็แข็งขึ้นตาม | +| D13 | เงื่อนไขบังคับของทาง ticket | **ตัด client HMAC check + `NEXT_PUBLIC_ADDIN_TICKET_SECRET` ออกจาก add-in ก่อนเปิดทาง ticket** — secret ตัวเดียวกันจะกลายเป็นจุดอ่อนของ login โดยตรง (แกะ bundle = ปลอม ticket เข้าได้) · add-in เหลือ parse + exp เป็น UX gate, การตรวจ "ไฟล์ถูกแก้" เกิดตอน server ตอบ 401 (แทนที่ D3 ของ PLAN-ADDIN-AUTH.md) · โบนัส: ทางเข้า ticket ที่ `/token` ของ add-in ก็แข็งขึ้นตาม · **✅ ทำแล้ว 2026-11-04** (พร้อมปรับ contract ทาง ticket ของ `/token` เป็น 400/401/403 + TTL 7 วัน — ดู PLAN-ADDIN-AUTH.md หัวไฟล์) | | D14 | หน้าแรก `/note` ใหม่ (2026-10-29) | เปลี่ยนจากรายการไฟล์แบบการ์ด เป็น**หน้าเลือกเอกสารแบบ dropdown 2 ระดับ** คล้าย `app/tmpl/page.tsx` (กลุ่ม → ไฟล์) + ปุ่ม "เปิดเอกสาร" เข้า annotate — ถอดรายการการ์ด N9 + ฟอร์มอัปโหลด N4 + การ์ดจำลองลิงก์ N8 ออกจากหน้า · N8 `?fileUrl=` ยังทำงานที่หน้านี้ **เฉพาะเมื่อมี session ที่ verify แล้ว** (ไม่มี = redirect `/note?sso=required` โดยไม่แตะ DB — ดู §1.1) · รายละเอียด §1.1 | | D15 | บังคับ session (2026-10-29) | `/note` **public** — middleware ยกเว้น `note` เพิ่ม (คนไม่มี cookie เห็นหน้าเลือกเอกสารได้) ส่วนหน้าอื่น (annotate/summary/groups) ยังบังคับ cookie — ไม่มี = redirect `/note?sso=required` (ไม่มี `next` แล้ว: ไม่มี login ให้วนกลับ ผู้ใช้ต้องกลับ e-Court คลิกลิงก์ใหม่) · action `logout` คงไว้ ปลายทางเปลี่ยนเป็น `/note` | | D16 | เซ็น session cookie (2026-10-29 รอบสอง — code review ข้อ 3) | `oacCase` เดิม**ไม่มีลายเซ็น** (`parseOacCase` อ่าน query string เปล่า — ใครก็ปลอม `u_userid` เป็นคนอื่นได้ token/ticket ทั้งแผนจะไม่เพิ่มความปลอดภัยจริง) → เก็บรูป `oacCase` เดิมไว้ + **cookie ที่สอง `oacCaseSig`** = `.` เซ็นครอบ **payload เต็มของ `oacCase` + `.` + exp** (แก้ exp เฉย ๆ ไม่ได้) ด้วย secret ใหม่ `NOTE_SESSION_SECRET` — **แยกจาก `DOCVER_TOKEN_SECRET`** (ไม่แตะของกลุ่ม documents: session ของแอป note ไม่ผูก secret กลุ่ม docver หมุนพร้อมกันไม่ได้ โดยเฉพาะตอน pre-production ที่ต้อง rotate DOCVER อยู่ด้วย) · ไม่ตั้ง/ว่าง = **fail closed** (verify ไม่ผ่านทุกกรณี = ไม่มี session, SSO route ตอบ `sso=error` — ไม่มี default ที่รู้กันหมด) · ตรวจ**ทุกจุดที่อ่าน identity**: middleware (async + Web Crypto — edge runtime ไม่มี node:crypto) + `getSessionUser()` (เป็น async + verify หน้า caller `await`) · cookie ทั้งสอง httpOnly/sameSite lax/path `/`/maxAge 8h + **`secure` เมื่อ production** · exp ฝังในของที่เซ็น = อายุจริงวัดฝั่ง server (maxAge เป็นแค่ cleanup ฝั่ง browser) | @@ -233,7 +233,7 @@ readNoteSession() / saveNoteSession() / clearNoteSession() / noteAuthFetch(path, - `app/note/annotate/[id]/page.tsx` — `await getSessionUser()` ตาม signature ใหม่ - `app/actions/auth.ts` — action `logout` เคลียร์**ทั้งสอง cookie** (`oacCase` + `oacCaseSig`) + ปลายทาง redirect เปลี่ยนจากหน้า login เป็น `/note` - `utils/user.ts` — เพิ่ม `OAC_SIG_COOKIE` + `SESSION_MAX_AGE` (§4) -- **add-in (D13):** `wordaddin/services/authService.ts` ถอดการเรียก `verifyTicketHmacWeb` (เหลือ parse + exp เป็น UX gate) · `wordaddin/services/ticketService.ts` ถอด `verifyTicketHmacWeb`/`hmacSha256HexWeb` · `wordaddin/config.ts` ถอด `ticketSecret` · `components/wordaddin/TaskpaneApp.tsx` ข้อความ no-access ปรับ (ไม่มีสาเหตุ HMAC ฝั่ง client — รวมเป็น "ticket ไม่ถูกต้อง" ตอน server 401) · `docs/PLAN-ADDIN-AUTH.md` (D3/D8 ถูกแทนที่) + `docs/WORDADDIN-DEV.md` +- **add-in (D13):** `wordaddin/services/authService.ts` ถอดการเรียก `verifyTicketHmacWeb` (เหลือ parse + exp เป็น UX gate) · `wordaddin/services/ticketService.ts` ถอด `verifyTicketHmacWeb`/`hmacSha256HexWeb` · `wordaddin/config.ts` ถอด `ticketSecret` · `components/wordaddin/TaskpaneApp.tsx` ข้อความ no-access ปรับ (ไม่มีสาเหตุ HMAC ฝั่ง client — รวมเป็น "ticket ไม่ถูกต้อง" ตอน server 401) · `docs/PLAN-ADDIN-AUTH.md` (D3/D8 ถูกแทนที่) + `docs/WORDADDIN-DEV.md` — **✅ ทำแล้ว 2026-11-04** - `lib/openapi.ts` — เพิ่ม path ใหม่ `POST /api/note/auth/token` (field ตาม §3.1) · รายการ `/api/documents/token` **คงเดิมไม่แต้** - `.env.example` — เช็คว่ามี `DOCVER_TOKEN_SECRET` + `NOTE_SESSION_SECRET` (ค่า dev อะไรก็ได้) — dev ไม่มีสองค่านี้ = เข้า annotate ไม่ได้ (ทางเข้า session เดียวคือลิงก์ SSO ส่วนหน้าแรก public) - `CLAUDE.md` — สถานะ auth ของแอป note (mock login ถอด, SSO 2 ทางเข้า, **ไม่มีหน้า login · `/note` public + หน้าเลือกเอกสารใหม่ · N4 ถอด**) + add-in ถอด client HMAC (D13) + invariant #4 ไม่เกี่ยว (route นี้ไม่แตะ DB ไม่ publish SSE) @@ -247,7 +247,7 @@ readNoteSession() / saveNoteSession() / clearNoteSession() / noteAuthFetch(path, - `MOCK_USERS` ใน `utils/user.ts` + comment "ใช้ร่วมกันสองที่" ที่ตายตาม (`OAC_CASE_COOKIE`/`parseOacCase`/`buildOacCaseCookie`/`SESSION_MAX_AGE` คงไว้ทั้งหมด — auth route ใช้อยู่) - `components/UploadFileForm.tsx` + action `uploadFile` ใน `app/actions/files.ts` (N4 — ไม่มีทางเข้า UI อีกตาม D14) - `components/EcourtLinkForm.tsx` (ทางทดสอบ N8 เหลือสคริปต์ D10) -- `NEXT_PUBLIC_ADDIN_TICKET_SECRET` ใน `.env.example` และทุกจุดอ้าง (D13 — secret ออกจาก bundle) +- ~~`NEXT_PUBLIC_ADDIN_TICKET_SECRET` ใน `.env.example` และทุกจุดอ้าง (D13 — secret ออกจาก bundle)~~ **✅ ถอดแล้ว 2026-11-04** ## 7. Env @@ -298,7 +298,7 @@ readNoteSession() / saveNoteSession() / clearNoteSession() / noteAuthFetch(path, ## 10. ลำดับงาน -0. **add-in D13** — ถอด client HMAC check + `NEXT_PUBLIC_ADDIN_TICKET_SECRET` ออกจาก bundle (+ ปรับ tests/docs) → 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 บางส่วน 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 (ข–ง) diff --git a/docs/WORDADDIN-DEV.md b/docs/WORDADDIN-DEV.md index 252aa0f..399c81e 100644 --- a/docs/WORDADDIN-DEV.md +++ b/docs/WORDADDIN-DEV.md @@ -24,7 +24,8 @@ Taskpane ของ Word add-in "e-Court Documents Versioning" พอร์ตม |---|---|---| | `NEXT_PUBLIC_ADDIN_API_BASE_URL` | `""` (ว่าง) | ปลด mock ต่อ backend จริง (ต้องเป็น HTTPS) — ไม่เกี่ยวกับการขอ token (ยิง same-origin `/docedit/api/documents/token` เสมอ) | | `NEXT_PUBLIC_ADDIN_MOCK_USERNAME` | `"tester01"` | ชื่อ fallback ของโหมด `?addindemo` (แสดง header + เป็น `createdByUserId` ของ mock) | -| `NEXT_PUBLIC_ADDIN_TICKET_SECRET` | `"super_secret"` | secret ตรวจ HMAC ของ ticket ฝั่ง client (UX gate) — ต้องตรงกับตัว stamp ของ e-Court และ `ADDIN_TICKET_SECRET`/`SECRET` ฝั่ง server (PLAN-ADDIN-AUTH.md §7) · **production ต้องตั้งค่าจริงทั้งสองฝั่ง** | + +(~~`NEXT_PUBLIC_ADDIN_TICKET_SECRET`~~ — ถอดออก 2026-11-04 ตาม PLAN-SSO.md D13: add-in ไม่ตรวจ HMAC เองแล้ว ไม่มี secret ใน bundle ฝั่ง client) ## รัน dev ด้วย HTTPS (จำเป็นสำหรับ sideload ใน Word) @@ -87,7 +88,7 @@ Word for Mac ไม่มี Trust Center แบบ Windows — สแกน add ## ทดสอบ e-Court ticket (auth ทางเข้าเดียว) -ไฟล์ที่ e-Court stamp custom properties **`ticket_data`** (JSON `exp`/`fid`/`tid`/`uid`/`uname`) + **`HmacSha256`** (HMAC-SHA256 ของ string ดิบ ด้วย secret ปัจจุบัน `super_secret`) ไว้ จะใช้ได้เลย — ไม่มี login ให้กด: `detect()` อ่านคู่นี้ตอนเปิด task pane แล้วเรียก `authService.signInWithTicket()` ตาม flow ของ `docs/requirement/addin-workflow.md` (ตรวจ HMAC ฝั่ง client → ตรวจ exp → ดู session ใน localStorage `wordaddin-auth` ตาม uid/fid → ขอ access_token ที่ `/docedit/api/documents/token` แบบ body ticket → เก็บ token ตาม fid) · ไฟล์ที่ไม่มีคู่นี้ / HMAC ไม่ตรง / ticket หมดอายุ = phase `no-access` แยกข้อความตามสาเหตุ +ไฟล์ที่ e-Court stamp custom properties **`ticket_data`** (JSON `exp`/`fid`/`tid`/`uid`/`uname`) + **`HmacSha256`** (HMAC-SHA256 ของ string ดิบ ด้วย secret ปัจจุบัน `super_secret`) ไว้ จะใช้ได้เลย — ไม่มี login ให้กด: `detect()` อ่านคู่นี้ตอนเปิด task pane แล้วเรียก `authService.signInWithTicket()` ตาม flow ของ `docs/requirement/addin-workflow.md` (แก้ 2026-11-04: pre-check ฟอร์แม็ต/exp ฝั่ง client → ยิง `/docedit/api/documents/token` **ทุกครั้งที่เปิดเอกสาร** ให้ server ตรวจลายเซ็นแล้วแยกตาม status 200/400/401/403 → เก็บ token ตาม fid · ต้อง online ตอนเปิดไฟล์เสมอ) · ไฟล์ที่ไม่มีคู่นี้ / ฟอร์แม็ตพัง / ลายเซ็นไม่ผ่าน (server 401) / ticket หมดอายุ = phase `no-access` แยกข้อความตามสาเหตุ ทดสอบเองใน Word โดยไม่ต้องรอ e-Court — สร้างไฟล์ทดสอบด้วยสคริปต์ (คำนวณ HMAC ตรงกับตัว stamp จริงของกลุ่ม template): @@ -100,13 +101,13 @@ node scripts/make-ticket-docx.mjs path/to/file.ticket.docx --list node scripts/make-ticket-docx.mjs path/to/file.docx --tracked --in-place ``` -แล้วเปิดไฟล์ใน Word ที่ sideload แล้ว → ต้องเข้าเฟส untracked (หรือ tracking เมื่อ `--tracked`) ทันที ชื่อผู้ใช้ = `uname` · ทดสอบ fail-case: แก้ `ticket_data` ผ่าน **File → Info → Properties → Advanced Properties → Custom** สัก 1 ตัวอักษร → no-access "ไฟล์ถูกแก้ไข" · สร้างด้วย `--exp +1` รอ 1 นาทีแล้วเปิด**เอกสารที่ยังไม่เคยสร้าง version** → no-access "ticket หมดอายุ" · เปิดเอกสารเดิมซ้ำหลัง ticket หมดอายุ → ยังใช้ได้ตาม requirement ขั้น 7: add-in ยิงขอ token ใหม่แล้ว server ตรวจว่า fid นี้เคยมี version โดย uid เดียวกัน → ออกให้ต่อ (แก้ 2026-10-28 — ใช้ได้แม้ token เก่าหมดอายุหรือ localStorage `wordaddin-auth` โดนเคลียร์ เพราะไม่พึ่ง cache ในเครื่องอีกต่อไป · ข้อจำกัด: เอกสารที่เคยเปิดแต่ยังไม่เคยสร้าง version ไม่มีหลักฐานฝั่ง server ต้องขอไฟล์ใหม่จาก e-Court) +แล้วเปิดไฟล์ใน Word ที่ sideload แล้ว → ต้องเข้าเฟส untracked (หรือ tracking เมื่อ `--tracked`) ทันที ชื่อผู้ใช้ = `uname` · ทดสอบ fail-case (แก้ 2026-11-04 ตาม contract ใหม่): แก้ `ticket_data` ผ่าน **File → Info → Properties → Advanced Properties → Custom** สัก 1 ตัวอักษร → no-access ข้อความลายเซ็นไม่ผ่าน (server 401) · สร้างด้วย `--exp +60` รอหมดอายุแล้วเปิดใหม่ — **มี session (JWT อายุ 7 วันยังไม่หมด)** → ยังใช้ได้ (Network เห็น /token ตอบ 403 แต่ UI ไหลต่อด้วย JWT เดิม) · **เคลียร์ localStorage `wordaddin-auth` ก่อนเปิด** → no-access "ticket หมดอายุ" (ต้องขอไฟล์ใหม่จาก e-Court — เลิกตรวจ "เคยมี version" จาก DB ตาม requirement ขั้น 7 เดิมแล้ว) · หยุด dev server แล้วเปิด task pane (ticket ยังไม่หมด) → no-access "ขอ token ไม่สำเร็จ" (ยืนยันว่าต้อง online ตอนเปิดเอกสาร) หมายเหตุ: การขอ token ยิง same-origin `/docedit/api/documents/token` จริง (ไม่ผูก `NEXT_PUBLIC_ADDIN_API_BASE_URL`) — dev server ต้องรันอยู่และ secret ฝั่ง server (`ADDIN_TICKET_SECRET` → `SECRET` → default `super_secret`) ตรงกับ `--secret` ของสคริปต์ ## การรันกับ backend จริง (ปลด mock แล้ว 2026-10-27) -- **Auth:** ไม่มี login — **ยกเลิก Keycloak ถาวร 2026-10-27** · auth ของ add-in คือ ticket ของ e-Court ผ่าน `signInWithTicket` (ticket_data + HmacSha256 ในไฟล์ — แผนเต็ม `docs/PLAN-ADDIN-AUTH.md`): add-in ตรวจ HMAC ฝั่ง client (UX gate) แล้วขอ access_token ที่ `/api/documents/token` ซึ่ง server ตรวจ HMAC+exp อีกชั้นด้วย secret ฝั่ง server · โดน 401 กลาง flow = ลอง renew จาก ticket 1 ครั้ง ยังไม่ผ่าน = `AuthRequiredError` → หน้า `no-access` (ticket ต่ออายุไม่ได้ — ต้องได้ไฟล์ใหม่จาก e-Court) +- **Auth:** ไม่มี login — **ยกเลิก Keycloak ถาวร 2026-10-27** · auth ของ add-in คือ ticket ของ e-Court ผ่าน `signInWithTicket` (ticket_data + HmacSha256 ในไฟล์ — แผนเต็ม `docs/PLAN-ADDIN-AUTH.md` แก้ 2026-11-04): add-in **ไม่ตรวจ HMAC เอง** (secret เป็นของ server อย่างเดียว — PLAN-SSO.md D13) — pre-check ฟอร์แม็ต/exp ฝั่ง client แล้วยิงขอ access_token ที่ `/api/documents/token` **ทุกครั้งที่เปิดเอกสาร**: 200 = ได้ token ใหม่ (อายุ default 7 วัน) · 401 = ลายเซ็นไม่ถูก · 403 = ลายเซ็นถูกแต่ ticket หมดอายุ (มี JWT เดิมใน session ที่ยังไม่หมด = ใช้ต่อ) · โดน 401 กลาง flow = ลอง renew จาก ticket 1 ครั้ง ยังไม่ผ่าน = `AuthRequiredError` → หน้า `no-access` (ticket ต่ออายุไม่ได้ — ต้องได้ไฟล์ใหม่จาก e-Court) - **Upload/download ของจริงเป็น default แล้ว:** `wordaddin/services/versioningService.ts` ยิงกลุ่ม `/api/documents/*` ผ่าน `apiClient` (Bearer จาก ticket) — **ไม่ต้องตั้ง env อะไรเพิ่ม** เพราะ `apiClient.toUrl` ใช้ same-origin `withBasePath("/api/documents/…")` เมื่อ `NEXT_PUBLIC_ADDIN_API_BASE_URL` ว่าง (API ของกลุ่ม docver อยู่แอปนี้เอง) · ตั้งค่า env นั้นเฉพาะวันที่ deploy add-in กับ API คนละ origin (ต้องรวม basePath เช่น `https://host/docedit` และเป็น HTTPS) - **เอกสารถูกระบุด้วย fid ใน token** — พารามิเตอร์ `documentId` (GUID ที่ stamp ในไฟล์) ของ uploadVersion/getVersions/getDownloadLink เหลือไว้ตาม signature เดิม ไม่ถูกส่งถึง server · templateId ก็เช่นกัน (มาจาก claims ของ token — ส่งใน form = 400) - **สิ่งที่ต้องรันฝั่ง server ก่อนใช้จริง:** MinIO (`docker compose up -d minio` — bucket `docver` app สร้างเอง) + env `DOCVER_API_KEY`/`DOCVER_TOKEN_SECRET`/`MINIO_*` ตาม `.env.example` ของกลุ่ม docver (docs/PLAN-DOCVER.md §6) · ทดสอบวงเต็ม: สร้างไฟล์ด้วย `make-ticket-docx.mjs` → เปิดใน Word → Save & Version → version ขึ้น MinIO จริง → กดดาวน์โหลด/PDF เปิด presigned URL ในเบราว์เซอร์ (โฮสต์ MinIO ต้องเข้าถึงได้จากเครื่องผู้ใช้) diff --git a/docs/addin-ui-flow.md b/docs/addin-ui-flow.md index 520bdfb..2162442 100644 --- a/docs/addin-ui-flow.md +++ b/docs/addin-ui-flow.md @@ -12,10 +12,10 @@ Task pane ทั้งหมดเป็น state machine เดียว **ไ ตั้งแต่ 2026-10-19 taskpane มี **2 แท็บ** (toggle กลุ่มปุ่ม `aria-pressed` ใต้ header — แสดงเฉพาะ phase `untracked`/`tracking`): **Versioning** คือทุกอย่างในเอกสารนี้ตามเดิม ส่วน **เครื่องมือ Template** (ดูหัวข้อ 10) คือเครื่องมือจัดการ Content Controls ฝั่ง Office.js ล้วน ไม่ยิง backend จึงใช้ได้ทั้งสอง phase ปุ่ม toggle **ทั้งสองปิดระหว่าง flow ที่เขียนเอกสาร** — `saving || starting` (versioning), `saveBusy` (บันทึก local/ปิดเอกสาร), `templateBusy` (operation ของแท็บ template รายงานผ่าน `onBusyChange` — กันสลับกลาง operation แล้ว flow versioning มา stamp/capture ซ้อน หรือกลับกัน; code review 2026-10-20) · สถานะแท็บรีเซ็ตกลับ `versioning` ตอนเข้า `no-access` กัน surprise · banner auto-save error (FR-17) ยกไว้เหนือ toggle ให้เห็นทั้งสองแท็บ -**ไม่มี login ตั้งแต่ 2026-10-27** (ยกเลิก Keycloak ตามคำขอผู้ใช้) — auth ทางเข้าเดียวคือ **ticket ของ e-Court**: `detect()` อ่าน custom properties `ticket_data` + `HmacSha256` ที่ e-Court stamp มากับไฟล์ แล้วเรียก `authService.signInWithTicket()` — ตรวจ HMAC ฝั่ง client → ตรวจ exp → ดู session ใน localStorage ตาม uid/fid → ขอ access_token ที่ `/api/documents/token` เก็บตาม fid (flow เต็ม: `docs/requirement/addin-workflow.md` + แผน `docs/PLAN-ADDIN-AUTH.md`) +**ไม่มี login ตั้งแต่ 2026-10-27** (ยกเลิก Keycloak ตามคำขอผู้ใช้) — auth ทางเข้าเดียวคือ **ticket ของ e-Court**: `detect()` อ่าน custom properties `ticket_data` + `HmacSha256` ที่ e-Court stamp มากับไฟล์ แล้วเรียก `authService.signInWithTicket()` — pre-check ฟอร์แม็ต/exp ฝั่ง client (UX gate) → ยิง `/api/documents/token` **ทุกครั้งที่เปิดเอกสาร** ให้ server ตรวจลายเซ็น (ไม่มี secret ฝั่ง client ตาม PLAN-SSO.md D13 — แก้ 2026-11-04) → แยกทางตาม status 200/400/401/403 → เก็บ access_token ใน localStorage ตาม fid (flow เต็ม: `docs/requirement/addin-workflow.md` + แผน `docs/PLAN-ADDIN-AUTH.md`) ``` -checking ──(ไม่มี ticket / HMAC ไม่ตรง / ticket หมดอายุ / ขอ token ไม่สำเร็จ)──→ no-access +checking ──(ไม่มี ticket / ฟอร์แม็ตพัง / ลายเซ็นไม่ผ่าน(401) / ticket หมดอายุ / ขอ token ไม่สำเร็จ)──→ no-access │ ├─(ticket ผ่าน + ไม่ใช่ tracked doc)──→ untracked └─(ticket ผ่าน + tracked doc)────────→ tracking @@ -25,7 +25,7 @@ checking ──(ไม่มี ticket / HMAC ไม่ตรง / ticket หม - `untracked` → (Start Versioning สำเร็จ) → `tracking` - ทุก phase → (ไม่มี ticket ตอน detect ใหม่ / 401 หลังพยายาม renew จาก ticket แล้ว = ticket หมดอายุไม่มีทางต่ออายุ) → `no-access` -- ปุ่ม "ลองใหม่" มีจริงแค่ 2 จุด: `bootError` ใน `checking` และ history error ใน `VersionHistoryPanel` — `untracked`/`tracking` **ไม่มี**ปุ่มลองใหม่แยกต่างหาก (เริ่ม flow ใหม่ด้วยการกดปุ่ม flow ซ้ำ) +- ปุ่ม "ลองใหม่" มีจริงแค่ 3 จุด: `bootError` ใน `checking`, history error ใน `VersionHistoryPanel` และ (เพิ่ม 2026-11-04) no-access เคส "อ่านข้อมูลเอกสารไม่สำเร็จ" — `untracked`/`tracking` **ไม่มี**ปุ่มลองใหม่แยกต่างหาก (เริ่ม flow ใหม่ด้วยการกดปุ่ม flow ซ้ำ) State ที่ UI ถือนอกเหนือจาก phase: @@ -36,7 +36,7 @@ State ที่ UI ถือนอกเหนือจาก phase: | `documentId` | tracking id ของเอกสาร (มีเฉพาะ `tracking`) | | `documentTemplateId` | template ต้นทางของ**ไฟล์ร่าง** (มีเฉพาะ `untracked` ที่เป็นไฟล์ร่าง — FR-16) | | `versions` + `historyLoading` + `historyError` | version history ของ `documentId` | -| `noAccessNotice` | ข้อความบนหน้า `no-access` — ต่างกันตามสาเหตุ (ไม่มี ticket ในไฟล์ / HMAC ไม่ตรง = ไฟล์ถูกแก้ / ticket หมดอายุ / ขอ token ไม่สำเร็จ) | +| `noAccessNotice` | ข้อความบนหน้า `no-access` — ต่างกันตามสาเหตุ (ไม่มี ticket ในไฟล์ / ฟอร์แม็ตพัง / ลายเซ็นไม่ผ่าน = ไฟล์ถูกแก้ / ticket หมดอายุ / ขอ token ไม่สำเร็จ) | | `starting`, `saving`, `progressMessage` | busy + ข้อความ progress ของ flow Start Versioning / Save & Version | | `confirmOpen`, `startConfirmOpen` | เปิด dialog ยืนยันของ flow ไหน | | `flowError`, `flowWarning`, `flowSuccess` | MessageBar 3 แบบของ flow หลัก (แสดงเฉพาะ `tracking`) | @@ -64,12 +64,17 @@ State ที่ UI ถือนอกเหนือจาก phase: ### `no-access` - ข้อความ: "เอกสารนี้ยังใช้งาน add-in ไม่ได้" -- คำอธิบายจางๆ (`noAccessNotice` ตามสาเหตุ): +- คำอธิบายจางๆ (`noAccessNotice` ตามสาเหตุ — ข้อความฉบับ 2026-11-04): - ไม่มี ticket ในไฟล์: "ไม่พบ ticket ของ e-Court ในไฟล์ — add-in ใช้ได้กับเอกสารที่ส่งมาจาก e-Court เท่านั้น" - - ticket รูปผิด / HMAC ไม่ตรง: "ตรวจสอบ ticket ไม่ผ่าน — ไฟล์ถูกแก้ไขหรือเสียหาย" - - ticket หมดอายุ (ตอนเปิด หรือโดน 401 กลาง flow หลังพยายาม renew): "ticket ของ e-Court หมดอายุ — โปรดขอไฟล์ใหม่จาก e-Court แล้วเปิดอีกครั้ง" + - ฟอร์แม็ตพัง (parse ฝั่ง client หรือ server ตอบ 400): "รูปแบบ ticket ในไฟล์ไม่ถูกต้อง — ไฟล์ถูกแก้ไขหรือเสียหาย โปรดขอไฟล์ใหม่จาก e-Court" + - ลายเซ็นไม่ผ่าน (server 401): "ตรวจสอบลายเซ็น (HMAC) ของ ticket กับระบบไม่ผ่าน — ไฟล์ถูกแก้ไขหรือเสียหาย โปรดขอไฟล์ใหม่จาก e-Court" + - ticket หมดอายุ (pre-check / server 403 และไม่มี JWT ที่ยังใช้ได้ใน session / โดน 401 กลาง flow หลังพยายาม renew): "ticket ของ e-Court หมดอายุ — โปรดขอไฟล์ใหม่จาก e-Court แล้วเปิดอีกครั้ง" - ขอ token ไม่สำเร็จ (server ลง/network): "ขอ token จากระบบไม่สำเร็จ — ตรวจการเชื่อมต่อแล้วเปิดเอกสารอีกครั้ง" -- **ไม่มีปุ่มใด ๆ** — ไม่มี login ให้กด (ยกเลิก Keycloak 2026-10-27; ขั้นตอนสำหรับเอกสารทั่วไปจะมีในอนาคต) ผู้ใช้ได้ไฟล์ใหม่จาก e-Court แล้วเปิดใหม่เท่านั้น + - อ่านไฟล์ไม่ได้ (Word.run พัง เช่นเปิดใน browser ธรรมดา): "อ่านข้อมูลเอกสารไม่สำเร็จ — ลองกด \"ลองใหม่\" หรือปิดแล้วเปิด task pane อีกครั้ง" +- **ปุ่ม "ลองใหม่" (เพิ่ม 2026-11-04) แสดงเฉพาะเคสอ่านไฟล์ไม่ได้** (`noAccessRetryable`) — กดแล้วรัน + `detect()` ใหม่ (ช่วยเคส office.js มาช้า/Word สะดุดชั่วคราว) · เคสอื่นไม่มีปุ่ม — ไม่มี login ให้กด + (ยกเลิก Keycloak 2026-10-27; ขั้นตอนสำหรับเอกสารทั่วไปจะมีในอนาคต) ผู้ใช้ได้ไฟล์ใหม่จาก e-Court + แล้วเปิดใหม่ หรือรอเน็ตกลับมาเท่านั้น ### `untracked` @@ -167,7 +172,7 @@ Dialog เดียวใช้ 2 flow (ปรับข้อความด้ ### Detect (FR-1) — ตอนเปิด task pane / กดลองใหม่ 1. อ่าน custom properties ครั้งเดียวทั้งชุด: `ticket_data`, `HmacSha256`, `SavedByAddIn`, `DocumentTrackingId`, `LastVersionId`, `LastVersionTimestamp`, `TemplateId` -2. ticket auth (`signInWithTicket` — PLAN-ADDIN-AUTH.md §5): อ่านไฟล์ไม่ได้ / ไม่มี ticket / HMAC ไม่ตรง / ticket หมดอายุ (ตามเงื่อนไข uid/fid ของ session) / ขอ token ไม่สำเร็จ → `no-access` แยกข้อความตามสาเหตุ · ผ่าน = มี access_token ของ fid นี้เก็บใน localStorage `wordaddin-auth` แล้วไปต่อ +2. ticket auth (`signInWithTicket` — PLAN-ADDIN-AUTH.md §5 ฉบับ 2026-11-04): อ่านไฟล์ไม่ได้ / ไม่มี ticket / ฟอร์แม็ตพัง (client หรือ server 400) / ลายเซ็นไม่ผ่าน (server 401) / ticket หมดอายุ (pre-check หรือ server 403 โดยไม่มี JWT ที่ยังใช้ได้ใน session) / ขอ token ไม่สำเร็จ → `no-access` แยกข้อความตามสาเหตุ · ผ่าน = มี access_token ของ fid นี้เก็บใน localStorage `wordaddin-auth` แล้วไปต่อ 3. `tracked = (SavedByAddIn === GUID ของ add-in)`: - tracked **และมี** `DocumentTrackingId` → `tracking` + โหลด history - ไม่ track หรือ track แต่ id หาย → `untracked` (เก็บ `TemplateId` ไว้ใช้ตอน Start Versioning — ไฟล์ร่างอยู่กลุ่มนี้) @@ -255,7 +260,7 @@ response: `{ url: string, expiresAt: string }` — UI เปิด `url` ใน ### Auth — e-Court ticket (ไม่มี login) -ยกเลิก Keycloak OIDC/PKCE แล้ว (2026-10-27) — ไม่มี Office Dialog ไม่มีหน้า sign-in: e-Court stamp custom properties **`ticket_data`** (JSON `exp`/`fid`/`tid`/`uid`/`uname`) + **`HmacSha256`** (HMAC-SHA256 ของ string ดิบ ด้วย secret ที่ config ได้ — ปัจจุบัน `super_secret`) ลงไฟล์ตอนสร้าง แล้ว `signInWithTicket()` ใช้ได้ทันที · flow ตาม `docs/requirement/addin-workflow.md` (แผนเต็ม `docs/PLAN-ADDIN-AUTH.md`): ตรวจ HMAC ฝั่ง client (UX gate) → ตรวจ exp → session ใน localStorage `wordaddin-auth` ตาม uid (user ใหม่ = เคลียร์ session เก่าทั้งก้อน) → **ขอ access_token ที่ `POST /api/documents/token`** แบบ body `{ticket_data, HmacSha256}` (server ตรวจ HMAC+exp อีกชั้นด้วย secret ฝั่ง server) → เก็บ token ตาม fid ("คีย์คือ fid") — กลับมาเปิดเอกสารเดิม = ใช้ token เดิมไม่ต้องขอใหม่ · token หมดอายุกลางใช้งาน (โดน 401) = ลองขอใหม่จาก ticket 1 ครั้ง (ticket ต้องยังไม่หมดอายุ) ยังไม่ผ่าน = `AuthRequiredError` → หน้า `no-access` ให้ขอไฟล์ใหม่จาก e-Court · token นี้คือสิ่งที่ `apiClient` แนบเป็น `Authorization: Bearer` ทุก request ตอนต่อ backend จริง (กลุ่ม docver บังคับ Bearer — docs/PLAN-DOCVER.md) +ยกเลิก Keycloak OIDC/PKCE แล้ว (2026-10-27) — ไม่มี Office Dialog ไม่มีหน้า sign-in: e-Court stamp custom properties **`ticket_data`** (JSON `exp`/`fid`/`tid`/`uid`/`uname`) + **`HmacSha256`** (HMAC-SHA256 ของ string ดิบ ด้วย secret ที่ config ได้ — ปัจจุบัน `super_secret`) ลงไฟล์ตอนสร้าง แล้ว `signInWithTicket()` ใช้ได้ทันที · flow ตาม `docs/requirement/addin-workflow.md` (แผนเต็ม `docs/PLAN-ADDIN-AUTH.md` — แก้ 2026-11-04): **client ไม่ตรวจ HMAC เอง** (ถอด secret ออกจาก bundle ตาม PLAN-SSO.md D13) — pre-check ฟอร์แม็ต + exp (หมดอายุและไม่มี JWT ของ fid ใน session = จบเลย) → **ยิง `POST /api/documents/token` ทุกครั้งที่เปิดเอกสาร** แบบ body `{ticket_data, HmacSha256}` ให้ server ตรวจลายเซ็นแล้วแยกตาม contract: 200 = ได้ token ใหม่ (user ใหม่ = เคลียร์ session เก่าทั้งก้อน) · 400 = ฟอร์แม็ตพัง · 401 = ลายเซ็นไม่ถูก (ไฟล์ถูกแก้) · 403 = ลายเซ็นถูกแต่ ticket หมดอายุ (มี JWT ของ fid ใน session ที่ยังไม่หมด = ใช้ต่อ) → เก็บ token ตาม fid ("คีย์คือ fid") อายุ 7 วัน (default ใหม่ของ `DOCVER_TOKEN_TTL_SECONDS`) · token หมดอายุกลางใช้งาน (โดน 401) = ลองขอใหม่จาก ticket 1 ครั้ง (ticket หมดแล้ว server ตอบ 403) ยังไม่ผ่าน = `AuthRequiredError` → หน้า `no-access` ให้ขอไฟล์ใหม่จาก e-Court · token นี้คือสิ่งที่ `apiClient` แนบเป็น `Authorization: Bearer` ทุก request ตอนต่อ backend จริง (กลุ่ม docver บังคับ Bearer — docs/PLAN-DOCVER.md) > **หมายเหตุสถานะปัจจุบัน:** UI นี้ถูก implement แล้วใน repo นี้ที่ route `/docedit/wordaddin` (ดู `components/wordaddin/` + `wordaddin/`) — auth เป็น ticket auth จริงครบ flow (`wordaddin/services/authService.ts` + `ticketService.ts` + ทางเข้า ticket ของ `/api/documents/token`) และ **API ทั้งสามข้างบนเป็นของจริงแล้ว** (`wordaddin/services/versioningService.ts` ยิงกลุ่ม `/api/documents/*` ผ่าน Bearer — ปลด mock 2026-10-27; เหลือ mock เฉพาะโหมด `?addindemo`) · รายละเอียดการรันกับ backend: `docs/WORDADDIN-DEV.md` · สร้างไฟล์ทดสอบพร้อม ticket: `node scripts/make-ticket-docx.mjs` diff --git a/lib/addin-ticket.ts b/lib/addin-ticket.ts index 8ca876b..f0b9e57 100644 --- a/lib/addin-ticket.ts +++ b/lib/addin-ticket.ts @@ -33,10 +33,9 @@ export function addinTicketSecret(): string { } /** - * ตรวจ ticket แบบไม่สน exp (HMAC → parse) — ใช้เมื่อ route จะตัดสิน "ticket หมดอายุ - * แต่เอกสารนี้เคยทำงานโดย uid เดียวกัน" เองผ่าน DB (requirement ขั้น 7: กลับมาทำ - * เอกสารเดิมผ่านได้แม้ ticket หมดอายุ) — ตัวตนของผู้ถือยังยึด HMAC ของ e-Court - * เหมือนเดิม แค่ไม่ใช้อายุ ticket เป็นเกณฑ์ตัดสิทธิ์ ณ จุดนี้ + * ตรวจ ticket แบบไม่สน exp (HMAC → parse) — route เป็นคนตัดสิน exp เองภายหลัง + * (ลายเซ็นถูกแต่หมดอายุ = 403, contract 2026-11-04) — ตัวตนของผู้ถือยึด HMAC + * ของ e-Court เหมือนเดิม แค่ไม่ใช้อายุ ticket เป็นเกณฑ์ตัดสิทธิ์ ณ จุดนี้ */ export function verifyAddinTicketHmac( ticketData: string, @@ -89,7 +88,7 @@ export type TicketTokenBodyResult = /** * parse body ของ POST /api/documents/token ทางเข้า ticket - * - `missing` = ไม่มี ticket มาเลย (route ตอบ 401 — คำเดียวกับตอน verify ไม่ผ่าน) + * - `missing` = ไม่มี ticket มาเลย (route ตอบ 400 — ฟอร์แม็ตพัง, contract 2026-11-04) * - `invalid` = รูปผิด (route ตอบ 400 ตาม convention กัน typo เงียบ ๆ) */ export function parseTicketTokenBody(raw: unknown): TicketTokenBodyResult { diff --git a/lib/docver-store.ts b/lib/docver-store.ts index 11083cd..4069f51 100644 --- a/lib/docver-store.ts +++ b/lib/docver-store.ts @@ -252,22 +252,6 @@ export async function findLatestVersionForDoc( return row?.version ?? null; } -/** - * เอกสาร (docId) นี้เคยมี version ที่สร้างโดย userId นี้หรือยัง — ใช้เป็นหลักฐาน - * "เคยทำงานกับเอกสารนี้" ตอนยอมรับ ticket ที่หมดอายุ (requirement ขั้น 7: - * กลับมาทำเอกสารเดิมผ่านได้แม้ ticket หมดอายุ — ตัวตนยังยึด HMAC ของ e-Court - * ทางเข้า /api/documents/token ของ add-in) · อ่านอย่างเดียว ไม่แตะ MinIO - */ -export async function hasVersionByUser(docId: string, userId: string): Promise { - const [row] = await db - .select({ one: sql`1` }) - .from(documentVersions) - .innerJoin(documents, eq(documentVersions.documentId, documents.id)) - .where(and(eq(documents.docId, docId), eq(documentVersions.userId, userId))) - .limit(1); - return row !== undefined; -} - export type InsertVersionInput = { /** กำหนดเอง (ไม่ปล่อย defaultRandom) เพราะ object ใน MinIO เขียนด้วย uuid นี้ไปก่อนแล้ว */ versionId: string; diff --git a/lib/openapi.ts b/lib/openapi.ts index 50b8cfe..2c27077 100644 --- a/lib/openapi.ts +++ b/lib/openapi.ts @@ -694,16 +694,18 @@ export const openApiSpec = { description: "ออก `access_token` (JWT HS256) ไว้เรียก endpoint อื่นของกลุ่ม documents — " + "token ฝัง claims `{userid, username, fid, template}` อายุตาม env " + - "`DOCVER_TOKEN_TTL_SECONDS` (default 3600 วินาที)\n\n" + + "`DOCVER_TOKEN_TTL_SECONDS` (default 604800 วินาที = 7 วัน — token อายุยาวแทน" + + "ระบบ refresh/renew ของ add-in)\n\n" + "**ทางเข้า 2 แบบ** — เลือกตามว่าใครเป็นคนเรียก (ดูตัวอย่างใน Request body ได้ทั้งคู่):\n\n" + "1. **ระบบภายนอก (server-to-server)** — แนบ header `x-api-key` (ค่าจาก env " + "`DOCVER_API_KEY`) แล้วส่ง claims ตรง ๆ ใน body ตาม schema `TokenRequest`\n" + "2. **add-in ใน Word** — ไม่ต้องมี `x-api-key` ส่ง body `{ticket_data, HmacSha256}` " + "ตาม schema `TicketTokenRequest` (คู่ค่าที่ e-Court stamp ไว้ใน custom properties " + "ของไฟล์ .docx) — server ตรวจลายเซ็น HMAC-SHA256 ด้วย env `ADDIN_TICKET_SECRET` " + - '(ไม่ตั้ง → env `SECRET` → `"super_secret"`) แล้วออก token จาก claims ที่อยู่ในตัว ticket เอง · ' + - "**ticket หมดอายุ** ยังออกให้ต่อเมื่อเอกสารนี้ (`fid`) เคยมี version ที่สร้างโดย `uid` เดียวกัน " + - "(ผู้ที่เคยทำงานกับเอกสารเดิมกลับมาใช้ต่อได้แม้ ticket หมดอายุ — ไม่เคยทำงาน = 401)\n\n" + + '(ไม่ตั้ง → env `SECRET` → `"super_secret"`) แล้วออก token จาก claims ที่อยู่ในตัว ticket เอง ' + + "(contract 2026-11-04: ฟอร์แม็ตพัง/ไม่ครบ = 400 · ลายเซ็นไม่ถูก = 401 · " + + "**ลายเซ็นถูกแต่ ticket หมดอายุ = 403** — add-in ที่มี access_token เดิมของ `fid` " + + "นี้ใน session ยังใช้ต่อได้ ไม่มี = ขอไฟล์ใหม่จาก e-Court)\n\n" + "ได้ token แล้วแนบเป็น `Authorization: Bearer ` กับทุก request ถัดไป", security: [{ ApiKeyAuth: [] }], requestBody: { @@ -753,7 +755,17 @@ export const openApiSpec = { 401: { description: "ทางเข้า x-api-key: ไม่ได้ส่ง header หรือค่าไม่ถูกต้อง (env DOCVER_API_KEY) · " + - "ทางเข้า ticket: ticket ไม่ถูกต้อง/หมดอายุ หรือไม่ได้ส่งมา (ข้อความเดียวกันทุกสาเหตุ)", + "ทางเข้า ticket: ลายเซ็น HMAC ไม่ถูก (ข้อความเดียวกันทุกสาเหตุ)", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + 403: { + description: + "เฉพาะทางเข้า ticket: ลายเซ็นถูกแต่ ticket หมดอายุ — ใช้ access_token เดิมของ " + + "fid นี้ใน session ต่อ หรือขอไฟล์ใหม่จาก e-Court (contract 2026-11-04)", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" }, @@ -945,7 +957,7 @@ export const openApiSpec = { "- `type=pdf` → คืนไบต์ pdf ตรง ๆ (version ล่าสุดไม่มีสำเนา PDF = 404)\n" + "- `type=docx` → server เติม `exp` ใหม่ (now + `TICKET_SEC` default 300 วิ) + " + "คำนวณ `HmacSha256` (secret ตาม chain `ADDIN_TICKET_SECRET` → `SECRET` → " + - "`\"super_secret\"` ให้ตรงฝั่ง verify ของ add-in) และ `Ed25519` (เฉพาะเมื่อ " + + "`\"super_secret\"` ให้ตรงฝั่ง verify ของ server — ทาง ticket ของ /token) และ `Ed25519` (เฉพาะเมื่อ " + "`PRIVATE_KEY_HEX` ตั้ง) แล้วเขียน custom properties ลง**สำเนาในหน่วยความจำ** " + "ก่อนคืนไฟล์ — object ใน MinIO ไม่ถูกแต่ (non-destructive) และเขียนทับ stamp " + "เดิมของ template ได้\n" + @@ -1127,13 +1139,13 @@ export const openApiSpec = { }, TokenResponse: { type: "object", - description: "access_token ของกลุ่ม documents (JWT HS256 อายุสั้น)", + description: "access_token ของกลุ่ม documents (JWT HS256)", properties: { access_token: { type: "string", description: "JWT พร้อมใช้แนบเป็น Bearer" }, token_type: { type: "string", example: "Bearer" }, expires_in: { type: "integer", - description: "อายุวินาที (env DOCVER_TOKEN_TTL_SECONDS, default 3600)", + description: "อายุวินาที (env DOCVER_TOKEN_TTL_SECONDS, default 604800 = 7 วัน)", }, }, }, diff --git a/tests/app/api/documents/token.test.ts b/tests/app/api/documents/token.test.ts new file mode 100644 index 0000000..803a271 --- /dev/null +++ b/tests/app/api/documents/token.test.ts @@ -0,0 +1,215 @@ +// POST /api/documents/token — contract ทางเข้า ticket ฉบับ 2026-11-04 + regression ทางเข้า x-api-key +// route pure ล้วนหลังถอด hasVersionByUser (ไม่ import @/db/minio อีกต่อไป) จึง unit test +// ได้โดยไม่ต้องตั้ง DATABASE_URL/MINIO_* — เคส 403 ผ่านโดยไม่มี DB เลย = พิสูจน์ว่า route +// ไม่แตะ DB (เลิก "ตรวจเคยมี version" ของ requirement ขั้น 7 ฉบับ 2026-10-28) +import { createHmac } from "node:crypto"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { POST } from "@/app/api/documents/token/route"; + +/** secret ตาม chain ของ addinTicketSecret — ลบ env ทั้งสองตัวใน setup เพื่อใช้ค่า default */ +const SECRET_DEFAULT = "super_secret"; +const TOKEN_SECRET = "unit-test-token-secret"; +const API_KEY = "unit-test-api-key"; + +const SAVED_ENV: Record = {}; + +function mintTicket( + exp: number, + overrides?: { fid?: string; uid?: string; uname?: string; tid?: string }, +): { ticketData: string; ticketHmac: string } { + const ticket = { + exp, + fid: overrides?.fid ?? "F1", + ...(overrides?.tid !== undefined ? { tid: overrides.tid } : { tid: "T12" }), + uid: overrides?.uid ?? "oom", + uname: overrides?.uname ?? "สรวิชญ์", + }; + const ticketData = JSON.stringify(ticket); + const ticketHmac = createHmac("sha256", SECRET_DEFAULT).update(ticketData, "utf8").digest("hex"); + return { ticketData, ticketHmac }; +} + +function futureExp(): number { + return Math.floor(Date.now() / 1000) + 3_600; +} + +function makeRequest(body: BodyInit, headers?: Record): Request { + return new Request("http://localhost/docedit/api/documents/token", { + method: "POST", + headers: { "content-type": "application/json", ...headers }, + body, + }); +} + +function ticketBody(ticketData: string, ticketHmac: string): string { + return JSON.stringify({ ticket_data: ticketData, HmacSha256: ticketHmac }); +} + +/** decode JWT payload (base64url) โดยไม่ตรวจ signature — ใช้ assert claims/exp เท่านั้น */ +function jwtPayload(token: string): Record { + const payload = token.split(".")[1]; + if (!payload) throw new Error("token ไม่มี payload"); + return JSON.parse( + Buffer.from(payload.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8"), + ) as Record; +} + +beforeEach(() => { + for (const key of [ + "ADDIN_TICKET_SECRET", + "SECRET", + "DOCVER_API_KEY", + "DOCVER_TOKEN_SECRET", + "DOCVER_TOKEN_TTL_SECONDS", + ]) { + SAVED_ENV[key] = process.env[key]; + } + process.env.DOCVER_TOKEN_SECRET = TOKEN_SECRET; + delete process.env.ADDIN_TICKET_SECRET; // ใช้ default "super_secret" ของ addinTicketSecret + delete process.env.SECRET; + delete process.env.DOCVER_TOKEN_TTL_SECONDS; +}); + +afterEach(() => { + for (const [key, value] of Object.entries(SAVED_ENV)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } +}); + +describe("POST /api/documents/token — ทางเข้า ticket (contract 2026-11-04)", () => { + it("ticket ถูกทั้งลายเซ็น+อายุ → 200 + JWT claims จาก ticket + expires_in 604800 (default 7 วัน)", async () => { + const { ticketData, ticketHmac } = mintTicket(futureExp(), { fid: "F9", uid: "u1", uname: "นายทดสอบ" }); + const response = await POST(makeRequest(ticketBody(ticketData, ticketHmac))); + + expect(response.status).toBe(200); + const data = (await response.json()) as { access_token: string; token_type: string; expires_in: number }; + expect(data.token_type).toBe("Bearer"); + expect(data.expires_in).toBe(604_800); + + const payload = jwtPayload(data.access_token); + expect(payload.userid).toBe("u1"); + expect(payload.username).toBe("นายทดสอบ"); + expect(payload.fid).toBe("F9"); + expect(payload.template).toBe("T12"); + expect(payload.purpose).toBe("docver"); + expect((payload.exp as number) - (payload.iat as number)).toBe(604_800); + }); + + it("body ไม่ใช่ JSON → 400", async () => { + const response = await POST(makeRequest("not-json{")); + expect(response.status).toBe(400); + }); + + it("ไม่ส่ง ticket มาเลย / ค่าว่าง → 400 (ย้ายจาก 401 ตาม contract ฟอร์แม็ตพัง)", async () => { + for (const body of ["{}", JSON.stringify({ ticket_data: "" , HmacSha256: "ab".repeat(32) })]) { + const response = await POST(makeRequest(body)); + expect(response.status, body).toBe(400); + } + }); + + it("field นอก allowlist / ผิดชนิด / ยาวเกิน cap → 400", async () => { + for (const body of [ + JSON.stringify({ ticket_data: "x", HmacSha256: "y", oops: 1 }), + JSON.stringify({ ticket_data: 42, HmacSha256: "y" }), + JSON.stringify({ ticket_data: "x".repeat(2049), HmacSha256: "y" }), + ]) { + const response = await POST(makeRequest(body)); + expect(response.status, body).toBe(400); + } + }); + + it("ลายเซ็นผิด (แก้ ticket_data / digest เพี้ยน / secret ของ server ต่าง) → 401 ข้อความเดียวกันทุกเคส", async () => { + const { ticketData, ticketHmac } = mintTicket(futureExp()); + const tampered = ticketData.replace("F1", "F2"); // แก้ 1 ตัว — HMAC ไม่ตรง + + const cases: { label: string; body: string; secretEnv?: [string, string] }[] = [ + { label: "แก้ ticket_data", body: ticketBody(tampered, ticketHmac) }, + { label: "digest ไม่ใช่ hex", body: ticketBody(ticketData, "zz".repeat(32)) }, + { + label: "secret ฝั่ง server ต่าง", + body: ticketBody(ticketData, ticketHmac), + secretEnv: ["ADDIN_TICKET_SECRET", "different-secret"], + }, + ]; + const messages = new Set(); + for (const { label, body, secretEnv } of cases) { + if (secretEnv) process.env[secretEnv[0]] = secretEnv[1]; + const response = await POST(makeRequest(body)); + expect(response.status, label).toBe(401); + messages.add((await response.json()).error); + if (secretEnv) delete process.env[secretEnv[0]]; + } + expect(messages.size).toBe(1); // ข้อความเดียว กัน oracle + expect(Array.from(messages)[0]).toBe("ticket ไม่ถูกต้อง"); + }); + + it("ลายเซ็นถูกแต่ exp อดีต → 403 \"ticket ของ e-Court หมดอายุ\" (ไม่ตรวจ DB — route ไม่มี DB)", async () => { + const { ticketData, ticketHmac } = mintTicket(Math.floor(Date.now() / 1000) - 60); + const response = await POST(makeRequest(ticketBody(ticketData, ticketHmac))); + + expect(response.status).toBe(403); + expect((await response.json()).error).toBe("ticket ของ e-Court หมดอายุ"); + }); + + it("HMAC ถูกแต่ ticket_data ผิดกติกา (field หลุด allowlist) → 401", async () => { + const invalid = JSON.stringify({ exp: futureExp(), fid: "f", uid: "u", uname: "n", oops: 1 }); + const hmac = createHmac("sha256", SECRET_DEFAULT).update(invalid, "utf8").digest("hex"); + const response = await POST(makeRequest(ticketBody(invalid, hmac))); + expect(response.status).toBe(401); + }); + + it("DOCVER_TOKEN_SECRET ไม่ตั้ง → 500 (ความผิด config ฝั่ง server ไม่ใช่ของผู้เรียก)", async () => { + delete process.env.DOCVER_TOKEN_SECRET; + const { ticketData, ticketHmac } = mintTicket(futureExp()); + const response = await POST(makeRequest(ticketBody(ticketData, ticketHmac))); + expect(response.status).toBe(500); + }); + + it("DOCVER_TOKEN_TTL_SECONDS=120 → expires_in 120 (env override default)", async () => { + process.env.DOCVER_TOKEN_TTL_SECONDS = "120"; + const { ticketData, ticketHmac } = mintTicket(futureExp()); + const response = await POST(makeRequest(ticketBody(ticketData, ticketHmac))); + expect(response.status).toBe(200); + expect(((await response.json()) as { expires_in: number }).expires_in).toBe(120); + }); +}); + +describe("POST /api/documents/token — ทางเข้า x-api-key (regression ไม่เปลี่ยน)", () => { + it("key ถูก + body claims → 200 ได้ token อายุ default ใหม่ 604800", async () => { + process.env.DOCVER_API_KEY = API_KEY; + const response = await POST( + makeRequest(JSON.stringify({ userid: "u1", username: "นายทดสอบ", fid: "F1" }), { + "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(604_800); + const payload = jwtPayload(data.access_token); + expect(payload.userid).toBe("u1"); + expect(payload.fid).toBe("F1"); + expect(payload.purpose).toBe("docver"); + }); + + it("key ผิด → 401", async () => { + process.env.DOCVER_API_KEY = API_KEY; + const response = await POST( + makeRequest(JSON.stringify({ userid: "u1", username: "n", fid: "F1" }), { + "x-api-key": "wrong-key", + }), + ); + expect(response.status).toBe(401); + }); + + it("มี header x-api-key แต่ env ไม่ตั้ง → 401 fail closed", async () => { + delete process.env.DOCVER_API_KEY; + const response = await POST( + makeRequest(JSON.stringify({ userid: "u1", username: "n", fid: "F1" }), { + "x-api-key": API_KEY, + }), + ); + expect(response.status).toBe(401); + }); +}); diff --git a/tests/lib/addin-ticket.test.ts b/tests/lib/addin-ticket.test.ts index d5003ff..9986e89 100644 --- a/tests/lib/addin-ticket.test.ts +++ b/tests/lib/addin-ticket.test.ts @@ -75,8 +75,7 @@ describe("verifyAddinTicket", () => { }); // verifyAddinTicketHmac = ตัวเดียวกันแต่ไม่ตรวจ exp — route ใช้ตรวจ HMAC ก่อน -// แล้วตัดสิน "ticket หมดอายุ + เอกสารนี้เคยทำงานโดย uid เดียวกัน" จาก DB เอง -// (requirement ขั้น 7 — แก้บั๊ก 2026-10-28) +// แล้วตัดสิน exp เองภายหลัง (ลายเซ็นถูกแต่หมดอายุ = 403 — contract 2026-11-04) describe("verifyAddinTicketHmac", () => { it("exp อดีต + HMAC ถูก = ผ่าน (ได้ ticket ครบ) — ต่างจาก verifyAddinTicket ที่ไม่ผ่าน", () => { const expired = JSON.stringify({ exp: 1, fid: "f", uid: "u", uname: "n" }); @@ -122,7 +121,7 @@ describe("parseTicketTokenBody", () => { } }); - it("ขาด field (ไม่ส่ง/ว่าง/null) = missing (→ 401 กัน oracle)", () => { + it("ขาด field (ไม่ส่ง/ว่าง/null) = missing (→ 400 — ฟอร์แม็ตพัง, contract 2026-11-04)", () => { for (const raw of [{}, { ticket_data: "" }, { HmacSha256: "y" }, { ticket_data: null, HmacSha256: "y" }]) { const result = parseTicketTokenBody(raw); expect(result.ok, JSON.stringify(raw)).toBe(false); diff --git a/tests/utils/docver-ticket.test.ts b/tests/utils/docver-ticket.test.ts index 7d09612..3d1bb78 100644 --- a/tests/utils/docver-ticket.test.ts +++ b/tests/utils/docver-ticket.test.ts @@ -5,7 +5,6 @@ import { normalizeHexDigest, parseDocverTicket, parseTicketDataParam, - timingSafeEqualHex, } from "@/utils/docver-ticket"; // PLAN-ADDIN-AUTH.md §4 — pure ล้วนไม่แตะ env/DB อะไรเลย @@ -116,15 +115,6 @@ describe("normalizeHexDigest", () => { }); }); -describe("timingSafeEqualHex", () => { - it("ค่าเท่ากัน = true ต่างกัน = false · ความยาวไม่เท่า = false ทันที", () => { - const a = "ab".repeat(32); - expect(timingSafeEqualHex(a, a)).toBe(true); - expect(timingSafeEqualHex(a, "cd".repeat(32))).toBe(false); - expect(timingSafeEqualHex(a, "ab")).toBe(false); - }); -}); - // parseTicketDataParam — query param `ticket_data` ของ GET /api/documents/latest: // ticket ก่อนเติม exp (server เติมตอน stamp) แต่บังคับ fid/uid/uname เพราะ // parseDocverTicket บังคับทั้งสามตอน add-in redeem — ขาด = ไฟล์ที่คืนไปเปิดไม่ได้ diff --git a/tests/wordaddin/ticket-auth-flow.test.ts b/tests/wordaddin/ticket-auth-flow.test.ts index 0ab6938..5cea20c 100644 --- a/tests/wordaddin/ticket-auth-flow.test.ts +++ b/tests/wordaddin/ticket-auth-flow.test.ts @@ -1,11 +1,11 @@ // @vitest-environment jsdom -// ticket auth ของ add-in (PLAN-ADDIN-AUTH.md §5) — คุมพฤติกรรม service layer: -// happy-path ขอ token ครั้งเดียวต่อ fid, เปิดเอกสารเดิมซ้ำใช้ token เดิมไม่ยิง /token ซ้ำ, -// memory fallback ของ session, ทุก fail path ต้องคืน result ไม่ใช่ throw และ -// ticket หมดอายุ + เอกสารที่เคยทำงาน (requirement ขั้น 7 — บั๊ก 2026-10-28: -// client ยิงขอ token เสมอ server เป็นคนตัดสินเรื่อง "เคยทำงาน" จาก DB) -// (jsdom ไม่มี crypto.subtle — ฉีด webcrypto ของ node แทน secure context ของ webview จริง) -import { createHmac, webcrypto } from "node:crypto"; +// ticket auth ของ add-in (PLAN-ADDIN-AUTH.md §5 — contract 2026-11-04) — คุมพฤติกรรม +// service layer: pre-check ฝั่ง client (missing/ฟอร์แม็ต/หมดอายุ+ไม่มี session ไม่ยิง API), +// ยิง /api/documents/token ทุกครั้งที่เปิดเอกสารแล้วแยกทางตาม status (200/400/401/403), +// memory fallback ของ session, ทุก fail path ต้องคืน result ไม่ใช่ throw +// (ไม่มีการตรวจ HMAC ฝั่ง client อีกต่อไป — PLAN-SSO.md D13: secret เป็นของ server +// อย่างเดียว "ไฟล์ถูกแก้" ตรวจจาก 400/401 ของ server) +import { createHmac } from "node:crypto"; import { afterEach, describe, expect, it, vi } from "vitest"; import * as authService from "@/wordaddin/services/authService"; @@ -18,110 +18,25 @@ function trapErrors() { window.addEventListener("unhandledrejection", (event) => errors.push(event.reason)); } -function makeTicket(expSec?: number): { ticketData: string; ticketHmac: string } { +const FID = "DBG_FID_001"; + +function makeTicket(overrides?: { exp?: number; fid?: string; uid?: string; uname?: string }): { + ticketData: string; + ticketHmac: string; +} { const ticket = { - exp: expSec ?? Math.floor(Date.now() / 1000) + 300, - fid: "DBG_FID_001", + exp: overrides?.exp ?? Math.floor(Date.now() / 1000) + 300, + fid: overrides?.fid ?? FID, tid: "T12", - uid: "oom", - uname: "สรวิชญ์", + uid: overrides?.uid ?? "oom", + uname: overrides?.uname ?? "สรวิชญ์", }; const ticketData = JSON.stringify(ticket); + // HMAC ยัง mint จริงตามตัว stamp (server ใน test อื่นจะเทียบค่านี้ — ฝั่ง client ไม่แตะแล้ว) const ticketHmac = createHmac("sha256", "super_secret").update(ticketData, "utf8").digest("hex"); return { ticketData, ticketHmac }; } -afterEach(() => { - window.localStorage.clear(); - // memory fallback ของ session เป็น module state — เคลียร์ด้วยไม่งั้นค้างข้ามเคส - ticketService.clearSession(); - authService.signInForDemo(); // รีเซ็ต state ของ authService (username/fid/ticket) - vi.restoreAllMocks(); - vi.unstubAllGlobals(); - errors.length = 0; -}); - -describe("signInWithTicket (service layer)", () => { - it("B: signInWithTicket happy-path (มี crypto.subtle + fetch ตอบ 200) ไม่ต้อง throw", async () => { - trapErrors(); - // jsdom ไม่มี crypto.subtle — ใส่ webcrypto ของ node แทน (secure context ของ webview จริงมีอยู่แล้ว) - vi.stubGlobal("crypto", webcrypto); - - const { ticketData, ticketHmac } = makeTicket(); - const fetchMock = vi.fn( - async () => - new Response( - JSON.stringify({ - access_token: [ - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9", - Buffer.from( - JSON.stringify({ userid: "oom", fid: "DBG_FID_001", exp: Math.floor(Date.now() / 1000) + 3600 }), - ).toString("base64url"), - "sig", - ].join("."), - token_type: "Bearer", - expires_in: 3600, - }), - { status: 200, headers: { "content-type": "application/json" } }, - ), - ); - vi.stubGlobal("fetch", fetchMock); - - const first = await authService.signInWithTicket({ ticketData, ticketHmac }); - expect(first.ok, JSON.stringify(first)).toBe(true); - - // เปิดเอกสารเดิมซ้ำ — ต้องไม่ยิง /token ซ้ำ (ใช้ token เดิมจาก localStorage) - await authService.signInWithTicket({ ticketData, ticketHmac }); - expect(fetchMock).toHaveBeenCalledTimes(1); - - expect(errors).toEqual([]); - expect(window.localStorage.getItem("wordaddin-auth")).toBeTruthy(); - }); - - it("C: signInWithTicket แบบไม่มี crypto.subtle (webview เก่า) ต้องจบเป็น no-access ไม่ใช่ throw", async () => { - trapErrors(); - const noSubtle = { getRandomValues: () => new Uint8Array(0) }; - vi.stubGlobal("crypto", noSubtle); // ไม่มี .subtle เลย - const { ticketData, ticketHmac } = makeTicket(); - const result = await authService.signInWithTicket({ ticketData, ticketHmac }); - expect(result.ok).toBe(false); - expect(errors).toEqual([]); - }); - - it("D: ticket ผิดทุกแบบ (ขาด/hex เพี้ยน/parse ไม่ผ่าน/หมดอายุ/fetch 401) ต้องคืน result ไม่ใช่ throw", async () => { - trapErrors(); - vi.stubGlobal("crypto", webcrypto); - vi.stubGlobal( - "fetch", - vi.fn(async () => new Response(JSON.stringify({ error: "no" }), { status: 401 })), - ); - const { ticketData, ticketHmac } = makeTicket(); - const bad = [ - { ticketData: undefined, ticketHmac: undefined }, - { ticketData: "not-json", ticketHmac }, - { ticketData, ticketHmac: "zz".repeat(32) }, - { - ticketData: JSON.stringify({ exp: 1, fid: "f", uid: "u", uname: "n" }), // หมดอายุ + HMAC ถูก - ticketHmac: createHmac("sha256", "super_secret") - .update(JSON.stringify({ exp: 1, fid: "f", uid: "u", uname: "n" }), "utf8") - .digest("hex"), - }, - { ticketData, ticketHmac }, // ถูก แต่ server 401 → token-request-failed - ] satisfies { ticketData?: string; ticketHmac?: string }[]; - for (const input of bad) { - const result = await authService.signInWithTicket(input); - expect(result.ok, JSON.stringify(input)).toBe(false); - if (!result.ok) expect(typeof result.message).toBe("string"); - } - expect(errors).toEqual([]); - }); -}); - -// ─── ticket หมดอายุ + เอกสารที่เคยทำงาน (requirement ขั้น 7 — บั๊ก 2026-10-28) ── -// เกณฑ์: uid ตรงกัน + เคยสร้าง token ของ fid เดิม → ไม่ต้องสน expire ของ ticket -// ฝั่ง client ยิงขอ token เสมอแล้วให้ server ตัดสิน (200 = เคยทำงาน / 401 = ไม่เคย) -const FID = "DBG_FID_001"; - function tokenResponse(token: string): Response { return new Response( JSON.stringify({ @@ -133,95 +48,323 @@ function tokenResponse(token: string): Response { ); } -function seedSession(token: string, tokenExpSec: number): void { +function errorResponse(status: number): Response { + return new Response(JSON.stringify({ error: "stub" }), { + status, + headers: { "content-type": "application/json" }, + }); +} + +function seedSession(token: string, tokenExpSec: number, userId = "oom"): void { ticketService.saveSession({ - userId: "oom", - userName: "สรวิชญ์", + userId, + userName: "ชื่อเก่า", tokens: { [FID]: { token, exp: tokenExpSec } }, }); } -describe("ticket หมดอายุ + เอกสารที่เคยทำงาน", () => { - it("session เดิม + token ยังไม่หมด → ผ่านเลย ไม่ยิง /token", async () => { +afterEach(() => { + window.localStorage.clear(); + // memory fallback ของ session เป็น module state — เคลียร์ด้วยไม่งั้นค้างข้ามเคส + ticketService.clearSession(); + authService.signInForDemo(); // รีเซ็ต state ของ authService (username/fid/ticket) + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + errors.length = 0; +}); + +describe("signInWithTicket — pre-check ฝั่ง client (เตะโดยไม่ยิง API)", () => { + it("ไม่มี ticket / ยาวเกิน 255 → reason missing", async () => { trapErrors(); - vi.stubGlobal("crypto", webcrypto); const fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); - seedSession("cached-token", Math.floor(Date.now() / 1000) + 1800); + const { ticketData, ticketHmac } = makeTicket(); - const { ticketData, ticketHmac } = makeTicket(1); // ticket หมดอายุตั้งแต่ 1970 - const result = await authService.signInWithTicket({ ticketData, ticketHmac }); - - expect(result.ok, JSON.stringify(result)).toBe(true); + const bad = [ + { ticketData: undefined, ticketHmac: undefined }, + { ticketData: "", ticketHmac }, + { ticketData: "x".repeat(256), ticketHmac }, + { ticketData, ticketHmac: "x".repeat(256) }, + ] satisfies { ticketData?: string; ticketHmac?: string }[]; + for (const input of bad) { + const result = await authService.signInWithTicket(input); + expect(result.ok, JSON.stringify(input)).toBe(false); + if (!result.ok) expect(result.reason).toBe("missing"); + } expect(fetchMock).not.toHaveBeenCalled(); expect(errors).toEqual([]); }); - it("session เดิม + token หมดด้วย → ยิงขอใหม่ (server ตัดสิน) ได้ token ใหม่", async () => { + it("parse ฟอร์แม็ตไม่ผ่าน → reason tampered ไม่ยิง fetch", async () => { trapErrors(); - vi.stubGlobal("crypto", webcrypto); - const fetchMock = vi.fn(async () => tokenResponse("token-fresh")); + const fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); - seedSession("token-stale", Math.floor(Date.now() / 1000) - 60); - const { ticketData, ticketHmac } = makeTicket(1); - const result = await authService.signInWithTicket({ ticketData, ticketHmac }); - - expect(result.ok, JSON.stringify(result)).toBe(true); - expect(fetchMock).toHaveBeenCalledTimes(1); - const stored = JSON.parse(window.localStorage.getItem("wordaddin-auth") ?? "{}"); - expect(stored.tokens[FID].token).toBe("token-fresh"); + const result = await authService.signInWithTicket({ ticketData: "not-json", ticketHmac: "aa".repeat(32) }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toBe("tampered"); + expect(fetchMock).not.toHaveBeenCalled(); expect(errors).toEqual([]); }); - it("ไม่มี session (localStorage หาย) + server 401 (ยังไม่เคยทำงาน) → expired ไม่ใช่ crash", async () => { + it("ticket หมดอายุ + ไม่มี session → reason expired ไม่ยิง fetch", async () => { trapErrors(); - vi.stubGlobal("crypto", webcrypto); - vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({ error: "no" }), { status: 401 }))); + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); - const { ticketData, ticketHmac } = makeTicket(1); + const { ticketData, ticketHmac } = makeTicket({ exp: 1 }); const result = await authService.signInWithTicket({ ticketData, ticketHmac }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toBe("expired"); + expect(fetchMock).not.toHaveBeenCalled(); + expect(errors).toEqual([]); + }); + it("ticket หมดอายุ + session เป็น uid คนอื่น → reason expired ไม่ยิง fetch", async () => { + trapErrors(); + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + seedSession("other-token", Math.floor(Date.now() / 1000) + 1800, "someone-else"); + + const { ticketData, ticketHmac } = makeTicket({ exp: 1 }); + const result = await authService.signInWithTicket({ ticketData, ticketHmac }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toBe("expired"); + expect(fetchMock).not.toHaveBeenCalled(); + expect(errors).toEqual([]); + }); + + it("ticket หมดอายุ + uid ตรงแต่ไม่มี entry ของ fid นี้ → reason expired ไม่ยิง fetch", async () => { + trapErrors(); + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + // session ของ oom แต่เก็บ token ของ fid อื่น + ticketService.saveSession({ + userId: "oom", + userName: "สรวิชญ์", + tokens: { OTHER_FID: { token: "other", exp: Math.floor(Date.now() / 1000) + 1800 } }, + }); + + const { ticketData, ticketHmac } = makeTicket({ exp: 1 }); + const result = await authService.signInWithTicket({ ticketData, ticketHmac }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toBe("expired"); + expect(fetchMock).not.toHaveBeenCalled(); + expect(errors).toEqual([]); + }); +}); + +describe("signInWithTicket — ยิง /token ทุกครั้งที่เปิดเอกสาร", () => { + it("happy-path: ไม่มี session + 200 → ok และสร้าง session ใหม่ครบ userId/userName/tokens[fid]", async () => { + trapErrors(); + const fetchMock = vi.fn(async () => tokenResponse("token-1")); + vi.stubGlobal("fetch", fetchMock); + + const { ticketData, ticketHmac } = makeTicket(); + const result = await authService.signInWithTicket({ ticketData, ticketHmac }); + expect(result.ok, JSON.stringify(result)).toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(1); + + const stored = JSON.parse(window.localStorage.getItem("wordaddin-auth") ?? "{}"); + expect(stored.userId).toBe("oom"); + expect(stored.userName).toBe("สรวิชญ์"); + expect(stored.tokens[FID].token).toBe("token-1"); + expect(errors).toEqual([]); + }); + + it("เปิดเอกสารเดิมซ้ำ → ยิง /token อีกครั้ง (ตรวจลายเซ็นที่ server ทุกครั้ง) แล้วแทน entry ด้วย token ใหม่", async () => { + trapErrors(); + const fetchMock = vi.fn(async () => tokenResponse("token-2")); + vi.stubGlobal("fetch", fetchMock); + + const { ticketData, ticketHmac } = makeTicket(); + await authService.signInWithTicket({ ticketData, ticketHmac }); + await authService.signInWithTicket({ ticketData, ticketHmac }); + + expect(fetchMock).toHaveBeenCalledTimes(2); + const stored = JSON.parse(window.localStorage.getItem("wordaddin-auth") ?? "{}"); + expect(stored.tokens[FID].token).toBe("token-2"); + expect(errors).toEqual([]); + }); + + it("user ใหม่ (session uid ต่าง) + 200 → เคลียร์ session เก่าทั้งก้อนแล้วสร้างใหม่", async () => { + trapErrors(); + const fetchMock = vi.fn(async () => tokenResponse("token-new-user")); + vi.stubGlobal("fetch", fetchMock); + seedSession("old-user-token", Math.floor(Date.now() / 1000) + 1800, "someone-else"); + + const { ticketData, ticketHmac } = makeTicket(); + const result = await authService.signInWithTicket({ ticketData, ticketHmac }); + expect(result.ok, JSON.stringify(result)).toBe(true); + + const stored = JSON.parse(window.localStorage.getItem("wordaddin-auth") ?? "{}"); + expect(stored.userId).toBe("oom"); + expect(stored.tokens[FID].token).toBe("token-new-user"); + expect(JSON.stringify(stored)).not.toContain("old-user-token"); // ของเดิมถูกเคลียร์ทิ้งจริง + expect(errors).toEqual([]); + }); + + it("คนเดิมแต่ uname เปลี่ยน → session.userName อัปเดตตามไฟล์ล่าสุด", async () => { + trapErrors(); + const fetchMock = vi.fn(async () => tokenResponse("token-keep")); + vi.stubGlobal("fetch", fetchMock); + + await authService.signInWithTicket(makeTicket({ uname: "ชื่อเดิม" })); + await authService.signInWithTicket(makeTicket({ uname: "ชื่อใหม่" })); + + const stored = JSON.parse(window.localStorage.getItem("wordaddin-auth") ?? "{}"); + expect(stored.userName).toBe("ชื่อใหม่"); + expect(errors).toEqual([]); + }); +}); + +describe("signInWithTicket — แยกทางตาม status ของ /token (contract 2026-11-04)", () => { + it("400 (ฟอร์แม็ตพัง) → reason tampered", async () => { + trapErrors(); + const fetchMock = vi.fn(async () => errorResponse(400)); + vi.stubGlobal("fetch", fetchMock); + + const result = await authService.signInWithTicket(makeTicket()); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toBe("tampered"); + expect(errors).toEqual([]); + }); + + it("401 (ลายเซ็นไม่ถูก) → reason tampered", async () => { + trapErrors(); + vi.stubGlobal("fetch", vi.fn(async () => errorResponse(401))); + + const result = await authService.signInWithTicket(makeTicket()); expect(result.ok).toBe(false); if (!result.ok) { - expect(result.reason).toBe("expired"); - expect(typeof result.message).toBe("string"); + expect(result.reason).toBe("tampered"); + expect(result.message).toContain("ลายเซ็น"); } expect(errors).toEqual([]); }); - it("ไม่มี session + เคยทำงาน (server 200) → สร้าง session ใหม่ได้แม้ ticket หมดอายุ", async () => { + it("403 (ลายเซ็นถูกแต่หมดอายุ) + มี JWT ของ fid ใน session ที่ยังไม่หมด → ใช้ entry เดิมต่อ ไม่แทนที่", async () => { trapErrors(); - vi.stubGlobal("crypto", webcrypto); - const fetchMock = vi.fn(async () => tokenResponse("token-rebuilt")); + const fetchMock = vi.fn(async () => errorResponse(403)); vi.stubGlobal("fetch", fetchMock); + seedSession("cached-token", Math.floor(Date.now() / 1000) + 1800); - const { ticketData, ticketHmac } = makeTicket(1); + const { ticketData, ticketHmac } = makeTicket({ exp: 1, uname: "ชื่อใหม่" }); const result = await authService.signInWithTicket({ ticketData, ticketHmac }); - expect(result.ok, JSON.stringify(result)).toBe(true); expect(fetchMock).toHaveBeenCalledTimes(1); + const stored = JSON.parse(window.localStorage.getItem("wordaddin-auth") ?? "{}"); - expect(stored.userId).toBe("oom"); - expect(stored.tokens[FID].token).toBe("token-rebuilt"); + expect(stored.tokens[FID].token).toBe("cached-token"); // ไม่ถูกแทนที่ + expect(stored.userName).toBe("ชื่อใหม่"); // refresh ชื่อตามไฟล์ล่าสุด expect(errors).toEqual([]); }); - it("renewTokenFromTicket กับ ticket หมดอายุ + server 200 → ยังยิงขอ (server ตัดสิน) คืน true", async () => { + it("403 + entry ของ fid หมดอายุแล้ว → reason expired", async () => { + trapErrors(); + const fetchMock = vi.fn(async () => errorResponse(403)); + vi.stubGlobal("fetch", fetchMock); + seedSession("token-stale", Math.floor(Date.now() / 1000) - 60); + + const { ticketData, ticketHmac } = makeTicket({ exp: 1 }); + const result = await authService.signInWithTicket({ ticketData, ticketHmac }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toBe("expired"); + expect(fetchMock).toHaveBeenCalledTimes(1); // pre-check ปล่อยผ่าน (มี entry) แล้ว server ตอบ 403 + expect(errors).toEqual([]); + }); + + it("500 / network / 200 แต่ body เสีย → reason token-request-failed (ไม่ใช่ความผิดของไฟล์)", async () => { + trapErrors(); + const cases: { label: string; mock: () => unknown }[] = [ + { label: "500", mock: () => vi.fn(async () => errorResponse(500)) }, + { label: "network", mock: () => vi.fn(async () => Promise.reject(new Error("offline"))) }, + { + label: "malformed", + mock: () => + vi.fn(async () => new Response(JSON.stringify({ nope: true }), { status: 200, headers: { "content-type": "application/json" } })), + }, + ]; + for (const { label, mock } of cases) { + window.localStorage.clear(); + ticketService.clearSession(); + vi.stubGlobal("fetch", mock()); + + const result = await authService.signInWithTicket(makeTicket()); + expect(result.ok, label).toBe(false); + if (!result.ok) expect(result.reason, label).toBe("token-request-failed"); + expect(errors, label).toEqual([]); + } + }); +}); + +describe("signInWithTicket — resilience", () => { + it("localStorage โดน block (webview/private mode) → ผ่านด้วย memory fallback ไม่ throw", async () => { + trapErrors(); + const blocked = () => { + throw new DOMException("blocked", "SecurityError"); + }; + vi.spyOn(Storage.prototype, "getItem").mockImplementation(blocked); + vi.spyOn(Storage.prototype, "setItem").mockImplementation(blocked); + vi.spyOn(Storage.prototype, "removeItem").mockImplementation(blocked); + vi.stubGlobal("fetch", vi.fn(async () => tokenResponse("token-memory"))); + + const result = await authService.signInWithTicket(makeTicket()); + expect(result.ok, JSON.stringify(result)).toBe(true); + // token ใช้ได้ภายใน task pane instance นี้ผ่าน memory fallback + expect(await authService.getAccessToken()).toBe("token-memory"); + expect(errors).toEqual([]); + }); +}); + +describe("renewTokenFromTicket", () => { + it("200 → true และ session ได้ token ใหม่", async () => { trapErrors(); - vi.stubGlobal("crypto", webcrypto); const fetchMock = vi.fn(async () => tokenResponse("token-renewed")); vi.stubGlobal("fetch", fetchMock); - // sign-in ครั้งแรกด้วย ticket หมดอายุผ่านทาง server 200 → currentTicket เป็นตัวหมดอายุ - const { ticketData, ticketHmac } = makeTicket(1); - const signIn = await authService.signInWithTicket({ ticketData, ticketHmac }); + const signIn = await authService.signInWithTicket(makeTicket()); expect(signIn.ok, JSON.stringify(signIn)).toBe(true); fetchMock.mockClear(); const renewed = await authService.renewTokenFromTicket(); expect(renewed).toBe(true); expect(fetchMock).toHaveBeenCalledTimes(1); + const stored = JSON.parse(window.localStorage.getItem("wordaddin-auth") ?? "{}"); + expect(stored.tokens[FID].token).toBe("token-renewed"); + expect(errors).toEqual([]); + }); + + it("ticket หมดอายุ (server ตอบ 403) / 401 / network → false ไม่ throw", async () => { + trapErrors(); + const cases: { label: string; mock: () => unknown }[] = [ + { label: "403", mock: () => vi.fn(async () => errorResponse(403)) }, + { label: "401", mock: () => vi.fn(async () => errorResponse(401)) }, + { label: "network", mock: () => vi.fn(async () => Promise.reject(new Error("offline"))) }, + ]; + for (const { label, mock } of cases) { + window.localStorage.clear(); + ticketService.clearSession(); + authService.signInForDemo(); + vi.stubGlobal("fetch", vi.fn(async () => tokenResponse("seed-token"))); + const signIn = await authService.signInWithTicket(makeTicket()); + expect(signIn.ok, label).toBe(true); + vi.stubGlobal("fetch", mock()); + + const renewed = await authService.renewTokenFromTicket(); + expect(renewed, label).toBe(false); + expect(errors, label).toEqual([]); + } + }); + + it("ยังไม่ได้ sign in (ไม่มี currentTicket) → false ไม่ยิง fetch", async () => { + trapErrors(); + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + // authService.signInForDemo() ใน afterEach/reset ตั้ง currentTicket = null อยู่แล้ว + + expect(await authService.renewTokenFromTicket()).toBe(false); + expect(fetchMock).not.toHaveBeenCalled(); expect(errors).toEqual([]); }); }); diff --git a/tests/wordaddin/versioningService.test.ts b/tests/wordaddin/versioningService.test.ts index a61ce94..4c146e8 100644 --- a/tests/wordaddin/versioningService.test.ts +++ b/tests/wordaddin/versioningService.test.ts @@ -5,7 +5,7 @@ // templateId — identity มาจาก Bearer หมด · และห้ามส่ง fields — server แกะจากไฟล์เอง // 2026-10-30), getVersions แปลง DTO, getDownloadLink ใส่ ?format= // - โหมด ?addindemo: ใช้ mock ในเครื่อง (ไม่ยิง network เลย) -import { createHmac, webcrypto } from "node:crypto"; +import { createHmac } from "node:crypto"; import { afterEach, describe, expect, it, vi } from "vitest"; import * as authService from "@/wordaddin/services/authService"; @@ -20,7 +20,6 @@ const TOKEN = "header.payload.sig"; // ค่าหยง ๆ ก็ได้ /** sign-in จริงผ่าน signInWithTicket (fetch ตอบ token 1 ครั้ง) — real mode + มี Bearer ใน session */ async function signInReal(): Promise { - vi.stubGlobal("crypto", webcrypto); // jsdom ไม่มี crypto.subtle const ticket = { exp: Math.floor(Date.now() / 1000) + 300, fid: "REAL_FID_001", diff --git a/utils/docver-ticket.ts b/utils/docver-ticket.ts index 696f96f..dcb1c43 100644 --- a/utils/docver-ticket.ts +++ b/utils/docver-ticket.ts @@ -1,7 +1,9 @@ // PLAN-ADDIN-AUTH.md §4 — ticket ของ add-in (custom properties `ticket_data` + // `HmacSha256` ที่ e-Court ผ่านกลุ่ม template stamp ลงไฟล์) — ส่วน pure ที่ // ใช้ร่วมกันทั้งฝั่ง server (lib/addin-ticket.ts — route /api/documents/token) -// และฝั่ง client ของ add-in (wordaddin/services/ticketService.ts) +// และฝั่ง client ของ add-in (wordaddin/services/authService.ts — ใช้แค่ +// parse + exp เป็น UX gate การตรวจ HMAC เป็นของ server อย่างเดียวตาม +// PLAN-SSO.md D13, 2026-11-04) // // กติกา (docs/requirement/addin-workflow.md + lib/lib-utils.ts generateHmacSha256 // ของตัว stamp ฝั่ง template): @@ -10,8 +12,7 @@ // - HmacSha256 เป็น hex lowercase 64 ตัว (การเทียบ normalize เป็น lowercase // ให้ก่อน — ฝั่ง stamp ตัวพิมพ์ใหญ่ก็ผ่านได้) // - ห้าม import อะไรที่เป็น platform-specific (node:crypto/crypto.subtle) — -// module นี้ต้องรันได้ทั้งใน route handler และ webview ของ Word โดยส่ง -// ตัวคำนวณ HMAC เข้ามาทาง parameter แทน +// module นี้ต้องรันได้ทั้งใน route handler และ webview ของ Word /** ticket ที่แกะจาก ticket_data — ชื่อ field ตามที่ e-Court กำหนด (requirement) */ export interface DocverTicket { @@ -216,25 +217,9 @@ export function isTicketExpired(exp: number, nowSec: number = Math.floor(Date.no /** * normalize ค่า digest ที่ได้รับ (property `HmacSha256` / body field) — * trim + lowercase แล้วตรวจว่าเป็น hex ครบ 64 ตัว คืน null เมื่อรูปไม่ใช่ + * (ใช้ฝั่ง server ตอนเทียบ HMAC — lib/addin-ticket.ts) */ export function normalizeHexDigest(value: string): string | null { const normalized = value.trim().toLowerCase(); return /^[0-9a-f]{64}$/.test(normalized) ? normalized : null; } - -/** - * เทียบ hex digest สองค่าแบบ constant-time (best-effort ของ pure JS — - * ฝั่ง server จริงใช้ node:crypto timingSafeEqual ผ่าน lib/addin-ticket.ts - * ตัวนี้ใช้กับ UX gate ฝั่ง client เป็นหลัก) — ค่าที่ส่งเข้าควร normalize แล้ว - */ -export function timingSafeEqualHex(a: string, b: string): boolean { - if (a.length !== b.length) return false; - let diff = 0; - for (let i = 0; i < a.length; i++) { - diff |= a.charCodeAt(i) ^ b.charCodeAt(i); - } - return diff === 0; -} - -/** ตัวคำนวณ HMAC-SHA256 คืนค่าเป็น hex lowercase — สองฝั่งฉีดของตัวเอง (Web Crypto / node:crypto) */ -export type HmacSha256Hex = (message: string, secret: string) => Promise; diff --git a/wordaddin/config.ts b/wordaddin/config.ts index 646079e..6c8e663 100644 --- a/wordaddin/config.ts +++ b/wordaddin/config.ts @@ -11,10 +11,6 @@ export const config = { apiBaseUrl: process.env.NEXT_PUBLIC_ADDIN_API_BASE_URL ?? "", /** ชื่อผู้ใช้ของ mock auth — แสดงหน้า header + เป็น createdByUserId ของ version ที่ mock สร้าง */ mockUsername: process.env.NEXT_PUBLIC_ADDIN_MOCK_USERNAME ?? "tester01", - /** - * secret ตรวจ HMAC ของ ticket ฝั่ง client (UX gate — ตัดเคสไฟล์ถูกแก้ก่อนยิง network; - * ความมั่นคงจริงอยู่ที่ server ตรวจอีกชั้น เพราะค่าใน bundle สกัดได้) — ต้องตรงกับ - * ตัว stamp ของ e-Court และ env ADDIN_TICKET_SECRET/SECRET ฝั่ง server (PLAN-ADDIN-AUTH.md §7) - */ - ticketSecret: process.env.NEXT_PUBLIC_ADDIN_TICKET_SECRET ?? "super_secret", + // (ไม่มี secret ฝั่ง client อีกต่อไป — การตรวจ HMAC ของ ticket เป็นของ server อย่างเดียว + // ตาม PLAN-SSO.md D13, ถอด NEXT_PUBLIC_ADDIN_TICKET_SECRET ออก 2026-11-04) }; diff --git a/wordaddin/services/apiClient.ts b/wordaddin/services/apiClient.ts index 0b4476e..9640483 100644 --- a/wordaddin/services/apiClient.ts +++ b/wordaddin/services/apiClient.ts @@ -64,9 +64,9 @@ async function doFetch( /** * request พร้อม Bearer — โดน 401 → ขอ token ใหม่จาก ticket ของเอกสารปัจจุบัน - * (renewTokenFromTicket — ticket ยังไม่หมดอายุเท่านั้น) แล้ว retry 1 ครั้ง - * ยัง 401 อีก / renew ไม่ได้ = AuthRequiredError (UI พาไป no-access ให้ขอไฟล์ - * ใหม่จาก e-Court) · Response ที่ได้คืนไปให้ caller เช็ค ok เอง + * (renewTokenFromTicket — server เป็นคนตัดสิน: 200 = ออกให้ใหม่ · 403 = ticket + * หมดอายุ → false) แล้ว retry 1 ครั้ง ยัง 401 อีก / renew ไม่ได้ = AuthRequiredError + * (UI พาไป no-access ให้ขอไฟล์ใหม่จาก e-Court) · Response ที่ได้คืนไปให้ caller เช็ค ok เอง */ export async function apiFetch(path: string, options: ApiRequestOptions = {}): Promise { const accessToken = await authService.getAccessToken(); diff --git a/wordaddin/services/authService.ts b/wordaddin/services/authService.ts index 0c22b6a..9ce5fed 100644 --- a/wordaddin/services/authService.ts +++ b/wordaddin/services/authService.ts @@ -1,21 +1,21 @@ /** * Auth ของ add-in — e-Court ticket ทางเข้าเดียว (PLAN-ADDIN-AUTH.md §5 ตาม - * docs/requirement/addin-workflow.md) + * docs/requirement/addin-workflow.md — ปรับ flow 2026-11-04) * * ไม่มี login ใน add-in: ไฟล์ที่ e-Court stamp custom properties `ticket_data` * + `HmacSha256` มา (ผ่านกลุ่ม template) = ผ่านเข้าได้ — signInWithTicket ตรวจ - * HMAC → session ใน localStorage ตาม uid/fid → ขอ access_token จาก - * /api/documents/token เก็บตาม fid ("คีย์คือ fid") · exp ของ ticket ไม่ใช่ - * เกณฑ์ตัดสิทธิ์ฝั่ง client — server ออก token ให้แม้ ticket หมดอายุเมื่อ - * เอกสารนี้เคยมี version โดย uid เดียวกัน (requirement ขั้น 7) + * ฟอร์แม็ต/exp เป็น UX gate ฝั่ง client (ไม่มี secret ใน bundle อีกต่อไปตาม + * PLAN-SSO.md D13 — การตรวจ HMAC เป็นของ server อย่างเดียว ยิง /api/documents/token + * ทุกครั้งที่เปิดเอกสาร) → แยกทางตาม contract: 200 = ได้ JWT ใหม่ / 400 ฟอร์แม็ตพัง / + * 401 ลายเซ็นไม่ถูก / 403 ลายเซ็นถูกแต่ ticket หมดอายุ (มี JWT ของ fid ใน session + * ที่ยังไม่หมด = ใช้ต่อ ไม่มี = ขอไฟล์ใหม่จาก e-Court) * - * token เก็บใน localStorage (ข้าม session ได้ — กลับมาเปิดเอกสารเดิมไม่ต้องขอใหม่ - * ตาม requirement ขั้น 7) ส่วน ticket ดิบของเอกสารปัจจุบันเก็บใน memory ไว้ renew - * ตอน token หมดอายุกลาง session + * token เก็บใน localStorage ตาม fid ("คีย์คือ fid") — อายุ 7 วัน แทนระบบ refresh + * ส่วน ticket ดิบของเอกสารปัจจุบันเก็บใน memory ไว้ renew ตอนโดน 401 กลาง session */ import { config } from "../config"; -import { isTicketExpired, parseDocverTicket } from "@/utils/docver-ticket"; +import { isTicketExpired, parseDocverTicket, type DocverTicket } from "@/utils/docver-ticket"; import * as ticketService from "./ticketService"; /** สาเหตุที่เข้าไม่ได้ — UI แยกข้อความตาม reason (TaskpaneApp) */ @@ -46,13 +46,16 @@ export function isDemoMode(): boolean { } /** - * e-Court ticket — จุดเข้าเดียวของ auth ตอนเปิดเอกสาร (requirement ขั้น 1–7): - * 1. ตรวจมี ticket_data + HmacSha256 ครบ - * 2. parse + ตรวจ HMAC ฝั่ง client (UX gate) - * 3. session ใน localStorage ตาม uid/fid — token ยังใช้ได้ = ผ่านเลย · ต้องขอ - * token ใหม่ = ยิงขอเสมอแล้วให้ **server เป็นคนตัดสิน** (ticket หมดอายุแต่ - * เอกสารนี้เคยมี version โดย uid เดียวกัน = ออกให้ต่อ — requirement ขั้น 7 - * กลับมาทำเอกสารเดิมได้แม้ ticket หมดอายุ แม้ session หายไปจากเครื่อง) + * e-Court ticket — จุดเข้าเดียวของ auth ตอนเปิดเอกสาร (flow 2026-11-04): + * 1. ตรวจมี ticket_data + HmacSha256 ครบ (client) — ไม่ครบ = เตะเลย + * 2. parse ฟอร์แม็ต (client UX gate) — พัง = เตะเลย + * 3. pre-check: ticket หมดอายุ และ ไม่มี JWT ของ fid นี้ใน session (uid ตรงกัน) + * = เตะเลย ไม่ยิง API (มีใน session = มีโอกาสเป็นไฟล์เก่าที่เคยแก้ → ไปให้ + * server ตัดสินที่ขั้นถัดไป) + * 4. ที่เหลือยิง /api/documents/token **ทุกครั้ง** ให้ server ตรวจลายเซ็น (HMAC) + * แล้วแยกทางตาม contract: 200 ได้ JWT ใหม่ (user ต่าง = เคลียร์ session ทั้งก้อน + * สร้างใหม่) · 403 ลายเซ็นถูกแต่ ticket หมดอายุ — มี JWT ของ fid ที่ยังไม่หมด + * ใน session ก็ใช้ต่อ · 400/401 ฟอร์แม็ต/ลายเซ็นพัง = เตะ (ไฟล์ถูกแก้) */ export async function signInWithTicket(input: { ticketData?: string; @@ -74,14 +77,7 @@ export async function signInWithTicket(input: { return { ok: false, reason: "tampered", - message: "ตรวจสอบ ticket ไม่ผ่าน (รูปแบบไม่ถูกต้อง) — ไฟล์ถูกแก้ไขหรือเสียหาย", - }; - } - if (!(await ticketService.verifyTicketHmacWeb(ticketData, ticketHmac, config.ticketSecret))) { - return { - ok: false, - reason: "tampered", - message: "ตรวจสอบลายเซ็น (HMAC) ของ ticket ไม่ผ่าน — ไฟล์ถูกแก้ไข", + message: "รูปแบบ ticket ในไฟล์ไม่ถูกต้อง — ไฟล์ถูกแก้ไขหรือเสียหาย โปรดขอไฟล์ใหม่จาก e-Court", }; } const ticket = parsed.ticket; @@ -91,56 +87,68 @@ export async function signInWithTicket(input: { message: "ticket ของ e-Court หมดอายุ — โปรดขอไฟล์ใหม่จาก e-Court แล้วเปิดอีกครั้ง", }; + // snapshot session ก่อนยิง — "มีใน session" = ยูสเซอร์ + ไฟล์ (uid+fid) ตรงกับ ticket const session = ticketService.loadSession(); - if (session === null || session.userId !== ticket.uid) { - // ไม่มี session / user ใหม่ — ขอ token ใหม่เสมอ (แม้ ticket หมดอายุ: server - // ออกให้ต่อเมื่อเอกสารนี้เคยทำงานโดย uid เดียวกัน ซึ่งช่วยเคส localStorage หาย - // ของคนที่กลับมาทำเอกสารเดิมด้วย) แล้วเริ่ม session ใหม่ทั้งก้อน - let entry: ticketService.SessionTokenEntry; - try { - entry = await ticketService.requestDocverToken(ticketData, ticketHmac); - } catch { - return isTicketExpired(ticket.exp) - ? expiredMessage - : { - ok: false, - reason: "token-request-failed", - message: "ขอ token จากระบบไม่สำเร็จ — ตรวจการเชื่อมต่อแล้วเปิดเอกสารอีกครั้ง", - }; - } - ticketService.clearSession(); - ticketService.saveSession({ userId: ticket.uid, userName: ticket.uname, tokens: { [ticket.fid]: entry } }); - } else { - // คนเดิม — userName อัปเดตตามไฟล์ล่าสุด (ชื่อเปลี่ยนได้) - session.userName = ticket.uname; - let entry = session.tokens[ticket.fid] ?? null; - if (entry !== null && ticketService.isTokenExpired(entry)) { - // token หมดอายุ — ยิง renew เสมอแล้วให้ server ตัดสิน (ticket ยังไม่หมด = - // ออกให้เสมอ · หมดแล้ว = ออกเฉพาะเอกสารนี้เคยทำงานโดย uid เดียวกัน) - try { - entry = await ticketService.requestDocverToken(ticketData, ticketHmac); - session.tokens[ticket.fid] = entry; - } catch { - // ใช้ token เก่าต่อ — ถ้าเน่าจริง apiClient จะ 401 → renew-retry → no-access - } - } - if (entry === null) { - // เอกสารใหม่ของคนเดิม — ขอ token เก็บเพิ่ม (server ตัดสินเรื่อง ticket หมดอายุ) - try { - session.tokens[ticket.fid] = await ticketService.requestDocverToken(ticketData, ticketHmac); - } catch { - return isTicketExpired(ticket.exp) - ? expiredMessage - : { - ok: false, - reason: "token-request-failed", - message: "ขอ token จากระบบไม่สำเร็จ — ตรวจการเชื่อมต่อแล้วเปิดเอกสารอีกครั้ง", - }; - } - } - ticketService.saveSession(session); + const inSession = + session !== null && session.userId === ticket.uid && session.tokens[ticket.fid] !== undefined; + + // pre-check: หมดอายุ + ไม่มีใน session = เตะโดยไม่ยิง API (ลดการเรียกใช้ network) + if (isTicketExpired(ticket.exp) && !inSession) { + return expiredMessage; } + // ยิงทุกครั้งที่เปิดเอกสาร — signature ถูกตรวจที่ server เท่านั้น (ไม่มี secret ฝั่ง client) + const result = await ticketService.requestDocverToken(ticketData, ticketHmac); + if (!result.ok) { + if (result.kind === "http" && result.status === 400) { + return { + ok: false, + reason: "tampered", + message: "รูปแบบ ticket ในไฟล์ไม่ถูกต้อง — ไฟล์ถูกแก้ไขหรือเสียหาย โปรดขอไฟล์ใหม่จาก e-Court", + }; + } + if (result.kind === "http" && result.status === 401) { + return { + ok: false, + reason: "tampered", + message: "ตรวจสอบลายเซ็น (HMAC) ของ ticket กับระบบไม่ผ่าน — ไฟล์ถูกแก้ไขหรือเสียหาย โปรดขอไฟล์ใหม่จาก e-Court", + }; + } + if (result.kind === "http" && result.status === 403) { + // ลายเซ็นถูกแต่ ticket หมดอายุ — มี JWT ของ fid (uid ตรง) ที่ยังไม่หมด = ใช้ต่อ + if (session !== null && session.userId === ticket.uid) { + const entry = session.tokens[ticket.fid]; + if (entry !== undefined && !ticketService.isTokenExpired(entry)) { + session.userName = ticket.uname; // refresh ชื่อตามไฟล์ล่าสุด + ticketService.saveSession(session); + return finishSignIn(ticket, ticketData, ticketHmac); + } + } + return expiredMessage; + } + // network/malformed/5xx และอื่น ๆ — ไม่ใช่ความผิดของไฟล์ ลองใหม่ได้เมื่อเน็ตกลับมา + return { + ok: false, + reason: "token-request-failed", + message: "ขอ token จากระบบไม่สำเร็จ — ตรวจการเชื่อมต่อแล้วเปิดเอกสารอีกครั้ง", + }; + } + + // 200 — user ใหม่ = เคลียร์ session เก่าทั้งก้อน (requirement ขั้น 6) สร้างใหม่ · + // คนเดิม = update userName (ชื่อเปลี่ยนได้) + เก็บ token ใหม่ตาม fid + if (session === null || session.userId !== ticket.uid) { + ticketService.clearSession(); + ticketService.saveSession({ userId: ticket.uid, userName: ticket.uname, tokens: { [ticket.fid]: result.entry } }); + } else { + session.userName = ticket.uname; + session.tokens[ticket.fid] = result.entry; + ticketService.saveSession(session); + } + return finishSignIn(ticket, ticketData, ticketHmac); +} + +/** ท้ายร่วมของทุกทางที่ผ่าน — ตั้ง module state ของ taskpane instance นี้ */ +function finishSignIn(ticket: DocverTicket, ticketData: string, ticketHmac: string): SignInResult { currentFid = ticket.fid; currentTicket = { data: ticketData, hmac: ticketHmac }; username = ticket.uname; @@ -151,26 +159,22 @@ export async function signInWithTicket(input: { /** * ขอ token ใหม่จาก ticket ของเอกสารปัจจุบัน — เรียกจาก apiClient ตอนโดน 401 * ก่อน throw AuthRequiredError 1 รอบ · ไม่ตัดสิน exp ของ ticket ฝั่งนี้ (server - * ออกให้ต่อได้แม้ ticket หมดอายุเมื่อเอกสารนี้เคยทำงานโดย uid เดียวกัน — - * requirement ขั้น 7) 401 กลับมา = false ตามเดิม + * เป็นคนตอบ: 200 = ออกให้ใหม่ · 403 = ticket หมดอายุ → false) * คืน true = ได้ token ใหม่เรียบร้อย (getAccessToken จะเห็นค่าใหม่) */ export async function renewTokenFromTicket(): Promise { if (currentTicket === null || currentFid === null) return false; const parsed = parseDocverTicket(currentTicket.data); if (!parsed.ok) return false; - try { - const entry = await ticketService.requestDocverToken(currentTicket.data, currentTicket.hmac); - const session = - ticketService.loadSession() ?? - { userId: parsed.ticket.uid, userName: parsed.ticket.uname, tokens: {} }; - session.userName = parsed.ticket.uname; - session.tokens[parsed.ticket.fid] = entry; - ticketService.saveSession(session); - return true; - } catch { - return false; - } + const result = await ticketService.requestDocverToken(currentTicket.data, currentTicket.hmac); + if (!result.ok) return false; + const session = + ticketService.loadSession() ?? + { userId: parsed.ticket.uid, userName: parsed.ticket.uname, tokens: {} }; + session.userName = parsed.ticket.uname; + session.tokens[parsed.ticket.fid] = result.entry; + ticketService.saveSession(session); + return true; } /** Bearer ของ apiClient — token ของ fid ปัจจุบัน (stale ได้ — apiClient เป็นคนตัดสิน 401) */ diff --git a/wordaddin/services/ticketService.ts b/wordaddin/services/ticketService.ts index 744c764..349a721 100644 --- a/wordaddin/services/ticketService.ts +++ b/wordaddin/services/ticketService.ts @@ -1,17 +1,18 @@ /** * Ticket ของ add-in — ของมือถือ (primitives) ให้ authService ประกอบเป็น flow * (PLAN-ADDIN-AUTH.md §5): - * - verifyTicketHmacWeb: ตรวจ HMAC ฝั่ง client ด้วย Web Crypto (UX gate — ตัดเคส - * ไฟล์ถูกแก้ก่อนยิง network ส่วนความมั่นคงจริงอยู่ที่ server ตรวจอีกชั้น) * - session store: localStorage key เดียว `wordaddin-auth` — userId/userName + * map token ต่อ fid · webview block localStorage = fallback memory ต่อ session * (pattern เดียวกับ versioningService) * - requestDocverToken: POST same-origin /api/documents/token แบบ body ticket — - * ไม่ผูกกับ config.apiBaseUrl (endpoint อยู่แอปนี้ ใช้ได้ทั้งช่วง mock และจริง) + * ไม่ผูกกับ config.apiBaseUrl (endpoint อยู่แอปนี้ ใช้ได้ทั้งช่วง mock และจริง) · + * คืน discriminated result แยก network/http status/malformed ให้ caller + * ตัดสินตาม contract 400/401/403 (2026-11-04) + * - (ไม่มีการตรวจ HMAC ฝั่ง client อีกต่อไป — secret เป็นของ server อย่างเดียว + * ตาม PLAN-SSO.md D13, ถอด verifyTicketHmacWeb ออก 2026-11-04) */ import { withBasePath } from "@/utils/base-path"; -import { normalizeHexDigest } from "@/utils/docver-ticket"; /** key เดียวของ session — v ไว้ migrate รูปในอนาคต */ const SESSION_STORAGE_KEY = "wordaddin-auth"; @@ -91,38 +92,6 @@ export function isTokenExpired(entry: SessionTokenEntry): boolean { return entry.exp * 1000 <= Date.now() - 30_000; } -// --------------------------------------------------------------------------- -// HMAC ฝั่ง client — Web Crypto (crypto.subtle ให้ promise เสมอ) -// --------------------------------------------------------------------------- - -/** คำนวณ HMAC-SHA256 คืน hex lowercase — รูปแบบเดียวกับ generateHmacSha256 ฝั่ง server */ -export async function hmacSha256HexWeb(message: string, secret: string): Promise { - const encoder = new TextEncoder(); - const key = await crypto.subtle.importKey( - "raw", - encoder.encode(secret), - { name: "HMAC", hash: "SHA-256" }, - false, - ["sign"], - ); - const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(message)); - return Array.from(new Uint8Array(signature), (byte) => byte.toString(16).padStart(2, "0")).join(""); -} - -/** - * ตรวจ HMAC ของ ticket ฝั่ง client — message คือ string ดิบของ ticket_data ตามที่อ่านจาก - * property (ห้าม trim — ตัว stamp เซ็นบนค่าดิบ) · เทียบหลัง normalize lowercase - */ -export async function verifyTicketHmacWeb(ticketData: string, expectedHex: string, secret: string): Promise { - const expected = normalizeHexDigest(expectedHex); - if (expected === null) return false; - try { - return (await hmacSha256HexWeb(ticketData, secret)) === expected; - } catch { - return false; - } -} - // --------------------------------------------------------------------------- // ขอ token จาก /api/documents/token (ทางเข้า ticket — PLAN-ADDIN-AUTH.md §3) // --------------------------------------------------------------------------- @@ -147,30 +116,50 @@ function jwtExp(token: string): number | undefined { } } +/** ผลของ requestDocverToken — แยกสาเหตุให้ caller ตัดสินตาม contract ไม่โยน exception */ +export type RequestDocverTokenResult = + | { ok: true; entry: SessionTokenEntry } + /** fetch reject — ไม่ถึง server (offline/dev server ตาย) */ + | { ok: false; kind: "network" } + /** ได้ response แต่ไม่ใช่ 200 — 400 ฟอร์แม็ตพัง / 401 ลายเซ็นไม่ถูก / 403 ลายเซ็นถูกแต่ ticket หมดอายุ */ + | { ok: false; kind: "http"; status: number } + /** ตอบ 200 แต่ body ใช้ไม่ได้ (ไม่ใช่ JSON / ไม่มี access_token) */ + | { ok: false; kind: "malformed" }; + /** - * ขอ access_token ด้วย ticket — โยน Error เมื่อ status ไม่สำเร็จ - * (401 = ticket ไม่ผ่านการตรวจฝั่ง server — ให้ caller ไป no-access ตาม flow) + * ขอ access_token ด้วย ticket — คืน discriminated result ไม่โยน Error: + * caller (authService) แยกทางตาม contract ของทางเข้า ticket ของ + * /api/documents/token (2026-11-04: 200 ผ่าน / 400 ฟอร์แม็ต / 401 ลายเซ็น / + * 403 ลายเซ็นถูกแต่ ticket หมดอายุ — มี JWT เดิมของ fid ใน session ใช้ต่อได้) */ -export async function requestDocverToken(ticketData: string, ticketHmac: string): Promise { - const response = await fetch(withBasePath("/api/documents/token"), { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ ticket_data: ticketData, HmacSha256: ticketHmac }), - }); +export async function requestDocverToken( + ticketData: string, + ticketHmac: string, +): Promise { + let response: Response; + try { + response = await fetch(withBasePath("/api/documents/token"), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ ticket_data: ticketData, HmacSha256: ticketHmac }), + }); + } catch { + return { ok: false, kind: "network" }; + } if (!response.ok) { - throw new Error(`ขอ token ไม่สำเร็จ (HTTP ${response.status})`); + return { ok: false, kind: "http", status: response.status }; } let data: TokenResponse; try { data = (await response.json()) as TokenResponse; } catch { - throw new Error("ขอ token ไม่สำเร็จ (response ไม่ใช่ JSON)"); + return { ok: false, kind: "malformed" }; } if (typeof data.access_token !== "string" || data.access_token === "") { - throw new Error("ขอ token ไม่สำเร็จ (ไม่มี access_token)"); + return { ok: false, kind: "malformed" }; } const exp = jwtExp(data.access_token) ?? (typeof data.expires_in === "number" ? Math.floor(Date.now() / 1000) + data.expires_in : 0); - return { token: data.access_token, exp }; + return { ok: true, entry: { token: data.access_token, exp } }; } From 02894f270595841f3f4c7f02009a2bbbe83a8da2 Mon Sep 17 00:00:00 2001 From: Suphonchai Phoonsawat Date: Thu, 5 Nov 2026 09:51:40 +0700 Subject: [PATCH 2/6] replace addin icons with Thai judiciary emblem, bump manifest version to refresh Word cache Co-Authored-By: Claude Code --- public/wordaddin/icons/icon-16.png | Bin 1596 -> 610 bytes public/wordaddin/icons/icon-32.png | Bin 2386 -> 1804 bytes public/wordaddin/icons/icon-64.png | Bin 2112 -> 5746 bytes public/wordaddin/icons/icon-80.png | Bin 4836 -> 8646 bytes public/wordaddin/manifest.template.xml | 2 +- 5 files changed, 1 insertion(+), 1 deletion(-) diff --git a/public/wordaddin/icons/icon-16.png b/public/wordaddin/icons/icon-16.png index b6509798aadcf9eb0e382bab24b3dc67de818729..3c3b0db728041bb51d2bde4cebc1e835164972be 100644 GIT binary patch delta 593 zcmV-X0OuEG=e=wsu$%*|I2TSzXs{SKoPOI%w9r?>5Kpe1D#2cpknZ_|Mr|2wNo& zg77fa3Zee8Xv77EmBnt;mbfZF)!i6=Oij4azM1g9Q(14&mKfd@zkfB!@_2R^Z96*@ zs^@(MUOr+0TvuRioYyIr>C7TE8Nsu^wwa|pwqb>!@FP5k@~l#yKNoePMcCdWX>*gJ5OrlYrl9fYGv8}VGU(l|PVQNFIwLZd_l zW201c1iN|1A7LZ=h?=gkR;&cX0FHI|HotUsDy{|%E{_~J?$N@Zw?90%&x^5kQZIX0dG>r~>S0xk!I7SpKnDO` zK4wL+Ozh$)0EN$8xTPG2PHk`ITKX<-w3FuUxq80g7X`30*e|sMpj+u^ f`Ht$ZHe`PT_65i>Uj~xM00000NkvXXu0mjfqfZmS literal 1596 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!6#=yXs@#Xw?AV;#qHKHUqKdq!Zu_%?nF(p4K zRlzeiF+DXXH8G{K@MNkD0|RqeW=KRygs+cPa(=E}B1ny1MQ#C5Hv@x>eMLcHa&~Ho zLQ-maW}dCm``!DM6f#q6mBLMZ4SWlnQ!_F>s)|yBtNcQetFn_VQY3>#8yZ_Em|N-@ znp#>Indm4O85o-B8(8Wan&=uBS{Ybc85k-+ffCTRqLehNAQv~NT|l0#QbtKhft9{~ zd3m{Bxv^e;QM$gNrKP35fswwEkuFe$ZgFK^Nn(X=Ua>OF1ees}t-3#_`hBq$Z(46Le)Ln;eW z^@CE2^Gl18f$@>14ATq@JNy=b6armiBkFOx%nuOw0|9EzK=pdOh=sOA_;vQ(<;z0_}$CHO8yg%DE^tu_V7JBtJg~ zmI?wg@=NlIGx7@*oSi|jZmysao|%`DUtX*UiYAD!T~doO%TiO^it=+6z~O9_iNy`X z`5&S`h1~Gd2Rce0lvt1w4@?M{B0)@eRseF~nJG07n1hOdS;gz?%1QF! zNj{#Qi51`9#YzfKSn=oo|9W)?217PhR>KB{%eS}Z|L5lB{?E>#u;IkvcK-jy4vfOg zQVbjlGv+ZU*nNF}e}B5piBtdo{k5LkIdkUB#_7|iAG~>UCS!)L+Tx8HKgupxVieM9 z;=ph)hmrG+qw#;SfJUnm*Ax~QmN$qQbaZt!^)fStg@u_ae3f7FKq5f(L`_@z`FV|^ zjX&(`{xGckeTwDEPbQ5698Y|blai8l)c!W}c*HDln&D_A15<(PF*A-M1|p>s*gIT~ zn1*Doc||WTP4AD)uh@2z0hc!;5D1oQ7ln-cB=>@Ff`rX+&w41;T!*&zXBV*Cz?$Vk!Iq)rRwX%^5-j9%af=3%!||AYENwP@joEdEZfX{dHR}v2RPpx>}6+lRN#BuRKcQm0YhU2PgTe~DWM4fV}M0Q diff --git a/public/wordaddin/icons/icon-32.png b/public/wordaddin/icons/icon-32.png index dcf56db7089a10edc61a0914be2af6736c8c676c..23241ebe9d207007b12afdac7ca159ba4bd8416f 100644 GIT binary patch delta 1796 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 E1JVSZtpET3 diff --git a/public/wordaddin/icons/icon-64.png b/public/wordaddin/icons/icon-64.png index 41051fce805b62ae4b779516cb29f9e36e1a9652..70a8095bb78c93a4a9e69bba17ce63660a70ea54 100644 GIT binary patch literal 5746 zcmV-&7LDnNP)Py097#k$RCt`tn|YX3#nJyiea^Y(-nsYAzOW1f!y+Im zi|imUuNwqK^CIpD1~kU?Oc>+eGZ{ivyF(|0`5~2nJA}$CDqO3FQg6skgJG0-p z+c{moKQ4l!%*-9Y_x;uL%rkS&bX8Y(gVYXH;;Uk6_-^?^lHQ@@rqmG z6?ZkFS6NcMy;*F`MV+(%H+51!bqBYM?Aq$(-+jGT8G=I4+9YZQd40~g%$e5$Fu44R zQpR4niJ%8;PWzQ&mpcGqYXn-a3I``!FPoC#62KPtfnK+`x;o1`suNQ5iW4zrVa;MU}$fTSNz`Sn?*Jw6;3f zag%wvx}UZOgw~!DvJQnX`aFnjRwZRTK(h|p`Eu?&4pmJi&tBVTdlA8vd}`tan7w{W zpmL|Nzxcee)7Ag90GK_s@C~ot$+4 z9>nZ-9l6H#%vYc9`-f9D;54Uza!Ufb=(^!7+17xvMISY3JW_mq1`MtuZUxZAR&w+1By>v1$$F2tF#|V{Mo!P0 zo6fHsHhi`QubIQPP3zh5t;xNQ4Z~H*PNDC}VAtZe$u{>Q=_wfPtD9_3Q+ot3B`#RK z?ja*dxBnq2Z4=#6Mwu$5Wp*x4{-yaT7!ROnH~4z0!UnqJsZ-LW$x}d<z zg5YhSp;7nuG14fsSxukY`_jC1A^U1Kv2@KKqUzLEG*NLLzHTYXW8p4WO`Zx(a+!Xe z`xP#oQM~J_+lR7v<5}RsAMaekgqJTop75x61{>x~!YEVdfJ@MC({|WziFyuUTPb`x zWjg1d?GVyA?AR2=YqcBCC7Ehu4Xf9qmK(x7{?z zao`R>QTpNg48HzK`rh;i+KhmhnBp+@g5~Vm6XuJ(z0gLKD=uEjGw-)Y8&M+FLWmZ~ z$^#+QC;5SGk_WgYUg>|s2=?r3O~_-S9Ge=+WcDL}WcAKmCjIMjg7Gx4YU=RSXou^& zFXP20_VTB{`RURDdUq|QwlW=GZW?E|tzzaoLE@^4UL7hKao3d`Gzzgq*Xl zE?+^9!rhF0@g97x3nVsKsljUYeEcd#4>xJMlJ)?-#t>x)eVa$vyf-ml#fo7?OPTa~ zAGRLsN)SOj)hMakf2y*XyKWHkE{B+yC}m(7wH(>71QlCD&iQu`lK`UhF@+SC{CGN> z#0faLygYjZPv16|c&&xcn*czgxciEoUxR@%A(qI}6h7O|LYz(C_k4X_F$K})H%fdwF|GAD6 zKRKfZfgblF@#GylDFE&n*Z)6VQ*r34ck#F?`O7-(@2BbGEDz=pRcgRz^^Z~@+Us167tuDBU2heb1Xu1`i* zQW}e-V0HNol|M*UN|(+|2yp^9mKUQ5F*RliN@=v%_0|kds*5(cWzsM_2&PWw;EFd9 zb)w$`H9~5i+i)CzQu2}lK#B1C+df9&sq@~~`=$i=W}FA64MSqdkE9z(80A4H2n=gF~65-7!Iy~5z z%tiqi(QVsE9dJ2!@W?vv_pN*#$0D(0X+awfqaV2#E6F8T5l<{$O5rF^5*%RbD6IzE zilYSW3QE8+6y?jNelPwgI$SUsm1;72#J)y12#o?DaSy+|dq^E64c8>S+s(&}NR+Dp z)@esF2pHN#=}1yMomw8GOl1OKDnXQiA(~Dz&@sDFRV;0@VAg9` z$7E}KsAF^gP}E0(ao|K-;IUUaBSzz%)hGZOMIfaDrA{&^LAg-?Ob5n| z+H(Bab(@jZ{SL%Pq@V(PKWi=1d30{JsnL#O5seZsG^iw!sEK3#jZUdJ{;ZUXmV^$j zg7%mG5^Y8kI*DR%|IHZE_!hfS0AjZ0=AR!qK0>{_e2B8Y?^;p^08zrycNW)^%ATN5 zD2>Evp#Tl99HurY8d!5Y)+ETPqyCYIOXuwU04RhzMjk|HJRUU&z#k?I>}0wo9oy|m z2n1f!90sJ4X*#{Z(O;)YYF57C(v z&wCr&u@MwK&npEK`Ox5LbAvs5B^WToz!!)mMpRXk*<*a&MoQPo!~kqtcy!#t2ED*C zEKrH1Fc3>`KnumXY16PhKZxie7(ZBX+YY{-u(tvb($W*tqu;#9=n5zU- zJdsvj(&I}E{|{13Q9rWNF&wTQw70=>LyD0YJwr!ut;%}a3Gn%%V4|PzlTyeJw#*gb z*7VGY9{cw(ue2Kugbf04>8LifS~NA@m(Jr!}r*;QVJL0^SQ6%aSh6=T?ELmBl{b&vB82! zzag)0)2mIkY|fK$#D#EUhpc_Le7gGahDzn104Zt;j`qfEg zyJi&a1VBtYu1Wl)0b6+Qw)coy22oEr9)TEw77uB0p(@41(i%k^+x3ESnEP4*5uHWJ ziWaoFw3?|Of7{^kS>qKUCxt1qI+9_yq-2(I$t5{trtd@fn?Y(bRC)`<-wLVSj|%Na zSMNhrmvgwRErlaS;;APRE3vWg5>tX}I`yccxZ$s3ST|=OSs5YBG(lyy!ANa`1oMz^ zC4|03g$^TCho~vfR97G6K>+^=ldYG7r{hhp>R>!AgTYBuE(L}`PUHE1(Y3#5^NR5P9ooLP_7v4pEH-0%eOG1Ik8qscF#0MyJ5+73aSt6&9P9FHF3sTF9C=J~=IA0Z?%w z?iM0B&Crdc!i|bjiqEI~1JA)v@WfNu@aY2F2)sPI^XV5v{&U>&>rB(-sfPyvP_ugr zom+py`T5&$n^evFfzp^vd9yz^-8Tenp4uVncQl}L3B5b+CBAraWQ68R~myK=5$sKw(u_dqIOR2=xK3pSO50_%ebBKgw4| z$=Y08*9SBXY*Iy0gBbCRQ%SQ;;+sp!>@}K?-rI&1Yv^KxF#hqMQM@IOW@p(Psp!U$ z!*TMmQ~7rFcCy=M6RFujxOz7EJ$tfs%>vT%3_N78V~85-+3;qpNdSy~xLtp@R!GiV}1tVFva6aj|Hip%Ma9MNu46J7M`xQL( z_hAhyuf6zFEUd~Egi2;Gd15J3pZNv}IW)V#Y;Y6w9VK1Jml9Kq-VfvTn5=s3MZ$;R zA8YcM^467@M!a58t`leNjOA2StRpkkLuq+4aywl@Rz?Mt-|QnEETm)iZY+KGpBV0L zQbb`>MI@sgMdOBJTJ?;i7>L+BGIlOSopzAgB2KTdzrYd?l`H;=aei`VgUJK1K1C3d z$NoH&hoAN`u+IVJzqN-*%%OeJ<*1aDlk#chVdf(*(>5=iV4#c|Iz!DtjvT6G@Px^@ z<^`<#`_G72N6Bx|ide-q!cLgdQkScqya(God7zC)BW#`b4!K#G^ct1Tn&MR6die;W zFIx+N3{>aCXZCPZbqyY5pv1RX0)U+RIN~ew=-~6m5$-()E&o3?26@W_p0VJ4>8Z}6OOqM6`WW)d)9tTySy;>+^*=_=`dy_o0N73Psx^3zAJ3?j40tC zi-eOP|&U6EmJZ`@gQfX#4Z&y&fTAT12;hi5U#Ab@5@^wfPbo3tF~BLTw0DEW&Ut z5Ca1n$vhv@2Y}fXBA-L$Dx`WNVB=b0f|-L+W*$=gG2%L`-`0t47j;Ct4jH*;;|=a$ z-{bT`HyjUwA-M(lyE+~S3ZEx@)GDjf)9T`hk?Uf#C6%U)uQ)}50GM1QNHRgeEH3g zwSz`3>Tx>ypOH^ys&{pX2FkYDtuMV5#Zg8LTSdj*bRK>2ESfiK#)7G@Gh$en;05;+ zvtqasz{^oA9Rxs|)x>okF;9eGyqfCIr}EwhCKvzmb`Bia!*3thLT+9*%a;W)okCW; z`!WZ7=T)>kXHDkm>VC#PLtFIrpcR8VEa|hWq8F`O@50~nM)v*tRkAZBGh>9($&AKVx*q)kQ(QDk!?dlD26#Nse`eT-a}@EPbUw+^;)`v!$*4dSrj` zW!xfO{{yo5FDX*7)*~wYSCZy^KqigoE$;S?qBr-JHE$Hi1zm5FX1<#xE#qF1~isqy`!u+=C@_uellsdvcj7mpCV+AGs1v=uw`Udiy? zBOg7LCsL9k4_3rc^#A|> delta 2099 zcmV-32+a5LEWi+uB!2;OQb$4nuFf3k00004XF*Lt006O%3;baP00009a7bBm000id z000id0mpBsWB>pM)k#D_RCr$Pn}1A|RT#$~MHEHlmp>L6#SgV0)HHBriKC*i)F#We zq#~OR%N$y^DV9)MtXW7k|IoEr>1xul&D3S7bGYeJA(!G($A2_SLb9RwBNajWe$RXE zE5fK?Yrkahs(=-p7%LF-g6FehufT9+WM6V6DFj?JOtefr9k&VaZrEg z7IYc<2dan8LN(AYH8nMLuztla0f57?FqzN`PzE##@`el0x6m%=I|QVKmA=HM0f6JF zFms{V&~VoI5PxRL=g+AHc19@_EY1^t*N==#&ve=c;OQ@iDK<($ z#YFT8=n@(@t#*sYCIIKhz*IqO|G~(=P$rtfQKRUJ+W^4%7?{1#1F9l;X@C+CgjS>I zjHf=}O@AoZohNreoUNK-ngMwFoZWF|#oj+uNJJL=uCML5%K%0!l$TcZc=~*$ z-46{^6`=$BfFxvr(;K8&2D}1=n)8Iy<)y}DJ5Cv(d;VXcdsIc}aUSA`z{M3s#|+4Z zLe+V~2h7)aI~@ZsUE5IRX>Aqu03iK2Eq>A{d4Fd0>eVuI=ulk>I-b#1;g|srLh;6> zXQHB_q<3$#{xc|u#lyC*QagIVdH?OgByF7n;O*MITLujpBqK+TWb=|yqdea}Ha1qq zjTU{`~p+ zlOmHRPnNlJ=gPHf*QBnlPLh(6BrYyaDk>^u|9;NhEMppg1Z}%bc6PQD6ck8lX{nq# zbxPNY3c{K-Ya}l(Pp)3Qs_F_03zb6)7k@64>({xqDKdEQU|G9%tz5cvNtFqmU9x0} zl$4an;lqb@tx>;n(;H)C4+hM=BX#`=sY=j@}#U^zg}j~=4O;-hG^>)0DqtC z=rE=E1dkj!qHCPlxpSv#>m(y1Lk=7`pfaE%Cr+G@n>V@lVUU`dDhCf9)Fo&3?b|0~ z#&oFBu#&bB5fRd>S1)_B>ByBU zS7hKo?_OUV1K|1?yntVF_;C08qJJD2nBt_hwN?7`aqNcLahzu`L;CmcuR2IGIAYlD zX^|`1It8Fn+it-x8y+66hBY%XGc#4YCC82(lk{{R9dIQvG0`aq>`yrSnZbJ=KYm=7 zEb+ItP66=AmUBx>i)`4iL6$9ZU4Mv=kC&-ar^=QsTU7bbp+l+{=a}vYXMdX0rcKi& zgUZTEH3D?Z7&fDsGiS<<9XoW5CG5W1m&Nhr9)@|$D0zm%SWHZenzZoC+tk+9rpEl* z+FF@1Ws3S@bcSD&LnDX0jT<*gb#=AAlc-*uy*pE!&4c%0kH35OZZ$X9x^=7i95aAf z#i5n+30{vMKVB9uUaUUD&VRKF{Ls+Qz|E8P^#J@~jw4?gCC@PB%w}p#X$HUsoneY| zvU29k8QHUEkKhmMpSCk%#0Z%*X_89u#>Pges;W|>$$|w7l!K0>q@<|NvLkJ7ZdOm; zabjhW{F<5?o-uF=0B1)=;|u_Db92?u$N7XGNJju_d13DZP{CP?Pk%bX?!x9UW5x`X zF@As#`8VUT!`2x30lpF&vy>j9xVTtNcsP8rZF9QJY3=39msMx#2R7khZ`Ri?Yl6?g zR2rq8fit+AoE+sidw9M)v0bxMyK%$olP{jIw|~+x0Gw%X+1O|A1p&q%P#OYYz9@ z^CkM)EuQ)SJGjyFFvg2{aPSTEmT~DBO99Y43w>`~1~lyL3-Qo6^mR(7O~3}4LK~U_ zZ++Y85=Sm{f8q}?JQ{CC+$Altvq@-PNZS#fiL?<^YJ^*jf0$A;&5k3mP^FwZRB d;SPR7F?Ij|002ovPDHLkV1gN9@`?Ze diff --git a/public/wordaddin/icons/icon-80.png b/public/wordaddin/icons/icon-80.png index 5e63769d8e0f21f7ba1dc81cbc6070d901b4d8ac..2fb810838ca3080f525847448dc401767700a3d3 100644 GIT binary patch literal 8646 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 YKhzd=(`Do&LjV8(07*qoM6N<$g3$iWk^lez literal 4836 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 4a1e184a-ab31-4fa4-b3c7-9a2283051c3f - 1.0.0.2 + 1.0.0.3 e-Court Documents Versioning en-US From 9fecaa7bcaef8e268394c5b9c81ca32596604319 Mon Sep 17 00:00:00 2001 From: Suphonchai Phoonsawat Date: Thu, 5 Nov 2026 12:42:02 +0700 Subject: [PATCH 3/6] add-in: auto-open taskpane phase 1 + final save after versioning + tracking id = fid - manifest: TaskpaneId -> Office.AutoShowTaskpaneWithDocument (auto-open marker), bump 1.0.0.4 - metadataService.enableAutoOpenTaskpane: tag doc setting (idempotent, best-effort) called from detect() after sign-in - versionFlow: final saveDocumentVerified at end of both versioning flows (bug: stamped docx never persisted -> reopen showed untracked) - tracking id now uses ticket fid (TRACKING_ID_SOURCE="fid", rollback by switching to "guid"), resolveTrackingId + authService.getCurrentFid - WordAddinGate: restore history.pushState/replaceState that office.js nullifies in taskpane (fixes Next App Router crash) Co-Authored-By: Claude Code --- components/wordaddin/TaskpaneApp.tsx | 4 + components/wordaddin/WordAddinGate.tsx | 36 ++++++++- public/wordaddin/manifest.template.xml | 10 ++- tests/wordaddin/metadataService.test.ts | 100 ++++++++++++++++++++++++ tests/wordaddin/versionFlow.test.ts | 18 +++++ wordaddin/services/authService.ts | 6 ++ wordaddin/services/metadataService.ts | 56 +++++++++++++ wordaddin/services/versionFlow.ts | 32 +++++++- 8 files changed, 256 insertions(+), 6 deletions(-) create mode 100644 tests/wordaddin/versionFlow.test.ts diff --git a/components/wordaddin/TaskpaneApp.tsx b/components/wordaddin/TaskpaneApp.tsx index 269d695..957e29a 100644 --- a/components/wordaddin/TaskpaneApp.tsx +++ b/components/wordaddin/TaskpaneApp.tsx @@ -212,6 +212,10 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) => } setUsername(authService.getUsername() ?? null); + // auto-open (2026-11-05) — sign-in ผ่าน = ไฟล์ e-Court: tag ให้ taskpane เด้งเองครั้งถัดไป + // ที่เปิดไฟล์นี้ · fire-and-forget (service กลืน error ทั้งหมด — ไฟล์ทั่วไปไม่มาทางนี้อยู่แล้ว) + void metadataService.enableAutoOpenTaskpane(); + const tracked = properties.savedByAddIn === metadataService.ADDIN_SIGNATURE_GUID; const id = tracked ? properties.documentTrackingId : undefined; if (!id) { diff --git a/components/wordaddin/WordAddinGate.tsx b/components/wordaddin/WordAddinGate.tsx index 2deeb10..326a2d4 100644 --- a/components/wordaddin/WordAddinGate.tsx +++ b/components/wordaddin/WordAddinGate.tsx @@ -31,6 +31,33 @@ const ONREADY_FALLBACK_MS = 5_000; /** เพดานรวมหลัง inject script — ครบแล้วยังไม่มี Office = ไปหน้า error (มีปุ่มลองใหม่) */ const ONREADY_HARD_CAP_MS = 60_000; +/** + * office.js **ลบ `history.pushState`/`replaceState` ทิ้ง** ใน taskpane (set เป็น null — + * กัน add-in พา host ไปที่อื่น: office-js#429) แต่ App Router ของ Next ต้องใช้ History API + * ตอน mount ไม่งั้นพัง "window.history.replaceState is not a function" — เก็บตัวจริงไว้ + * ก่อนฉีด script แล้วคืนคืนหลัง office.js โหลด/พร้อม (workaround มาตรฐานของ SPA ใน + * add-in — taskpane เป็น iframe ของตัวเอง Next ใช้ history เฉพาะ SPA state ภายใน) + */ +const nativeHistory: { pushState?: History["pushState"]; replaceState?: History["replaceState"] } = + typeof window === "undefined" + ? {} + : { + pushState: typeof window.history.pushState === "function" ? window.history.pushState.bind(window.history) : undefined, + replaceState: + typeof window.history.replaceState === "function" ? window.history.replaceState.bind(window.history) : undefined, + }; + +/** คืน History API ที่ office.js ลบทิ้ง — idempotent (มีอยู่แล้ว = ไม่แตะ) */ +function restoreHistoryApi(): void { + if (typeof window === "undefined") return; + if (typeof window.history.pushState !== "function" && nativeHistory.pushState) { + window.history.pushState = nativeHistory.pushState; + } + if (typeof window.history.replaceState !== "function" && nativeHistory.replaceState) { + window.history.replaceState = nativeHistory.replaceState; + } +} + /** * โหลด office.js ครั้งเดียวต่อ page เป็น singleton promise ระดับ module — * ห้ามฉีด