feat(addin): implement e-Court ticket auth (ticket_data + HmacSha256)

Replace the token/owner pass-through with the ticket flow from
docs/requirement/addin-workflow.md (full plan: docs/PLAN-ADDIN-AUTH.md):

- utils/docver-ticket.ts: shared pure ticket parse + hex digest helpers,
  client/server inject their own crypto (Web Crypto / node:crypto)
- lib/addin-ticket.ts + POST /api/documents/token second entry: body
  {ticket_data, HmacSha256} verified server-side (HMAC timing-safe + exp,
  secret ADDIN_TICKET_SECRET -> SECRET -> "super_secret"); x-api-key path
  unchanged
- wordaddin: ticketService (Web Crypto HMAC, localStorage session
  wordaddin-auth keyed by fid, same-origin token request), authService
  rewrite (7-step flow, silent renew), apiClient 401 renew-retry once,
  metadataService reads ticket_data/HmacSha256, no-access messages per
  cause
- scripts/make-ticket-docx.mjs: dev tool to stamp tickets onto any .docx
  for testing in real Word
- app/wordaddin/error.tsx: error boundary showing the real error + stack
  in the pane (webviews have no devtools)
- tests: docver-ticket + addin-ticket unit suites (21 cases) + temporary
  crash repro suite for the ongoing client-crash debug session
- docs sync: PLAN-DOCVER, WORDADDIN-DEV, addin-ui-flow, openapi (two
  request bodies for /token), CLAUDE.md, .env.example

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
Suphonchai Phoonsawat 2026-10-27 11:21:15 +07:00
parent 8ebe5f8644
commit cffc571da5
21 changed files with 1725 additions and 124 deletions

View file

@ -23,9 +23,10 @@ export const LAST_VERSION_TIMESTAMP_PROPERTY = "LastVersionTimestamp";
/** FR-16 — stamp เฉพาะไฟล์ร่าง (draft) ที่เว็บสร้าง: template ต้นทาง (ค่าเดียวกับ AddInMetadata.TemplateIdPropertyName ฝั่ง backend) */
export const TEMPLATE_ID_PROPERTY = "TemplateId";
/** e-Court pass-through — ไฟล์ที่ e-Court stamp มาใน custom properties เข้าใช้ได้เลย (auth ทางเข้าเดียว) */
export const ECOURT_TOKEN_PROPERTY = "token";
export const ECOURT_OWNER_PROPERTY = "owner";
/** e-Court ticket — ไฟล์ที่ e-Court stamp คู่นี้มาใน custom properties คือ auth ทางเข้าเดียวของ add-in
* (PLAN-ADDIN-AUTH.md §2: HMAC-SHA256(secret, ticket_data ดิบ) เป็น hex lowercase 64 ตัว) */
export const TICKET_DATA_PROPERTY = "ticket_data";
export const TICKET_HMAC_PROPERTY = "HmacSha256";
/** ค่า property ที่หาไม่เจอเป็น `undefined` (ไม่ใช่ throw) */
export interface TrackedDocumentProperties {
@ -35,9 +36,9 @@ export interface TrackedDocumentProperties {
lastVersionTimestamp?: string;
/** มีเฉพาะไฟล์ร่าง (FR-16) — ส่งต่อให้ backend ตอน Start Versioning เพื่อผูก Document กับ template */
templateId?: string;
/** e-Court pass-through — มีอย่างใดอย่างหนึ่ง = ใช้งานได้ทันที (username จาก owner หรือ uid ใน token) */
ecourtToken?: string;
ecourtOwner?: string;
/** e-Court ticket — มีครบทั้งคู่ = ตรวจ HMAC แล้วเข้าใช้งานได้ (auth ทางเข้าเดียว) */
ticketData?: string;
ticketHmac?: string;
}
/** item ใน Custom XML Part `<VersionHistory>` */
@ -49,7 +50,7 @@ export interface XmlVersionEntry {
}
/** อ่าน custom properties ทั้ง 7 ตัวที่ระบบสนใจ (4 ตัวของ tracked + TemplateId ของไฟล์ร่าง FR-16
* + token/owner ที่ e-Court stamp มากับไฟล์) */
* + ticket_data/HmacSha256 ที่ e-Court stamp มากับไฟล์) */
export async function readTrackedProperties(): Promise<TrackedDocumentProperties> {
return Word.run(async (context) => {
const properties = context.document.properties.customProperties;
@ -58,16 +59,16 @@ export async function readTrackedProperties(): Promise<TrackedDocumentProperties
const lastVersionId = properties.getItemOrNullObject(LAST_VERSION_ID_PROPERTY);
const lastVersionTimestamp = properties.getItemOrNullObject(LAST_VERSION_TIMESTAMP_PROPERTY);
const templateId = properties.getItemOrNullObject(TEMPLATE_ID_PROPERTY);
const ecourtToken = properties.getItemOrNullObject(ECOURT_TOKEN_PROPERTY);
const ecourtOwner = properties.getItemOrNullObject(ECOURT_OWNER_PROPERTY);
const ticketData = properties.getItemOrNullObject(TICKET_DATA_PROPERTY);
const ticketHmac = properties.getItemOrNullObject(TICKET_HMAC_PROPERTY);
// load "value" ของ null-object proxy ก็ทำให้ isNullObject แม่นหลัง sync
savedByAddIn.load("value");
documentTrackingId.load("value");
lastVersionId.load("value");
lastVersionTimestamp.load("value");
templateId.load("value");
ecourtToken.load("value");
ecourtOwner.load("value");
ticketData.load("value");
ticketHmac.load("value");
await context.sync();
const read = (property: Word.CustomProperty): string | undefined =>
@ -81,8 +82,8 @@ export async function readTrackedProperties(): Promise<TrackedDocumentProperties
lastVersionId: read(lastVersionId),
lastVersionTimestamp: read(lastVersionTimestamp),
templateId: read(templateId),
ecourtToken: read(ecourtToken),
ecourtOwner: read(ecourtOwner),
ticketData: read(ticketData),
ticketHmac: read(ticketHmac),
};
});
}