- POST /api/documents/token issues JWT HS256 (claims userid/username/fid/template) after x-api-key check against new env DOCVER_API_KEY (fail-closed, timing-safe, uniform 401) - all docver endpoints now require Authorization: Bearer, verified with env DOCVER_TOKEN_SECRET (TTL via DOCVER_TOKEN_TTL_SECONDS, default 3600); secret unset = 401 on verify / 500 on issue - identity (userId/userName) + templateId extracted from token claims instead of request payload; removed from multipart form (legacy fields now 400) - lib/docver-token.ts: hand-rolled JWT via node:crypto only — alg pinned to HS256 (anti alg-confusion), timing-safe compare with both sides hashed, exp enforced, identical 401 for all bearer failure modes - openapi spec: /api/documents/token path + DocverBearer scheme; PLAN-DOCVER.md / CLAUDE.md / .env.example updated - tests: new tests/lib/docver-token.test.ts + adjusted parseVersionUploadForm tests (258 pass, no DB env needed) - package-lock.json: sync minio (^8.0.7) that was missing from the earlier docver commit Co-Authored-By: Claude Code <noreply@anthropic.com>
18 KiB
PLAN-DOCVER — Document Versioning API (stand-alone)
วันที่ตกลงแผน: 2026-10-20 · เพิ่ม auth ด้วย access_token: 2026-10-22 · สถานะ: implement รอบแรก (API ก่อน — ยังไม่ต่อ wordaddin)
0. บริบท/เป้าหมาย
ทำ backend จริงของฟีเจอร์ "e-Court Documents Versioning" (ตอนนี้ wordaddin ใช้ mock ใน
wordaddin/services/versioningService.ts):
- รับ
docIdจากผู้เรียก → ตารางเอกสารที่docIdห้ามซ้ำ (unique index) - แต่ละ 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) แกะจาก Bearer token (2026-10-22 — เดิมส่ง plain field ใน multipart): ขอ token ที่POST /api/documents/tokenด้วย headerx-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เทียบ envDOCVER_API_KEY— patternlib/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 เสมอ
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 |
integer | optional — fileId ของ e-Court เก็บเป็น claim อย่างเดียว ยังไม่มี operation ใดอ่าน/บังคับ (ตารางของกลุ่มนี้คีย์ด้วย docId ไม่มี column fileId) |
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 envDOCVER_TOKEN_TTL_SECONDSdefault 3600) · verify บังคับ alg=HS256 (กัน alg-confusion) และเทียบ signature แบบ timing-safe - endpoint อื่นของกลุ่มนี้ทั้งหมดต้องแนบ
Authorization: Bearer <access_token>— ไม่มี header / รูปผิด / signature ผิด / หมดอายุ / secret ไม่ตั้ง = 401 ข้อความไทย เดียวกันทุกกรณี (ไม่เปิดให้ใช้ endpoint เป็น oracle รู้สาเหตุ)
POST /api/documents/{docId}/versions — อัปโหลด version ใหม่
auth: Authorization: Bearer (ดู /token ด้านบน) — identity/templateId ของ row ใหม่
มาจาก claims ของ token ไม่รับจาก 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 เอง |
fields |
string (JSON) | optional — map { [sdtTag]: text } (≤100 keys, value ≤10,000) |
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 นั้นแทน - เอกสาร (
documentsrow) สร้าง แบบ implicit ตอน upload version แรก — ไม่มี endpoint "สร้างเอกสาร" แยก (ตาม flow ของ add-in ที่ generate documentId เองแล้วอัปโหลดทันที) - response 200:
{ "hasPdf": boolean, "versionId": string, "versionNumber": number }(=UploadVersionResultเป๊ะ) —versionNumberเป็นค่า authoritative จาก server (client เดาlatest+1แล้วแก้ XML part ตัวเองเมื่อไม่ตรง — กรณีอัปโหลดชนกัน)
GET /api/documents/{docId}/versions — history
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: Record<string, string> | null;
message: string | null;
hasPdf: boolean; // derive จาก pdf_object_key !== null ตัวเดียว
}
docIdไม่รู้จัก (ยังไม่เคยมี version) = 404 (สัญญาณ REST ตรง ๆ แบบ N10 — UI ของ add-in แสดง error + ปุ่มลองใหม่ ไม่พัง)
GET /api/documents/{docId}/versions/{versionId}/download-link?format=docx|pdf
- format ไม่ส่ง/"" =
docx· ค่าอื่น = 400 - query version row เช็คทั้ง docId และ versionId พร้อมกัน (กันโยน id ข้ามเอกสาร) — ไม่เจอ = 404
format=pdfแต่ version นั้นไม่มี pdf = 404 "version นี้ไม่มีสำเนา PDF"- response 200:
{ "url": string, "expiresAt": string }— url เป็น MinIO presigned GET (อายุตาม envDOCVER_LINK_TTL_SECONDSdefault 900 วิ) ให้ UI เปิดในเบราว์เซอร์จริงของ ผู้ใช้ (webview ของ Word ทิ้ง blob download เงียบ ๆ — เหตุผลเดียวกับแผน add-in เดิม) - URL อ้าง
MINIO_ENDPOINT:MINIO_PORTโดยตรง — โฮสต์นี้ต้องเข้าถึงได้จากเบราว์เซอร์ ของผู้ใช้ ไม่ใช่แค่จาก Next server (หมายเหตุนี้อยู่ใน.env.example/README ด้วย)
ตาราง error
| เหตุ | สถานะ |
|---|---|
| รูป docId/versionId/format ผิด · field ไม่รู้จัก (รวม userId/userName/templateId เก่า) · ไม่ใช่ multipart · magic bytes ผิด · body ขอ /token ผิดกติกา | 400 |
x-api-key ไม่ตั้ง/ไม่ใส่/ผิด (ที่ /token) · Bearer ขาด/เน่า/หมดอายุ/secret ไม่ตั้ง (ทุก endpoint อื่น) |
401 |
ไฟล์เกิน 100MB (เช็ค file.size ก่อน buffer เสมอ — route ไม่มี bodySizeLimit คุม) |
413 |
| เอกสาร/version ไม่มี · ขอ pdf ที่ version ไม่มี | 404 |
MinIO env ไม่ตั้ง/ล้ม · DB ล้ม (หลังเก็บกวาด object กำพร้า) · DOCVER_TOKEN_SECRET ไม่ตั้ง (ที่ /token) |
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):
INSERT INTO documents (doc_id) VALUES (…) ON CONFLICT DO NOTHING— สร้างเอกสารถ้ายังไม่มีSELECT id FROM documents WHERE doc_id = … FOR UPDATE— serialize ผู้จ่ายเลขรายเอกสาร (uniquedoc_id→ ได้ row เดียวเสมอ ไม่มี deadlock ข้ามเอกสาร)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 → 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 ค่าดิบ |
DOCVER_TOKEN_SECRET |
— บังคับ | secret เซ็น/ตรวจ JWT HS256 — ไม่ตั้ง: /token = 500 · endpoint อื่น = 401 · ห้าม log ค่าดิบ |
DOCVER_TOKEN_TTL_SECONDS |
3600 |
อายุ access_token (วินาที) |
putObject แนบ Content-Type + Content-Disposition (ชื่อไฟล์ไทยผ่าน filename*=UTF-8''
best-effort + ASCII fallback document.docx/document.pdf) — presigned GET ได้ชื่อไฟล์สวย
ตอนดาวน์โหลด
7. ไฟล์ของกลุ่มนี้
db/schema.ts— ตาราง +DocumentVersionDTO/toDocumentVersionDTOlib/document-versioning.ts— pure validation (ห้าม import@/db/minio— unit test ได้ โดยไม่ต้องตั้ง env อะไรเลย ตาม patternlib/external-api-auth.ts)lib/docver-token.ts— JWT HS256 issue/verify +requireDocverApiKey/requireDocverBearerparseTokenRequestBody(pure เหมือนกัน — ไม่ import@/db/minio)
lib/docver-store.ts— MinIO client (เก็บบนglobalThisใน dev แบบdb/index.ts) +ensureBucket+ put/remove/presign +insertVersionRecord(tx) + query helpersapp/api/documents/token/route.ts— POST ขอ access_token (x-api-key)app/api/documents/[docId]/versions/route.ts— POST + GETapp/api/documents/[docId]/versions/[versionId]/download-link/route.ts— GETdocker-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
8. Out of scope รอบนี้
- ไม่แต้
wordaddin/หรือ client ใด ๆ (สลับ mock ทีหลังผ่านNEXT_PUBLIC_ADDIN_API_BASE_URL) - ไม่มีหน้า UI ดูรายการเอกสาร/version ของกลุ่มนี้ · ไม่มี GC object กำพร้า · ไม่มี idempotency
บน
clientVersionId· ไม่มีการแปลง docx→pdf ฝั่ง server (Office.js ของ add-in ทำ)