Refactor authentication flow and ticket handling in add-in
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

- 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.
This commit is contained in:
Suphonchai Phoonsawat 2026-11-04 21:18:19 +07:00
parent 2e951f99f4
commit c0833c00f7
24 changed files with 858 additions and 460 deletions

View file

@ -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. ลำดับงาน

View file

@ -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`) |

View file

@ -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 (ข–ง)

View file

@ -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 ต้องเข้าถึงได้จากเครื่องผู้ใช้)

View file

@ -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`