Refactor ticket verification and installation scripts
All checks were successful
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 3s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Successful in 1m17s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 4s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 1s

- Split `verifyAddinTicket` into two functions: `verifyAddinTicketHmac` (ignores expiration) and `verifyAddinTicket` (checks expiration).
- Add `hasVersionByUser` function to check if a document has been previously worked on by a user, allowing expired tickets to be accepted if the user has a history with the document.
- Update OpenAPI specification to reflect changes in ticket expiration handling.
- Introduce new scripts for installing and uninstalling the Word add-in on Windows and macOS, providing a streamlined process for sideloading the add-in.
- Enhance tests for ticket verification to cover new functionality regarding expired tickets and user history.
This commit is contained in:
Suphonchai Phoonsawat 2026-10-28 14:42:03 +07:00
parent 3726cb742f
commit b1449ab668
14 changed files with 727 additions and 75 deletions

View file

@ -40,6 +40,7 @@ scripts/patch-pdf-worker.mjs ← postinstall: คัดลอก pdf.worker.min.
scripts/copy-swagger-ui.mjs ← postinstall ตัวที่สอง: คัดลอก swagger-ui-dist (bundle+css) ไป public/note/swagger-ui/ สำหรับหน้า Swagger UI ที่ GET /api/swagger (intranet ไม่ใช้ CDN — หาไฟล์ไม่เจอหลังอัปเกรด = exit 1 ดัง ๆ เหมือน patch-pdf-worker; Docker build รัน script นี้ด้วย ดู docker/web.Dockerfile)
scripts/fetch-office-js.mjs ← postinstall ตัวที่สาม: ดาวน์โหลด Office.js runtime **ฉบับ CDN ล่าสุด** (appsforoffice.microsoft.com — ชุดไฟล์เฉพาะ Word: loader + host files ราย platform + legacy 15.x + o15apptofilemappingtable/microsoftajax + office_strings.js ต่อ locale en-us/th-th บังคับ) ไป public/wordaddin/office-js/ ให้ taskpane โหลด same-origin — npm `@microsoft/office-js` เลิกอัปเดตที่ 1.1.110 (~2018) ไม่มี `Document.close`/enum `CloseBehavior` ที่ runtime ใหม่+`@types/office-js` มี; ไฟล์หลักหาย/ตรวจไม่เจอ `"Document.close"`+`CloseBehavior` ใน word-win32-16.01.js หลังโหลด = exit 1 ดัง ๆ (ไฟล์ปลายทาง gitignore — ดาวน์โหลดใหม่ทุกครั้ง; ติดตั้งต้องออกเน็ตถึง CDN ของ Microsoft ส่วน runtime ตัวระบบ intranet ล้วน)
scripts/make-ticket-docx.mjs ← dev tool รันมือ (ไม่ใช่ postinstall): stamp ticket ขอ e-Court (`ticket_data` + `HmacSha256`) ลงไฟล์ .docx ใด ๆ ผ่าน pizzip เพื่อทดสอบ ticket auth ของ add-in ใน Word จริงโดยไม่ต้องผ่านกลุ่ม template (TMPL_PATH) — คำนวณ HMAC ตรงกับตัว stamp จริง (key เรียงตามตัวอักษรหลังเติม exp + เซ็นบน string ดิบ hex lowercase) · ตัวเลือก --fid/--uid/--uname/--tid/--exp <unix|+sec>/--secret/--tracked/--in-place/--list (PLAN-ADDIN-AUTH.md §9)
scripts/install-wordaddin.{mjs,sh,ps1} ← dev tool ติดตั้ง/ถอด (sideload) Word add-in บนเครื่องทดสอบ Windows + macOS — 3 รูปแบบกลไกเดียวกัน (Windows = registry `Wef\Developer`, macOS = โฟลเดอร์ wef ของ Word — กลไกเดียวกับ office-addin-dev-settings): .mjs = ใน repo (`npm run addin:install`, zero-dependency), .sh (macOS) / .ps1 (Windows) = **ไฟล์ส่งต่อให้เครื่องที่ไม่มี Node/repo** (.ps1 ต้องมี BOM — PS 5.1 อ่าน UTF-8 ไร้ BOM เป็น ANSI) — โหลด manifest จาก server ปัจจุบัน (default `https://ecourt-note.chamomind.com/docedit/wordaddin/manifest.xml`, `--url`/`-Url` ชี้ dev ได้) + `--remove`/`-Remove` ถอด
db/schema.ts ← ตาราง + JSONB types + DTO + mapper toDTO
db/index.ts ← postgres.js + drizzle (DATABASE_URL → container judes-note-db, prepare:false)
middleware.ts ← บังคับ login ทุกหน้า — ไม่มี cookie oacCase ที่ parse ได้ = redirect /note/login (path ที่ตัด basePath แล้ว: หน้าขึ้นต้น /note/… ส่วน API ขึ้นต้น /api/note/… — ยกเว้น note/login, api/note/*, api/template/* (กลุ่ม docx template เปิดกว้างให้ทางศาล/ผู้ทดสอบภายนอกยิงได้เลย — ไม่แตะ DB), api/documents/* (กลุ่ม docver จัด version เอกสาร — stand-alone ไม่มี session ใช้ auth ของกลุ่มเอง: ขอ access_token ผ่าน x-api-key แล้วแนบ Bearer ทุก endpoint, docs/PLAN-DOCVER.md), api/swagger/* (หน้า Swagger UI), wordaddin/* (taskpane ของ Word add-in — ไม่มี login: auth เป็น e-Court pass-through จาก token/owner ในไฟล์), note/uploads/*, note/samples, note/swagger-ui/*, note/pdf.worker.min.mjs, _next, fonts, favicon และ **root "/" เอง** (matcher ใช้ `.+` — root เป็น redirect สู่ /wordaddin ซึ่ง public ตามคำขอผู้ใช้ 2026-10-14, ลิงก์ e-Court N8 ที่ root ยังถูกคุมผ่าน /note ที่รับส่งต่อ))
@ -53,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 + fields JSON ≤1,000 keys **ความยาวค่าไม่จำกัด** (2026-10-27 เอกสารจริง 100–200 หน้า) + 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") + verifyAddinTicket (HMAC timing-safe บน ticket_data ดิบ + exp ผ่าน utils/docver-ticket.ts) + 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 เอง: 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/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) → exp → session localStorage `wordaddin-auth` ตาม uid (user ใหม่ = เคลียร์ทั้งก้อน) → ขอ token ที่ `/api/documents/token` แบบ body ticket ผูก fid; ไม่มี ticket/HMAC ไม่ตรง/หมดอายุ = phase `no-access` แยกข้อความตามสาเหตุ; apiClient โดน 401 = renew จาก ticket 1 ครั้ง ยังไม่ผ่าน = 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)**; 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: ตรวจ 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)**; 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 ใช้ร่วม)

View file

@ -3,9 +3,10 @@
// 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+exp ด้วย env ฝั่ง server (ADDIN_TICKET_SECRET → SECRET →
// ตรวจ HMAC ด้วย env ฝั่ง server (ADDIN_TICKET_SECRET → SECRET →
// "super_secret", lib/addin-ticket.ts) แล้วออก token จาก claims ของ ticket
// (secret ตัวเซ็น JWT ไม่มีทางไปอยู่ใน bundle ของ add-in)
// (secret ตัวเซ็น JWT ไม่มีทางไปอยู่ใน bundle ของ add-in) — ticket หมดอายุ
// ยังออกให้ต่อเมื่อ fid นี้เคยมี version โดย uid เดียวกัน (requirement ขั้น 7)
// ทั้งสองทางออก JWT HS256 เซ็นด้วย env DOCVER_TOKEN_SECRET อายุตาม env
// DOCVER_TOKEN_TTL_SECONDS (default 3600) แล้วนำไปแนบ Authorization: Bearer กับ
// endpoint อื่นของกลุ่ม (lib/docver-token.ts) · segment คงที่ "token" ไม่ชน [docId]
@ -15,8 +16,10 @@ import {
addinTicketSecret,
parseTicketTokenBody,
ticketToDocverClaims,
verifyAddinTicket,
verifyAddinTicketHmac,
} from "@/lib/addin-ticket";
import { hasVersionByUser } from "@/lib/docver-store";
import { isTicketExpired } from "@/utils/docver-ticket";
import {
issueDocverToken,
parseTokenRequestBody,
@ -66,8 +69,22 @@ async function postWithTicket(request: Request): Promise<Response> {
// invalid = รูป body ผิดจริง ๆ ให้ 400 ตาม convention กัน typo เงียบ ๆ
return docverError(parsed.error, parsed.kind === "invalid" ? 400 : 401);
}
const verified = verifyAddinTicket(parsed.ticketData, parsed.hmacHex, addinTicketSecret());
// ตรวจ 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 เหมือนไม่มีสิทธิ์
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 issueToken(ticketToDocverClaims(verified.ticket));
}

View file

@ -59,9 +59,10 @@ 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}` | **ทางใหม่** — `verifyAddinTicket` → map claims → `issueDocverToken` → response รูปเดียวกัน `{access_token, token_type, expires_in}` |
| ไม่มี `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 ไม่ผ่าน / `exp` หมดอายุ | 401 **ข้อความเดียวกันทุกสาเหตุ** (กัน oracle — addin รู้สาเหตุจริงจากการตรวจเองฝั่ง client อยู่แล้ว) |
| 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 |
| `DOCVER_TOKEN_SECRET` ไม่ตั้ง | 500 (เดิม) |
@ -94,14 +95,16 @@ export type HmacSha256Hex = (message: string, secret: string) => Promise<string>
HMAC (Web Crypto + NEXT_PUBLIC_ADDIN_TICKET_SECRET) ไม่ตรง → no-access "ไฟล์ถูกแก้ไข"
3. session = localStorage["wordaddin-auth"] (fallback memory)
4. ไม่มี session หรือ uid ≠ session.userId (user ใหม่):
· ticket หมดอายุ → no-access "ticket หมดอายุ"
· เคลียร์ session ทั้งก้อน → ขอ token (ทาง ticket ของ /token) → fail → no-access "ขอ token ไม่สำเร็จ"
· เก็บ session ใหม่ {userId: uid, userName: uname, tokens: {[fid]: {token, exp}}}
· ขอ 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] มี (กลับมาทำเอกสารเดิม) → ผ่านเลย ไม่ตรวจ ticket exp (ตามเอกสาร)
— token หมดอายุ + ticket ยังไม่หมด → renew เงียบ ๆ ก่อน (D6)
— token หมดอายุ + ticket หมดด้วย → เข้า UI ได้แต่ API จะ 401 → renew fail → no-access ตอนใช้งาน
· tokens[fid] ไม่มี (เอกสารใหม่) → ตรวจ exp แล้วขอ token เก็บเหมือนข้อ 4
· 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
untracked/tracking เดิม (SavedByAddIn → history) ไม่เปลี่ยน
```
@ -163,7 +166,10 @@ export type HmacSha256Hex = (message: string, secret: string) => Promise<string>
- **property ยาวเกิน 255** → ไฟล์เสียหาย → no-access (Word ตัดค่าทิ้งจะ parse/HMAC ไม่ผ่านอยู่แล้ว —
เช็คความยาวเพื่อข้อความที่ชัดกว่า)
- **localStorage ถูก block** (webview บางตัว/private mode) → session อยู่ใน memory ต่อ session เดียว
(เปิดใหม่เริ่มใหม่ ขอ token ใหม่ — ยอมรับได้)
(เปิดใหม่เริ่มใหม่ ขอ token ใหม่ — และหลังแก้ 2026-10-28 ขอใหม่ได้**แม้ ticket หมดอายุ**
ตราบใดที่เอกสารนี้เคยมี version โดย uid เดียวกัน: server ออกให้จากหลักฐานใน DB
ไม่ต้องพึ่ง token เก่าในเครื่องอีกต่อไป — ข้อจำกัดเหลือเฉพาะเอกสารที่เคยเปิดแต่
**ยังไม่เคยสร้าง version** ซึ่งไม่มีร่องรอยฝั่ง server ต้องขอไฟล์ใหม่จาก e-Court)
- **uname ไทย** — เป็น string ตรง ๆ ไม่ผ่าน base64 ไม่มีปัญหา mojibake (ต่างจาก decode JWT payload)
- **exp == now** → หมดอายุ
- **401 จาก endpoint อื่นระหว่างใช้งาน** → renew 1 ครั้ง → retry → ยังไม่ผ่าน → no-access
@ -191,7 +197,9 @@ Dev tool รันมือ `node scripts/make-ticket-docx.mjs <file.docx> [opti
(ข) ticket body ออก token ได้ (ค) HMAC ผิดกับ exp หมด = 401 ข้อความเดียวกัน (ง) body ปนกัน = 400
4. ทดสอบใน Word จริง (sideload ตาม `docs/WORDADDIN-DEV.md`) ด้วยไฟล์จาก make-ticket-docx:
ไฟล์ถูกต้อง → ใช้ได้ ชื่อผู้ใช้ = uname · แก้ `ticket_data` 1 ตัวอักษร → no-access "ไฟล์ถูกแก้ไข" ·
exp อดีต → no-access "ticket หมดอายุ" · เปิดเอกสารเดิมซ้ำ → ไม่ยิง /token ซ้ำ (ใช้ token เดิม) ·
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 เดิมถูกเคลียร์
5. รัน `plan-reviewer` + `code-reviewer` ตามกติกา `.claude/`

View file

@ -33,7 +33,27 @@ manifest ต้องชี้ https — 2 ทางเลือก:
1. **self-signed ของ Next (ง่ายสุด):** `npm run dev:https` → https://localhost:3000 — Word อาจเตือน certificate ครั้งแรก (เปิด URL ใน browser กดผ่านก่อน 1 ครั้ง)
2. **ใบรับรอง trusted (แนะนำ ไม่มี warning):** รัน `npx office-addin-dev-certs install` ครั้งเดียว แล้ว copy `%USERPROFILE%\.office-addin-dev-certs\localhost.crt` + `localhost.key` มาที่ `certs/` (gitignore แล้ว) แล้วใช้ `npm run dev:https:cert`
## Sideload ใน Word Desktop (Windows)
## ติดตั้ง (sideload) ด้วยสคริปต์ — Windows + macOS
`scripts/install-wordaddin.*` (มี 3 รูปแบบ กลไกเดียวกัน) โหลด manifest จาก server แล้วจดทะเบียนให้ Word Desktop เห็น add-in ในคำสั่งเดียว:
```bash
# ใน repo (มี Node)
npm run addin:install
# ส่งให้เครื่องทดสอบที่ไม่มี Node/repo — ส่งไฟล์เดี่ยวได้เลย
./install-wordaddin.sh # macOS (bash + curl ของเครื่อง)
powershell -ExecutionPolicy Bypass -File install-wordaddin.ps1 # Windows
node install-wordaddin.mjs # ใช้ Node ก็ได้ทั้งสอง OS
```
- **default โหลดจาก server ทดสอบ** `https://ecourt-note.chamomind.com/docedit/wordaddin/manifest.xml` — เครื่องทดสอบไม่ต้องรันอะไรเพิ่ม · จะชี้ dev ให้ `--url https://localhost:3000/docedit/wordaddin/manifest.xml` (ต้อง `npm run dev:https` ไว้ก่อน)
- **Windows:** เก็บ manifest ไว้ `%USERPROFILE%\Documents\wordaddin-manifest\` แล้วเขียน registry `HKCU\Software\Microsoft\Office\16.0\Wef\Developer` (กลไกเดียวกับ `office-addin-dev-settings`) — ไม่ต้องคลิก Trust Center
- **macOS:** วางไฟล์ manifest เป็น `<GUID>.manifest.xml` ใน `~/Library/Containers/com.microsoft.Word/Data/Documents/wef/`
- หลังรัน **restart Word** แล้วเปิดได้ที่ **Insert > My Add-ins > SHARED FOLDER** (Windows) / **Insert > My Add-ins** (macOS)
- **ถอด:** `--remove` ทุกไฟล์ (`npm run addin:install -- --remove` / `./install-wordaddin.sh --remove` / `-Remove` ของ .ps1 — ลบ registry value / ไฟล์ใน wef · ถอดแบบ manual: `reg delete "HKCU\Software\Microsoft\Office\16.0\Wef\Developer" /v <GUID> /f` หรือลบไฟล์ในโฟลเดอร์ wef เอง) · ⚠️ ไฟล์ .ps1 ต้องมี **BOM** ติดตัวมา (PowerShell 5.1 อ่าน UTF-8 ไร้ BOM เป็น ANSI แล้วข้อความไทยพัง) — แก้ไฟล์แล้วอย่าลืมเซฟแบบ UTF-8 with BOM
- รันซ้ำได้ (idempotent) — โหลด manifest ใหม่ทุกครั้งจึงได้ URL ของ environment ปัจจุบันเสมอ
## Sideload แบบ manual (Windows)
เครื่อง dev ที่มี repo (แนะนำ — ไม่ต้องดาวน์โหลด/คัดลอกอะไรเลย):
@ -49,6 +69,14 @@ manifest ต้องชี้ https — 2 ทางเลือก:
Deploy จริง (intranet): **ไม่ต้อง rewrite อะไรแล้ว** — manifest serve ผ่าน `app/wordaddin/manifest.xml/route.ts` ซึ่งเขียนทับ origin ให้ตรงโฮสต์ที่ขอไฟล์อัตโนมัติ ดาวน์โหลด `https://<โฮสต์>/docedit/wordaddin/manifest.xml` แล้วใช้ sideload ได้ทันทีทุก environment (dev/UAT/production) · proxy ฝั่งหน้าควรส่ง `X-Forwarded-Host`/`X-Forwarded-Proto` ตามมาตรฐาน — ถ้าไม่ส่ง route ก็ยังใช้ Host ของ request และบังคับ https ให้โฮสต์ที่ไม่ใช่ localhost อยู่ดี
### macOS (manual)
Word for Mac ไม่มี Trust Center แบบ Windows — สแกน add-in จากโฟลเดอร์ของตัวเอง:
1. ดาวน์โหลด manifest จาก environment นั้น: `https://<โฮสต์>/docedit/wordaddin/manifest.xml`
2. วางไฟล์ใน `~/Library/Containers/com.microsoft.Word/Data/Documents/wef/` (สร้างโฟลเดอร์ได้ถ้ายังไม่มี) — ชื่อไฟล์เป็นอะไรก็ได้ ขอแค่ลงท้าย `.xml` (สคริปต์ด้านบนตั้งชื่อ `<GUID>.manifest.xml` ให้เอง)
3. restart Word แล้วเปิดได้ที่ **Insert > My Add-ins**
รุ่น Office ที่รองรับ: manifest กำหนด WordApi **1.3** เป็นค่าต่ำสุด (customXmlParts คือ API สูงสุดที่ใช้ประจำ) — Microsoft 365 / Office 2021+ ใช้ได้ครบทุกฟีเจอร์ · **Office 2019 โหลดได้ แต่ปุ่ม "…และปิดเอกสาร" ถูกซ่อนอัตโนมัติ** เพราะ `document.close()` ต้องการ WordApi 1.4/1.5 (Microsoft 365 เท่านั้น) — `canCloseDocument()` เช็ค `isSetSupported` ตอน runtime แล้วซ่อน พฤติกรรมเดียวกับ Word on the web · Office 2016 (WordApi 1.1) โหลดไม่ขึ้น (เดิมเคยตั้ง 1.5 ทำให้ Office 2019 โหลดไม่ขึ้นด้วย — แก้ 2026-10-16)
## ทดสอบใน browser ธรรมดา (ไม่ต้องเปิด Word)
@ -72,7 +100,7 @@ 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 นาทีแล้วเปิด → no-access "ticket หมดอายุ" · เปิดเอกสารเดิมซ้ำ (จน ticket หมดอายุแต่ token ยังอยู่ใน localStorage) → ยังใช้ได้ตาม requirement ขั้น 7
แล้วเปิดไฟล์ใน 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)
หมายเหตุ: การขอ 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` ของสคริปต์

View file

@ -33,15 +33,15 @@ export function addinTicketSecret(): string {
}
/**
* ตรวจ ticket เต็มชุด (HMAC → parse → exp) — ไม่แยกสาเหตุออกมา
* (route ตอบ 401 ข้อความเดียวกันทุกกรณี กันใช้ endpoint เป็น oracle —
* add-in รู้สาเหตุจริงจากการตรวจเองฝั่ง client อยู่แล้ว)
* ตรวจ ticket แบบไม่สน exp (HMAC → parse) — ใช้เมื่อ route จะตัดสิน "ticket หมดอายุ
* แต่เอกสารนี้เคยทำงานโดย uid เดียวกัน" เองผ่าน DB (requirement ขั้น 7: กลับมาทำ
* เอกสารเดิมผ่านได้แม้ ticket หมดอายุ) — ตัวตนของผู้ถือยังยึด HMAC ของ e-Court
* เหมือนเดิม แค่ไม่ใช้อายุ ticket เป็นเกณฑ์ตัดสิทธิ์ ณ จุดนี้
*/
export function verifyAddinTicket(
export function verifyAddinTicketHmac(
ticketData: string,
hmacHex: string,
secret: string,
nowSec: number = Math.floor(Date.now() / 1000),
): { ok: true; ticket: DocverTicket } | { ok: false } {
const digest = normalizeHexDigest(hmacHex);
if (digest === null) return { ok: false };
@ -55,10 +55,26 @@ export function verifyAddinTicket(
const parsed = parseDocverTicket(ticketData);
if (!parsed.ok) return { ok: false };
if (isTicketExpired(parsed.ticket.exp, nowSec)) return { ok: false };
return { ok: true, ticket: parsed.ticket };
}
/**
* ตรวจ ticket เต็มชุด (HMAC → parse → exp) — ไม่แยกสาเหตุออกมา
* (route ตอบ 401 ข้อความเดียวกันทุกกรณี กันใช้ endpoint เป็น oracle —
* add-in รู้สาเหตุจริงจากการตรวจเองฝั่ง client อยู่แล้ว)
*/
export function verifyAddinTicket(
ticketData: string,
hmacHex: string,
secret: string,
nowSec: number = Math.floor(Date.now() / 1000),
): { ok: true; ticket: DocverTicket } | { ok: false } {
const verified = verifyAddinTicketHmac(ticketData, hmacHex, secret);
if (!verified.ok) return { ok: false };
if (isTicketExpired(verified.ticket.exp, nowSec)) return { ok: false };
return verified;
}
/** แปลง ticket → claims ของ access_token (uid→userid, uname→username, tid→template) */
export function ticketToDocverClaims(ticket: DocverTicket): DocverTokenClaims {
return { userid: ticket.uid, username: ticket.uname, fid: ticket.fid, template: ticket.tid };

View file

@ -206,6 +206,22 @@ export async function findVersionForDoc(
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;

View file

@ -629,8 +629,9 @@ export const openApiSpec = {
"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"`) และเวลาหมดอายุ (`exp`) ของ ticket ' +
"ก่อน แล้วออก token จาก claims ที่อยู่ในตัว ticket เอง\n\n" +
'(ไม่ตั้ง → env `SECRET` → `"super_secret"`) แล้วออก token จาก claims ที่อยู่ในตัว ticket เอง · ' +
"**ticket หมดอายุ** ยังออกให้ต่อเมื่อเอกสารนี้ (`fid`) เคยมี version ที่สร้างโดย `uid` เดียวกัน " +
"(ผู้ที่เคยทำงานกับเอกสารเดิมกลับมาใช้ต่อได้แม้ ticket หมดอายุ — ไม่เคยทำงาน = 401)\n\n" +
"ได้ token แล้วแนบเป็น `Authorization: Bearer <token>` กับทุก request ถัดไป",
security: [{ ApiKeyAuth: [] }],
requestBody: {

View file

@ -8,6 +8,7 @@
"test": "vitest run",
"start": "next start",
"seed-samples": "node --env-file=.env scripts/seed-samples.mjs",
"addin:install": "node scripts/install-wordaddin.mjs",
"postinstall": "node scripts/patch-pdf-worker.mjs && node scripts/copy-swagger-ui.mjs && node scripts/fetch-office-js.mjs",
"dev:https": "next dev --experimental-https",
"dev:https:cert": "next dev --experimental-https --experimental-https-key certs/localhost.key --experimental-https-cert certs/localhost.crt"

View file

@ -0,0 +1,246 @@
#!/usr/bin/env node
// Dev tool ติดตั้ง (sideload) / ถอด Word add-in "e-Court Documents Versioning"
// บนเครื่องทดสอบ Windows + macOS — โหลด manifest จาก server (default ตัวทดสอบ
// ecourt-note.chamomind.com ที่ app/wordaddin/manifest.xml/route.ts rewrite origin
// ให้ตรงโฮสต์ผู้ขออยู่แล้ว) แล้วจดทะเบียนให้ Word Desktop เห็น add-in
//
// กลไกตรงตาม office-addin-dev-settings@4 (อ่านจาก source ใน node_modules) แต่
// เขียนเองด้วย node builtins ล้วน เพื่อใช้ได้ทั้งรันใน repo (npm run addin:install)
// และ copy ไฟล์เดี่ยวนี้ไปรัน `node install-wordaddin.mjs` บนเครื่องที่ไม่มี repo:
// - Windows: registry HKCU\Software\Microsoft\Office\16.0\Wef\Developer
// value ชื่อ = GUID ของ add-in, REG_SZ, data = path manifest บนดิสก์ (ต้องอยู่ถาวร)
// - macOS: วางไฟล์ <GUID>.manifest.xml ใน
// ~/Library/Containers/com.microsoft.Word/Data/Documents/wef/ (Word สแกน *.xml ทุกไฟล์)
//
// ใช้: node scripts/install-wordaddin.mjs [options]
// --url <manifest-url> default https://ecourt-note.chamomind.com/docedit/wordaddin/manifest.xml
// (dev: https://localhost:3000/docedit/wordaddin/manifest.xml — ต้อง npm run dev:https ก่อน)
// --remove ถอดการติดตั้ง (ไม่ต้องต่อเน็ต — ใช้ manifest ที่เก็บไว้ตอนลง)
// --help | -h
//
// หลังลง/ถอดต้อง restart Word ทุกครั้ง · รายละเอียดเต็ม: docs/WORDADDIN-DEV.md
import { spawnSync } from "node:child_process";
import { mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
const PREFIX = "[install-addin]";
const DEFAULT_MANIFEST_URL = "https://ecourt-note.chamomind.com/docedit/wordaddin/manifest.xml";
/** ตรงคีย์ที่ office-addin-dev-settings ใช้ (Office 2016+ ทุกรุ่นอยู่ภายใต้ 16.0) */
const WIN_DEV_KEY = "HKCU\\Software\\Microsoft\\Office\\16.0\\Wef\\Developer";
/** โฟลเดอร์เก็บ manifest ถาวรบน Windows — path เดียวกับที่ docs/WORDADDIN-DEV.md สอน manual */
const winManifestDir = () => path.join(os.homedir(), "Documents", "wordaddin-manifest");
const winManifestPath = () => path.join(winManifestDir(), "manifest.xml");
/** โฟลเดอร์ที่ Word for Mac สแกนหา add-in ที่ sideload */
const macWefDir = () =>
path.join(os.homedir(), "Library", "Containers", "com.microsoft.Word", "Data", "Documents", "wef");
function usage(code = 1) {
console.log(`ใช้: node scripts/install-wordaddin.mjs [options]
--url <manifest-url> default ${DEFAULT_MANIFEST_URL}
--remove ถอดการติดตั้ง
--help | -h`);
process.exit(code);
}
function parseArgs(argv) {
const options = { url: DEFAULT_MANIFEST_URL, remove: false };
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
const next = () => {
const value = argv[++i];
if (value === undefined) usage();
return value;
};
switch (arg) {
case "--url": options.url = next(); break;
case "--remove": options.remove = true; break;
case "--help": case "-h": usage(0); break;
default: usage();
}
}
return options;
}
function fail(message) {
console.error(`${PREFIX} ✗ ${message}`);
process.exit(1);
}
/** โหลด manifest จาก server แล้วดึง GUID ของ add-in จาก <Id> — ไม่ hardcode เพราะเปลี่ยน environment ได้ */
async function downloadManifest(url) {
let response;
try {
response = await fetch(url, { redirect: "follow", signal: AbortSignal.timeout(30_000) });
} catch (error) {
fail(`โหลด manifest จาก ${url} ไม่ได้ (${error instanceof Error ? error.message : String(error)})`);
}
if (!response.ok) {
fail(`server ตอบ HTTP ${response.status} จาก ${url} — เช็คว่า environment นั้นรันอยู่`);
}
const xml = await response.text();
if (!xml.includes("<OfficeApp")) {
fail(`response จาก ${url} ไม่ใช่ manifest ของ Office add-in (ไม่มี <OfficeApp)`);
}
const idMatch = xml.match(/<Id>([0-9a-fA-F-]{36})<\/Id>/);
if (!idMatch) {
fail(`หา <Id> (GUID) ใน manifest ไม่เจอจาก ${url}`);
}
return { xml, guid: idMatch[1].toLowerCase() };
}
/** อ่าน GUID จากไฟล์ manifest ที่เก็บไว้ตอนติดตั้ง (สำหรับ --remove ที่ไม่ต้องต่อเน็ต) */
function readStoredGuid(manifestPath) {
try {
const xml = readFileSync(manifestPath, "utf8");
const idMatch = xml.match(/<Id>([0-9a-fA-F-]{36})<\/Id>/);
return idMatch ? idMatch[1].toLowerCase() : null;
} catch {
return null;
}
}
/** reg.exe ผ่าน args array เสมอ (ไม่ใช่ shell string — path มี space ก็ไม่พัง) */
function reg(args) {
return spawnSync("reg", args, { encoding: "utf8" });
}
function installWindows(xml, guid) {
const dest = winManifestPath();
mkdirSync(winManifestDir(), { recursive: true });
writeFileSync(dest, xml);
console.log(`${PREFIX} ✓ เก็บ manifest ไว้ที่ ${dest}`);
const add = reg(["add", WIN_DEV_KEY, "/v", guid, "/t", "REG_SZ", "/d", dest, "/f"]);
if (add.status !== 0) {
fail(`เขียน registry ไม่สำเร็จ (reg add exit ${add.status}): ${add.stderr?.trim() ?? "ไม่มี stderr"}`);
}
const query = reg(["query", WIN_DEV_KEY, "/v", guid]);
if (query.status !== 0) {
fail(`reg add สำเร็จแต่ยืนยันด้วย reg query ไม่ผ่าน (exit ${query.status})`);
}
console.log(`${PREFIX} ✓ จดทะเบียน add-in ใน registry (${WIN_DEV_KEY} → ${guid})`);
return dest;
}
function removeWindows() {
const stored = winManifestPath();
const guid = readStoredGuid(stored);
if (!guid) {
fail(
`ไม่พบ manifest ที่เก็บไว้ (${stored}) จึงไม่รู้ GUID ของ add-in — ` +
`ลบเองได้ที่ reg delete "${WIN_DEV_KEY}" /v <GUID> /f`,
);
}
const del = reg(["delete", WIN_DEV_KEY, "/v", guid, "/f"]);
if (del.status !== 0) {
fail(`reg delete ไม่สำเร็จ (exit ${del.status}): ${del.stderr?.trim() ?? "ไม่มี stderr"}`);
}
try {
unlinkSync(stored);
} catch {
// registry ถอดแล้ว — ไฟล์ค้างไม่ทำให้ Word เห็น add-in
}
console.log(`${PREFIX} ✓ ถอดการติดตั้งแล้ว (registry ${guid} + ไฟล์ ${stored})`);
}
/** ไฟล์ sideload ของ GUID นี้ทั้งหมดใน wef — ทั้งของเราและชื่อ basename เก่า ๆ ที่เคยใช้ */
function macSideloadFiles(guid) {
const dir = macWefDir();
try {
return readdirSync(dir)
.filter((name) => name === `${guid}.manifest.xml` || name.startsWith(`${guid}.`))
.map((name) => path.join(dir, name));
} catch {
return [];
}
}
function installMac(xml, guid) {
const dir = macWefDir();
mkdirSync(dir, { recursive: true });
// ลบของเก่าก่อนเขียนใหม่ — idempotent และไม่มีไฟล์ซ้ำ GUID ค้างในโฟลเดอร์
for (const stale of macSideloadFiles(guid)) {
try {
unlinkSync(stale);
} catch {
console.warn(`${PREFIX} ⚠ ลบไฟล์เก่าไม่ได้: ${stale}`);
}
}
const dest = path.join(dir, `${guid}.manifest.xml`);
writeFileSync(dest, xml);
console.log(`${PREFIX} ✓ วาง manifest ไว้ที่ ${dest}`);
return dest;
}
function removeMac() {
const dir = macWefDir();
// บน Mac ไม่มี manifest เก็บแยกที่อื่น (ตัวไฟล์ใน wef คือของที่ลงไว้) — หาทุก *.xml
// ในโฟลเดอร์ที่เป็น manifest ของ add-in นี้จากชื่อในไฟล์เอง จึงไม่ต้อง hardcode GUID
let files;
try {
files = readdirSync(dir)
.filter((name) => name.toLowerCase().endsWith(".xml"))
.map((name) => path.join(dir, name))
.filter((file) => {
try {
const xml = readFileSync(file, "utf8");
return xml.includes("<OfficeApp") && xml.includes("e-Court Documents Versioning");
} catch {
return false;
}
});
} catch {
files = [];
}
if (files.length === 0) {
console.log(`${PREFIX} ไม่พบไฟล์ sideload ของ add-in นี้ใน ${dir} — ถือว่าถอดแล้ว`);
return;
}
for (const file of files) {
unlinkSync(file);
console.log(`${PREFIX} ✓ ลบ ${file}`);
}
}
function printNextSteps() {
const where = process.platform === "win32" ? "Insert > My Add-ins > SHARED FOLDER" : "Insert > My Add-ins";
console.log(`
ขั้นต่อไป:
1. restart Word ให้ครบทุกหน้าต่าง (จำเป็นทุกครั้งหลังลง/ถอด)
2. ${where} > เปิด "e-Court Documents Versioning"
3. เปิดไฟล์ .docx ที่มี ticket เพื่อทดสอบ เช่นสร้างใหม่ด้วย:
node scripts/make-ticket-docx.mjs file.docx --fid DEMO_FID_001 --tid T12
รายละเอียด: docs/WORDADDIN-DEV.md`);
}
async function main() {
const options = parseArgs(process.argv.slice(2));
console.log(`${PREFIX} platform: ${process.platform}${options.remove ? " · โหมดถอดการติดตั้ง" : ""}`);
if (process.platform === "win32") {
if (options.remove) {
removeWindows();
} else {
const { xml, guid } = await downloadManifest(options.url);
installWindows(xml, guid);
}
} else if (process.platform === "darwin") {
if (options.remove) {
removeMac();
} else {
const { xml, guid } = await downloadManifest(options.url);
installMac(xml, guid);
}
} else {
fail(`รองรับเฉพาะ Windows (win32) และ macOS (darwin) — platform นี้คือ ${process.platform}`);
}
console.log(`${PREFIX} ✓ เสร็จ`);
printNextSteps();
}
main().catch((error) => {
fail(error instanceof Error ? error.stack ?? error.message : String(error));
});

View file

@ -0,0 +1,92 @@
# ติดตั้ง (sideload) / ถอด Word add-in "e-Court Documents Versioning" บน Windows
# เวอร์ชัน PowerShell แบบส่งให้เครื่องทดสอบที่ไม่มี Node/repo — คู่หูของ
# scripts/install-wordaddin.mjs (รันใน repo) และ scripts/install-wordaddin.sh (macOS)
# กลไกเดียวกันทั้งสามไฟล์: โหลด manifest จาก server เก็บไว้
# %USERPROFILE%\Documents\wordaddin-manifest\ แล้วจดทะเบียน registry
# HKCU\Software\Microsoft\Office\16.0\Wef\Developer (กลไกเดียวกับ office-addin-dev-settings)
#
# ใช้: powershell -ExecutionPolicy Bypass -File install-wordaddin.ps1 [-Url <manifest-url>] [-Remove]
# -Url default https://ecourt-note.chamomind.com/docedit/wordaddin/manifest.xml
# -Remove ถอดการติดตั้ง
#
# (ไฟล์นี้ต้องมี BOM — Windows PowerShell 5.1 อ่าน UTF-8 ไม่มี BOM เป็น ANSI แล้วข้อความไทยพัง)
# หลังลง/ถอดต้อง restart Word ทุกครั้ง · รายละเอียดเต็ม: docs/WORDADDIN-DEV.md
param(
[string]$Url = "https://ecourt-note.chamomind.com/docedit/wordaddin/manifest.xml",
[switch]$Remove
)
$ErrorActionPreference = "Stop"
# ให้ console พิมพ์ไทย/✓ ถูกต้อง และบังคับ TLS 1.2 (PS 5.1 บางเครื่อง default ยังเป็น TLS 1.0)
try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch { }
[Net.ServicePointManager]::SecurityProtocol = `
[Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
$Prefix = "[install-addin]"
$DevKeyCmd = "HKCU\Software\Microsoft\Office\16.0\Wef\Developer"
$ManifestDir = Join-Path $env:USERPROFILE "Documents\wordaddin-manifest"
$ManifestPath = Join-Path $ManifestDir "manifest.xml"
function Fail($message) {
Write-Host "$Prefix ✗ $message" -ForegroundColor Red
exit 1
}
function Print-NextSteps {
Write-Host ""
Write-Host "ขั้นต่อไป:"
Write-Host " 1. restart Word ให้ครบทุกหน้าต่าง (จำเป็นทุกครั้งหลังลง/ถอด)"
Write-Host ' 2. Insert > My Add-ins > SHARED FOLDER > เปิด "e-Court Documents Versioning"'
Write-Host " 3. เปิดไฟล์ .docx ที่มี ticket ของ e-Court (ขอจากทีม) เพื่อเริ่มทดสอบ"
}
if ($Remove) {
Write-Host "$Prefix platform: win32 · โหมดถอดการติดตั้ง"
if (-not (Test-Path $ManifestPath)) {
Fail "ไม่พบ manifest ที่เก็บไว้ ($ManifestPath) จึงไม่รู้ GUID ของ add-in — ลบเองได้ที่: reg delete `"$DevKeyCmd`" /v <GUID> /f"
}
$stored = Get-Content -Raw -Encoding UTF8 $ManifestPath
if ($stored -notmatch "<Id>([0-9a-fA-F-]{36})</Id>") {
Fail "อ่าน GUID จาก $ManifestPath ไม่ได้"
}
$guid = $Matches[1].ToLower()
& reg.exe delete $DevKeyCmd /v $guid /f | Out-Null
if ($LASTEXITCODE -ne 0) {
Fail "reg delete ไม่สำเร็จ (exit $LASTEXITCODE) — ลบเองได้ที่: reg delete `"$DevKeyCmd`" /v $guid /f"
}
Remove-Item $ManifestPath -ErrorAction SilentlyContinue
Write-Host "$Prefix ✓ ถอดการติดตั้งแล้ว (registry $guid + ไฟล์ $ManifestPath)"
Write-Host "$Prefix ✓ เสร็จ"
Print-NextSteps
exit 0
}
Write-Host "$Prefix platform: win32"
try {
$response = Invoke-WebRequest -Uri $Url -UseBasicParsing -TimeoutSec 30
} catch {
Fail "โหลด manifest จาก $Url ไม่ได้ ($_)"
}
$xml = $response.Content
if ($xml -notmatch "<OfficeApp") {
Fail "response จาก $Url ไม่ใช่ manifest ของ Office add-in (ไม่มี <OfficeApp)"
}
if ($xml -notmatch "<Id>([0-9a-fA-F-]{36})</Id>") {
Fail "หา <Id> (GUID) ใน manifest ไม่เจอจาก $Url"
}
$guid = $Matches[1].ToLower()
New-Item -ItemType Directory -Force -Path $ManifestDir | Out-Null
# เขียน UTF-8 ไม่มี BOM — ให้ manifest เหมือนต้นฉบับบน server เป๊ะ
[System.IO.File]::WriteAllText($ManifestPath, $xml, (New-Object System.Text.UTF8Encoding($false)))
Write-Host "$Prefix ✓ เก็บ manifest ไว้ที่ $ManifestPath"
& reg.exe add $DevKeyCmd /v $guid /t REG_SZ /d $ManifestPath /f | Out-Null
if ($LASTEXITCODE -ne 0) {
Fail "เขียน registry ไม่สำเร็จ (exit $LASTEXITCODE)"
}
Write-Host "$Prefix ✓ จดทะเบียน add-in ใน registry ($DevKeyCmd → $guid)"
Write-Host "$Prefix ✓ เสร็จ"
Print-NextSteps

95
scripts/install-wordaddin.sh Executable file
View file

@ -0,0 +1,95 @@
#!/bin/bash
# ติดตั้ง (sideload) / ถอด Word add-in "e-Court Documents Versioning" บน macOS
# เวอร์ชัน shell แบบส่งให้เครื่องทดสอบที่ไม่มี Node/repo — คู่หูของ
# scripts/install-wordaddin.mjs (รันใน repo) และ scripts/install-wordaddin.ps1 (Windows)
# กลไกเดียวกันทั้งสามไฟล์: โหลด manifest จาก server แล้ววางเป็น <GUID>.manifest.xml
# ใน ~/Library/Containers/com.microsoft.Word/Data/Documents/wef/ ของ Word
#
# ใช้: ./install-wordaddin.sh [--url <manifest-url>] [--remove]
# --url default https://ecourt-note.chamomind.com/docedit/wordaddin/manifest.xml
# --remove ถอดการติดตั้ง
# --help | -h
#
# หลังลง/ถอดต้อง restart Word ทุกครั้ง · รายละเอียดเต็ม: docs/WORDADDIN-DEV.md
set -euo pipefail
PREFIX="[install-addin]"
DEFAULT_URL="https://ecourt-note.chamomind.com/docedit/wordaddin/manifest.xml"
WEF_DIR="$HOME/Library/Containers/com.microsoft.Word/Data/Documents/wef"
usage() {
cat <<EOF
ใช้: ./install-wordaddin.sh [options]
--url <manifest-url> default ${DEFAULT_URL}
--remove ถอดการติดตั้ง
--help | -h
EOF
exit "${1:-1}"
}
fail() {
echo "${PREFIX} ✗ $1" >&2
exit 1
}
URL="$DEFAULT_URL"
REMOVE=0
while [ $# -gt 0 ]; do
case "$1" in
--url) [ $# -ge 2 ] || usage; URL="$2"; shift 2 ;;
--remove) REMOVE=1; shift ;;
--help | -h) usage 0 ;;
*) usage 1 ;;
esac
done
[ "$(uname)" = "Darwin" ] || fail "สคริปต์นี้สำหรับ macOS — Windows ใช้ install-wordaddin.ps1"
next_steps() {
cat <<EOF
ขั้นต่อไป:
1. restart Word ให้ครบทุกหน้าต่าง (จำเป็นทุกครั้งหลังลง/ถอด)
2. Insert > My Add-ins > เปิด "e-Court Documents Versioning"
3. เปิดไฟล์ .docx ที่มี ticket ของ e-Court (ขอจากทีม) เพื่อเริ่มทดสอบ
EOF
}
if [ "$REMOVE" = "1" ]; then
echo "${PREFIX} platform: $(uname) · โหมดถอดการติดตั้ง"
found=0
# บน Mac ไม่มี manifest เก็บแยกที่อื่น — หาทุก *.xml ใน wef ที่เป็นของ add-in
# นี้จากชื่อในไฟล์เอง จึงไม่ต้อง hardcode GUID
for file in "$WEF_DIR"/*.xml; do
[ -f "$file" ] || continue
if grep -q "e-Court Documents Versioning" "$file" && grep -q "<OfficeApp" "$file"; then
rm -f "$file"
echo "${PREFIX} ✓ ลบ $file"
found=1
fi
done
if [ "$found" = "0" ]; then
echo "${PREFIX} ไม่พบไฟล์ sideload ของ add-in นี้ใน $WEF_DIR — ถือว่าถอดแล้ว"
fi
echo "${PREFIX} ✓ เสร็จ"
next_steps
exit 0
fi
echo "${PREFIX} platform: $(uname)"
TMP="$(mktemp)"
trap 'rm -f "$TMP"' EXIT
curl -fsSL --max-time 30 "$URL" -o "$TMP" || fail "โหลด manifest จาก $URL ไม่ได้ — เช็คเน็ต และว่า environment นั้นรันอยู่"
grep -q "<OfficeApp" "$TMP" || fail "response จาก $URL ไม่ใช่ manifest ของ Office add-in"
GUID="$(sed -nE 's/.*<Id>([0-9a-fA-F-]{36})<\/Id>.*/\1/p' "$TMP" | head -n 1 | tr '[:upper:]' '[:lower:]')"
[ -n "$GUID" ] || fail "หา <Id> (GUID) ใน manifest ไม่เจอจาก $URL"
mkdir -p "$WEF_DIR"
rm -f "$WEF_DIR/$GUID".* # ลบของรอบก่อน — idempotent ไม่มีไฟล์ GUID เดียวค้างสองชื่อ
DEST="$WEF_DIR/$GUID.manifest.xml"
cp "$TMP" "$DEST"
echo "${PREFIX} ✓ วาง manifest ไว้ที่ $DEST"
echo "${PREFIX} ✓ เสร็จ"
next_steps

View file

@ -6,6 +6,7 @@ import {
parseTicketTokenBody,
ticketToDocverClaims,
verifyAddinTicket,
verifyAddinTicketHmac,
} from "@/lib/addin-ticket";
// PLAN-ADDIN-AUTH.md §3 — pure ล้วน (verifyAddinTicket รับ secret ทาง parameter)
@ -73,6 +74,26 @@ describe("verifyAddinTicket", () => {
});
});
// verifyAddinTicketHmac = ตัวเดียวกันแต่ไม่ตรวจ exp — route ใช้ตรวจ HMAC ก่อน
// แล้วตัดสิน "ticket หมดอายุ + เอกสารนี้เคยทำงานโดย uid เดียวกัน" จาก DB เอง
// (requirement ขั้น 7 — แก้บั๊ก 2026-10-28)
describe("verifyAddinTicketHmac", () => {
it("exp อดีต + HMAC ถูก = ผ่าน (ได้ ticket ครบ) — ต่างจาก verifyAddinTicket ที่ไม่ผ่าน", () => {
const expired = JSON.stringify({ exp: 1, fid: "f", uid: "u", uname: "n" });
const hmacResult = verifyAddinTicketHmac(expired, sign(expired), SECRET);
ok(hmacResult);
expect(hmacResult.ticket.uid).toBe("u");
expect(verifyAddinTicket(expired, sign(expired), SECRET).ok).toBe(false);
});
it("HMAC ผิด / digest รูปเพี้ยน / ticket ผิดกติกา = ไม่ผ่านเหมือนเดิม (แม้ exp ยังไม่หมด)", () => {
expect(verifyAddinTicketHmac(TICKET_DATA, sign(TICKET_DATA, "wrong-secret"), SECRET).ok).toBe(false);
expect(verifyAddinTicketHmac(TICKET_DATA, "zz".repeat(32), SECRET).ok).toBe(false);
const extra = JSON.stringify({ exp: Math.floor(Date.now() / 1000) + 60, fid: "f", uid: "u", uname: "n", oops: 1 });
expect(verifyAddinTicketHmac(extra, sign(extra), SECRET).ok).toBe(false);
});
});
describe("ticketToDocverClaims", () => {
it("แปลง uid→userid, uname→username, tid→template · tid ไม่มี = null", () => {
expect(

View file

@ -1,7 +1,9 @@
// @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
// 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";
import { afterEach, describe, expect, it, vi } from "vitest";
@ -16,9 +18,9 @@ function trapErrors() {
window.addEventListener("unhandledrejection", (event) => errors.push(event.reason));
}
function makeTicket(): { ticketData: string; ticketHmac: string } {
function makeTicket(expSec?: number): { ticketData: string; ticketHmac: string } {
const ticket = {
exp: Math.floor(Date.now() / 1000) + 300,
exp: expSec ?? Math.floor(Date.now() / 1000) + 300,
fid: "DBG_FID_001",
tid: "T12",
uid: "oom",
@ -114,3 +116,112 @@ describe("signInWithTicket (service layer)", () => {
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({
access_token: token,
token_type: "Bearer",
expires_in: 3600,
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
}
function seedSession(token: string, tokenExpSec: number): void {
ticketService.saveSession({
userId: "oom",
userName: "สรวิชญ์",
tokens: { [FID]: { token, exp: tokenExpSec } },
});
}
describe("ticket หมดอายุ + เอกสารที่เคยทำงาน", () => {
it("session เดิม + token ยังไม่หมด → ผ่านเลย ไม่ยิง /token", 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(1); // ticket หมดอายุตั้งแต่ 1970
const result = await authService.signInWithTicket({ ticketData, ticketHmac });
expect(result.ok, JSON.stringify(result)).toBe(true);
expect(fetchMock).not.toHaveBeenCalled();
expect(errors).toEqual([]);
});
it("session เดิม + token หมดด้วย → ยิงขอใหม่ (server ตัดสิน) ได้ token ใหม่", async () => {
trapErrors();
vi.stubGlobal("crypto", webcrypto);
const fetchMock = vi.fn(async () => tokenResponse("token-fresh"));
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");
expect(errors).toEqual([]);
});
it("ไม่มี session (localStorage หาย) + server 401 (ยังไม่เคยทำงาน) → expired ไม่ใช่ crash", async () => {
trapErrors();
vi.stubGlobal("crypto", webcrypto);
vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({ error: "no" }), { status: 401 })));
const { ticketData, ticketHmac } = makeTicket(1);
const result = await authService.signInWithTicket({ ticketData, ticketHmac });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.reason).toBe("expired");
expect(typeof result.message).toBe("string");
}
expect(errors).toEqual([]);
});
it("ไม่มี session + เคยทำงาน (server 200) → สร้าง session ใหม่ได้แม้ ticket หมดอายุ", async () => {
trapErrors();
vi.stubGlobal("crypto", webcrypto);
const fetchMock = vi.fn(async () => tokenResponse("token-rebuilt"));
vi.stubGlobal("fetch", fetchMock);
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.userId).toBe("oom");
expect(stored.tokens[FID].token).toBe("token-rebuilt");
expect(errors).toEqual([]);
});
it("renewTokenFromTicket กับ ticket หมดอายุ + server 200 → ยังยิงขอ (server ตัดสิน) คืน true", 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 });
expect(signIn.ok, JSON.stringify(signIn)).toBe(true);
fetchMock.mockClear();
const renewed = await authService.renewTokenFromTicket();
expect(renewed).toBe(true);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(errors).toEqual([]);
});
});

View file

@ -4,8 +4,10 @@
*
* ไม่มี login ใน add-in: ไฟล์ที่ e-Court stamp custom properties `ticket_data`
* + `HmacSha256` มา (ผ่านกลุ่ม template) = ผ่านเข้าได้ — signInWithTicket ตรวจ
* HMAC → exp → session ใน localStorage ตาม uid/fid แล้วขอ access_token จาก
* /api/documents/token เก็บตาม fid ("คีย์คือ fid")
* HMAC → session ใน localStorage ตาม uid/fid → ขอ access_token จาก
* /api/documents/token เก็บตาม fid ("คีย์คือ fid") · exp ของ ticket ไม่ใช่
* เกณฑ์ตัดสิทธิ์ฝั่ง client — server ออก token ให้แม้ ticket หมดอายุเมื่อ
* เอกสารนี้เคยมี version โดย uid เดียวกัน (requirement ขั้น 7)
*
* token เก็บใน localStorage (ข้าม session ได้ — กลับมาเปิดเอกสารเดิมไม่ต้องขอใหม่
* ตาม requirement ขั้น 7) ส่วน ticket ดิบของเอกสารปัจจุบันเก็บใน memory ไว้ renew
@ -47,9 +49,10 @@ export function isDemoMode(): boolean {
* e-Court ticket — จุดเข้าเดียวของ auth ตอนเปิดเอกสาร (requirement ขั้น 1–7):
* 1. ตรวจมี ticket_data + HmacSha256 ครบ
* 2. parse + ตรวจ HMAC ฝั่ง client (UX gate)
* 3. session ใน localStorage: ยังไม่มี/user ใหม่ → ตรวจ exp → เคลียร์ session เก่า
* แล้วขอ token ใหม่ · user เดิม + fid เดิม → ผ่านเลย (renew เงียบ ๆ ถ้า token
* หมดอายุแต่ ticket ยังไม่หมด) · user เดิม + fid ใหม่ → ตรวจ exp → ขอ token เก็บเพิ่ม
* 3. session ใน localStorage ตาม uid/fid — token ยังใช้ได้ = ผ่านเลย · ต้องขอ
* token ใหม่ = ยิงขอเสมอแล้วให้ **server เป็นคนตัดสิน** (ticket หมดอายุแต่
* เอกสารนี้เคยมี version โดย uid เดียวกัน = ออกให้ต่อ — requirement ขั้น 7
* กลับมาทำเอกสารเดิมได้แม้ ticket หมดอายุ แม้ session หายไปจากเครื่อง)
*/
export async function signInWithTicket(input: {
ticketData?: string;
@ -82,63 +85,57 @@ export async function signInWithTicket(input: {
};
}
const ticket = parsed.ticket;
const ticketAlive = !isTicketExpired(ticket.exp);
const expiredMessage = {
ok: false as const,
reason: "expired" as const,
message: "ticket ของ e-Court หมดอายุ — โปรดขอไฟล์ใหม่จาก e-Court แล้วเปิดอีกครั้ง",
};
const session = ticketService.loadSession();
if (session === null || session.userId !== ticket.uid) {
// ไม่มี session / user ใหม่ — ตรวจ exp ก่อนแล้วเริ่ม session ใหม่ทั้งก้อน
if (!ticketAlive) {
return {
ok: false,
reason: "expired",
message: "ticket ของ e-Court หมดอายุ — โปรดขอไฟล์ใหม่จาก e-Court แล้วเปิดอีกครั้ง",
};
}
ticketService.clearSession();
// ไม่มี session / user ใหม่ — ขอ token ใหม่เสมอ (แม้ ticket หมดอายุ: server
// ออกให้ต่อเมื่อเอกสารนี้เคยทำงานโดย uid เดียวกัน ซึ่งช่วยเคส localStorage หาย
// ของคนที่กลับมาทำเอกสารเดิมด้วย) แล้วเริ่ม session ใหม่ทั้งก้อน
let entry: ticketService.SessionTokenEntry;
try {
entry = await ticketService.requestDocverToken(ticketData, ticketHmac);
} catch {
return {
ok: false,
reason: "token-request-failed",
message: "ขอ token จากระบบไม่สำเร็จ — ตรวจการเชื่อมต่อแล้วเปิดเอกสารอีกครั้ง",
};
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 ได้เฉพาะตอน ticket ยังไม่หมด; ticket หมดด้วยก็เก็บของ
// เก่าไว้ (requirement ขั้น 7: ผ่านเลยไม่ตรวจ exp) ให้ server ตัดสินตอนใช้งาน
if (ticketAlive) {
try {
entry = await ticketService.requestDocverToken(ticketData, ticketHmac);
session.tokens[ticket.fid] = entry;
} catch {
// ใช้ token เก่าต่อ — ถ้าเน่าจริง apiClient จะ 401 → renew-retry → no-access
}
// 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) {
// เอกสารใหม่ของคนเดิม — ตรวจ exp แล้วขอ token เก็บเพิ่ม
if (!ticketAlive) {
return {
ok: false,
reason: "expired",
message: "ticket ของ e-Court หมดอายุ — โปรดขอไฟล์ใหม่จาก e-Court แล้วเปิดอีกครั้ง",
};
}
// เอกสารใหม่ของคนเดิม — ขอ token เก็บเพิ่ม (server ตัดสินเรื่อง ticket หมดอายุ)
try {
session.tokens[ticket.fid] = await ticketService.requestDocverToken(ticketData, ticketHmac);
} catch {
return {
ok: false,
reason: "token-request-failed",
message: "ขอ token จากระบบไม่สำเร็จ — ตรวจการเชื่อมต่อแล้วเปิดเอกสารอีกครั้ง",
};
return isTicketExpired(ticket.exp)
? expiredMessage
: {
ok: false,
reason: "token-request-failed",
message: "ขอ token จากระบบไม่สำเร็จ — ตรวจการเชื่อมต่อแล้วเปิดเอกสารอีกครั้ง",
};
}
}
ticketService.saveSession(session);
@ -152,14 +149,16 @@ export async function signInWithTicket(input: {
}
/**
* ขอ token ใหม่จาก ticket ของเอกสารปัจจุบัน (ticket ต้องยังไม่หมดอายุ) —
* เรียกจาก apiClient ตอนโดน 401 ก่อน throw AuthRequiredError 1 รอบ
* ขอ token ใหม่จาก ticket ของเอกสารปัจจุบัน — เรียกจาก apiClient ตอนโดน 401
* ก่อน throw AuthRequiredError 1 รอบ · ไม่ตัดสิน exp ของ ticket ฝั่งนี้ (server
* ออกให้ต่อได้แม้ ticket หมดอายุเมื่อเอกสารนี้เคยทำงานโดย uid เดียวกัน —
* requirement ขั้น 7) 401 กลับมา = 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 || isTicketExpired(parsed.ticket.exp)) return false;
if (!parsed.ok) return false;
try {
const entry = await ticketService.requestDocverToken(currentTicket.data, currentTicket.hmac);
const session =