eCourt-note/docs/PLAN-ADDIN-AUTH.md
Suphonchai Phoonsawat c0833c00f7
All checks were successful
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 4s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Successful in 1m23s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 5s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 0s
Refactor authentication flow and ticket handling in add-in
- Removed client-side HMAC verification; server now handles all HMAC checks.
- Updated ticket processing logic to improve UX and security.
- Adjusted API request handling to return discriminated results based on HTTP status.
- Enhanced error messages for better clarity on authentication failures.
- Added unit tests for the new token endpoint to ensure compliance with updated contract.
- Cleaned up unused code and comments related to previous HMAC handling.
2026-11-04 21:18:19 +07:00

28 KiB
Raw Blame History

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. บริบท/เป้าหมาย

เปลี่ยน auth ของ wordaddin จาก e-Court pass-through แบบเดิม (e-Court ขอ token เองแล้ว stamp token/owner ลงไฟล์) เป็น ticket ตาม requirement:

  • e-Court stamp custom properties 2 ค่าในไฟล์ .docx: ticket_data (JSON) + HmacSha256
  • 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 ยังไม่มีไฟล์จริงใช้ — ไม่ต้องรองรับสองแบบพร้อมกัน)

1. ข้อตกลงที่ตัดสินใจแล้ว (2026-10-27)

# เรื่อง ข้อตกลง
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 แทนที่ 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 หมดอายุ แก้ 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 ต่อ
D10 เครื่องมือทดสอบ มีสคริปต์ stamp ticket ลงไฟล์ .docx ตัวอย่าง (scripts/make-ticket-docx.mjs) — ไม่งั้นทดสอบ flow นี้ใน Word จริงไม่ได้เลย

2. Contract ของ ticket (ต้องส่งให้ทีม e-Court ฝั่ง stamp)

Custom properties ของไฟล์ .docx (ชื่อ key ต้องตรงเป๊ะ — case-sensitive):

ticket_data = {"exp":1793067433,"fid":"T12_FD155_3s-long_ZZZ","tid":"T12","uid":"oom","uname":"สรวิชญ์"}
HmacSha256  = <hex 64 ตัว lowercase>  = HMAC-SHA256(secret, ticket_data ดิบทั้ง string ไม่ trim)
field type เงื่อนไข
exp int (unix วินาที) บังคับ · exp <= now = หมดอายุ
fid string บังคับ · trim 1–200 · เป็น docId ของทุก operation ของกลุ่ม docver
uid string บังคับ · trim 1–100
uname string บังคับ · trim 1–200
tid string optional · ≤100 · template ต้นทาง (ไม่มี = template: null)
  • field อื่นปนมา = reject (allowlist ตาม convention ของ repo)
  • 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)

3. ฝั่ง server — POST /api/documents/token รับ ticket เพิ่ม

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) → 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/* อยู่แล้ว — ไม่ต้องแต้

4. โค้ดแชร์ — utils/docver-ticket.ts (pure, ไม่มี dependency เลย)

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}$ (ใช้ฝั่ง 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 ตอนเปิดเอกสาร (แก้ 2026-11-04 — pre-check + ยิง /token ทุกครั้ง)

TaskpaneApp.detect() → authService.signInWithTicket({ticketData, ticketHmac}):

1. อ่าน properties ticket_data + HmacSha256 (readTrackedProperties)
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) ไม่เปลี่ยน
  • authService surface ใหม่: signInWithTicket / getAccessToken (token ของ fid ปัจจุบัน) / renewTokenFromTicket / getUsername — ถอด signInFromDocument + decodeJwtField สำหรับ uid (ticket มี uid/uname ตรง ๆ แล้ว เหลือ decode เฉพาะ exp ของ JWT ตอนเก็บ token)
  • apiClient ตอนโดน 401: renewTokenFromTicket() → สำเร็จ = retry request 1 ครั้ง → ยัง 401/ไม่มี ticket = AuthRequiredError (UI ไป no-access — เดิมมีอยู่แล้ว)
  • ?addindemo ยังใช้ได้ (signInForDemo คงเดิม)

6. ไฟล์ที่สร้าง/แต้/ถอด

สร้าง

  • utils/docver-ticket.ts — §4
  • 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) (ถอด 2026-11-04), session store (loadSession/saveSession/clearSession — try/catch + memory fallback), requestDocverToken (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" (ถอด 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 (แก้ 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 (แก้ 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

ถอด

  • ระบบ token/owner ทั้งเส้น (properties + signInFromDocument + decodeJwtField ส่วน uid) — แทนด้วย ticket เต็มตัว

7. Env

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 — ถอด 2026-11-04 (PLAN-SSO.md D13) — ฝั่ง add-in ไม่มี secret อีกต่อไป

หมายเหตุจากตอน implement: ตัว stamp ticket จริงคือกลุ่ม template (app/api/template/render/route.ts → applyTemplate — sort key ตามตัวอักษรหลังเติม exp แล้ว HMAC hex lowercase ผ่าน lib/lib-utils.ts + stamp ลงไฟล์ด้วย setCustomDocProps ของ lib/lib-tmpl.ts อ่าน env SECRET กับ TICKET_SEC default 300 วิ — ไฟล์ยังมี property Ed25519 เพิ่มด้วยแต่ out of scope ของแผนนี้)

(เลือก default แทน fail-closed ตาม requirement ที่ระบุค่าปัจจุบันไว้ — dev ใช้ได้ทันที แต่ comment เตือน production ชัด ๆ ทั้ง .env.example และแผนนี้)

8. Edge cases

  • property ยาวเกิน 255 → ไฟล์เสียหาย → no-access (Word ตัดค่าทิ้งจะ parse ไม่ผ่านอยู่แล้ว — เช็คความยาวเพื่อข้อความที่ชัดกว่า)
  • เปิดเอกสาร = ต้อง 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 ครั้ง (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 เหมือนเดิม (ข้อความเดิมปรับคำ)

9. สคริปต์ทดสอบ — scripts/make-ticket-docx.mjs

Dev tool รันมือ node scripts/make-ticket-docx.mjs <file.docx> [options]:

  • option: --fid --uid --uname --tid --exp <unix|+seconds> --secret <hmac-secret> และ --tracked (stamp SavedByAddIn+DocumentTrackingId เพื่อทดสอบ phase tracking ด้วย)
  • ทำงาน: สร้าง JSON ticket → HMAC-SHA256 hex → เขียน custom properties ลง docProps/custom.xml ผ่าน pizzip (dependency อยู่แล้วจากกลุ่ม template) — สร้าง custom.xml เองถ้าไฟล์ยังไม่มี (พร้อม override ใน [Content_Types].xml) · เขียนทับไฟล์เดิมเฉพาะเมื่อส่ง --in-place ไม่งั้นเขียนไฟล์ใหม่ <name>.ticket.docx (กฎ non-destructive ผูกกับ public/ เท่านั้นแต่ถือหลักเดียวกัน)
  • default secret/exp สอดคล้อง .env ตัวอย่าง

10. Verify criteria

  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 ได้ expires_in = 604800 (ค) HMAC ผิด = 401 ข้อความเดียว (ง) body ปนกัน/ไม่ส่ง ticket = 400 (จ) exp อดีตลายเซ็นถูก = 403
  4. ทดสอบใน Word จริง (sideload ตาม docs/WORDADDIN-DEV.md) ด้วยไฟล์จาก make-ticket-docx: ไฟล์ถูกต้อง → ใช้ได้ ชื่อผู้ใช้ = 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. ลำดับงาน

  1. utils/docver-ticket.ts + tests/utils/docver-ticket.test.ts
  2. lib/addin-ticket.ts + tests/lib/addin-ticket.test.ts
  3. app/api/documents/token/route.ts + lib/openapi.ts → verify 10.3 ด้วย curl
  4. wordaddin/config.ts + metadataService.ts (properties)
  5. ticketService.ts + authService.ts (rewrite)
  6. TaskpaneApp.tsx (detect + ข้อความ) + apiClient.ts (renew-retry)
  7. scripts/make-ticket-docx.mjs
  8. env/docs/CLAUDE.md sync → verify 10.1–10.5

12. Out of scope

  • ปลด mock versioningService (ทำแล้ว 2026-10-27 — versioningService ยิงกลุ่ม docver จริงผ่าน Bearer same-origin; mock เหลือเฉพาะ ?addindemo ดู docs/WORDADDIN-DEV.md)
  • ความไม่ตรงกันของ documentId (GUID ที่ stamp) กับ docId (= fid) (ยุติแล้ว 2026-10-27 — เอกสารระบุด้วย fid ใน token ฝั่งเดียว; พารามิเตอร์ documentId ของ versioningService เหลือตาม signature เดิมไม่ส่งถึง server)
  • UI จัดการ session/ออกจากระบบ, refresh token แบบมี revoke, ฝั่ง e-Court stamp (เขาทำ — เราส่ง §2 ให้)