Merge branch 'dev' of https://forgejo.chamomind.com/chamomind/eCourt-note into dev
27
.env.example
|
|
@ -33,3 +33,30 @@ MINIO_SECRET_KEY=minioadmin
|
|||
# MINIO_BUCKET=docver
|
||||
# DOCVER_LINK_TTL_SECONDS=900
|
||||
# DOCVER_TOKEN_TTL_SECONDS=3600
|
||||
|
||||
# (ถอดแล้ว 2026-10-30) NOTE_SESSION_SECRET ของรอบ cookie oacCaseSig — แอป note
|
||||
# เปลี่ยน session เป็น token แบบ add-in แล้ว (access_token purpose=login จาก
|
||||
# POST /api/note/auth/session เก็บใน localStorage "note-auth") จึงใช้
|
||||
# DOCVER_TOKEN_SECRET ตัวเดียวทั้งระบบ: เซ็น login-token ของลิงก์ + เซ็น/ตรวจ
|
||||
# session token ฝั่ง Server Actions และ API ของแอป (lib/note-token.ts)
|
||||
|
||||
# dev-only (ทางเลือก): เปิดฟอร์ม "สร้างลิงก์ทดสอบ" บนหน้า /note + route
|
||||
# POST /api/note/auth/dev-link — mint ลิงก์ SSO จากหน้าเว็บเพื่อทดสอบ flow จริง
|
||||
# ⚠️ ตัวช่วยทดสอบเท่านั้น ไม่ใช่ทางเข้าจริง: ปิด = route ตอบ 404 + ฟอร์มหายจากหน้า
|
||||
# และไม่ทำงานใน production build เด็ดขาด (NODE_ENV=production) — dev server ที่แชร์
|
||||
# บน LAN เปิดไว้ = คนในวงแชร์เครือข่าย mint ลิงก์เข้าระบบได้ จึงควรเปิดเฉพาะตอนทดสอบ
|
||||
# NOTE_SSO_DEV_MINT=1
|
||||
|
||||
# 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 ต้องตรงกันสองฝั่ง: e-Court (ตัว stamp) กับ server นี้ (ตัว verify)
|
||||
# ฝั่ง server ตรวจ HMAC ของ ticket: ADDIN_TICKET_SECRET (ไม่ตั้ง = ใช้ env SECRET
|
||||
# ของกลุ่ม template → ยังไม่ตั้งอีก = "super_secret" ตาม requirement)
|
||||
# ⚠️ production ต้องตั้งค่าจริง — ค่า default รู้กันหมด = ใครก็ปลอม ticket ได้
|
||||
# (ใช้ร่วมกับทาง ticket ของ SSO POST /api/note/auth/session ด้วย — PLAN-SSO.md D12)
|
||||
# 2026-10-30: ทาง ticket ของ SSO (POST /api/note/auth/session) ปฏิเสธค่า default
|
||||
# นี้เฉพาะใน 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
|
||||
|
|
|
|||
|
|
@ -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/<versionId>/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:<fileId>" ไม่เจอ 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)
|
||||
|
|
|
|||
|
|
@ -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/<versionId>/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:<fileId>" ไม่เจอ 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)
|
||||
|
|
|
|||
106
app/api/auth/session/route.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
// POST /api/auth/session (path สั้นของ POST /api/note/auth/session — โค้ดก้อนเดียวกัน
|
||||
// copy มา 2026-11-05) — แลก credential ของลิงก์ e-Court เป็น access_token
|
||||
// (ฉบับ token 2026-10-30 ตามคำขอผู้ใช้ "note ใช้แบบ add-in" — แทน GET e-court ที่
|
||||
// set cookie คู่ oacCase ซึ่งถอดแล้ว): body {t | ticket_data+HmacSha256, fileUrl?,
|
||||
// typeId?} → verify → ลงทะเบียนเอกสาร N8 แบบ trusted ฝั่ง server เลย (fileId จาก
|
||||
// fid ที่เซ็นมา — ไม่ต้องมี link proof อีก) → ออก login-token อายุ NOTE_SESSION_TTL
|
||||
// (8 ชม.) ให้ client เก็บ localStorage "note-auth"
|
||||
// · 400 body ผิด · 401 {error, reason: expired|invalid} · 500 secret ไม่ตั้ง
|
||||
// · audit log เฉพาะ userid (strip อักขระควบคุม) — ห้าม log token/query ดิบ
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { addinTicketSecret, verifyAddinTicket } from "@/lib/addin-ticket";
|
||||
import { issueDocverToken, verifyDocverLoginToken } from "@/lib/docver-token";
|
||||
import { registerExternalFile } from "@/lib/external-import";
|
||||
import { parseSessionExchangeBody } from "@/lib/note-auth";
|
||||
import { NOTE_SESSION_TTL } from "@/lib/note-token";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function json(body: unknown, status = 200): NextResponse {
|
||||
return NextResponse.json(body, { status, headers: { "cache-control": "no-store" } });
|
||||
}
|
||||
|
||||
export async function POST(request: Request): Promise<Response> {
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = await request.json();
|
||||
} catch {
|
||||
raw = null;
|
||||
}
|
||||
const parsed = parseSessionExchangeBody(raw);
|
||||
if (!parsed.ok) return json({ error: parsed.error }, 400);
|
||||
|
||||
const secret = process.env.DOCVER_TOKEN_SECRET?.trim();
|
||||
if (secret === undefined || secret === "") {
|
||||
console.error("[sso] DOCVER_TOKEN_SECRET ยังไม่ถูกตั้งค่า");
|
||||
return json({ error: "ระบบยังไม่พร้อมให้เข้าสู่ระบบ" }, 500);
|
||||
}
|
||||
|
||||
let identity: { userid: string; username: string; fid: string };
|
||||
if (parsed.credential.kind === "jwt") {
|
||||
const verified = verifyDocverLoginToken(parsed.credential.token, secret);
|
||||
if (!verified.ok) {
|
||||
return json(
|
||||
{ error: "ลิงก์เข้าสู่ระบบหมดอายุหรือไม่ถูกต้อง", reason: verified.reason },
|
||||
401,
|
||||
);
|
||||
}
|
||||
identity = verified.claims;
|
||||
} else {
|
||||
// ค่า default "super_secret" ของ addinTicketSecret() เป็นที่รู้กันทั้ง repo/
|
||||
// .env.example — ใครก็ปลอม ticket ของ uid ใครก็ได้ จึงปฏิเสธใน production
|
||||
// (fail-closed — กลุ่ม docver/add-in อนุญาต default ตาม requirement อยู่แล้ว
|
||||
// คนละประตู) · **dev ยอมรับค่า default** เพื่อให้ทดสอบทาง ticket รอบเครื่องได้
|
||||
// โดยไม่ต้องตั้ง env ใหม่ — เครื่องมือ dev (make-login-link/make-ticket-docx/
|
||||
// dev-link) ก็ default ค่าเดียวกัน (review 2026-10-30 รอบสอง)
|
||||
if (process.env.NODE_ENV === "production" && addinTicketSecret() === "super_secret") {
|
||||
console.error("[sso] ticket secret ยังเป็นค่า default (ADDIN_TICKET_SECRET/SECRET) — ปฏิเสธทาง ticket");
|
||||
return json({ error: "ระบบยังไม่พร้อมให้เข้าสู่ระบบ" }, 500);
|
||||
}
|
||||
const verified = verifyAddinTicket(
|
||||
parsed.credential.ticketData,
|
||||
parsed.credential.hmacHex,
|
||||
addinTicketSecret(),
|
||||
);
|
||||
if (!verified.ok) {
|
||||
return json({ error: "ลิงก์เข้าสู่ระบบหมดอายุหรือไม่ถูกต้อง", reason: "invalid" }, 401);
|
||||
}
|
||||
identity = { userid: verified.ticket.uid, username: verified.ticket.uname, fid: verified.ticket.fid };
|
||||
}
|
||||
|
||||
// N8: ลงทะเบียนเอกสารตอนแลก token เลย — ฝั่ง server เชื่อ fid จาก credential
|
||||
// ที่ verify แล้วเท่านั้น (trusted) ส่วน ?fileUrl พิมพ์เองบนหน้าเว็บไม่มีทาง
|
||||
// เข้ามาถึงจุดนี้ · ล้ม = ยังออก token ให้ + fileError ให้หน้าแรกแสดง (คู่ขนาน
|
||||
// พฤติกรรมสมัย cookie: session ตั้งก่อน แล้วค่อยแจ้ง error ของไฟล์)
|
||||
let fileId: string | undefined;
|
||||
let fileError: string | undefined;
|
||||
if (parsed.fileUrl !== undefined) {
|
||||
const result = await registerExternalFile(parsed.fileUrl, identity.fid, parsed.typeId, {
|
||||
trusted: true,
|
||||
});
|
||||
if (result.ok) fileId = result.fileId;
|
||||
else fileError = result.error;
|
||||
}
|
||||
|
||||
const access_token = issueDocverToken(
|
||||
{ userid: identity.userid, username: identity.username, fid: identity.fid, template: null },
|
||||
secret,
|
||||
NOTE_SESSION_TTL,
|
||||
"login",
|
||||
);
|
||||
// audit: เฉพาะ userid + เวลา — ห้าม log token/query ดิบ · strip อักขระควบคุม
|
||||
// ก่อนพิมพ์ (claims ไม่จำกัด charset — กันฝัง \n ปลอมบรรทัด log)
|
||||
const auditId = identity.userid.replace(/[\u0000-\u001f\u007f-\u009f]/g, "");
|
||||
console.log(`[sso] e-Court login ok: userid=${auditId}`);
|
||||
return json({
|
||||
access_token,
|
||||
token_type: "Bearer",
|
||||
expires_in: NOTE_SESSION_TTL,
|
||||
userid: identity.userid,
|
||||
username: identity.username,
|
||||
...(fileId !== undefined ? { fileId } : {}),
|
||||
...(fileError !== undefined ? { fileError } : {}),
|
||||
});
|
||||
}
|
||||
113
app/api/auth/token/route.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
// PLAN-DOCVER.md §auth — POST /api/auth/token (path สั้นของ POST /api/documents/token
|
||||
// — โค้ดก้อนเดียวกัน copy มา 2026-11-05) ออก access_token ของกลุ่ม docver
|
||||
// ทางเข้า 2 ทาง (PLAN-ADDIN-AUTH.md §3 — header x-api-key เป็นตัวตั้ง):
|
||||
// 1) e-Court server-to-server: header "x-api-key" เทียบ env DOCVER_API_KEY
|
||||
// (fail closed เมื่อ env ไม่ตั้ง/ว่าง) + body claims ตรง ๆ (เดิมทุกอย่าง)
|
||||
// 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 — 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 604800 = 7 วัน — token อายุยาวแทนระบบ
|
||||
// refresh/renew) แล้วนำไปแนบ Authorization: Bearer กับ endpoint อื่นของกลุ่ม
|
||||
// (lib/docver-token.ts) · segment คงที่ "token" ไม่ชน [docId]
|
||||
// (Next ให้ static ชนะ) และ middleware ยกเว้น api/documents/* อยู่แล้ว
|
||||
import { docverError } from "@/lib/document-versioning";
|
||||
import {
|
||||
addinTicketSecret,
|
||||
parseTicketTokenBody,
|
||||
ticketToDocverClaims,
|
||||
verifyAddinTicketHmac,
|
||||
} from "@/lib/addin-ticket";
|
||||
import { isTicketExpired } from "@/utils/docver-ticket";
|
||||
import {
|
||||
issueDocverToken,
|
||||
parseTokenRequestBody,
|
||||
requireDocverApiKey,
|
||||
type DocverTokenClaims,
|
||||
} from "@/lib/docver-token";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** 401 ของทางเข้า ticket — ข้อความเดียวทุกสาเหตุ (HMAC ผิด/รูปผิด) กัน oracle · หมดอายุแยกเป็น 403 */
|
||||
const TICKET_DENIED = "ticket ไม่ถูกต้อง";
|
||||
/** 403 ของทางเข้า ticket — ลายเซ็นถูกแต่หมดอายุ (client มี JWT เดิมใน session ก็ใช้ต่อได้) */
|
||||
const TICKET_EXPIRED = "ticket ของ e-Court หมดอายุ";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
return request.headers.get("x-api-key") !== null
|
||||
? postWithApiKey(request)
|
||||
: postWithTicket(request);
|
||||
}
|
||||
|
||||
/** ทางเข้า 1 (เดิม) — e-Court server-to-server */
|
||||
async function postWithApiKey(request: Request): Promise<Response> {
|
||||
const apiKeyFail = requireDocverApiKey(request);
|
||||
if (apiKeyFail !== null) return apiKeyFail;
|
||||
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = await request.json();
|
||||
} catch {
|
||||
return docverError("body ต้องเป็น JSON", 400);
|
||||
}
|
||||
const parsed = parseTokenRequestBody(raw);
|
||||
if (!parsed.ok) return docverError(parsed.error, 400);
|
||||
return issueToken(parsed.claims);
|
||||
}
|
||||
|
||||
/** ทางเข้า 2 — add-in ส่ง ticket ที่ e-Court stamp มากับไฟล์ */
|
||||
async function postWithTicket(request: Request): Promise<Response> {
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = await request.json();
|
||||
} catch {
|
||||
return docverError("body ต้องเป็น JSON", 400);
|
||||
}
|
||||
const parsed = parseTicketTokenBody(raw);
|
||||
if (!parsed.ok) {
|
||||
// contract 2026-11-04: ฟอร์แม็ตพัง = 400 ทั้ง missing/invalid (เดิม missing เป็น 401)
|
||||
return docverError(parsed.error, 400);
|
||||
}
|
||||
// ตรวจ HMAC ก่อนเสมอ (ตัวตนของผู้ถือไฟล์) — exp ไม่ใช่เกณฑ์ตัดสิทธิ์ ณ จุดนี้
|
||||
const verified = verifyAddinTicketHmac(parsed.ticketData, parsed.hmacHex, addinTicketSecret());
|
||||
if (!verified.ok) return docverError(TICKET_DENIED, 401);
|
||||
|
||||
// ลายเซ็นถูกแต่ ticket หมดอายุ → 403 เสมอ (contract 2026-11-04 — เลิก requirement
|
||||
// ขั้น 7 ฉบับ 2026-10-28: ไม่ตรวจ hasVersionByUser อีกต่อไป เพราะ ticket ฝังอยู่ใน
|
||||
// ไฟล์ ใครได้ไฟล์ก็ได้ ticket การปล่อยให้ ticket เก่าแลก token ใหม่ได้ตลอดไป
|
||||
// เท่ากันคือปล่อยสิทธิ์ตามการครอบครองไฟล์ — ฝั่ง client ที่มี JWT ของ fid นี้
|
||||
// ใน session ที่ยังไม่หมด (7 วัน) ใช้ต่อเอง ไม่มี = ขอไฟล์ใหม่จาก e-Court)
|
||||
if (isTicketExpired(verified.ticket.exp)) {
|
||||
return docverError(TICKET_EXPIRED, 403);
|
||||
}
|
||||
return issueToken(ticketToDocverClaims(verified.ticket));
|
||||
}
|
||||
|
||||
/** ออก JWT ของ claims ที่ผ่านการยืนยันแล้วจากทั้งสองทาง — ส่วนกลาง */
|
||||
async function issueToken(claims: DocverTokenClaims): Promise<Response> {
|
||||
// ความผิด config ฝั่ง server ไม่ใช่ความผิดของผู้เรียก — ไม่ตั้ง = 500 (ห้าม log ค่า secret)
|
||||
const secret = process.env.DOCVER_TOKEN_SECRET?.trim();
|
||||
if (secret === undefined || secret === "") {
|
||||
return docverError("DOCVER_TOKEN_SECRET ยังไม่ถูกตั้งค่า", 500);
|
||||
}
|
||||
const ttlRaw = Number(process.env.DOCVER_TOKEN_TTL_SECONDS);
|
||||
// default 7 วัน (2026-11-04) — token อายุยาวแทนระบบ refresh/renew ของ add-in
|
||||
const ttlSeconds =
|
||||
Number.isFinite(ttlRaw) && ttlRaw > 0 ? Math.floor(ttlRaw) : 604_800;
|
||||
|
||||
const accessToken = issueDocverToken(claims, secret, ttlSeconds);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
access_token: accessToken,
|
||||
token_type: "Bearer",
|
||||
expires_in: ttlSeconds,
|
||||
}),
|
||||
{ headers: { "content-type": "application/json; charset=utf-8" } },
|
||||
);
|
||||
}
|
||||
|
|
@ -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<Response> {
|
|||
}
|
||||
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<Response> {
|
|||
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(
|
||||
|
|
|
|||
|
|
@ -114,6 +114,10 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) =>
|
|||
|
||||
/** ข้อความบนหน้า no-access — ต่างกันตามสาเหตุ (ไม่มี token ในไฟล์ / token หมดอายุ) */
|
||||
const [noAccessNotice, setNoAccessNotice] = useState<string | null>(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;
|
||||
}
|
||||
|
|
@ -206,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) {
|
||||
|
|
@ -469,6 +479,15 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) =>
|
|||
<p className="text-sm text-ink-soft">
|
||||
{noAccessNotice ?? "ไม่พบ ticket ของ e-Court ในไฟล์ — ขณะนี้ add-in ใช้ได้กับเอกสารที่ส่งมาจาก e-Court เท่านั้น"}
|
||||
</p>
|
||||
{noAccessRetryable ? (
|
||||
<button
|
||||
type="button"
|
||||
className="focus-ring w-fit rounded-md border border-error/40 bg-white px-2 py-1.5 text-xs text-error-ink"
|
||||
onClick={() => void detect()}
|
||||
>
|
||||
ลองใหม่
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,8 +22,41 @@ 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 **ลบ `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 —
|
||||
|
|
@ -39,16 +72,44 @@ function ensureOfficeJs(): Promise<boolean> {
|
|||
if (!officeReadyPromise) {
|
||||
officeReadyPromise = new Promise<boolean>((resolve) => {
|
||||
if (typeof Office !== "undefined") {
|
||||
void Office.onReady(() => resolve(true));
|
||||
restoreHistoryApi(); // office.js evaluate ไปแล้ว — คืนก่อนให้ router ใช้ต่อได้
|
||||
void Office.onReady(() => {
|
||||
restoreHistoryApi(); // host handshake อาจลบซ้ำตอน initialize
|
||||
resolve(true);
|
||||
});
|
||||
return;
|
||||
}
|
||||
const script = document.createElement("script");
|
||||
const fallbackTimer = setTimeout(() => resolve(true), ONREADY_FALLBACK_MS);
|
||||
const startedAt = Date.now();
|
||||
let fallbackTimer: ReturnType<typeof setTimeout>;
|
||||
const armFallback = () => {
|
||||
fallbackTimer = setTimeout(() => {
|
||||
// Office โผล่แล้ว (script evaluate แล้ว) = proceed ได้เลย ไม่รอ onReady —
|
||||
// กัน webview ที่โหลดสำเร็จแต่ callback เงียบ (เจตนาเดิมของ fallback)
|
||||
if (typeof Office !== "undefined") {
|
||||
restoreHistoryApi(); // office.js evaluate แล้วอาจลบ history ทิ้ง — คืนก่อน proceed
|
||||
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 ตลอดไป)
|
||||
restoreHistoryApi(); // script evaluate แล้ว — office.js ลบ history ตรงนี้ คืนก่อน router ใช้
|
||||
void Office.onReady(() => {
|
||||
clearTimeout(fallbackTimer);
|
||||
restoreHistoryApi(); // handshake ของ host อาจลบซ้ำ — คืนอีกรอบก่อน mount TaskpaneApp
|
||||
resolve(true);
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 = <hex 64 ตัว lowercase> = 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<string> // 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<string>
|
|||
- `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<string>
|
|||
| 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<string>
|
|||
|
||||
## 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 <file.docx> [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. ลำดับงาน
|
||||
|
|
|
|||
|
|
@ -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 <access_token>`** —
|
||||
ไม่มี 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`) |
|
||||
|
|
|
|||
|
|
@ -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`** = `<exp epoch วินาที>.<HMAC-SHA256 hex>` เซ็นครอบ **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 (ข–ง)
|
||||
|
|
|
|||
|
|
@ -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 ต้องเข้าถึงได้จากเครื่องผู้ใช้)
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<boolean> {
|
||||
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;
|
||||
|
|
|
|||
291
lib/openapi.ts
|
|
@ -1,6 +1,7 @@
|
|||
// OpenAPI 3.0 spec ของ API ทดสอบทั้งหมดของ judes-note — เส้นเดียวกับที่ Swagger UI
|
||||
// แสดงผล: กลุ่ม template (DOCX Content Control) + N10 external API + docver
|
||||
// (จัด version เอกสาร)
|
||||
// (จัด version เอกสาร) + auth (ทางเข้า path สั้น /api/auth/* — alias ของ
|
||||
// /api/documents/token กับ /api/note/auth/session เพิ่ม 2026-11-05)
|
||||
// route handler GET /api/swagger (app/api/swagger/route.ts) เป็นคนเดียวที่ใช้
|
||||
// — inline เป็น JSON ลงใน HTML ตอน respond (ไม่มี endpoint spec แยก)
|
||||
//
|
||||
|
|
@ -25,7 +26,8 @@ export const openApiSpec = {
|
|||
version: "1.0.0",
|
||||
description:
|
||||
"API ทดสอบของระบบ Judge's Note — กลุ่ม Template (DOCX Content Control), " +
|
||||
"API ภายนอกสำหรับ backend e-Court (N10) และ API จัด version เอกสาร (docver)",
|
||||
"API ภายนอกสำหรับ backend e-Court (N10), API จัด version เอกสาร (docver) " +
|
||||
"และทางเข้า auth แบบ path สั้น (/api/auth/*)",
|
||||
},
|
||||
servers: [{ url: BASE_PATH, description: "origin เดียวกับหน้านี้ (basePath /docedit)" }],
|
||||
tags: [
|
||||
|
|
@ -59,6 +61,15 @@ export const openApiSpec = {
|
|||
"server-to-server ดึง annotations/bookmarks ของเอกสารที่เคยเปิดในระบบนี้ · " +
|
||||
"SSO: เส้น mint login-token สำหรับประกอบลิงก์เข้าสู่ระบบ (PLAN-SSO.md)",
|
||||
},
|
||||
{
|
||||
name: "auth",
|
||||
description:
|
||||
"ทางเข้า auth แบบ **path สั้น** `/api/auth/*` (เพิ่ม 2026-11-05) — endpoint " +
|
||||
"ก้อนเดียวกับของเดิมคนละ URL เลือกใช้ทางใดทางหนึ่งได้:\n" +
|
||||
"- `POST /api/auth/token` = `POST /api/documents/token` — ออก access_token ของกลุ่ม documents (docver)\n" +
|
||||
"- `POST /api/auth/session` = `POST /api/note/auth/session` — แลก credential ของลิงก์ e-Court " +
|
||||
"เป็น session token ของแอป note",
|
||||
},
|
||||
],
|
||||
paths: {
|
||||
"/api/template/test": {
|
||||
|
|
@ -687,23 +698,27 @@ export const openApiSpec = {
|
|||
},
|
||||
},
|
||||
},
|
||||
"/api/documents/token": {
|
||||
"/api/auth/token": {
|
||||
post: {
|
||||
tags: ["documents"],
|
||||
summary: "ขอ access_token ของกลุ่ม documents (docver)",
|
||||
tags: ["auth"],
|
||||
summary: "ขอ access_token ของกลุ่ม documents (docver) — path สั้นของ /api/documents/token",
|
||||
description:
|
||||
"ทางเข้า **path สั้น** ของ `POST /api/documents/token` — endpoint ก้อนเดียวกัน " +
|
||||
"พฤติกรรมเหมือนกันทุกประการ (เพิ่ม 2026-11-05)\n\n" +
|
||||
"ออก `access_token` (JWT HS256) ไว้เรียก endpoint อื่นของกลุ่ม documents — " +
|
||||
"token ฝัง claims `{userid, username, fid, template}` อายุตาม env " +
|
||||
"`DOCVER_TOKEN_TTL_SECONDS` (default 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 <token>` กับทุก request ถัดไป",
|
||||
security: [{ ApiKeyAuth: [] }],
|
||||
requestBody: {
|
||||
|
|
@ -753,7 +768,190 @@ 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" },
|
||||
},
|
||||
},
|
||||
},
|
||||
500: { $ref: "#/components/responses/ServerError" },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/auth/session": {
|
||||
post: {
|
||||
tags: ["auth"],
|
||||
summary: "แลก credential ของลิงก์ e-Court เป็น session token ของแอป note (SSO)",
|
||||
description:
|
||||
"ทางเข้า **path สั้น** ของ `POST /api/note/auth/session` — endpoint ก้อนเดียวกัน " +
|
||||
"พฤติกรรมเหมือนกันทุกประการ (เพิ่ม 2026-11-05) · flow SSO ของแอป note " +
|
||||
"(PLAN-SSO.md §3.2 ฉบับ token): ลิงก์ e-Court พา browser มาหน้า `/note` พร้อม " +
|
||||
"credential → client เรียกเส้นนี้แลกเป็น access_token เก็บ localStorage " +
|
||||
'("note-auth") แล้วแนบ `Authorization: Bearer` กับทุก API/Server Action ของแอป\n\n' +
|
||||
"- ส่ง credential **ทางเดียว**: `t` (login-token จาก `POST /api/note/auth/token`) " +
|
||||
"หรือ `ticket_data`+`HmacSha256` (คู่ค่าชุดเดียวกับที่ e-Court stamp ใน custom " +
|
||||
"properties ของไฟล์ .docx ของ add-in) — สองชุดพร้อมกัน = 400 (D12)\n" +
|
||||
"- token ที่ได้เป็นชนิด `purpose=login` อายุ 8 ชม. (28800 วินาที) — ใช้ได้กับแอป " +
|
||||
"note เท่านั้น เอาไปยิง `/api/documents/*` ของกลุ่ม docver ไม่ได้ (401)\n" +
|
||||
"- ส่ง `fileUrl` มาด้วย = ลงทะเบียนเอกสาร N8 แบบ trusted ฝั่ง server ทันที " +
|
||||
"(fid จาก credential ที่ verify แล้ว) — ล้มก็ยังได้ token พร้อม `fileError` " +
|
||||
"ให้หน้าแรกแสดงสาเหตุ · `fileUrl` ต้องผ่าน allowlist ของ N8 " +
|
||||
"(admincourt.go.th + env `EXTERNAL_FILE_HOSTS`) หรือเป็น path `/samples/…`\n" +
|
||||
"- **ทาง ticket ใน production**: secret ต้องตั้งจริง (env `ADDIN_TICKET_SECRET` " +
|
||||
'→ `SECRET`) — ค่า default `"super_secret"` ที่รู้กันทั้งระบบถูกปฏิเสธ (500)',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/SessionExchangeRequest" },
|
||||
examples: {
|
||||
"ทาง JWT (param t ของลิงก์ e-Court)": {
|
||||
value: {
|
||||
t: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJwdXJwb3NlIjoibG9naW4iLCJ1c2VyaWQiOiJvb20iLCJ1c2VybmFtZSI6IuC4geC4m+C4tOC5iOC4quC4lOC5iCIsImZpZCI6IkYxMjMifQ.….….…",
|
||||
fileUrl: "https://service.admincourt.go.th/uploads/F123.pdf",
|
||||
typeId: "T12",
|
||||
},
|
||||
},
|
||||
"ทาง ticket (คู่ค่าเดียวกับไฟล์ .docx ของ add-in)": {
|
||||
value: {
|
||||
ticket_data:
|
||||
'{"exp":1793067433,"fid":"T12_FD155_3s-long_ZZZ","tid":"T12","uid":"oom","uname":"สรวิชญ์"}',
|
||||
HmacSha256:
|
||||
"1dc53fbd48410845c895ddb841a8fe4ad93da413126fae9c688202e57f2baaa9",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description:
|
||||
"session token ของแอป note (พร้อม fileId ถ้าลงทะเบียน fileUrl สำเร็จ)",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/SessionResponse" },
|
||||
},
|
||||
},
|
||||
},
|
||||
400: { $ref: "#/components/responses/BadRequest" },
|
||||
401: {
|
||||
description:
|
||||
"credential หมดอายุหรือไม่ถูกต้อง — ทาง JWT แยก `reason: expired` (หมดอายุ) กับ " +
|
||||
"`invalid` · ทาง ticket รวมเป็น `invalid` ทั้งลายเซ็นผิดและหมดอายุ",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
allOf: [
|
||||
{ $ref: "#/components/schemas/Error" },
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
reason: {
|
||||
type: "string",
|
||||
enum: ["expired", "invalid"],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
500: { $ref: "#/components/responses/ServerError" },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/documents/token": {
|
||||
post: {
|
||||
tags: ["documents"],
|
||||
summary: "ขอ access_token ของกลุ่ม documents (docver)",
|
||||
description:
|
||||
"ออก `access_token` (JWT HS256) ไว้เรียก endpoint อื่นของกลุ่ม documents — " +
|
||||
"token ฝัง claims `{userid, username, fid, template}` อายุตาม env " +
|
||||
"`DOCVER_TOKEN_TTL_SECONDS` (default 604800 วินาที = 7 วัน — token อายุยาวแทน" +
|
||||
"ระบบ refresh/renew ของ add-in)\n\n" +
|
||||
"**ทางเข้า 2 แบบ** — เลือกตามว่าใครเป็นคนเรียก (ดูตัวอย่างใน Request body ได้ทั้งคู่):\n\n" +
|
||||
"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 เอง ' +
|
||||
"(contract 2026-11-04: ฟอร์แม็ตพัง/ไม่ครบ = 400 · ลายเซ็นไม่ถูก = 401 · " +
|
||||
"**ลายเซ็นถูกแต่ ticket หมดอายุ = 403** — add-in ที่มี access_token เดิมของ `fid` " +
|
||||
"นี้ใน session ยังใช้ต่อได้ ไม่มี = ขอไฟล์ใหม่จาก e-Court)\n\n" +
|
||||
"ได้ token แล้วแนบเป็น `Authorization: Bearer <token>` กับทุก request ถัดไป",
|
||||
security: [{ ApiKeyAuth: [] }],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
oneOf: [
|
||||
{ $ref: "#/components/schemas/TokenRequest" },
|
||||
{ $ref: "#/components/schemas/TicketTokenRequest" },
|
||||
],
|
||||
},
|
||||
examples: {
|
||||
"server-to-server ด้วย x-api-key (ครบทุก claim)": {
|
||||
value: {
|
||||
userid: "123",
|
||||
username: "Sorawit Bholsithi",
|
||||
fid: "3",
|
||||
template: "T12",
|
||||
},
|
||||
},
|
||||
"server-to-server ขั้นต่ำ (template เป็นทางเลือก)": {
|
||||
value: { userid: "123", username: "Sorawit Bholsithi", fid: "3" },
|
||||
},
|
||||
"add-in ด้วย ticket จากไฟล์ (ไม่ต้องมี x-api-key)": {
|
||||
value: {
|
||||
ticket_data:
|
||||
'{"exp":1793067433,"fid":"T12_FD155_3s-long_ZZZ","tid":"T12","uid":"oom","uname":"สรวิชญ์"}',
|
||||
HmacSha256:
|
||||
"1dc53fbd48410845c895ddb841a8fe4ad93da413126fae9c688202e57f2baaa9",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "access_token พร้อมใช้ (แนบเป็น Authorization: Bearer ทุก request ถัดไป)",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/TokenResponse" },
|
||||
},
|
||||
},
|
||||
},
|
||||
400: { $ref: "#/components/responses/BadRequest" },
|
||||
401: {
|
||||
description:
|
||||
"ทางเข้า x-api-key: ไม่ได้ส่ง header หรือค่าไม่ถูกต้อง (env DOCVER_API_KEY) · " +
|
||||
"ทางเข้า 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 +1143,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 +1325,80 @@ 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 วัน)",
|
||||
},
|
||||
},
|
||||
},
|
||||
SessionExchangeRequest: {
|
||||
type: "object",
|
||||
description:
|
||||
"credential ของลิงก์ e-Court ที่จะแลกเป็น session token — ส่งทางเดียว: `t` " +
|
||||
"**หรือ** `ticket_data`+`HmacSha256` (สองชุดพร้อมกัน = 400 ตาม D12) · " +
|
||||
"allowlist 5 field เป๊ะ — ส่ง field อื่น = 400",
|
||||
properties: {
|
||||
t: {
|
||||
type: "string",
|
||||
description:
|
||||
"login-token (JWT purpose=login) จาก POST /api/note/auth/token — ≤ 8192 ตัวอักษร",
|
||||
},
|
||||
ticket_data: {
|
||||
type: "string",
|
||||
description:
|
||||
"JSON string ของ ticket ชุดเดียวกับที่ e-Court stamp ในไฟล์ .docx — " +
|
||||
"≤ 2048 ตัวอักษร (ส่งตรง ๆ ไม่แก้ string — HMAC ผูกกับค่าดิบ)",
|
||||
},
|
||||
HmacSha256: {
|
||||
type: "string",
|
||||
description: "hex 64 ตัว ของ HMAC-SHA256(secret, ticket_data ดิบ) — ≤ 128 ตัวอักษร",
|
||||
},
|
||||
fileUrl: {
|
||||
type: "string",
|
||||
description:
|
||||
"URL เอกสารบน e-Court — ต้องผ่าน allowlist ของ N8 (admincourt.go.th + env " +
|
||||
"EXTERNAL_FILE_HOSTS) · ส่งมา = ลงทะเบียนเอกสารตอนแลก token (≤ 2048 ตัวอักษร)",
|
||||
},
|
||||
typeId: {
|
||||
type: "string",
|
||||
description: "รหัสกลุ่มเอกสารของ e-Court (≤ 200 ตัวอักษร)",
|
||||
},
|
||||
},
|
||||
},
|
||||
SessionResponse: {
|
||||
type: "object",
|
||||
description:
|
||||
"session ของแอป note — client เก็บใน localStorage (\"note-auth\") แล้วแนบ " +
|
||||
"Authorization: Bearer กับทุก API/Server Action ของแอป",
|
||||
properties: {
|
||||
access_token: {
|
||||
type: "string",
|
||||
description: "JWT purpose=login อายุ 8 ชม. — ใช้ได้กับแอป note เท่านั้น",
|
||||
},
|
||||
token_type: { type: "string", example: "Bearer" },
|
||||
expires_in: {
|
||||
type: "integer",
|
||||
description: "อายุวินาที (NOTE_SESSION_TTL = 28800 = 8 ชม.)",
|
||||
example: 28800,
|
||||
},
|
||||
userid: { type: "string", description: "รหัสผู้ใช้จาก credential (u_userid ของ e-Court)" },
|
||||
username: { type: "string", description: "ชื่อแสดงผลจาก credential" },
|
||||
fileId: {
|
||||
type: "string",
|
||||
format: "uuid",
|
||||
description:
|
||||
"มีเมื่อส่ง fileUrl และลงทะเบียนสำเร็จ — id ของเอกสารในระบบนี้ " +
|
||||
"(ใช้เข้าหน้าอ่าน /note/annotate/<fileId>)",
|
||||
},
|
||||
fileError: {
|
||||
type: "string",
|
||||
description:
|
||||
"มีเมื่อลงทะเบียน fileUrl ไม่สำเร็จ (ไม่ผ่าน allowlist ฯลฯ) — token ยังออกให้ " +
|
||||
"หน้าแรกแสดงสาเหตุ",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
626
package-lock.json
generated
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 841 B |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 9.9 KiB |
|
|
@ -5,7 +5,7 @@
|
|||
-->
|
||||
<OfficeApp xmlns="http://schemas.microsoft.com/office/appforoffice/1.1" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:bt="http://schemas.microsoft.com/office/officeappbasictypes/1.0" xmlns:ov="http://schemas.microsoft.com/office/taskpaneappversionoverrides" xsi:type="TaskPaneApp">
|
||||
<Id>4a1e184a-ab31-4fa4-b3c7-9a2283051c3f</Id>
|
||||
<Version>1.0.0.2</Version>
|
||||
<Version>1.0.0.4</Version>
|
||||
<ProviderName>e-Court Documents Versioning</ProviderName>
|
||||
<DefaultLocale>en-US</DefaultLocale>
|
||||
<DisplayName DefaultValue="e-Court Documents Versioning"/>
|
||||
|
|
@ -67,8 +67,14 @@
|
|||
<bt:Image size="32" resid="Icon.32x32"/>
|
||||
<bt:Image size="80" resid="Icon.80x80"/>
|
||||
</Icon>
|
||||
<!-- TaskpaneId ค่านี้เป็น **ค่าคงที่ตามข้อบังคับของ Office** สำหรับฟีเจอร์ auto-open
|
||||
(learn.microsoft.com/.../automatically-open-a-task-pane-with-a-document) —
|
||||
ห้ามเปลี่ยนกลับเป็นชื่ออื่น: ต้องตรงกับ document setting ชื่อเดียวกันที่
|
||||
metadataService.enableAutoOpenTaskpane เขียน และ <we:property> ที่ lib/lib-tmpl.ts
|
||||
stamp ลง word/webextensions/ ฝั่ง server — เปลี่ยนค่า = auto-open ตายทั้งสองฝั่ง
|
||||
(โบนัส: marker นี้ทำให้ pane เด้งอัตโนมัติตอนติดตั้ง add-in ครั้งแรกด้วย) -->
|
||||
<Action xsi:type="ShowTaskpane">
|
||||
<TaskpaneId>ButtonId1</TaskpaneId>
|
||||
<TaskpaneId>Office.AutoShowTaskpaneWithDocument</TaskpaneId>
|
||||
<SourceLocation resid="Taskpane.Url"/>
|
||||
</Action>
|
||||
</Control>
|
||||
|
|
|
|||
215
tests/app/api/documents/token.test.ts
Normal file
|
|
@ -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<string, string | undefined> = {};
|
||||
|
||||
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<string, string>): 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<string, unknown> {
|
||||
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<string, unknown>;
|
||||
}
|
||||
|
||||
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<string>();
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
51
tests/lib/openapi.test.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { openApiSpec } from "@/lib/openapi";
|
||||
|
||||
// spec ถูก inline เป็น JSON ลงหน้า Swagger UI ตรง ๆ (app/api/swagger/route.ts) —
|
||||
// $ref ที่ชี้หาย (แก้ชื่อ schema ใน lib/openapi.ts แล้วลืมแก้อีกฝั่ง) จะไม่มี error
|
||||
// ตอน build เพราะเป็นแค่ string กลาง object literal ที่ serialize ได้ปกติ — จับได้ที่นี่ก่อน
|
||||
// เห็นเป็นช่องว่าง ๆ บนหน้า /api/swagger
|
||||
|
||||
function collectRefs(node: unknown, out: string[] = []): string[] {
|
||||
if (Array.isArray(node)) {
|
||||
for (const item of node) collectRefs(item, out);
|
||||
} else if (node !== null && typeof node === "object") {
|
||||
for (const [key, value] of Object.entries(node)) {
|
||||
if (key === "$ref" && typeof value === "string") out.push(value);
|
||||
else collectRefs(value, out);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** แกะ JSON pointer (#/a/b/c) — null = ชี้ไปที่ไม่มีจริง */
|
||||
function resolveRef(spec: object, ref: string): unknown {
|
||||
if (!ref.startsWith("#/")) return null;
|
||||
let node: unknown = spec;
|
||||
for (const part of ref.slice(2).split("/")) {
|
||||
if (node === null || typeof node !== "object") return null;
|
||||
const key = part.replace(/~1/g, "/").replace(/~0/g, "~");
|
||||
node = (node as Record<string, unknown>)[key];
|
||||
if (node === undefined) return null;
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
describe("openApiSpec", () => {
|
||||
it("ทุก $ref ใน spec ชี้ถึงจุดที่มีจริง", () => {
|
||||
const refs = collectRefs(openApiSpec);
|
||||
expect(refs.length).toBeGreaterThan(0);
|
||||
const broken = refs.filter((ref) => resolveRef(openApiSpec, ref) === null);
|
||||
expect(broken).toEqual([]);
|
||||
});
|
||||
|
||||
it("กลุ่ม auth: path สั้น /api/auth/* ครบทั้งคู่ และ path เดิมที่เป็นต้นทางยังอยู่", () => {
|
||||
const paths = openApiSpec.paths as Record<string, unknown>;
|
||||
expect(paths["/api/auth/token"]).toBeDefined();
|
||||
expect(paths["/api/auth/session"]).toBeDefined();
|
||||
// path เดิม (ที่ /api/auth/* alias มา) ต้องยัง document อยู่
|
||||
expect(paths["/api/documents/token"]).toBeDefined();
|
||||
expect(paths["/api/note/auth/token"]).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 — ขาด = ไฟล์ที่คืนไปเปิดไม่ได้
|
||||
|
|
|
|||
|
|
@ -93,3 +93,103 @@ describe("readContentControlFields", () => {
|
|||
expect(await readContentControlFields()).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── enableAutoOpenTaskpane (auto-open 2026-11-05) ──────────────────────────
|
||||
// function มี module-level memo (กัน double-call ของ detect) — แยกเคสด้วย
|
||||
// vi.resetModules + dynamic import ให้ทุกเคสได้ module ใหม่ ไม่ให้ memo รั่ยข้ามเคส
|
||||
interface StubbedSettings {
|
||||
get: ReturnType<typeof vi.fn>;
|
||||
set: ReturnType<typeof vi.fn>;
|
||||
saveAsync: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
/** stub Office.context.document.settings + Word.run ของ saveDocumentVerified */
|
||||
function stubAutoOpen(options?: { existing?: boolean; saveOk?: boolean; nativeSaveThrows?: boolean }): {
|
||||
settings: StubbedSettings;
|
||||
saveFn: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
const settings = {
|
||||
get: vi.fn(() => (options?.existing === true ? true : undefined)),
|
||||
set: vi.fn(),
|
||||
saveAsync: vi.fn((callback: (result: { status: string }) => void) =>
|
||||
callback({ status: options?.saveOk === false ? "failed" : "succeeded" }),
|
||||
),
|
||||
};
|
||||
const saveFn = vi.fn(() => {
|
||||
if (options?.nativeSaveThrows) throw new Error("read-only");
|
||||
});
|
||||
vi.stubGlobal("Office", {
|
||||
AsyncResultStatus: { Succeeded: "succeeded", Failed: "failed" },
|
||||
context: { document: { settings } },
|
||||
});
|
||||
vi.stubGlobal("Word", {
|
||||
run: async (batch: (context: unknown) => Promise<unknown>) =>
|
||||
batch({
|
||||
document: { save: saveFn, load: vi.fn(), saved: true },
|
||||
sync: async () => undefined,
|
||||
}),
|
||||
});
|
||||
return { settings, saveFn };
|
||||
}
|
||||
|
||||
async function importFreshMetadata() {
|
||||
vi.resetModules();
|
||||
return await import("@/wordaddin/services/metadataService");
|
||||
}
|
||||
|
||||
describe("enableAutoOpenTaskpane", () => {
|
||||
it("ยังไม่มี setting → set(true) + saveAsync + native save ครบ คืน true", async () => {
|
||||
const { settings, saveFn } = stubAutoOpen();
|
||||
const metadata = await importFreshMetadata();
|
||||
|
||||
const ok = await metadata.enableAutoOpenTaskpane();
|
||||
|
||||
expect(ok).toBe(true);
|
||||
expect(metadata.AUTO_OPEN_SETTING).toBe("Office.AutoShowTaskpaneWithDocument");
|
||||
expect(settings.set).toHaveBeenCalledWith("Office.AutoShowTaskpaneWithDocument", true);
|
||||
expect(settings.saveAsync).toHaveBeenCalledTimes(1);
|
||||
expect(saveFn).toHaveBeenCalled(); // saveDocumentVerified ตามหลัง saveAsync
|
||||
});
|
||||
|
||||
it("มี setting = true อยู่แล้ว → no-op idempotent ไม่ set ไม่ saveAsync", async () => {
|
||||
const { settings, saveFn } = stubAutoOpen({ existing: true });
|
||||
const metadata = await importFreshMetadata();
|
||||
|
||||
expect(await metadata.enableAutoOpenTaskpane()).toBe(true);
|
||||
expect(settings.set).not.toHaveBeenCalled();
|
||||
expect(settings.saveAsync).not.toHaveBeenCalled();
|
||||
expect(saveFn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("นอก host Word (ไม่มี Office global) → คืน false ไม่ throw", async () => {
|
||||
const metadata = await importFreshMetadata(); // ไม่ stub อะไรเลย
|
||||
expect(await metadata.enableAutoOpenTaskpane()).toBe(false);
|
||||
});
|
||||
|
||||
it("settings.saveAsync ล้ม → คืน false ไม่ throw", async () => {
|
||||
stubAutoOpen({ saveOk: false });
|
||||
const metadata = await importFreshMetadata();
|
||||
expect(await metadata.enableAutoOpenTaskpane()).toBe(false);
|
||||
});
|
||||
|
||||
it("native save ล้ม (ไฟล์ read-only) → ยังคืน true (setting เข้า memory doc แล้ว) + warn ไม่ throw", async () => {
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
stubAutoOpen({ nativeSaveThrows: true });
|
||||
const metadata = await importFreshMetadata();
|
||||
|
||||
expect(await metadata.enableAutoOpenTaskpane()).toBe(true);
|
||||
expect(warn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("memo: ล้มครั้งแรกแล้วล้าง — call ถัดไปยิงใหม่ได้ (สำเร็จในครั้งที่สอง)", async () => {
|
||||
const { settings } = stubAutoOpen({ saveOk: false });
|
||||
const metadata = await importFreshMetadata();
|
||||
|
||||
expect(await metadata.enableAutoOpenTaskpane()).toBe(false);
|
||||
settings.saveAsync.mockImplementation(
|
||||
(callback: (result: { status: string }) => void) => callback({ status: "succeeded" }),
|
||||
);
|
||||
expect(await metadata.enableAutoOpenTaskpane()).toBe(true);
|
||||
expect(settings.saveAsync).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,15 +1,16 @@
|
|||
// @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/auth/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";
|
||||
import * as ticketService from "@/wordaddin/services/ticketService";
|
||||
import { withBasePath } from "@/utils/base-path";
|
||||
|
||||
// จับ error ทุกทาง (window error / unhandled rejection) — flow ต้องไม่ปล่อยอะไรหลุดเลย
|
||||
const errors: unknown[] = [];
|
||||
|
|
@ -18,110 +19,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 +49,325 @@ 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 (_url: string | URL | Request) => 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);
|
||||
// endpoint ที่ยิงจริง = path สั้น /api/auth/token (เปลี่ยนจาก /api/documents/token 2026-11-05)
|
||||
expect(fetchMock.mock.calls[0][0]).toBe(withBasePath("/api/auth/token"));
|
||||
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
18
tests/wordaddin/versionFlow.test.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
// @vitest-environment jsdom
|
||||
// resolveTrackingId (งาน B 2026-11-05) — tracking id ของ Start Versioning ใช้ fid จาก
|
||||
// ticket แทน GUID สุ่มใหม่ (TRACKING_ID_SOURCE = "fid") · ไม่มี fid = fallback GUID เดิม
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { resolveTrackingId } from "@/wordaddin/services/versionFlow";
|
||||
|
||||
const GUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
|
||||
describe("resolveTrackingId", () => {
|
||||
it("มี fid (sign-in ผ่านแล้ว) → ใช้ fid ตรง ๆ ตรงกับ docId ฝั่ง server", () => {
|
||||
expect(resolveTrackingId("T12_FD155_3s-long_ZZZ")).toBe("T12_FD155_3s-long_ZZZ");
|
||||
});
|
||||
|
||||
it("ไม่มี fid (null) → fallback เป็น GUID รูปแบบเดิม", () => {
|
||||
expect(resolveTrackingId(null)).toMatch(GUID_RE);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<void> {
|
||||
vi.stubGlobal("crypto", webcrypto); // jsdom ไม่มี crypto.subtle
|
||||
const ticket = {
|
||||
exp: Math.floor(Date.now() / 1000) + 300,
|
||||
fid: "REAL_FID_001",
|
||||
|
|
|
|||
|
|
@ -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<string>;
|
||||
|
|
|
|||
|
|
@ -4,17 +4,13 @@
|
|||
*
|
||||
* ช่วง mock: versioningService เป็น mock ไม่ใช้ apiBaseUrl —
|
||||
* ตั้งค่านี้เมื่อปลด mock ต่อ backend จริง (ต้องเป็น HTTPS เสมอ — webview ของ Word บล็อก http)
|
||||
* ส่วนการขอ token (ticket auth) ยิง same-origin /api/documents/token เสมอ ไม่ผูกกับ apiBaseUrl
|
||||
* ส่วนการขอ token (ticket auth) ยิง same-origin /api/auth/token เสมอ ไม่ผูกกับ apiBaseUrl
|
||||
*/
|
||||
export const config = {
|
||||
/** base URL ของ backend API (ไม่มี slash ท้าย) — ว่าง = ยังไม่ต่อ backend จริง */
|
||||
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)
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import * as authService from "./authService";
|
|||
|
||||
/**
|
||||
* fetch wrapper ของ add-in — แนบ Bearer ทุก request (access_token ที่ authService
|
||||
* ขอจาก /api/documents/token ด้วย ticket ของไฟล์ — PLAN-ADDIN-AUTH.md)
|
||||
* ขอจาก /api/auth/token ด้วย ticket ของไฟล์ — PLAN-ADDIN-AUTH.md)
|
||||
* โดน 401 → ลองขอ token ใหม่จาก ticket แล้ว retry 1 ครั้ง ยังไม่ผ่าน → AuthRequiredError:
|
||||
* UI จับ error นี้แล้วพาไปหน้า no-access ให้ขอไฟล์ใหม่จาก e-Court
|
||||
* พอร์ตจาก D:\word-addin-poc\addin\src\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<Response> {
|
||||
const accessToken = await authService.getAccessToken();
|
||||
|
|
|
|||
|
|
@ -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/auth/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/auth/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<boolean> {
|
||||
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 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] = entry;
|
||||
session.tokens[parsed.ticket.fid] = result.entry;
|
||||
ticketService.saveSession(session);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Bearer ของ apiClient — token ของ fid ปัจจุบัน (stale ได้ — apiClient เป็นคนตัดสิน 401) */
|
||||
|
|
@ -185,3 +189,9 @@ export async function getAccessToken(): Promise<string | null> {
|
|||
export function getUsername(): string | undefined {
|
||||
return username ?? undefined;
|
||||
}
|
||||
|
||||
/** fid ของเอกสารปัจจุบัน (จาก ticket ที่ sign-in ผ่าน) — null = ยังไม่ได้ sign-in หรือโหมด demo
|
||||
* ใช้เป็น tracking id ตอน Start Versioning (versionFlow.resolveTrackingId, 2026-11-05) */
|
||||
export function getCurrentFid(): string | null {
|
||||
return currentFid;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@
|
|||
* (known bug บน Mac บาง build — เขียนไม่ได้ก็ปล่อยผ่าน)
|
||||
*/
|
||||
|
||||
import { saveDocumentVerified } from "./documentService";
|
||||
|
||||
/** GUID คงที่ของ add-in — ต้องตรงเป๊ะกับ `AddInMetadata.AddInSignatureGuid` ฝั่ง backend (spec ส่วน 8) */
|
||||
export const ADDIN_SIGNATURE_GUID = "9B7A3C51-4E6F-4D2A-8B5C-1E0F2A3B4C5D";
|
||||
|
||||
|
|
@ -128,6 +130,60 @@ export async function stampTrackedProperties(input: StampPropertiesInput): Promi
|
|||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auto-open taskpane (Office.AutoShowTaskpaneWithDocument) — 2026-11-05
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** ชื่อ setting ที่ทำให้ taskpane เด้งอัตโนมัติเมื่อเปิดเอกสารนี้ครั้งถัดไป — ต้องตรงกับ
|
||||
* `<TaskpaneId>` ใน manifest.template.xml และ `<we:property>` ที่ lib/lib-tmpl.ts stamp
|
||||
* ลง word/webextensions/ ฝั่ง server (learn.microsoft.com/.../automatically-open-a-task-pane-with-a-document) */
|
||||
export const AUTO_OPEN_SETTING = "Office.AutoShowTaskpaneWithDocument";
|
||||
|
||||
/** memo กัน double-call — detect() ถูกเรียกซ้ำ (ปุ่ม "ลองใหม่") ก็แชร์ promise เดิม;
|
||||
* ล้มแล้วล้างทิ้งให้ครั้งถัดไปได้ลองใหม่ */
|
||||
let autoOpenPromise: Promise<boolean> | null = null;
|
||||
|
||||
/**
|
||||
* tag เอกสารปัจจุบันให้ taskpane เด้งอัตโนมัติครั้งถัดไปที่เปิดไฟล์นี้ — **best-effort
|
||||
* ห้าม throw ทุกกรณี** (auto-open เป็น UX enhancement: ล้ม = ผู้ใช้กดปุ่มเองตามเดิม)
|
||||
* คืน true = setting มีอยู่แล้ว หรือเขียนสำเร็จ · false = ทำไม่สำเร็จ (เงียบ ไม่ใช่ error ของ UI)
|
||||
*/
|
||||
export function enableAutoOpenTaskpane(): Promise<boolean> {
|
||||
if (autoOpenPromise === null) {
|
||||
autoOpenPromise = (async () => {
|
||||
try {
|
||||
const settings = Office.context.document.settings;
|
||||
if (settings.get(AUTO_OPEN_SETTING) === true) return true; // idempotent — ไม่เขียนซ้ำ
|
||||
settings.set(AUTO_OPEN_SETTING, true);
|
||||
const saved = await new Promise<boolean>((resolve) => {
|
||||
settings.saveAsync((result) => resolve(result.status === Office.AsyncResultStatus.Succeeded));
|
||||
});
|
||||
if (!saved) {
|
||||
console.warn("auto-open: settings.saveAsync ไม่สำเร็จ — taskpane จะไม่เด้งครั้งถัดไป");
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
// saveAsync เขียนเฉพาะ document ในหน่วยความจำ — persist ลงดิสก์จริงต้องตามด้วย native
|
||||
// save (ไฟล์ read-only/Protected View = ยอมแพ้เงียบ ๆ: auto-save 30 วิเป็น net รอง)
|
||||
await saveDocumentVerified();
|
||||
} catch (error) {
|
||||
console.warn("auto-open: บันทึกไฟล์ไม่สำเร็จ (read-only?) — setting อาจยังไม่ถึงดิสก์", error);
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
// นอก host Word (browser/SSR) หรือ host ไม่รองรับ Settings API — เงียบไป
|
||||
console.warn("auto-open: เขียน setting ไม่สำเร็จ (นอก Word หรือ host ไม่รองรับ)", error);
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
// ล้ม = ล้าง memo ให้ detect ครั้งถัดได้ลองใหม่ (ผ่านปุ่ม "ลองใหม่")
|
||||
void autoOpenPromise.then((ok) => {
|
||||
if (!ok) autoOpenPromise = null;
|
||||
});
|
||||
}
|
||||
return autoOpenPromise;
|
||||
}
|
||||
|
||||
/** GUID ใหม่สำหรับ tracking id / clientVersionId */
|
||||
export function generateGuid(): string {
|
||||
if (typeof crypto.randomUUID === "function") {
|
||||
|
|
|
|||
|
|
@ -1,17 +1,19 @@
|
|||
/**
|
||||
* 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 และจริง)
|
||||
* - requestDocverToken: POST same-origin /api/auth/token แบบ body ticket
|
||||
* (path สั้นของ /api/documents/token — endpoint ก้อนเดียวกัน เปลี่ยนมาใช้ 2026-11-05) —
|
||||
* ไม่ผูกกับ 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";
|
||||
|
|
@ -92,39 +94,7 @@ export function isTokenExpired(entry: SessionTokenEntry): boolean {
|
|||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HMAC ฝั่ง client — Web Crypto (crypto.subtle ให้ promise เสมอ)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** คำนวณ HMAC-SHA256 คืน hex lowercase — รูปแบบเดียวกับ generateHmacSha256 ฝั่ง server */
|
||||
export async function hmacSha256HexWeb(message: string, secret: string): Promise<string> {
|
||||
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<boolean> {
|
||||
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)
|
||||
// ขอ token จาก /api/auth/token (ทางเข้า ticket — PLAN-ADDIN-AUTH.md §3)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface TokenResponse {
|
||||
|
|
@ -147,30 +117,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/auth/token (2026-11-04: 200 ผ่าน / 400 ฟอร์แม็ต / 401 ลายเซ็น /
|
||||
* 403 ลายเซ็นถูกแต่ ticket หมดอายุ — มี JWT เดิมของ fid ใน session ใช้ต่อได้)
|
||||
*/
|
||||
export async function requestDocverToken(ticketData: string, ticketHmac: string): Promise<SessionTokenEntry> {
|
||||
const response = await fetch(withBasePath("/api/documents/token"), {
|
||||
export async function requestDocverToken(
|
||||
ticketData: string,
|
||||
ticketHmac: string,
|
||||
): Promise<RequestDocverTokenResult> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(withBasePath("/api/auth/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 } };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,17 @@ import {
|
|||
} from "./metadataService";
|
||||
import { uploadVersion } from "./versioningService";
|
||||
|
||||
/** 2026-11-05 — tracking id ใช้ **fid จาก ticket** (ตรงกับ docId ของทุก operation ฝั่ง server —
|
||||
* uploadVersion real mode ไม่ส่ง documentId ไป server อยู่แล้ว identity มาจาก Bearer) แทน GUID
|
||||
* สุ่มใหม่ · **ย้อนกลับเป็นแบบเดิม**: เปลี่ยนค่าเป็น "guid" (generateGuid ยังอยู่ — ไฟล์เก่า
|
||||
* ที่ stamp GUID ไว้แล้วยัง track ได้เพราะ detect อ่านค่าตาม property ไม่สนรูปแบบค่า) */
|
||||
const TRACKING_ID_SOURCE: "fid" | "guid" = "fid";
|
||||
|
||||
/** เลือกค่า tracking id ตามโหมด — pure แยกไว้ให้ unit test ได้ (ไม่มี fid = fallback GUID เดิม) */
|
||||
export function resolveTrackingId(currentFid: string | null): string {
|
||||
return TRACKING_ID_SOURCE === "fid" && currentFid !== null ? currentFid : generateGuid();
|
||||
}
|
||||
|
||||
export interface StartVersioningResult {
|
||||
hasPdf: boolean;
|
||||
documentId: string;
|
||||
|
|
@ -121,7 +132,7 @@ export async function startVersioning(
|
|||
// FR-17 — หยุด auto-save ตลอด flow กัน save แทรกกลาง stamp/capture แล้วค่อนคืนตอนจบ
|
||||
await autoSaveService.suspend();
|
||||
try {
|
||||
const documentId = generateGuid();
|
||||
const documentId = resolveTrackingId(authService.getCurrentFid());
|
||||
const clientVersionId = generateGuid();
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
|
|
@ -147,6 +158,13 @@ export async function startVersioning(
|
|||
// 4) upload version แรก (แนบ templateId เมื่อเป็นไฟล์ร่าง — FR-16)
|
||||
onProgress?.("กำลังอัปโหลด version 1…");
|
||||
const result = await uploadCurrentFile(documentId, clientVersionId, message, onProgress, templateId);
|
||||
|
||||
// 5) final save (2026-11-05 bug fix) — การันตีว่าทุกอย่างที่ stamp ใน flow นี้ persist
|
||||
// ถึงดิสก์จริงก่อนจบ (เดิม save ครั้งเดียวช่วงขั้น 3 ก่อน upload — เคยเจอไฟล์ถูก stamp
|
||||
// แล้วได้ version แต่ดิสก์ไม่ได้รับค่า ปิดไฟล์กลับมาเปิดใหม่กลายเป็น untracked)
|
||||
onProgress?.("กำลังบันทึกไฟล์…");
|
||||
await saveDocumentVerified();
|
||||
|
||||
// FR-3 — สร้าง version สำเร็จ → เคลียร์ flag "มีแก้ไขที่ยังไม่เป็น version"
|
||||
autoSaveService.clearUncommittedSinceVersion();
|
||||
return { documentId, ...result };
|
||||
|
|
@ -206,11 +224,19 @@ export async function saveAndVersion(
|
|||
}
|
||||
}
|
||||
|
||||
// 6) final save (2026-11-05 bug fix) — ขั้น 5 เขียน XML part หลัง upload โดยไม่มี save ตาม
|
||||
// = เอกสาร dirty ตอน flow จบ ผู้ใช้ปิด Word โดยไม่ save เพิ่ม = ค่าที่ stamp ทั้ง flow
|
||||
// (รวม tracking properties ของขั้น 1) ไม่ถึงดิสก์ → เปิดใหม่กลายเป็น untracked —
|
||||
// ต้อง save ปิดท้ายหลังทุก mutation ก่อนคืนผล (และก่อน close เพราะหลัง close เรียก
|
||||
// Word.run ต่อไม่ได้)
|
||||
onProgress?.("กำลังบันทึกไฟล์…");
|
||||
await saveDocumentVerified();
|
||||
|
||||
// FR-3 — สร้าง version สำเร็จ → เคลียร์ flag "มีแก้ไขที่ยังไม่เป็น version"
|
||||
autoSaveService.clearUncommittedSinceVersion();
|
||||
|
||||
// 6) FR-18 — ปิดเอกสารเฉพาะเมื่อ upload สำเร็จแล้วเท่านั้น (ต้องอยู่หลังขั้น 5 เพราะหลัง close
|
||||
// เอกสารหมดสิทธิ์เรียก Word.run ต่อ)
|
||||
// 7) FR-18 — ปิดเอกสารเฉพาะเมื่อ upload สำเร็จแล้วเท่านั้น (ต้องอยู่หลังขั้น final save
|
||||
// เพราะหลัง close เอกสารหมดสิทธิ์เรียก Word.run ต่อ)
|
||||
if (closeAfter) {
|
||||
try {
|
||||
await closeDocument(DocumentCloseMode.Save);
|
||||
|
|
|
|||