eCourt-note/docs/PLAN-DOCVER.md
Suphonchai Phoonsawat 0b6ee8cc84
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
feat: add GET /api/documents/latest returning latest version file with stamped ticket
Server-to-server endpoint for the e-Court backend (x-api-key against
DOCVER_API_KEY or EXTERNAL_API_KEY):
- type=pdf returns the latest version's PDF bytes (404 when absent)
- type=docx stamps fresh ticket custom properties (exp + HmacSha256 via
  addinTicketSecret(), Ed25519 when PRIVATE_KEY_HEX is set) into an
  in-memory copy before returning — MinIO object is never modified
- docId comes from fid inside the ticket_data query param, which must
  include fid/uid/uname so the stamped file stays redeemable by the
  add-in at /api/documents/token (buildStampedTicketData drops null
  values because parseDocverTicket rejects tid:null)
- adds parseTicketDataParam, parseLatestTypeParam, requireDocumentApiKey,
  findLatestVersionForDoc, getVersionObjectBytes + unit tests (320 pass)
- syncs PLAN-DOCVER.md, OpenAPI spec and .env.example

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-31 11:09:42 +07:00

29 KiB
Raw Blame History

PLAN-DOCVER — Document Versioning API (stand-alone)

วันที่ตกลงแผน: 2026-10-20 · เพิ่ม auth ด้วย access_token: 2026-10-22 · สถานะ: implement รอบแรก (API ก่อน) · ปลด mock wordaddin แล้ว 2026-10-27 — versioningService ยิงกลุ่มนี้จริงผ่าน Bearer (mock เหลือเฉพาะ ?addindemo)

0. บริบท/เป้าหมาย

ทำ backend จริงของฟีเจอร์ "e-Court Documents Versioning" (ตอนนี้ wordaddin ใช้ mock ใน wordaddin/services/versioningService.ts):

  • ตารางเอกสารคีย์ด้วย docId ห้ามซ้ำ (unique index) — docId = claim fid (string) จาก Bearer token ตรง ๆ (2026-10-26 ถอด path param {docId} ออกจาก URL ทุก endpoint — fileId ของ e-Court ใน token เป็นที่มาเดียวของรหัสเอกสาร)
  • แต่ละ version อ้าง object ใน MinIO — เก็บทั้ง .docx และ .pdf (pdf เป็น optional/best-effort ตาม contract ของ add-in — pdf.size === 0 = ไม่มี, มี hasPdf บอกใน response)
  • เก็บ userId, userName, createdAt ต่อ version เพื่อรู้ว่าใครอัปโหลด
  • identity (userId/userName/templateId) และ docId แกะจาก Bearer token (2026-10-22 — เดิมส่ง plain field ใน multipart): ขอ token ที่ POST /api/documents/token ด้วย header x-api-key แล้วแนบ Authorization: Bearer <token> กับทุก request — ระบบ stand-alone ไม่มี session ร่วมกับใคร (ผู้เรียกคือ add-in ใน Word หรือระบบภายนอก) token จึงเป็น พาหะ identity เดียว
  • auth ทุก endpoint ของกลุ่มนี้ = Bearer JWT HS256 เซ็นด้วย DOCVER_TOKEN_SECRET (ออก token ผ่าน x-api-key เทียบ env DOCVER_API_KEY — pattern lib/external-api-auth.ts ของ N10) · ไม่มี CORS

ข้อตกลงกับผู้ใช้ (2026-10-20): ทำ API ก่อน ไม่แต้ wordaddin รอบนี้ — contract ตรง surface ที่ mock คงไว้เป๊ะ วันหน้าสลับของจริงแค่ตั้ง NEXT_PUBLIC_ADDIN_API_BASE_URL แล้วก้อบ body ของต้นฉบับกลับมา (ดู docs/WORDADDIN-DEV.md) · MinIO dev รันผ่าน docker-compose.yml (ผู้เรียกอัปโหลดทั้ง docx+pdf — ฝั่ง add-in ใช้ Office.js แปลง Word→PDF เอง)

Contract อ้างอิง: docs/addin-ui-flow.md §7 + wordaddin/services/versioningService.ts + wordaddin/models/versionRecord.ts — โค้ด server ห้าม import จาก wordaddin/ (client-only zone) type define เองใน db/schema.ts/lib

1. Endpoint (URL เต็ม = /docedit/api/documents/… — prefix ใหม่ ได้รับยกเว้นจาก

middleware เหมือน api/template)

POST /api/documents/token — ขอ access_token (2026-10-22)

auth: header x-api-key เทียบ env DOCVER_API_KEY (ไม่ตั้ง/ว่าง = 401 fail closed · ไม่ใส่/ผิด = 401 ข้อความเดียวกันเป๊ะ กัน enumerate) — ตรวจก่อนอ่าน body เสมอ

ทางเข้าที่สอง (2026-10-27, add-in ใน Word — PLAN-ADDIN-AUTH.md): ไม่มี header x-api-key + body {ticket_data, HmacSha256} (ชื่อ field เหมือน custom properties ในไฟล์) — server ตรวจ HMAC-SHA256(secret, ticket_data ดิบ) + exp ผ่าน lib/addin-ticket.ts (env ADDIN_TICKET_SECRET → SECRET → default "super_secret") แล้วออก token จาก claims ใน ticket (uid→userid, uname→username, fid, tid→template) · response/error รูปเดียวกับทางแรกทุกอย่าง (HMAC ผิด/หมดอายุ/ไม่ส่งมา = 401 ข้อความ เดียวกัน · body ผิดรูป = 400)

JSON body (allowlist — key อื่น = 400 กัน typo):

field type หมายเหตุ
userid string บังคับ — trim 1–100 (claim userid → column user_id)
username string บังคับ — trim 1–200 (claim username → column user_name)
fid string บังคับ — trim 1–200 · fileId ของ e-Court เป็น docId ของทุก operation ตรง ๆ · verify ไม่รับ token ที่ไม่มี fid
template string optional ≤100 (claim template → column template_id ตอน upload)
  • 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) · verify บังคับ alg=HS256 (กัน alg-confusion) และเทียบ signature แบบ timing-safe
  • endpoint อื่นของกลุ่มนี้ทั้งหมดต้องแนบ Authorization: Bearer <access_token> — ไม่มี header / รูปผิด / signature ผิด / หมดอายุ / secret ไม่ตั้ง = 401 ข้อความไทย เดียวกันทุกกรณี (ไม่เปิดให้ใช้ endpoint เป็น oracle รู้สาเหตุ)

POST /api/documents/versions — อัปโหลด version ใหม่

auth: Authorization: Bearer (ดู /token ด้านบน) — identity/templateId ของ row ใหม่และ docId (= fid — string) มาจาก claims ของ token ทั้งหมด ไม่รับจาก URL/form อีกต่อไป

multipart/form-data:

field type หมายเหตุ
file File (.docx) บังคับ — ≤ 100MB, magic bytes PK\x03\x04
pdf File (.pdf) optional — size === 0 = ถือว่าไม่มี (add-in แนบ blob เปล่าได้)
clientVersionId string optional ≤100 — GUID ที่ client generate เอง
message string optional — commit message ≤2048 (trim ว่าง = NULL)
fileName string optional — ชื่อไฟล์ดั้งเดิม (ผ่าน decodeUploadFilename แล้ว)
  • field ที่ไม่อยู่ในตาราง = 400 กัน typo เงียบ ๆ (pattern parseQueryBody ของ N10) — breaking change 2026-10-22: userId/userName/templateId ถอดออกจาก form แล้ว (ส่งมา = 400) identity/template มาจาก Bearer token ของ request นั้นแทน · breaking change 2026-10-30: fields ถอดออกจาก form เช่นกัน (ส่งมา = 400) — fields ของ version ถูกแกะจากไฟล์ .docx ที่อัปโหลดฝั่ง server เก็บทั้งก้อน เหมือน return ของ /api/template/extract เลย ({cc, bookmark, properties, verify} ผ่าน extractDocumentSnapshot ของ lib/lib-tmpl.ts ตัวเดียวกับ route นั้น — route extract เรียก helper ตัวเดียวกัน จึงตรงกันเป๊ะโดยการก่อสร้าง) · verify.HmacSha256 ตรวจด้วย secret ของ add-in ticket ฝั่ง server (env chain ADDIN_TICKET_SECRET → SECRET → "super_secret" ของ lib/addin-ticket.ts — ต่างจาก extract ที่ใช้ secret ของผู้เรียกเพื่อทดสอบ) · เห็น content control ทุกชนิดเสมอ ไม่ขึ้นกับเวอร์ชัน WordApi ของ host แบบตอน add-in อ่านเองผ่าน Office.js · ไฟล์ที่แกะไม่ได้ (ไม่มี word/document.xml/ZIP เสีย/ticket_data ไม่ใช่ JSON) = 400 · snapshot เก็บทุกครั้ง ที่แกะสำเร็จ (NULL เหลือเฉพาะ row เก่าก่อน 2026-10-30 ที่เก็บ map ดิบ)
  • เอกสาร (documents row) สร้าง แบบ implicit ตอน upload version แรก — ไม่มี endpoint "สร้างเอกสาร" แยก (เอกสารถูกระบุที่ fid ใน token ตั้งแต่ขอ token)
  • response 200: { "hasPdf": boolean, "versionId": string, "versionNumber": number } (= UploadVersionResult เป๊ะ) — versionNumber เป็นค่า authoritative จาก server (client เดา latest+1 แล้วแก้ XML part ตัวเองเมื่อไม่ตรง — กรณีอัปโหลดชนกัน)

GET /api/documents/versions — history (ของเอกสาร fid ใน token)

response: DocumentVersionDTO[] เรียง ใหม่ → เก่า (ตาม VersionRecord[] ของ add-in + ส่วนเสริม createdByUserName):

{
  versionId: string;          // = id ของ row (uuid)
  versionNumber: number;
  createdAt: string;          // ISO-8601
  createdByUserId: string;    // = user_id
  createdByUserName: string;  // = user_name (ส่วนเสริมนอก contract เดิม)
  contentHash: string;        // SHA-256 hex ของ docx (คำนวณฝั่ง server)
  fields: {                  // ก้อนเดียวกับ return ของ /api/template/extract (2026-10-30)
    cc: Record<string, string>;        // ค่า Content Control { [tag]: text }
    bookmark: Record<string, string>;  // ข้อความใน bookmark { [name]: text }
    properties: {                      // core/extended/custom props (Date → ISO string)
      standard?: Record<string, string | undefined>;
      extended?: Record<string, number | string | undefined>;
      custom?: Record<string, string | number | boolean | null>;
    };
    verify: Record<string, unknown>;   // exp (ฟอร์แมตไทย) + HmacSha256 (secret ฝั่ง server)
  } | null;                  // null = row เก่าก่อน 2026-10-30 (map ดิบ) หรือไม่มีเลย
  message: string | null;
  hasPdf: boolean;            // derive จาก pdf_object_key !== null ตัวเดียว
}
  • docId (จาก fid ใน token) ไม่รู้จัก (ยังไม่เคยมี version) = 404 (สัญญาณ REST ตรง ๆ แบบ N10 — UI ของ add-in แสดง error + ปุ่มลองใหม่ ไม่พัง)
  • format ไม่ส่ง/"" = docx · ค่าอื่น = 400
  • query version row เช็ค docId (จาก fid ใน token) กับ versionId พร้อมกัน (กันโยน id ข้ามเอกสาร) — ไม่เจอ = 404
  • format=pdf แต่ version นั้นไม่มี pdf = 404 "version นี้ไม่มีสำเนา PDF"
  • response 200: { "url": string, "expiresAt": string } — url เป็น MinIO presigned GET (อายุตาม env DOCVER_LINK_TTL_SECONDS default 900 วิ) ให้ UI เปิดในเบราว์เซอร์จริงของ ผู้ใช้ (webview ของ Word ทิ้ง blob download เงียบ ๆ — เหตุผลเดียวกับแผน add-in เดิม)
  • URL อ้าง MINIO_ENDPOINT:MINIO_PORT โดยตรง — โฮสต์นี้ต้องเข้าถึงได้จากเบราว์เซอร์ ของผู้ใช้ ไม่ใช่แค่จาก Next server (หมายเหตุนี้อยู่ใน .env.example/README ด้วย)

GET /api/documents/latest?type=docx|pdf&ticket_data=<json> — ไฟล์ version ล่าสุด (2026-10-31)

ให้ backend e-Court ดึงไฟล์ version ล่าสุด กลับไปเปิดให้ผู้ใช้ (server-to-server — ไม่ใช่ Bearer: auth ด้วย x-api-key อย่างเดียว) · docId = ฟิลด์ fid ใน ticket_data ไม่ใช่ claim จาก token

  • auth: header x-api-key เทียบ env DOCVER_API_KEY หรือ EXTERNAL_API_KEY ตัวใด ตัวหนึ่ง (ต่างจาก /token ที่รับ DOCVER_API_KEY อย่างเดียว — ผู้เรียกถือ key สาย N10 หรือสาย docver ก็ได้) · ทั้งคู่ไม่ตั้ง/ว่าง = 401 fail closed · ไม่ใส่/ผิด = 401 ข้อความ เดียวกันเป๊ะ (timing-safe เหมือน requireDocverApiKey)
  • query (บังคับทั้งคู่):
    • type = docx | pdf — ไม่มี default (ต่างจาก format ของ download-link)
    • ticket_data = JSON string ของ ticket — บังคับ fid + uid + uname (tid optional, exp ถ้าส่งต้องเป็นจำนวนเต็มแต่จะถูกเติมใหม่เสมอ) เพราะ parseDocverTicket (utils/docver-ticket.ts) บังคับทั้งสามตอน add-in เอาไฟล์ไป redeem ที่ /token ทาง ticket — validate ตั้งแต่รับเข้า กันคืนไฟล์ที่ add-in เปิด ไม่ได้เงียบ ๆ · ⚠️ หมายเหตุถึงฝั่ง e-Court: uid ต้องเป็นรหัสผู้ใช้ (ไม่ใช่ ชื่อ) และต้องส่ง uname (ชื่อแสดงผล) เช่น {"fid":"DOC-2569-0001","uid":"123", "uname":"สมชาย ใจดี"}
  • type=pdf → คืนไบต์ pdf ของ version ล่าสุดตรง ๆ (Content-Type: application/pdf) · version ล่าสุดไม่มี pdf = 404 (ข้อความเดียวกับ download-link)
  • type=docx → ดึงไบต์ docx จาก MinIO มา stamp ticket ใหม่บนสำเนาในหน่วยความจำ ก่อนคืน (buildStampedTicketData + stampTicketProps ของ lib/lib-tmpl.ts — recipe เดียวกับ applyTemplate ของกลุ่ม template: เติม exp = now + TICKET_SEC → เรียง key → HmacSha256 ด้วย secret จาก addinTicketSecret() (chain ADDIN_TICKET_SECRET → SECRET → "super_secret" — ตรงฝั่ง verify ของ add-in//token ทาง ticket เป๊ะ) → Ed25519 เฉพาะเมื่อ PRIVATE_KEY_HEX ตั้ง (ไม่ตั้ง = ไม่ใส่ prop — chain ของ add-in verify แค่ HMAC) → setCustomDocProps upsert เขียนทับ stamp เดิมได้) — ไม่เขียนกลับ MinIO ตาม invariant #1 (non-destructive) · stamp ล้ม = 500 (ห้ามกลืนเหมือน applyTemplate — ไฟล์ไม่มี stamp = add-in เปิดไม่ได้)
  • ticket_data หลังเติม exp ยาวเกิน 255 ตัวอักษร = 400 (Word ตัดทอนค่า custom property ที่ 255 — คืนไฟล์ที่โดนตัด = HMAC เสียเงียบ ๆ)
  • response 200: ไบต์ไฟล์ + Content-Type ตามชนิด + Content-Disposition จาก fileName ของ version (contentDispositionValue) + Cache-Control: no-store · เช็ค magic bytes ของไบต์ที่อ่านได้ ไม่ตรง = 500 (ความผิดปกติของพื้นที่เก็บ ไม่ใช่ความผิดของผู้เรียก)

ตาราง error

เหตุ สถานะ
รูป versionId/format ผิด · field ไม่รู้จัก (รวม userId/userName/templateId/fields เก่า) · ไม่ใช่ multipart · magic bytes ผิด · แกะข้อมูลจากไฟล์ไม่ได้ · body ขอ /token ผิดกติกา (รวมขาด fid) 400
type ไม่ส่ง/ค่าอื่น · ticket_data ไม่ส่ง/ไม่ใช่ JSON object/field นอก allowlist/ขาด fid·uid·uname/เกิน cap/ยาว >2048 · ticket_data หลังเติม exp ยาว >255 (ที่ /latest เฉพาะ type=docx) — ทั้งหมดของ GET /latest 400
x-api-key ไม่ตั้ง/ไม่ใส่/ผิด (ที่ /token) · Bearer ขาด/เน่า/หมดอายุ/secret ไม่ตั้ง (ทุก endpoint อื่น) · x-api-key ไม่ตั้ง (ทั้งคู่)/ไม่ใส่/ไม่ตรงทั้ง DOCVER_API_KEY·EXTERNAL_API_KEY (ที่ /latest) 401
ไฟล์เกิน 100MB (เช็ค file.size ก่อน buffer เสมอ — route ไม่มี bodySizeLimit คุม) 413
เอกสาร/version ไม่มี · ขอ pdf ที่ version ไม่มี (รวม type=pdf ที่ /latest แต่ version ล่าสุดไม่มี pdf) 404
MinIO env ไม่ตั้ง/ล้ม · DB ล้ม (หลังเก็บกวาด object กำพร้า) · DOCVER_TOKEN_SECRET ไม่ตั้ง (ที่ /token) · stamp ticket ล้ม/magic bytes ของ object ไม่ตรง (ที่ /latest) 500 (log เต็มฝั่ง server + ข้อความกลาง ๆ ไทย)

2. ตาราง (db/schema.ts)

documents          — id uuid PK · doc_id text UNIQUE NOT NULL · created_at timestamptz
document_versions  — id uuid PK · document_id uuid FK→documents CASCADE
                   · version_number int NOT NULL (+ UNIQUE(document_id, version_number))
                   · client_version_id · content_hash NOT NULL · message · fields jsonb
                   · template_id · file_name · docx_object_key NOT NULL · pdf_object_key
                   · docx_size int NOT NULL · pdf_size int · user_id NOT NULL
                   · user_name NOT NULL · created_at
                   + index (document_id, created_at)
  • documents ≠ files ของแอป note — ไม่มี FK ไม่มี field ร่วม ไม่มีหน้า note ไหนอ่าน (ชื่อคล้ายกันแต่คนละโดเมน: ตารางนี้เป็นของกลุ่ม API นี้ล้วน ๆ)
  • content_hash/docx_size เป็น NOT NULL เพราะ server buffer ไฟล์และคำนวณทั้งคู่ก่อน insert เสมอ · hasPdf ไม่มี column — derive จาก pdf_object_key !== null
  • object key ใน MinIO: docver/<versionId>/document.docx / document.pdf — uuid ล้วน ไม่มี input ผู้ใช้ (เหตุผลเดียวกับ N4 ที่ห้ามชื่อผู้ใช้ตกแต่ง path) · versionId สร้าง ด้วย randomUUID() ฝั่ง server แล้วส่งเป็น id ตอน insert (object เขียนไปก่อนแล้ว)

3. จ่ายเลข version ปลอดภัยเรียกคู่ขนาน

เลือก: insert-on-conflict + SELECT … FOR UPDATE ใน transaction เดียว (ใช้ .for("update") ที่ repo ใช้อยู่แล้วใน app/actions/annotations.ts):

  1. INSERT INTO documents (doc_id) VALUES (…) ON CONFLICT DO NOTHING — สร้างเอกสารถ้ายังไม่มี
  2. SELECT id FROM documents WHERE doc_id = … FOR UPDATE — serialize ผู้จ่ายเลขรายเอกสาร (unique doc_id → ได้ row เดียวเสมอ ไม่มี deadlock ข้ามเอกสาร)
  3. SELECT max(version_number) + 1 แล้ว insert row version

ทางเลือกที่ตัดทิ้ง: retry-on-23505 (ต้องเก็บกวาด object ซ้ำทุกรอบ ไม่มี ordering) · pg_advisory_xact_lock (ได้แต่ต้อง raw SQL และซ้ำซ้อนกับ row lock) — UNIQUE (document_id, version_number) เก็บไว้เป็น safety net

4. ลำดับงานต่อ request (POST) + เก็บกวาด object กำพร้า

validate (text field ก่อนแตะไฟล์) → cap ขนาด → buffer → magic bytes → แกะ fields snapshot จากไฟล์ทั้งก้อนด้วย extractDocumentSnapshot ของ lib/lib-tmpl.ts (ตัวเดียวกับ /api/template/extract — 2026-10-30) → sha256 → versionId → putObject MinIO (docx ก่อน pdf; pdf ล้ม = ลบ docx ทิ้ง) → insert DB (tx §3) — ล้ม = removeObject ทั้งคู่แล้วโยนต่อ (pattern เดียวกับ unlink + rethrow ของ N4)

  • crash ระหว่าง putObject↔insert เหลือ object ไร้ row ชี้ (key เป็น uuid ไม่ชนใคร) — ไม่ทำ GC รอบนี้ บันทึกไว้เป็นงานอนาคต
  • ไม่มี idempotency บน clientVersionId — retry หลัง network timeout เกิด version ใหม่ (client แก้เลขจาก response เองได้) ยอมรับได้

5. Carve-out จาก invariant #4 (CLAUDE.md)

กลุ่ม API นี้ เขียน DB ผ่าน route handler (ไม่ใช่ Server Action) และไม่ publish SSE ไม่ revalidatePath — ตาราง documents/document_versions เป็นของกลุ่มนี้ล้วน ไม่มีหน้า note ใดอ่าน จึงไม่มี Router Cache/SSE ที่ต้อง invalidate (นับเป็นข้อยกเว้นเดียวกับลักษณะ read-only ของ N10 แต่ตัวนี้เขียนได้)

6. Env (อ่านตอนเรียก ไม่ใช่ตอน module load — ขาด/ล้ม = 500 ไทยกลาง ๆ + log เต็มฝั่ง server)

env default หมายเหตุ
MINIO_ENDPOINT — บังคับ host อย่างเดียว ไม่มี scheme
MINIO_PORT 9000 dev compose แมพโฮสต์เป็น 19000 (9000/9001 ชน service อื่นในเครื่อง)
MINIO_USE_SSL false
MINIO_ACCESS_KEY / MINIO_SECRET_KEY — บังคับ dev compose = minioadmin
MINIO_BUCKET docver app สร้างเองตอน first use (ensureBucket — จับ race ข้าม process)
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 (วินาที)
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)

putObject แนบ Content-Type + Content-Disposition (ชื่อไฟล์ไทยผ่าน filename*=UTF-8'' best-effort + ASCII fallback document.docx/document.pdf) — presigned GET ได้ชื่อไฟล์สวย ตอนดาวน์โหลด

7. ไฟล์ของกลุ่มนี้

  • db/schema.ts — ตาราง + DocumentVersionDTO/toDocumentVersionDTO
  • lib/document-versioning.ts — pure validation (ห้าม import @/db/minio — unit test ได้ โดยไม่ต้องตั้ง env อะไรเลย ตาม pattern lib/external-api-auth.ts)
  • lib/docver-token.ts — JWT HS256 issue/verify + requireDocverApiKey/requireDocverBearer
    • parseTokenRequestBody + requireDocumentApiKey (auth ของ GET /latest — สอง env) (pure เหมือนกัน — ไม่ import @/db/minio)
  • lib/docver-store.ts — MinIO client (เก็บบน globalThis ใน dev แบบ db/index.ts) + ensureBucket + put/remove/presign + getVersionObjectBytes (อ่านไบต์ของ /latest) + insertVersionRecord (tx) + query helpers (findLatestVersionForDoc ของ /latest)
  • app/api/documents/token/route.ts — POST ขอ access_token (x-api-key)
  • app/api/documents/versions/route.ts — POST + GET (docId = fid จาก token)
  • app/api/documents/versions/[versionId]/download-link/route.ts — GET
  • app/api/documents/latest/route.ts — GET ไฟล์ version ล่าสุด (2026-10-31 — docx stamp ticket บนสำเนาในหน่วยความจำผ่าน buildStampedTicketData/stampTicketProps ของ lib/lib-tmpl.ts + parseTicketDataParam ของ utils/docver-ticket.ts + parseLatestTypeParam ของ lib/document-versioning.ts)
  • docker-compose.yml — db (ชื่อ/วอลุ่มเดิมกับ docker run ใน README) + minio (โฮสต์ 19000 API / 19001 console — ข้างใน container 9000/9001; image จาก quay.io)
  • tests/lib/document-versioning.test.ts + tests/lib/docver-token.test.ts — unit test ส่วน pure (+ parseTicketDataParam ใน tests/utils/docver-ticket.test.ts + stamp helpers ใน tests/lib/lib-tmpl.test.ts ของ /latest)

8. Out of scope รอบนี้

  • ไม่แต้ wordaddin/ หรือ client ใด ๆ (ปลด mock แล้ว 2026-10-27 — wordaddin/services/versioningService.ts ยิงกลุ่มนี้จริง same-origin ผ่าน Bearer; ดู docs/WORDADDIN-DEV.md หัว "การรันกับ backend จริง")
  • ไม่มีหน้า UI ดูรายการเอกสาร/version ของกลุ่มนี้ · ไม่มี GC object กำพร้า · ไม่มี idempotency บน clientVersionId · ไม่มีการแปลง docx→pdf ฝั่ง server (Office.js ของ add-in ทำ)